vuejs设计与实现-编译优化

150 阅读6分钟

动态节点收集与补丁标志

传统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-forv-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)
    ]))
}

预字符串化

静态提升的虚拟节点或虚拟节点树本身是静态的, 那么还可以将其预字符串化. 其好处有:

  1. 大块的静态内容可以通过innerHTML进行设置, 在性能上具有一定优势.
  2. 减少创建虚拟节点产生的性能开销
  3. 减少内存占用等.
<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操作.