揭秘,Vue3 compile 和 runtime 结合的 patch 过程(源码分析)

前言

在上一篇文章中,咱们分析了在编译过程静态节点的提高。而且,在文章的结尾也说了,下一篇文章将会介绍 patch 过程。javascript

提及「Vue3」的 patch 过程,其中最为津津乐道的就是靶向更新。靶向更新,顾名思义,即更新的过程是带有目标性的直接性的。而,这也是和静态节点提高同样,是「Vue3」针对 VNode 更新性能问题的一大优化。vue

那么,今天,咱们就来揭秘「Vue3」compile 和 runtime 结合的 patch过程 到底是如何实现的!java

什么是 shapeFlag

提及「Vue3」的 patch,老生常谈的就是 patchFlag。因此,对于 shapeFlag 我想你们可能有点蒙,这是啥?node

ShapeFlag 顾名思义,是对具备形状的元素进行标记,例如普通元素、函数组件、插槽、keep alive 组件等等。它的做用是帮助 Rutime 时的 render 的处理,能够根据不一样 ShapeFlag 的枚举值来进行不一样的 patch 操做。数组

在「Vue3」源码中 ShapeFlagpatchFlag 同样被定义为枚举类型,每个枚举值以及意义会是这样:
缓存

组件建立过程

了解过「Vue2.x」源码的同窗应该知道第一次 patch 的触发,就是在组件建立的过程。只不过此时,oldVNodenull,因此会表现为挂载的行为。所以,在认知 pathc 的过程以前,不可或缺地是咱们须要知道组件是怎么建立的微信

既然说 patch 的第一次触发会是组件的建立过程,那么在「Vue3」中组件的建立过程会是怎么样的?它会经历这么三个过程svg

在以前,咱们讲过 compile 编译过程会将咱们的 template 转化为可执行代码,即 render 函数。而,compiler 生成的 render 函数会绑定在当前组件实例render 属性上。例如,此时有这样的 template 模板:函数

<div><div>hi vue3</div><div>{{msg}}</div></div>

它通过 compile 编译处理后生成的 render 函数会是这样:源码分析

const _Vue = Vue
const _hoisted_1 = _createVNode("div", null, "hi vue3", -1 /* HOISTED */)

function render(_ctx, _cache) {
  with (_ctx) {
    const { createVNode: _createVNode, toDisplayString: _toDisplayString, Fragment: _Fragment, openBlock: _openBlock, createBlock: _createBlock } = _Vue

    return (_openBlock(), _createBlock(_Fragment, null, [
      _createVNode("div", null, [
        _hoisted_1,
        _createVNode("div", null, _toDisplayString(msg), 1 /* TEXT */)
      ])
    ]))
  }
}

这个 render 函数真正执行的时机是在安装全局的渲染函数对应 effect 的时候,即 setupRenderEffect。而渲染 effect 会在组件建立时更新时触发

这个时候,可能又有同窗会问什么是 effecteffect 并非「Vue3」的新概念,它的本质是「Vue2.x」源码中的 watcher,一样地,effect也会负责依赖收集派发更新

有兴趣了解「Vue3」依赖收集和派发更新过程的同窗能够看一下这篇文章 4k+ 字分析 Vue 3.0 响应式原理(依赖收集和派发更新)

setupRenderEffect 函数对应的伪代码会是这样:

