动态节点收集与补丁标志
传统diff算法的问题
在如下模板中, 最高效的操作更新方式是直接设置p标签的内容, 因为当前模板唯一可能发生变化的就是p标签的文本子节点内容. 但传统diff算法在响应式数据text发生变化时, 会产生一棵新的虚拟dom树, 然后比较新旧两棵虚拟dom树.
<div id="foo">
<p class="bar">{{ text }}</p>
</div>
传统diff算法因为没有足够的信息, 无法区分动态内容和静态内容, 总是要进行某些无用的虚拟dom树比较操作. 而模板的结构相对固定, 只要运行时能够区分动态内容和静态内容, 就可以实现极致的优化策略.
Block与PatchFlags
如下模板, 编译优化后生成的虚拟dom包含了一些额外信息.
template = `
<div>
<div>foo</div>
<p>{{ bar }}</p>
</div>`
// 带有 dynamicChildren 属性的虚拟节点就是 Block
const vnode = {
tag: 'div',
children: [
{ tag: 'div', children: 'foo' },
// patchFlag 补丁标志, 数值代表不同的含义
{ tag: 'p', children: ctx.bar, patchFlag: 1 } // 动态节点
],
// Block 可以收集所有动态子代节点
dynamicChildren: [
{ tag: 'p', children: ctx.bar, patchFlag: 1 }
]
}
当渲染器在更新一个Block时, 会跳过静态内容, 直接找到该虚拟节点的dynamicChildren数组, 并根据patchFlag值做到靶向更新. 所有模板的根节点、任何带有v-for、v-is/v-else-if/v-else等指令的节点都是一个Block.
收集动态节点
编译器在对模板进行编译优化后, 会生成带有补丁标志的渲染函数. (渲染函数内并不会直接包含用来描述虚拟节点的数据结构, 而是包含用来创建虚拟dom节点的辅助函数)
render(){
return createVnode('div', { id: 'foo' }, [
// 第四个参数 patchFlags.TEXT, 当前动态因子是 动态文本子节点 的虚拟dom节点
createVnode('p', { class: 'bar' }, text, patchFlags.TEXT)
])
}
将根节点变成一个Block, 并将动态子代节点收集到dynamicChildren数组.
const dynamicChildrenStack = []
let currentDynamicChildren = null
// 创建新的动态节点集合, 并push到动态节点栈
function openBlock(){
dynamicChildrenStack.push(currentDynamicChildren = [])
}
// 关闭时弹出
function closeBlock(){
currentDynamicChildren = dynamicChildrenStack.pop()
}
// 如果是动态节点则加入集合中
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
}
// Block是一个附加dynamicChildren属性的虚拟dom节点
function createBlock(tag, props, children){
const block = createVnode(tag, props, children)
// 将当前动态节点集合作为 dynamicChildren
block.dynamicChildren = currentDynamicChildren
closeBlock()
return block
}
// 重新设计的渲染函数
render(){
// 首先调用 openBlock 创建动态节点集合
// 然后调用 createBlock 代替 createVnode
return (openBlock(), createBlock('div', { id: 'foo' }, [
// 第四个参数 patchFlag.TEXT, 当前动态因子是 动态文本子节点 的虚拟dom节点
createVnode('p', { class: 'bar' }, text, patchFlag.TEXT)
]))
}
渲染器的运行时支持
有了动态节点集合vnode.dynamicChildren和补丁标志patchFlag后, 就可以优化更新节点的方式.
function patchElement(n1, n2){
const el = n2.el = n1.el
const oldProps = n1.props
const newProps = n2.props
// ...
// 单个动态节点存在补丁标志, 可以进行靶向更新
if(n2.patchFlags){
if(n2.patchFlags === 1) {
// 只更新class
} else if(n2.patchFlags === 2) {
// 只更新style
} else if(...) {
// ...
}
} else {
// 全量更新
for(const key in newProps){
if(newProps[key] !== oldProps[key]) {
patchProps(el, key, oldProps[key], newProps[key])
}
}
for(const key in oldProps){
if(!(key in newProps)) {
patchProps(el, key, oldProps[key], null)
}
}
}
// ...
// 如果动态子节点数组存在, 跳过静态节点
if(n2.dynamicChildren) {
patchBlockChlidren(n1, n2)
}else {
patchChlidren(n1, n2, el)
}
}
// 更新动态子节点
function patchBlockChlidren(n1, n2){
for(let i = 0; i < n2.dynamicChildren.length; i++) {
patchElement(n1.dynamicChildren[i], n2.dynamicChildren[i])
}
}
Block树
除了根结点, 带有结构化(v-if、v-for)指令的节点, 也需要作为Block. 因为结构化指令会导致更新前后模板的结构发生变化(模板结构不稳定).
<div>
<section v-if="foo">
<p>{{ a }}</p>
</section>
<div v-else>
<p>{{ a }}</p>
</div>
<p v-for="item in list">{{ item }}</p>
<i>{{ foo }}</i>
</div>
cosnt vnode = {
tag: 'div',
// ...
dynamicChildren: [
// 真时 Block(section v-if)
// 假时 Block(div v-else)
{ tag: 'section', dynamicChildren: [
{ tag: 'p', children: ctx.a, patchFlags: 1 }
]}
{ tag: Fragment, dynamicChildren: [/* v-for的节点 */] },
{ tag: 'i', children: ctx.foo, 1 }
]
}
v-for渲染的是一个片段, 需使用Fragment表达其渲染结果. 然后Fragment本身收集的动态节点数量或顺序不一致, 无法直接进行靶向更新. 只能退回到传统虚拟dom的diff手段, 直接使用children而非dynamicChildren来进行diff操作. (如果v-for指令的表达式是常量或者模板中多个根节点, 对应的Fragment也是稳定的, 可以恢复优化模式)
静态提升
将静态的节点或者节点属性提升到渲染函数之外, 这样渲染函数重新执行时, 静态的内容不会再重新创建一次.
// 静态提升的props对象
const hoistProp = { foo: 'bar', a: 'b' }
// 静态节点, 减少创建虚拟dom产生的开销及内存占用
const hoist1 = createVnode('p', null, 'text')
// 渲染函数持有静态提升的引用
function render(){
return (openBlock(), createBlock('div', hoistProp, [
hoist1,
createVnode('p', null, ctx.title, 1)
]))
}
预字符串化
静态提升的虚拟节点或虚拟节点树本身是静态的, 那么还可以将其预字符串化. 其好处有:
- 大块的静态内容可以通过
innerHTML进行设置, 在性能上具有一定优势. - 减少创建虚拟节点产生的性能开销
- 减少内存占用等.
<div>
<p></p> // ... 20个p标签
</div>
// 静态提升后的渲染函数
const hoist1 = createVnode('p', null, null, PatchFlags.HOISTED)
// ...
const hoist20 = createVnode('p', null, null, PatchFlags.HOISTED)
render(){
return (openBlock(), createBlock('div', null, [
hoist1, ... hoist20
]))
}
// 预字符串化后的渲染函数, 将静态节点序列化为字符串
const hoist = createStaticVnode('<p></p> * 20')
render(){
return (openBlock(), createBlock('div', null, [
hoist
]))
}
缓存内联事件处理函数
缓存内联事件处理函数, 可以避免不必要的更新.
<Comp @change="a + b" />
// 不缓存时, 每次重新渲染时, 都会重新创建一个props对象
// 同时对象内的onChange属性也是全新的函数. 会导致渲染器对组件进行更新
render(ctx, cache){
return h(Comp, {
onChange: ($event) => (ctx.a + ctx.b)
})
}
render(ctx, cache){
return h(Comp, {
onChange: cache[0] || (cache[0] = ($event) => ctx.a + ctx.b)
})
}
渲染函数的第二个参数cache是一个数组, 来自组件实例. 当渲染函数重新执行并创建新的虚拟dom树时, 会优先读取缓存中的事件处理函数. 这样无论执行多少次渲染函数, props对象中onChange属性的值始终不变.
v-once
被v-once标记的节点只会被更新一次, 通常用于不会发生改变的动态绑定中(绑定一个常量等). 从两个方面带来性能提升:
- 避免组件更新时重新创建虚拟dom带来的性能开销. 因为虚拟dom被缓存了, 更新时无需重建.
- 避免无用的diff开销. 因为被
v-once标记的虚拟dom树不会被父级Block节点收集
<div>
<span v-once>{{ foo }}</span>
</div>
// v-once 指令 当前虚拟节点进行缓存, 后续的更新不会重新创建
render(ctx, cache){
return (openBlock(), createBlock('div', null, [
cache[1] || (
setBlockTracking(-1), // 阻止被 Block 收集
cache[1] = h('span', null, ctx.foo, 1),
setBlockTracking(1), // 恢复
cache[1]
)
]))
}
总结
编译优化指的是通过编译的手段提取关键信息, 并以此为指导生成最优代码的过程. 核心在于区分静态节点与动态节点, 为动态节点打上补丁标志, 并使用dynamicChildren收集所有动态子节点作为Block. 从而减少不必要的diff操作.
- 静态提升, 减少更新时创建虚拟dom的性能开销及内存占用
- 预字符串化,
- 缓存内联事件处理函数, 避免造成不必要的更新
- v-once指令, 缓存全部或部分虚拟节点避免更新时创建虚拟dom的开销及无用的diff操作.