Vue3的编译优化:Block树和PatchFlags
注:本文只是介绍实现原理,并非常规源码解读
众所周知,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