function setupRenderEffect() {
    instance.update = effect(function componentEffect() {
      // 组件未挂载
      if (!instance.isMounted) {
        // 建立组件对应的 VNode tree
        const subTree = (instance.subTree = renderComponentRoot(instance))
        ...
        instance.isMounted = true
      } else {
        // 更新组件
        ...
      }
  }

能够看到,组件的建立会命中 renderComponentRoot(instance) 的逻辑,此时 renderComponentRoot(instance) 会调用 instance 上的 render 函数,而后为当前组件实例构造整个 VNode Tree,即这里的 subTreerenderComponentRoot 函数对应的伪代码会是这样:

function renderComponentRoot(instance) {
  const {
    ...
    render,
    ShapeFlags,
    ...
  } = instance
  if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
    ...
    result = normalizeVNode(
      render!.call(
        proxyToUse,
        proxyToUse!,
        renderCache,
        props,
        setupState,
        data,
        ctx
      )
    )
    ...
  }
}

能够看到,在 renderComponentRoot 中,若是当前 ShapeFlagsSTATEFUL_COMPONENT 时会命中调用 render 的逻辑。这里的 render 函数,就是上面咱们所说的 compile 编译后生成的可执行代码。它最终会返回一个 VNode Tree,它看起来会是这样:

{
  ...
  children: (2) [{…}, {…}],
  ...
  dynamicChildren: (2) [{…}, {…}],
  ...
  el: null,
  key: null,
  patchFlag: 64,
  ...
  shapeFlag: 16,
  ...
  type: Symbol(Fragment),
  ...
}

靶向更新为例,了解过的同窗应该知道,它的实现离不开 VNode Tree 上的 dynamicChildren 属性,dynamicChildren 则是用来承接整个 VNode Tree 中的全部动态节点, 而标记动态节点的过程又是在 compile 编译的 transform 阶段,能够说是环环相扣,因此,这也是咱们常说的「Vue3」RuntimeCompile巧妙结合

显然在「Vue2.x」是不具有构建 VNodedynamicChildren 属性的条件。那么,「Vue3」又是如何生成的 dynamicChildren

Block VNode 建立过程

Block VNode

Block VNode 是「Vue3」针对靶向更新而提出的概念,它的本质是动态节点对应的 VNode。而,VNode 上的 dynamicChildren 属性则是衍生于 Block VNode,所以,它也就是充当着靶向更新中的靶的角色

这里,咱们再回到前面所提到的 compiler 编译时生成 render 函数,它返回的结果:

(_openBlock(), _createBlock(_Fragment, null, [
  _createVNode("div", null, [
    _hoisted_1,
    _createVNode("div", null, _toDisplayString(msg), 1 /* TEXT */)
  ])
]))
须要注意的是 openBlock 必须写在 createBlock 以前,由于在 Block Tree 中的 Children 老是会在 createBlock 以前执行。

能够看到有两个和 Block 相关的函数:_openBlock()_createBlock()。实际上,它们分别对应着源码中的 openBlock()createBlock() 函数。那么,咱们分别来认识一下这二者:

openBlock

openBlock 会为当前 Vnode 初始化一个数组 currentBlock 来存放 BlockopenBlock 函数的定义十分简单,会是这样:

function openBlock(disableTracking = false) {
    blockStack.push((currentBlock = disableTracking ? null : []));
}

openBlock 函数会有一个形参 disableTracking,它是用来判断是否初始化 currentBlock。那么,在什么状况下不须要建立 currentBlock

当存在 v-for 造成的 VNode 时,它的 render 函数中的 openBlock() 函数形参 disableTracking 就是 true。由于,它不须要靶向更新,来优化更新过程,即它在 patch 时会经历完整的 diff 过程。

换个角理解,为何这么设计?靶向更新的本质是为了从一颗存在动态、静态节点的 VNode Tree 中筛选出动态的节点造成 Block Tree,即 dynamicChildren,而后在 patch 时实现精准、快速的更新。因此,显然 v-for 造成的 VNode Tree 它不须要靶向更新

这里,你们可能还会有一个疑问,为何建立好的 Block VNode 又被 push 到了 blockStack 中?它又有什么做用?有兴趣的同窗能够去试一下 v-if 场景,它最终会构造一个 Block Tree,有兴趣的同窗能够看一下这篇文章 Vue3 Compiler 优化细节,如何手写高性能渲染函数

createBlock

createBlock 则负责建立 Block VNode,它会调用 createVNode 方法来依次建立 Block VNodecreateBlock 函数的定义:

function createBlock(type, props, children, patchFlag, dynamicProps) {
    const vnode = createVNode(type, props, children, patchFlag, dynamicProps, true);
    // 构造 `Block Tree`
    vnode.dynamicChildren = currentBlock || EMPTY_ARR;
    closeBlock();
    if (shouldTrack > 0 && currentBlock) {
        currentBlock.push(vnode);
    }
    return vnode;
}

能够看到在 createBlock 中仍然会调用 createVNode 建立 VNode。而 createVNode 函数本质上调用的是源码中的 _createVNode 函数,它的类型定义看起来会是这样:

function _createVNode(
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
  props: (Data & VNodeProps) | null = null,
  children: unknown = null,
  patchFlag: number = 0,
  dynamicProps: string[] | null = null,
  isBlockNode = false
): VNode {}

当咱们调用 _createVNode() 建立 Block VNode 时,须要传入的 isBlockNodetrue,它用来标识当前 VNode 是否为 Block VNode,从而避免 Block VNode 依赖本身的状况发生,即就不会将当前 VNode 加入到 currentBlock 中。其对应的伪代码会是这样:

function _createVNode() {
  ...
  if (
    shouldTrack > 0 &&
    !isBlockNode &&
    currentBlock &&
    patchFlag !== PatchFlags.HYDRATE_EVENTS &&
    (patchFlag > 0 || shapeFlag & ShapeFlags.COMPONENT)
  ) {
    currentBlock.push(vnode)
  }
  ...
}

因此,只有知足上面的 if 语句中的全部条件的 VNode,才能做为 Block Node,它们对应的具体含义会是这样:

  • sholdTrack 大于 0,即没有 v-once 指令下的 VNode
  • isBlockNode 是否为 Block Node
  • currentBlock 为数组时才建立 Block Node,对于 v-for 场景下,curretBlocknull,它不须要靶向更新。
  • patchFlag 有意义且不为 32 事件监听,只有事件监听状况时事件监听会被缓存。
  • shapeFlags 是组件的时候,必须为 Block Node,这是为了保证下一个 VNode 的正常卸载。
至于,再深一层次探索为何?有兴趣的同窗能够自行去了解。

小结

讲完 VNode 的建立过程,我想你们都会意识到一点,若是使用手写 render 函数的形式开发,咱们就须要对 createBlockopenBlock 等函数的概念有必定的认知。由于,只有这样,咱们写出的 render 函数才能充分利用好靶向更新过程,实现的应用更新性能也是最好的

patch 过程

对比 Vue2.x 的 patch

前面,咱们也说起了 patch 是组件建立和更新的最后一步,有时候它也会被称为 diff。在
「Vue2.x」中它的 patch 过程会是这样:

  • 同一级 VNode 间的比较,判断这两个新旧 VNode 是否属于同一个引用,是则不进行后续比较,不是则对比每一级的 VNode
  • 比较过程,分别定义四个指针指向新旧VNode 的首尾,循环条件为头指针索引小于尾指针索引
  • 匹配成功则将旧 VNode 的当前匹配成功的真实 DOM 移动到对应新 VNode 匹配成功的位置。
  • 匹配不成功,则将新 VNode 中的真实 DOM 节点插入到旧 VNode 的对应位置中,即,此时是建立旧 VNode 中不存在的 DOM 节点。
  • 不断递归,直到 VNodechildren 不存在为止。

粗略一看,就能明白「Vue2.x」patch 是一个硬比较的过程。因此,这也是它的缺陷所在,没法合理地处理大型应用状况下的 VNode 更新。

Vue3 的 patch

虽然「Vue3」的 patch 没有像 compile 同样会从新命名一些例如 baseCompiletransform 阶段性的函数。可是,其内部的处理相对于「Vue2.x」变得更为智能

它会利用 compile 阶段的 typepatchFlag 来处理不一样状况下的更新,这也能够理解为是一种分而治之的策略。其对应的伪代码会是这样:

function patch(...) {
  if (n1 && !isSameVNodeType(n1, n2)) {
    ...
  }
  if (n2.patchFlag === PatchFlags.BAIL) {
    ...
  }
  const { type, ref, shapeFlag } = n2
  switch (type) {
    case Text:
      processText(n1, n2, container, anchor)
      break
    case Comment:
      processCommentNode(n1, n2, container, anchor)
      break
    case Static:
      if (n1 == null) {
        mountStaticNode(n2, container, anchor, isSVG)
      } else if (__DEV__) {
        patchStaticNode(n1, n2, container, isSVG)
      }
      break
    case Fragment:
      processFragment(...)
      break
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(...)
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        processComponent(...)
      }else if (shapeFlag & ShapeFlags.TELEPORT) {
        ;(type as typeof TeleportImpl).process(...)
      } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
        ;(type as typeof SuspenseImpl).process(...)
      } else if (__DEV__) {
        warn('Invalid VNode type:', type, `(${typeof type})`)
      }
}

