Vue3源码11: 编译优化之Block Tree 与 PatchFlags

2022-09-27 14:28:22 浏览数 (2)

  • Vue3源码01 : 代码管理策略-monorepo
  • Vue3源码02: 项目构建流程和源码调试方法
  • Vue3源码03: Vue3响应式核心原理
  • Vue3源码04: Vue3响应式系统源码实现1/2
  • Vue3源码05 : Vue3响应式系统源码实现(2/2)
  • Vue3源码06: reactive、ref相关api源码实现
  • Vue3源码07: 故事要从createApp讲起
  • Vue3源码08: 虚拟Node到真实Node的路其实很长
  • Vue3源码09: 组件的渲染和更新流程
  • Vue3源码10: 名动江湖的diff算法

Vue3是一个编译时和运行时相结合的框架。所谓编译时就是把我们编写的模版代码转化成一个render函数,该render函数的返回结果是一个虚拟Node,而运行时的核心工作就是把虚拟Node转化为真实Node进而根据情况对DOM树进行挂载或者更新。前面的文章已经分析了虚拟Node转化为真实Node的核心流程,但有些细节并没有讲,原因是这些内容和本文的主题Block Tree和PatchFlags相关,没有这些背景知识很难去理解那些内容。

本文会从一段模版代码开始,并将模版代码和对应的编译结果进行比较,引出虚拟Node的patchflag属性值,并在patchflag机制的基础上,讲解了dynamicChildren属性存在的意义,并分析为虚拟Node添加dynamicChildren属性值的过程,也就是Block机制。有了Block机制,我们又继续探讨Block机制的缺陷,进而又分析Block Tree。

编译结果

请大家先看一段模版代码:

代码语言:javascript复制
<!--代码片段1-->
<div>
  <div key="firstLevel 001">firstLevel: {{a}}</div>
  <div key="firstLevel 002">
    <div key="secondLevel">secondLevel: {{b}} </div>
  </div>
</div>

我们在网站https://vue-next-template-explorer.netlify.app/上对代码片段1中的代码转化成render函数:

代码语言:javascript复制
<!--代码片段2  文件名:xx.html-->
<script type="module">
    import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, renderList as _renderList, createCommentVNode as _createCommentVNode, createTextVNode as _createTextVNode } from "./runtime-dom.esm-browser.js"

    function render(_ctx, _cache, $props, $setup, $data, $options) {
        return (_openBlock(), _createElementBlock("div", null, [
            _createElementVNode("div", { key: "firstLevel 001" }, "firstLevel: "   _toDisplayString(_ctx.a), 1 /* TEXT */),
            _createElementVNode("div", { key: "firstLevel 002" }, [
                _createElementVNode("div", { key: "secondLevel" }, "secondLevel: "   _toDisplayString(_ctx.b), 1 /* TEXT */)
            ])
        ]))
    }

    let vNode = render({ a: 'a', b: 'b'});
    console.log(vNode)
</script>

“注意:为了方便调试,对编译的结果代码进行了少量改动。代码片段2中,第一行代码import {...} from "./runtime-dom.esm-browser.js"里面./runtime-dom.esm-browser.js是我本地编译的runtime-dom的结果文件路径,由于type="module的限制,需要开启一个本地服务器,然后在浏览器中访问该html页码,在控制台中可以查看打印的调用该render函数生成的虚拟Node结果。 ”

关于代码片段2的内容,大家如果是初次看见肯定会充满疑惑,脑海里会盘旋着诸如下面的问题:_createElementVNode是做什么的?_createElementBlock又是做什么的?怎么还有个openBlock这又是做什么的?还有 1 /* TEXT */代表什么含义?

如果此时脑海里充满了这些疑惑,不要着急,接下来将会为大家拨开迷雾,洞察这些充满疑问的地方背后的工作原理。

render函数概述

至于,代码片段1具体是如何转化成代码片段2的内容,我们在后面的文章会进行细致的分析。我们先看看这个编译结果render函数做了什么事情,或者说这个函数应该做什么事情。其实我们前面的文章中已经提到过,Vue3最核心的工作流程就是将模版文件转化为可以返回虚拟Node的render函数,以及将虚拟Node转化成真实Node。那代码片段2的render函数自然就是返回一个虚拟Node对象。

