阅读 972

深度解读 Vue3 源码 | 从编译过程,理解静态节点提升

前言

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

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

那么,今天,我们就来看看「Vue3」源码中patch究竟是如何实现的!

什么是 shapeFlag

说起「Vue3」的 patch,老生常谈的就是 patchFlag。所以,对于 shapeFlag 我想大家可能有点蒙,这是啥?

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

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

组件创建过程

了解过「Vue2.x」源码的同学应该知道第一次 patch 的触发,就是在组件创建的过程。只不过此时,oldVNodenull,所以会表现为挂载的行为。因此,在认知靶向更新的过程之前,不可或缺地是我们需要知道组件是怎么创建的

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

在之前,我们讲过 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

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 原理(源码分析)

❤️ 爱心三连击

通过阅读,如果你觉得有收获的话,可以爱心三连击!!!