前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Vue3源码11: 编译优化之Block Tree 与 PatchFlags

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

作者头像
杨艺韬
发布2022-09-27 14:28:22
1.2K0
发布2022-09-27 14:28:22
举报
文章被收录于专栏:前端框架源码剖析

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循环有相似之处,大家可以多调试,深入理解相关知识。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-04-02,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 杨艺韬 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 编译结果
  • render函数概述
  • PatchFlags
  • Block
  • Block存在的问题
  • Block Tree
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档