此时你可能会回头看代码片段2中调用的函数_createElementVNode,惊喜的发现,这个函数就是创建虚拟Node的函数。但你马上就会感觉奇怪,创建虚拟Node这个函数其实就是返回一个对象,这很好理解,这个对象可以描述一个DOM节点,而且也不难理解DOM节点有子节点,这里的虚拟Node也有子虚拟Node,所以函数_createElementVNode的第三个参数是个数组,这个数组里面的每一个元素都是调用函数_createElementVNode来创建的子虚拟Node。

到目前为止,这些内容理解起来都毫无压力。但你可能马上大喝一声,不对!我们代码片段1中有一个根节点,而代码片段2中却都是创建的子节点,根节点谁来创建。我们冷静下来,发现函数_createElementBlock的参数和函数_createElementVNode的参数几乎是一模一样的,没错,我们可以认为_createElementBlock的功能也是创建虚拟Node。

到目前为止,我们知道了代码片段2的render函数的核心任务就是返回虚拟Node,并且也知道了所谓的虚拟Node其实就是一个描述DOM节点的对象,而函数_createElementVNode_createElementBlock具备创建该对象的能力。但是毕竟这两个创建虚拟Node的函数名称都有差异,那背后肯定也存在着深刻的原因,而这正和本文需要讨论的主题PatchFlags和Block Tree有着深刻的联系。

PatchFlags

我们将代码片段2中生成的虚拟Node从控制台打印截图如下:

从这张图我们可以发现虚拟Node有一个属性叫patchFlag。其实在代码中PatchFlags代码如下:

代码语言:javascript复制
// 代码片段3
export const enum PatchFlags {
  TEXT = 1,
  CLASS = 1 << 1,
  STYLE = 1 << 2,
  PROPS = 1 << 3,
  FULL_PROPS = 1 << 4,
  HYDRATE_EVENTS = 1 << 5,
  STABLE_FRAGMENT = 1 << 6,
  KEYED_FRAGMENT = 1 << 7,
  UNKEYED_FRAGMENT = 1 << 8,
  NEED_PATCH = 1 << 9,
  DYNAMIC_SLOTS = 1 << 10,
  DEV_ROOT_FRAGMENT = 1 << 11,
  HOISTED = -1,
  BAIL = -2
}

这些枚举值为什么是以位运算的形式来标识,之前的文章介绍过,本文不再赘述。我们需要知道的是,除了HOISTEDBAIL,其他所有的值都代表着虚拟Node所代表的节点是动态的。所谓动态的,就是可能发生变化的。比如<div>abc</div>这样的节点就不是动态的,里面没有响应式元素,正常情况下是不会发生变化的,在patch过程中对其进行比较是没有意义的。所以Vue3对虚拟Node打上标记,如果节点的标记大于0则说明是在patch的时候是需要比较新旧虚拟Node的差异进行更新的。

这时候你可能会说,如果是区分节点是否是动态的,直接打上标记大于0或者小于0不就行了吗,这里为什么有十几个枚举值来表示?这个问题问得很好,回答这个问题之前我们先问各位另外一个问题,假设让我们来比较两个节点有什么差异,怎么比较呢?

面对这个问题,按照正常的思维,既然要比较两个事物是否有差异,就得看两个事物的各组成部分是否有差异,我们知道虚拟Node有标签名、类型名、事件名等各种属性名,同时还有有子节点,子节点又可能有子节点。那么要比较两个虚拟Node的差异,就得逐个属性逐级进行比较。而这样必然导致全部属性遍历,性能不可避免的低下。

Vue3的作者创造性的不仅标记某个虚拟Node是否动态,而且精准的标记具体是哪个属性是动态的,这样在进行更新的时候只需要定向查找相应属性的状态,比如patchflag的值如果包含的状态是CLASS对应的值1<<1,则直接比对新旧虚拟Node的class属性的值的变化。注意,由于patchflag是采用位运算的方式进行赋值,结合枚举类型PatchFlagspatchflag可以同时表示多种状态。也就是说可以表示class属性是动态的,也可以表示style属性是动态的,具体原理我们在前面的文章以及解释过,此处不再赘述。

我们发现,虽然对虚拟Node已经精准的标记了动态节点,甚至标识到了具体什么属性的维度。但是还是无法避免递归整颗虚拟Node树。追求极致的工程师们又创造性的想到了利用Block的机制来规避全量对虚拟Node树进行递归。

Block

在解释什么是Block机制之前,我们继续思考,如果是我们自己来想办法去规避全量比较虚拟Node的话怎么做?可能你会想到,是不是可以把这些动态的节点放到某一个独立的地方进行维护,这样新旧虚拟Node的节点可以在一个地方进行比较,就像下面这样:

