1. 首页
  2. IT资讯

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

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

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

什么是 shapeFlag

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

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

在「Vue3」源码中 ShapeFlag 和 patchFlag 一样被定义为枚举类型,每一个枚举值以及意义会是这样:
揭秘,Vue3 compile 和 runtime 结合的 patch 过程(源码分析)

组件创建过程

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

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

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

在之前,我们讲过 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 中,如果当前 ShapeFlags 为 STATEFUL_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」Runtime 和 Compile 的巧妙结合

显然在「Vue2.x」是不具备构建 VNode 的 dynamicChildren 属性的条件。那么,「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 时,需要传入的 isBlockNode 为 true,它用来标识当前 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 场景下,curretBlock 为 null,它不需要靶向更新。
  • 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 节点。
  • 不断递归,直到 VNode 的 children 不存在为止。

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

Vue3 的 patch

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

它会利用 compile 阶段的 type 和 patchFlag 来处理不同情况下的更新,这也可以理解为是一种分而治之的策略。其对应的伪代码会是这样:

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 VNode 到 patch 阶段根据特定 shapeFlag 的不同处理,在一定程度上,shapeFlag 具有和 patchFlag 一样的价值

这里取其中一种情况,当 ShapeFlag 为 ELEMENT 时,我们来分析一下 processElement 是如何处理 VNode 的 patch 的。

processElement

同样地 processElement 会处理挂载的情况,即 oldVNode 为 null 的时候。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
    )
    ...
  }
  ...
}

所以,如果 n2 的 dynamicChildren 存在时,则会调用 patchBlockChildren 方法。而,patchBlockChildren 方法实际上是基于 patch 方法的一层封装。

patchBlockChildren

patchBlockChildren 会遍历 newChildren,即 dynamicChildren 来处理每一个同级别的 oldVNode 和 newVNode,以及它们作为参数来调用 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 的类型为文档碎片时。
  • oldVNode 和 newVNode 不是同一个节点时。
  • shapeFlag 为 teleport 或 component 时。

2. 初始调用 patch 的容器

  • 除开上述情况,都是以最初的 patch 方法传入的根 VNode 的挂载点作为容器。

具体每一种情况为什么需要这样处理,讲起来又将是长篇大论,预计会放在下一篇文章中和大家见面。

写在最后

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

本文来自投稿,不代表程序员编程网立场,如若转载,请注明出处:http://www.cxybcw.com/203558.html

联系我们

13687733322

在线咨询:点击这里给我发消息

邮件:1877088071@qq.com

工作时间:周一至周五,9:30-18:30,节假日休息

QR code