能够看到,除开文本、静态、文档碎片、注释等 VNode 会根据 type 处理。默认状况下,都是根据 shapeFlag 来处理诸如组件、普通元素、TeleportSuspense 组件等。因此,这也是为何文章开头会介绍 shapeFlag 的缘由。

而且,从 render 阶段建立 Block VNodepatch 阶段根据特定 shapeFlag 的不一样处理,在必定程度上,shapeFlag 具备和 patchFlag 同样的价值

这里取其中一种状况,当 ShapeFlagELEMENT 时,咱们来分析一下 processElement 是如何处理 VNodepatch 的。

processElement

一样地 processElement 会处理挂载的状况,即 oldVNodenull 的时候。processElement 函数的定义:

const processElement = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    optimized: boolean
  ) => {
    isSVG = isSVG || (n2.type as string) === 'svg'
    if (n1 == null) {
      mountElement(
        n2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        optimized
      )
    } else {
      patchElement(n1, n2, parentComponent, parentSuspense, isSVG, optimized)
    }
  }
其实,我的认为 oldVNode 改成 n1newVNode 改成 n2,这命名是否有点仓促?

能够看到,processElement 在处理更新的状况时,实际上会调用 patchElement 函数。

patchElement

patchElement 会处理咱们所熟悉的 props、生命周期、自定义事件指令等。这里,咱们不会一一分析每一种状况会发生什么。咱们就以文章开头提的靶向更新为例,它是如何处理的?

