likes
comments
collection
share

Vue3的编译优化:Block树和PatchFlags

作者站长头像
站长
· 阅读数 86

注:本文只是介绍实现原理,并非常规源码解读

众所周知,Vue中的tamplate会被编译为渲染函数,渲染函数执行生成DOM子树,然后根据组件实例的状态执行mount或patch,如果patch,diff算法会对比两颗子树并进行更新。

观察这段模板:

<div>
  <div>HelloVue!</div>
  <p>{{ text }}</p>
</div>

这段模板对应的虚拟DOM如下:

{
  tag:'div',
  children:[
  {tag:'div',children:'HelloVue'},
  {tag:'p,children: _context.text}
  ]
}

传统diff算法对比DOM树更新流程:

1.对比外层div,它的props,和它的子节点,
2.对比内层div,它的props,和它的文本子节点
3.对比内层p,它的props,和它的文本子节点,

可以看到diff算法做了很多没啥意义的对比,这段代码其实只有text绑定了渲染上下文中的内容,其余内容都是静态内容,所以虚拟DOM只需要对比p标签的文本节点就可以完成内容更新,从而提升性能。但是从上面的虚拟DOM可以看到,运行时其实没办法知道哪个文本节点是动态节点,children数组只是两个普通的vnode

Blocks和patchFags

还是相同的一段模板:

<div>
  <div>HelloVue!</div>
  <p>{{ text }}</p>
</div>

我们很容易就能看出来只有p标签含有动态内容,Vue3的模板经过编译后,可以把从模板中提取到这个信息附着到对应的vnode上,可以看到编译后的vnode中p标签多了一个patchFlag属性。

{
  tag:'div',
  children:[
  {tag:'div',children:'HelloVue'},
  //patchFlag等于1说明这个节点的textContent是动态内容
  {tag:'p,children: _context.text,patchFlag: 1 } 
  ]
}

patchFlag属性可以让运行时知道这个vnode是一个含有动态内容的vnode(后面简称动态vnode),而且根据这个属性的值还可以精准的判断标签的动态部分到底在哪。所以可以在创建VNode的时候,把Vnode子节点中的被patchFlag标记的动态vnode提取出来(这个提取过程后面会讲到),保存在它自己的dynamicChildren数组内。我们把含有这个dynamicChildren数组的VNode就叫做一个block,block不只会提取到children的动态VNode,还可以提取到所有子代动态VNode。

当然vnode也并不是凭空产生的,而是由render函数产生,但是render函数不会直接返回vnode,要通过辅助函数createVNode()

createVNode的实现大概如下:

 function createVNode(tag,props,children,patchFlags) {
 //返回的VNode应该还有一个key来判断是否是相同节点,暂且不表,以后的文章会介绍
  const VNode = {
     tag,
     props,
     children,
     patchFlags //补丁标志
  }
 } 

由于Block会收集动态vnode,所以我们把createVNode函数和收集动态子代vnode的部分封装为一个createBlock函数,一个block不止会收集它的所有子代动态vnode,还会收集它的子block(关于这一点我们一会儿会说明)。由于createVNode和createBlock都是嵌套执行(嵌套调用的执行从内层执行到到外层),最内层的函数执行上下文最先弹出最先执行完毕。

但是有时候block不止是有动态VNode,还会存在block嵌套的情况,这意味着内层的block同样会收集它们的子代的动态节点,同时外层block会和收集内层动态vnode一样收集这些内层block。

为了同时实现上面提到的block嵌套和当前动态vnode收集 ,vue设计一个类似于栈的数据结构来让不同层级的block收集含有当前动态内容的VNode,大概如下:

//首先定义一个补丁标志的对象
const PatchFlags = {
  TEXT: 1, //代表动态TextContent
  CLASS: 2, //代表动态class绑定
  STYLE: 3, //同上
  //其他...
};

//定义一个数组模拟栈结构,保存currentDynamicChildren
const dynamicChildrenStack = []
//定义另外一个变量指向currentDynamicChildren
let currentDynamicChildren = null

function openBlock() {
  //每次block执行之前,给在模拟的栈中压入一个空数组,currentDynamicChildren指向这个空数组代表当前动态VNode
 dynamicChildrenStack.push((currentDynamicChildren = []))
}

function closeBlock() {
//当收集完currentDynamicChildren之后closeblock,弹出当前的currentDynamicChildren,并让currentDynamicChildren指向父级currentDynamicChildren,实现栈结构的后进先出
  dynamicChildrenStack.pop();
  currentDynamicChildren = dynamicChildrenStack[dynamicChildrenStack.length-1]
}
//render函数,用来生成虚拟dom树
render() {
//每次createBlock()之前先openBlock(),在模拟栈中压入一个空数组用来保存当前block应该收集到的动态vnode
 openblock();
 //嵌套执行
 return createBlock('div',[
  createVNode('div', 'HelloVue'),
  createVNode('p', null, text, PatchFlags.TEXT) //补丁标志
 ]
}
 //createVNode实现,同上
 function createVNode(tag,props,children,patchFlags) {
  const VNode = {
     tag,
     props,
     children,
     patchFlags //补丁标志
  }
  //如果有patchFlags,说明是动态VNode,同时currentDynamicChildren也存在,那么就把当前刚创建好的这个VNode推入数组
  if(typeof patchFlags !== 'undefined' && currentDynamicChildren){
    currentDynamicChildren.push(VNode)
  }
 } 
 
  function createBlock(tag, props, children,patchFlags){
  //block也是一个节点,也可以有patchFlags,但是block无论有没有patchFlag都会被父级block收集到dynamiChildren中
  const block = createVNode(tag, props, children,patchFlags)
  
  //因为嵌套执行总是从内到外执行,所以block创建好之后currentDynamicChildren已经收集完毕(可以理解为函数执行要先计算参数的值,也就是children)
  //因为全局变量currentDynamicChildren指向了当前openblock对应的数组,所以直接把这个数组放到block即可
  block.dynamicChildren = currentDynamicChildren
  
  //弹出当前currentDynamicChildren,指针移到父currentDynamicChildren
  closeBlock();
  
  //如果父级存在currentDynamicChildren数组,那么让父级dynamicChildren收集当前block
  if(currentDynamicChildren){
   currentDynamicChildren.push(block)
  }

可以看到vue使用数组实现了栈结构用来保存当前动态vnode,openBlock和closeBlock分别模拟了栈的push和pop。创建block之前先调用openBlock,往栈中压入一个空数组,用来保存稍后创建的动态vnode。之后创建block,从内到外执行,currentDynamicChildren中会逐渐收集动态vnode,block创建完之后把currentDynamicChildren放到当前block的dynamicChildren属性,就完成了block的动态节点收集。

回顾一下之前我们更新vnode的方式,vue会使用patchElement函数,遍历新旧vnode的props,更新共有的props,挂载新props,卸载新vnode不存在的旧props。然后处理两个vnode的子节点,也就是patchChildren(),如果两个vnode子节点都是一组子节点,那么就会使用diff算法递归的patch子元素。

现在有了优化过的虚拟DOM,我们可以使用更简便的方式实现patchElement和patchChildren。

对于block的更新,因为我们知道静态内容不会变,所以只需要线性遍历更新我们收集到的动态vnode

对于patchElement的更新,因为我们的虚拟vnode上已经有了patchFlags,所以只要判断一下patchFlags的值就可以只更新对应的动态内容(靶向更新),这样就不需要遍历props了

function patchElement(oldVNode, newVNode) {
  //省略部分代码

  //处理节点的props
  //简单模拟patchFlages进行靶向更新,实际上源码中的patchFlags更复杂
   if(newVNode.patchFlags){
    //有patchFlags,靶向更新
    if(patchFlags == 1){
    //只更新文本节点
    }else if(patchFlags == 2){
    //只更新class绑定
    }else(patchFlags == 3){
    //...
    }
   }else{
   //进行全量更新,遍历props
   }

   //处理子节点
   if(newVNode.dynamicChildren){
    //有dynamicChildren,说明oldVNode, newVNode是block,可以进行靶向更新
     patchBlockChildren(oldVNode,newVNode)
   }else{
     //传统diff算法更新
     patchChildren(oldVNode,newVNode)
   }

   //省略部分代码
  }
  
  function patchBlockChildren(oldVNode,newVNode) {
    //收集了所有的子动态vnode,遍历所有它们并更新
    for (let i=0;i<newVNode.dynamicChildren.length;i++){
      patchElement(oldVNode.dynamicChildren[i],newVNode.dynamicChildren[i])
    }
  }
Block的问题

先来看一段代码:

<div>
    //被忽略
    <section v-if="foo">
        <p>{{ a }}</p>
    </section>
    //被忽略
    <div v-else>
        <p>{{ a }}</p>
    </div>
</div>

上文我们提到过,block也可以收集block,但是这些被收集的block是怎么来的呢。首先我们假设没有其余子block,只有一个根节点block收集所有的子代动态vnode, 然后按照上面的逻辑更新:foo值为true或false,两个p标签总会有一个被收集。patchElement然后在patchElement时patchBlockChildren处理子节点。此时会发现只有这个block的dynamicChildren会被更新,而代码中的div和section被忽略了

如果仔细思考就会发现,我们之前认为只有动态内容会更新,是因为假设虚拟DOM结构不变,虚拟DOM不变的大前提下,动态节点的顺序是一一对应的,直接遍历更新即可,不再需要深度递归去寻找节点间的差异,但是如果新旧虚拟DOM的结构发生变化,一来有可能子动态Vnode顺序就不再一一对应(有可能新增了新的动态Vnode,比如v-for),也就不能直接遍历更新,再者,只更新子动态vnode没办法捕捉到静态节点的变化

解决办法也很简单,vue把所有会造成虚拟DOM结构变化的部分都单独视为一个block,在这段代码中,v-if指令和v-else指令会造成虚拟DOM结构变化,因此我们把它们视为两个Block,这两个节点显然只会二选一,所以父block的顺序和结构就变得稳定了。因为block会被父block收集,所以section和div会被收集到dynamicChildren数组。一个block同时也是一个节点,所以当patch时div和section就会被识别并更新。这样通过把虚拟DOM结构会改变的区域视为一个block,就解决了虚拟DOM结构变化的问题

const block = {
    tag:'div',
    children: [
      //...
    ],
    dynamicChildren:[
      //根据v-if和v-else变化,Block(section) or Block(div)
      //子block会收集自己的子动态VNode
      {
        tag:'section',
        children: [
          //...
        ],
        dynamicChildren:[
          {tag: 'p', children:_context.text, PatchFlags.TEXT}
        ]

      }
    ]
  }

再来看看前文提到的v-for,同样的我们也把它视为一个block:

<div>
    <!-- 视为block(v-for) -->
    <div v-for="item in list">{{ item }}</div>
</div>
const block = {
    tag:'div',
    children:[
      //...
    ],
    dynamicChildren:[
        //block(v-for),v-for渲染的是一个片段,所以tag类型为fragment
      {
        tag:Fragment, 
        children:[
        //...
        ],
        dynamicChildren:[
          //block(v-for)的动态子节点
        ]
      }
    ]
  }

虽然这段代码的根节点dynamicChildren数组的子动态VNode是稳定的,不会变化,可以直接遍历更新(patchblockChildren),但是block(v-for)的dynamicChildren会因为list变化而变化,更新前后子动态Vnode顺序和数量没办法一一对应,也就没办法直接遍历更新,只能使用传统的diff算法更新

block(v-for):

//list = [bar]
  const prevBlock = {
    tag: 'fragment',
    dynamicChildren:[
      { tag: 'p', children: _context.bar, patchFlags: 1 }
    ]
  }
//list = [bar,foo]
  const currentBlock = {
    tag: 'fragment',
    dynamicChildren:[
      { tag: 'p', children: _context.bar, patchFlags: 1 },
      { tag: 'p', children: _context.foo, patchFlags: 1 }
    ]
  }

Vue模板编译时能够取得很多信息,针对性的也进行了很多优化,比如还有本文没提到的静态提升hoist:vue每次更新都会调用render函数取得新子树,但是静态内容createVNode的反复调用显然是没意义的,所以这部分会被提升到render函数之外创建出来,每次render函数树执行直接引用提升了的静态节点即可,不需要反复创建。

第一次写博客,比想象中更花时间,有所收获,也希望能把知识分享给更多的人

参考:Vue.js设计与实现

转载自:https://juejin.cn/post/7090526650059718686
评论
请登录