Before
前面我们介绍了vue的编译器,
在编译过程中,除了主线任务将模板编译为渲染函数,还有支线任务,就是尽可能地多提取些关键信息,并以此直到生成最优代码,这就是编译优化。
编译优化在不同框架之间的设计思路不尽相同,但优化的方向基本一致,就是尽可能地区分动态内容和静态内容,并根据不同的内容采取不同的优化策略。
传统Diff
在介绍渲染器的时候,我们了解了更新DOM的时候会用到Diff算法,它在对比新旧两颗虚拟DOM树时,总 是按照其层级结构一层一层进行遍历。
然而很多情况下,其实并不需要遍历整颗树,如果能够直接找到变化的节点进行更新,就能大幅提升性能。比如下面这段模板:
<div>
<p class="bar">{{ text }}</p>
</div>
这段模板中唯一可能发生变化的就是p标签的文本子节点,那么更新时,最高效的方式就是直接设置p标签的文本内容。 然而如果使用Diff算法,就会产生一颗新的虚拟DOM树,从div开始,一层一层对比新旧节点以及其属性,就做不到这么高效。 那么如何跳过那些无意义的操作直接对变化的DOM进行更新呢?那就得通过编译手段进行关键信息的分析,并将其存储到生成的虚拟DOM上传给渲染器。 渲染器通过这些信息执行“快捷路径”,就能提升运行时的性能。
补丁标志
进行优化最关键的就是让渲染器能够区分动态内容和静态内容,以如下模板为例:
<div>
<div>foo</div>
<p>{{ bar }}</p>
</div>
上面这段代码只有{{ bar }} 是动态的内容,因此更新时只需要更新p标签的文本节点即可。所以我们需要将动态节点的信息附加到虚拟DOM上。
优化前这段模板生成的虚拟DOM是这样的:
const vnode = {
type: 'div',
children: [
{ type: 'div', children: 'foo' },
{ type: 'p', children: ctx.bar }
]
}
经过编译优化,我们给p标签打上动态节点的标识:
const vnode = {
type: 'div',
children: [
{ type: 'div', children: 'foo' },
{ type: 'p', children: ctx.bar, patchFlag: 1 } // 动态节点
]
}
我们添加一个patchFlag属性,它就是我们的补丁标志,它是一系列的数字标记,不同数字有不同的含义:
- 1:节点有动态的contentText
- 2:元素有动态的class绑定
- 3:元素有动态的style绑定
- 4:其他...
动态节点
有了这项信息,我们就可以在虚拟ODM的创建阶段,把它的动态子节点提取出来,并将其存储到对应虚拟DOM节点的dynamicChildren数组内
const PatchFlags = {
TEXT: 1,
CLASS: 2,
STYLE: 3,
// others
}
const vnode = {
type: 'div',
children: [
{ type: 'div', children: 'foo' },
{ type: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT } // 动态节点
],
dynamicChildren: [
{ type: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT } // 动态节点
]
}
这段vnode比普通虚拟DOM多出了一个dynamicChildren属性,我们把有该属性的虚拟DOM称为“Block”。
一个Block不仅能够收集它的直接动态子结点,还能收集所有动态子代节点,比如:
<div>
<div>
<p>{{ bar }}</p>
</div>
</div>
这段代码对应的Block如下:
const vnode = {
type: 'div',
children: [
{ type: 'div', children: [
{ type: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT } // 动态节点
] },
],
dynamicChildren: [
{ type: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT } // 动态节点
]
}
有了Block之后,渲染器的更新操作会以Block为维度,忽略虚拟DOM的children数组,而是直接去找它的dynamicChildren数组,
并且只更新该数组中的动态节点。同时,因为动态节点中存在补丁标志,所以更新时能够做到靶向更新。
那么什么时候需要将一个普通的虚拟DOM变成Block节点呢?答案是所有模板的根节点,当然其实还有v-for、v-if等指令的节点,我们后续讨论。
<template>
<div>
<p>{{ bar }}</p>
</div>
<h1>
<span :id="dynamicId"></span>
</h1>
</template>
上面这段模板就会生成两个Block,div标签和h1标签。
收集动态节点
编译器生成的渲染函数代码中,并不会直接包含用来描述虚拟DOM的数据结构,而是包含用来创建虚拟DOM的辅助函数:
<div id="foo">
<p class="bar">{{ text }}</p>
</div>
这段模板经过编译优化后生成的渲染函数如下:
render() {
return createVNode('div', {id: foo}, [
createVNode('p', {class: 'bar'}, 'text', PatchFlags.TEXT)
])
}
createVNode就是用来创建虚拟DOM的辅助函数,它的返回值是一个虚拟DOM节点,在其内部通常会对props和children做一些额外的处理工作。
它接收的第四个参数就是补丁标志,代表当前虚拟DOM节点是一个动态节点。
function createVNode(tag, props, children, flags) {
const key = props || props.key
props && delete props.key
return {
tag,
props,
children,
key,
patchFlags: flags
}
}
那么我们如何将根节点变成一个Block,以及如何将动态子代节点收街道该Block的dynamicChildren数组中呢?
首先我们要清楚,在渲染函数内部,对createVNode函数的调用是层层嵌套的结构,该函数的执行顺序是先内层后外层。
所以当外层createVNode执行时,内容的createVNode函数已经执行完毕了。因此,外层Block要收集到内容动态节点,就需要一个栈结构的数据来临时存储内层的动态节点。
const dynamicChildrenStack = []
let currentDynamicChildren = null
function openBlock() {
dynamicChildrenStack.push(currentDynamicChildren = [])
}
function closeBlock() {
dynamicChildrenStack.pop()
currentDynamicChildren = dynamicChildrenStack[dynamicChildrenStack.length - 1] || null
}
每次openBlock,都会创建一个新的currentDynamicChildren数组用来存储动态节点。
调整createVNode函数:
function createVNode(tag, props, children, flags) {
const key = props || props.key
props && delete props.key
const vnode = {
tag,
props,
children,
key,
patchFlags: flags
}
if (typeof flags !== 'undefined' && currentDynamicChildren) {
currentDynamicChildren.push(vnode)
}
return vnode
}
当节点带有补丁标志,就将其收集到currentDynamicChildren数组当中。
最后我们需要重新设计渲染函数的执行:
function createBlock(tag, props, children) {
const block = createVNode(tag, props, children)
block.dynamicChildren = currentDynamicChildren
closeBlock()
return block
}
function render() {
return (
openBlock(), createBlock('div', {id: 'foo'}, [
createVNode('p', {class: 'bar'}, 'text', PatchFlags.TEXT)
])
)
}
使用createBlock来创建Block,当然它本质还是返回虚拟节点,只是增加了dynamicChildren属性。
由于执行顺序是由内向外,所以执行createBlock时,内容部的createVNode都已经执行完毕,动态子代节点也都已经收集到currentDynamicChildren数组中了,
在createBlock函数内部,只需要将currentDynamicChildren数组赋值给dynamicChildren属性就完成了动态节点的收集。
渲染器的运行时支持
目前生成的虚拟DOM中有了补丁标志和动态节点,接下来就是在渲染器中进行靶向更新了。
传统的更新方式就是遍历属性进行更新,然后使用Diff算法对子节点进行新旧对比然后更新。
有了dynamicChildren之后,我们就可以直接对比动态节点,然后根据patchFlag,完成针对性的更新。
具体代码见dynamic.js。
Block树
上面提到了除了根节点会block化,v-if和v-for指令的节点都应该作为Block,我们来详细看一看。
v-if指令
以下面这段模板为例:
<div>
<section v-if="foo">
<p>{{ a }}</p>
</section>
<div v-else>
<p>{{ a }}</p>
</div>
</div>
当只有最外层的div作为Block时,无论foo的值是true还是false,收集到的动态节点都是
{ tag: 'p', children: ctx.a, patchFlags: 1 }
这就意味着在Diff阶段不会做任何的更新,然而foo值由true变为false,div标签下的节点会从section变为div,不做更新必然会导致bug。
这个问题的根本原因在于dynamicChildren收集的动态节点是忽略DOM层级的,如果更新前后模板的层级结构发生变化,就会导致更新问题。
而v-if这种结构化指令就会导致模板结构不稳定。
解决方案也很简单,就是让v-if/v-else/v-else-if等结构化指令的节点也作为Block。
如上面的模板,将第二层的section标签以及div标签页作为Block,这样就构成了一颗Block树。
Block(div)
-Block(section v-if)
-Block(div v-else)
父级Block除了收集动态子代节点之外,还会收集子Block,代码如下:
const blockIf = {
type: 'div',
dynamicChildren: [
{type: 'section', key: 0, dynamicChildren: [{ tag: 'p', children: ctx.a, patchFlags: 1 }]}
]
}
const blockElse = {
type: 'div',
dynamicChildren: [
{type: 'div', key: 1, dynamicChildren: [{ tag: 'p', children: ctx.a, patchFlags: 1 }]}
]
}
当v-if为true时,父级Block的dynamicChildren数组中包含的是section的Block,为false时,包含的是div的Block,两个Block的key不同, 这样更新时,渲染器就能够根据key的不同,使用新的Block替代旧的Block。
v-for指令
同样的v-for指令也会使得虚拟DOM树的结构不稳定,而且情况更加复杂一些。以如下模板为例:
<div>
<p v-for="item in list">{{ item }}</p>
</div>
假设list是一个数组,当list的值由[1,2]变为[1],其更新前后的Block树如下
const prevBlock = {
type: 'div',
dynamicChildren: [
{type: 'p', children: 1, patchFlags: PatchFlags.TEXT},
{type: 'p', children: 2, patchFlags: PatchFlags.TEXT}
]
}
const nextBlock = {
type: 'div',
dynamicChildren: [
{type: 'p', children: 1, patchFlags: PatchFlags.TEXT}
]
}
显而易见的,更新前后dynamicChildren数组中的节点数量改变了,这就无法进行Diff操作,要使用传统的Diff也是不行的, 因为dynamicChildren中的节点未必是同层级的节点,无法进行比较。解决方案就是保证更新前后的DOM树结构稳定,方法就是让带有v-for指令的标签页作为Block。
const prevBlock = {
type: 'div',
dynamicChildren: [
{type: Fragment, dynamicChildren: [
{type: 'p', children: 1, patchFlags: PatchFlags.TEXT},
{type: 'p', children: 2, patchFlags: PatchFlags.TEXT}
]}
]
}
const nextBlock = {
type: 'div',
dynamicChildren: [
{type: Fragment, dynamicChildren: [
{type: 'p', children: 1, patchFlags: PatchFlags.TEXT},
]}
]
}
使用Fragment节点来表达v-for指令恶渲染结果,并作为Block。
当然因为Fragment收集的动态节点仍旧是结构不稳定的,所以也只能根据Fragment的children来进行Diff,而不能使用dynamicChildren进行靶向更新。
静态提升
静态提升能够减少更新时创建虚拟DOM带来的性能开销和内存占用。以如下模板为例:
<div>
<p>static text</p>
<p>{{ title }}</p>
</div>
在没有静态提升的情况下,它对应的渲染函数是:
function render() {
return (
openBlock(),
createBlock('div', null, [
createVNode('p', null, 'static text'),
createVNode('p', null, ctx.title, PatchFlags.TEXT)
])
)
}
在这段虚拟DOM中,存在一个p标签是完全静态的,而现在每次更新时创建虚拟DOM,都需要执行一次createVNode('p', null, 'static text'), 为了避免这种性能开销,我们可以将这段代码提升到渲染函数之外:
const hoist1 = createVNode('p', null, 'static text')
function render() {
return (
openBlock(),
createBlock('div', null, [
hoist1,
createVNode('p', null, ctx.title, PatchFlags.TEXT)
])
)
}
这样渲染函数重新执行时,就不会二次创建静态的虚拟节点了。
需要注意的是,静态提升是以树为单位的,如下模板:
<div>
<section>
<p>text</p>
</section>
</div>
除了div会作为Block,其下的section元素及其子节点都会被提升。
另外,虽然动态节点本身不会被提升,但是如果动态节点上存在纯静态的属性,如下:
<div>
<p foo="bar" a="b">{{ text }}</p>
</div>
那么纯静态的props会被提升到渲染函数之外:
const hoistProp = { foo: 'bar', a: 'b'}
function render() {
return (
openBlock(),
createBlock('div', null, [
createVNode('p', hoistProp, ctx.text, PatchFlags.TEXT)
])
)
}
这也是同样能够减少创建虚拟DOM产生的开销以及内存占用。