其实,对于靶向更新的处理非常简单,即若是此时 n2newVNode) 的 dynamicChildren 存在时,直接"梭哈",一把更新 dynamicChildren,不须要处理其余 VNode。它对应的伪代码会是这样:

function patchElement(...) {
  ...
  if (dynamicChildren) {
    patchBlockChildren(
      n1.dynamicChildren!,
      dynamicChildren,
      el,
      parentComponent,
      parentSuspense,
      areChildrenSVG
    )
    ...
  }
  ...
}

因此,若是 n2dynamicChildren 存在时,则会调用 patchBlockChildren 方法。而,patchBlockChildren 方法其实是基于 patch 方法的一层封装。

patchBlockChildren

patchBlockChildren 会遍历 newChildren,即 dynamicChildren 来处理每个同级别的 oldVNodenewVNode,以及它们做为参数来调用 patch 函数。以此类推,不断重复上述过程。

const patchBlockChildren: PatchBlockChildrenFn = (
    oldChildren,
    newChildren,
    fallbackContainer,
    parentComponent,
    parentSuspense,
    isSVG
  ) => {
    for (let i = 0; i < newChildren.length; i++) {
      const oldVNode = oldChildren[i]
      const newVNode = newChildren[i]

      const container =
        oldVNode.type === Fragment ||
        !isSameVNodeType(oldVNode, newVNode) ||
        oldVNode.shapeFlag & ShapeFlags.COMPONENT ||
        oldVNode.shapeFlag & ShapeFlags.TELEPORT
          ? hostParentNode(oldVNode.el!)!
          : fallbackContainer
      patch(
        oldVNode,
        newVNode,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        true
      )
    }
  }

你们应该会注意到,此时还会获取当前 VNode 须要挂载的容器,由于 dynamicChildren 有时候会是跨层级的,并非此时的 VNode 就是它的 parent。具体会分为两种状况:

1. oldVNode 的父节点做为容器

  • 当此时 oldVNode 的类型为文档碎片时。
  • oldVNodenewVNode 不是同一个节点时。
  • shapeFlagteleportcomponent 时。

2. 初始调用 patch 的容器

  • 除开上述状况,都是以最初的 patch 方法传入的VNode 的挂载点做为容器。
具体每一种状况为何须要这样处理,讲起来又将是 长篇大论,预计会放在下一篇文章中和你们见面。

写在最后

原本初衷是想化繁为简,没想到最后仍是写了 3k+ 的字。由于,「Vue3」将 compileruntime 结合运用实现了诸多优化。因此,已经不可能出现如「Vue2.x」同样分析 patch 只须要关注 runtime,不须要关注在这以前的 compile 作了一些奠基基调的处理。所以,文章总会不可避免地有点晦涩,这里建议想加深印象的同窗能够结合实际栗子单步调式一番。

往期文章回顾

从编译过程,理解 Vue3 静态节点提高(源码分析)

从零到一,带你完全搞懂 vite 中的 HMR 原理(源码分析)

❤️ 爱心三连击

经过阅读,若是你以为有收获的话,能够爱心三连击!!!

微信公众号: Code center
相关文章
相关标签/搜索