代码语言:javascript复制
<!-- 代码片段4-->
<div>
  <div>static content</div>
  <div>{{dynamic}}</div>
  <div>
    <div>{{dynamic}}</div>
  </div>
</div>

对应的虚拟Node对属性进行精简后大致如下:

代码语言:javascript复制
// 代码片段4
{
    "type": "div",
    "children": [
        {
            "type": "div",
            "children": "static content",
            "patchFlag": 0
        },
        {
            "type": "div",
            "children": "",
            "patchFlag": 1
        },
        {
            "type": "div",
            "children": [
                {
                    "type": "div",
                    "children": "",
                    "staticCount": 0,
                    "shapeFlag": 1,
                    "patchFlag": 1
                }
            ],
            "staticCount": 0,
            "shapeFlag": 17,
            "patchFlag": 0
        }
    ],
    "patchFlag": 0,
    "dynamicChildren": [
        {
            "type": "div",
            "children": "",
            "staticCount": 0,
            "shapeFlag": 1,
            "patchFlag": 1
        },
        {
            "type": "div",
            "children": "",
            "staticCount": 0,
            "shapeFlag": 1,
            "patchFlag": 1
        }
    ]
}

从代码片段4中,可以发现虚拟Node上有个属性叫dynamicChildren,正常一个虚拟Node是没有这样一个属性的,因为我们前面说过虚拟Node是用来描述DOM节点的对象,而DOM节点是没有一项信息叫dynamicChildren的。那这个属性有什么用呢?还记得我们在分析patchElemenet函数的时候吗,有这样一段代码:

代码语言:javascript复制
// 代码片段5
if (dynamicChildren) {
      patchBlockChildren(
        n1.dynamicChildren!,
        dynamicChildren,
        el,
        parentComponent,
        parentSuspense,
        areChildrenSVG,
        slotScopeIds
      )
      if (__DEV__ && parentComponent && parentComponent.type.__hmrId) {
        traverseStaticChildren(n1, n2)
      }
    } else if (!optimized) {
      // full diff
      patchChildren(
        n1,
        n2,
        el,
        null,
        parentComponent,
        parentSuspense,
        areChildrenSVG,
        slotScopeIds,
        false
      )
    }

当时我叫大家先忽略patchBlockChildren函数,只告诉大家该函数和优化相关。我们来看看函数patchBlockChildren的具体实现:

代码语言:javascript复制
// 代码片段6
// The fast path for blocks.
  const patchBlockChildren: PatchBlockChildrenFn = (
    oldChildren,
    newChildren,
    fallbackContainer,
    parentComponent,
    parentSuspense,
    isSVG,
    slotScopeIds
  ) => {
    for (let i = 0; i < newChildren.length; i  ) {
      const oldVNode = oldChildren[i]
      const newVNode = newChildren[i]
      // Determine the container (parent element) for the patch.
      const container =
        // oldVNode may be an errored async setup() component inside Suspense
        // which will not have a mounted element
        oldVNode.el &&
        // - In the case of a Fragment, we need to provide the actual parent
        // of the Fragment itself so it can move its children.
        (oldVNode.type === Fragment ||
          // - In the case of different nodes, there is going to be a replacement
          // which also requires the correct parent container
          !isSameVNodeType(oldVNode, newVNode) ||
          // - In the case of a component, it could contain anything.
          oldVNode.shapeFlag & (ShapeFlags.COMPONENT | ShapeFlags.TELEPORT))
          ? hostParentNode(oldVNode.el)!
          : // In other cases, the parent container is not actually used so we
            // just pass the block element here to avoid a DOM parentNode call.
            fallbackContainer
      patch(
        oldVNode,
        newVNode,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        true
      )
    }
  }

该函数的逻辑很简单,对新旧虚拟Node的dynamicChildren属性所代表的虚拟Node数组进行遍历,并调用patch函数进行更新操作。

我们从代码片段5中可以发现,如果属性dynamicChildren有值,则不会执行patchChildren函数进行比较新旧虚拟Node的差异并进行更新。为什么可以直接比较虚拟Node的dynamicChildren属性对应的数组元素,就可以完成更新呢?

我们知道dynamicChildren中存放的是所有的代表动态节点的虚拟Node,而且从代码片段4中不难看出dynamicChildren记录的动态节点不仅包括自己所属层级的动态节点,也包括子级的动态节点,也就是说根节点内部所有的动态节点都会收集在dynamicChildren中。由于新旧虚拟Node的根节点下都有dynamicChildren属性,都保存了所有的动态元素对应的值,也就是说动态节点的顺序是一一对应的,所以代码片段6中不再需要深度递归去寻找节点间的差异,而是简单的线性遍历并执行patch函数就完成了节点的更新。

这种机制这么优秀,是如何给属性dynamicChildren赋值的呢?

还记得代码片段2中,让我们倍感疑惑的函数_openBlock_createElementBlock吗。我们来探索这两个函数的内部实现:

代码语言:javascript复制
// 代码片段7
export function openBlock(disableTracking = false) {
  blockStack.push((currentBlock = disableTracking ? null : []))
}

代码片段7中不难发现,所谓的openBlock函数,逻辑非常简单,给数组blockStack添加一个或为null或为[]的元素。

代码语言:javascript复制
// 代码片段8
export function createElementBlock(
  type: string | typeof Fragment,
  props?: Record<string, any> | null,
  children?: any,
  patchFlag?: number,
  dynamicProps?: string[],
  shapeFlag?: number
) {
  return setupBlock(
    createBaseVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      shapeFlag,
      true /* isBlock */
    )
  )
}

function setupBlock(vnode: VNode) {
  // save current block children on the block vnode
  vnode.dynamicChildren =
    isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR as any) : null
  // close block
  closeBlock()
  // a block is always going to be patched, so track it as a child of its
  // parent block
  if (isBlockTreeEnabled > 0 && currentBlock) {
    currentBlock.push(vnode)
  }
  return vnode
}

代码片段8中调用了一个函数createBaseVNode,该函数功能是创建虚拟Node对象,这才是createElementBlock的核心工作,那这里的函数setupBlock发挥了什么作用呢?可以概括为下面3个作用:

  1. 虚拟Node创建完成后,给该虚拟Node的属性dynamicChildren赋值,赋的值为currentBlock,我们知道,currentBlock是在调用openBlock函数的时候初始化的一个数组。
  2. 调用closeBlock的作用就是将调用openBlock时候初始化的数组对象currentBlock移除,并将currentBlock赋值为blockStack的最后一个元素。该函数内容如下:
代码语言:javascript复制
// 代码片段9
export function closeBlock() {
  blockStack.pop()
  currentBlock = blockStack[blockStack.length - 1] || null
}
  1. 执行语句currentBlock.push(vnode),将当前创建的节点自身添加到上一级(因为closeBlock的时候已经pop出刚刚创建完成的虚拟Node所在的currentBlockcurrentBock中。

描述了上面3点,可能大家觉得有些疑惑,上面的描述和代码虽然很一致,但是究竟发挥了什么作用呢?我们先将源码实现进行精简,在下文讨论Block Tree的时候再回过头看代码片段7到代码片段9的代码:

代码语言:javascript复制
// 代码片段10
export function createElementBlock(
  type: string | typeof Fragment,
  props?: Record<string, any> | null,
  children?: any,
  patchFlag?: number,
  dynamicProps?: string[],
  shapeFlag?: number
) {
  return setupBlock(
    createBaseVNode(/*此处省略若干参数*/)
  )
}

function createBaseVNode(/* ...*/) {
  const vnode = { /* ...*/} as VNode
  if (/*如果是动态元素*/) {
    currentBlock.push(vnode)
  }
  return vnode
}

function setupBlock(vnode: VNode) {
  vnode.dynamicChildren = currentBlock
  return vnode
}

将代码精简到极致,其实就是如果是动态节点,就添加到currentBlock中,并且在创建完毕虚拟Node后,就将currentBlock赋值给创建好的虚拟Node的dynamicChildren属性。注意,通过createElementBlock创建的虚拟节点才会为虚拟Node添加dynamicChildren属性值。

Block存在的问题

上面我们知道了,dynamicChildren的赋值的过程,确实可以让我们更新DOM元素的效率提高,但遗憾的是,这里面存在一些问题。问题的关键是,当DOM结构不稳定的时候,我们无法通过代码片段6中的方式来更新元素。因为要想能通过遍历数组的方式去调用patch函数对元素进行更新的前提条件是新旧虚拟Node的dynamicChildren的元素是一一对应的,因为只有新旧虚拟Node是同一个元素进行调用patch依次更新才有意义。但是如果新旧虚拟Node的dynamicChildren元素不能一一对应,那就无法通过这种方式来更新。

然而在我们的程序中包含了大量的v-ifv-elsev-else-ifv-for等可能改变DOM树结构的指令。比如下面的模版:

代码语言:javascript复制
<!--代码片段11-->
<div>
    <div v-if="flag">
        <div>{{name}}</div>
        <div>{{age}}</div>
    </div>
    <div v-else>
        <div>{{city}}</div>
    </div>
    <div v-for="item in arr">{{item}}</div>
</div>

代码片段11中,当flag的值不同的时候,收集的动态节点个数是不相同的,同时,不同虚拟Node对应的真实DOM也是不同的,当我们通过代码片段6的方式,直接进行遍历更新是无法生效的。

举个例子,flagtrue的时候,动态节点中包含{{name}}所在的div{{age}}所在的div,而当条件发生改变后,新的虚拟Node收集的动态节点是{{city}}所在的div,当进行遍历比较的时候,会用{{city}}所在div对应的虚拟Node去和{{name}}所在的div所在的虚拟Node进行比较和更新。但是{{name}}所在div的虚拟Node的el属性是节点<div>{{name}}</div>,然而该节点已经因为条件变化而消失。所以即使对该节点进行更新,浏览器页面也不会发生任何变化。

Block Tree

为了解决只使用Block来提升更新性能的时候所产生的问题,Block Tree产生了。所谓的Block Tree,其实就是把那些DOM结构可能发生改变的地方也作为一个动态节点进行收集。其实代码片段6到代码片段9之所以维护一个全局的栈结构,就是为了配合Block Tree这种机制的正常运转。我们来看一个具体例子:

代码语言:javascript复制
<!--代码片段12-->
<div>
  <div>
    {{name}}
  </div>
  <div v-for="(item,index) in arr" :key="index">{{item}}</div>
</div>

转化成render函数:

代码语言:javascript复制
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("div", null, _toDisplayString(_ctx.name), 1 /* TEXT */),
    (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.arr, (item, index) => {
      return (_openBlock(), _createElementBlock("div", { key: index }, _toDisplayString(item), 1 /* TEXT */))
    }), 128 /* KEYED_FRAGMENT */))
  ]))
}

我们来看看该render函数的返回值,为了方便阅读做了大量精简,关键信息如下:

代码语言:javascript复制
// 代码片段13
{
    "type": "div",
    "children": [
        {
            "type": "div",
            "children": "yangyitao",
            "staticCount": 0,
            "shapeFlag": 9,
            "patchFlag": 1,
            "dynamicChildren": null
        },
        {
            "children": [
                {
                    "type": "div",
                    "key": 0,
                    "children": "10",
                    "patchFlag": 1,
                    "dynamicChildren": []
                },
                {
                    "type": "div",
                    "key": 1,
                    "children": "100",
                    "patchFlag": 1,
                    "dynamicChildren": []
                },
                {
                    "type": "div",
                    "key": 2,
                    "children": "1000",
                    "patchFlag": 1,
                    "dynamicChildren": []
                }
            ],
            "patchFlag": 128,
            "dynamicChildren": []
        }
    ],
    "patchFlag": 0,
    "dynamicChildren": [
        {
            "type": "div",
            "children": "yangyitao",
            "patchFlag": 1,
            "dynamicChildren": null
        },
        {
            "children": [
                {
                    "type": "div",
                    "key": 0,
                    "children": "10",
                    "patchFlag": 1,
                    "dynamicChildren": []
                },
                {
                    "type": "div",
                    "key": 1,
                    "children": "100",
                    "patchFlag": 1,
                    "dynamicChildren": []
                },
                {
                    "type": "div",
                    "key": 2,
                    "children": "1000",
                    "patchFlag": 1,
                    "dynamicChildren": []
                }
            ],
            "patchFlag": 128,
            "dynamicChildren": []
        }
    ]
}

我们可以看见根节点下有dynamicChildren属性值,该属性对应的数组有两个元素,一个对应{{name}}所在的div;一个对应for循环的外层节点,该节点的dynamicChildren为空元素,这是因为无法保证里面的元素数量上的一致,无法进行通过循环遍历,新旧虚拟Node一一对应进行更新,因此只能正常比较children下的元素。对于v-ifv-else等情况和for循环有相似之处,大家可以多调试,深入理解相关知识。

0 人点赞