vue的编译优化

2024-11-13
vue

Before

前面我们介绍了vue的编译器, 在编译过程中,除了主线任务将模板编译为渲染函数,还有支线任务,就是尽可能地多提取些关键信息,并以此直到生成最优代码,这就是编译优化。
编译优化在不同框架之间的设计思路不尽相同,但优化的方向基本一致,就是尽可能地区分动态内容和静态内容,并根据不同的内容采取不同的优化策略。

传统Diff

在介绍渲染器的时候,我们了解了更新DOM的时候会用到Diff算法,它在对比新旧两颗虚拟DOM树时,总是按照其层级结构一层一层进行遍历。
然而很多情况下,其实并不需要遍历整颗树,如果能够直接找到变化的节点进行更新,就能大幅提升性能。比如下面这段模板:

Copy
<div>
    <p class="bar">{{ text }}</p>
</div>
全屏

这段模板中唯一可能发生变化的就是p标签的文本子节点,那么更新时,最高效的方式就是直接设置p标签的文本内容。 然而如果使用Diff算法,就会产生一颗新的虚拟DOM树,从div开始,一层一层对比新旧节点以及其属性,就做不到这么高效。 那么如何跳过那些无意义的操作直接对变化的DOM进行更新呢?那就得通过编译手段进行关键信息的分析,并将其存储到生成的虚拟DOM上传给渲染器。 渲染器通过这些信息执行“快捷路径”,就能提升运行时的性能。

补丁标志

进行优化最关键的就是让渲染器能够区分动态内容和静态内容,以如下模板为例:

Copy
<div>
    <div>foo</div>
    <p>{{ bar }}</p>
</div>
全屏

上面这段代码只有{{ bar }} 是动态的内容,因此更新时只需要更新p标签的文本节点即可。所以我们需要将动态节点的信息附加到虚拟DOM上。
优化前这段模板生成的虚拟DOM是这样的:

Copy
const vnode = {
    type: 'div',
    children: [
        { type: 'div', children: 'foo' },
        { type: 'p', children: ctx.bar }
    ]
}
全屏

经过编译优化,我们给p标签打上动态节点的标识:

Copy
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数组内

Copy
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不仅能够收集它的直接动态子结点,还能收集所有动态子代节点,比如:

Copy
<div>
    <div>
        <p>{{ bar }}</p>
    </div>
</div>
全屏

这段代码对应的Block如下:

Copy
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等指令的节点,我们后续讨论。

Copy
<template>
    <div>
        <p>{{ bar }}</p>
    </div>
    <h1>
        <span :id="dynamicId"></span>
    </h1>
</template>
全屏

上面这段模板就会生成两个Block,div标签和h1标签。

收集动态节点

编译器生成的渲染函数代码中,并不会直接包含用来描述虚拟DOM的数据结构,而是包含用来创建虚拟DOM的辅助函数:

Copy
<div id="foo">
    <p class="bar">{{ text }}</p>
</div>
全屏

这段模板经过编译优化后生成的渲染函数如下:

Copy
render() {
    return createVNode('div', {id: foo}, [
        createVNode('p', {class: 'bar'}, 'text', PatchFlags.TEXT)
    ])
}
全屏

createVNode就是用来创建虚拟DOM的辅助函数,它的返回值是一个虚拟DOM节点,在其内部通常会对props和children做一些额外的处理工作。
它接收的第四个参数就是补丁标志,代表当前虚拟DOM节点是一个动态节点。

Copy
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要收集到内容动态节点,就需要一个栈结构的数据来临时存储内层的动态节点。

Copy
const dynamicChildrenStack = []
let currentDynamicChildren = null

function openBlock() {
    dynamicChildrenStack.push(currentDynamicChildren = [])
}

function closeBlock() {
    dynamicChildrenStack.pop()
    currentDynamicChildren = dynamicChildrenStack[dynamicChildrenStack.length - 1] || null
}
全屏

每次openBlock,都会创建一个新的currentDynamicChildren数组用来存储动态节点。
调整createVNode函数:

Copy
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数组当中。
最后我们需要重新设计渲染函数的执行:

Copy
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指令

以下面这段模板为例:

Copy
<div>
    <section v-if="foo">
        <p>{{ a }}</p>
    </section>
    <div v-else>
        <p>{{ a }}</p>
    </div>
</div>
全屏

当只有最外层的div作为Block时,无论foo的值是true还是false,收集到的动态节点都是

Copy
{ 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树。

Copy
Block(div)
    -Block(section v-if)
    -Block(div v-else)
全屏

父级Block除了收集动态子代节点之外,还会收集子Block,代码如下:

Copy
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树的结构不稳定,而且情况更加复杂一些。以如下模板为例:

Copy
<div>
    <p v-for="item in list">{{ item }}</p>
</div>
全屏

假设list是一个数组,当list的值由[1,2]变为[1],其更新前后的Block树如下

Copy
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。

Copy
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带来的性能开销和内存占用。以如下模板为例:

Copy
<div>
    <p>static text</p>
    <p>{{ title }}</p>
</div>
全屏

在没有静态提升的情况下,它对应的渲染函数是:

Copy
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'), 为了避免这种性能开销,我们可以将这段代码提升到渲染函数之外:

Copy
const hoist1 = createVNode('p', null, 'static text')
function render() {
    return (
        openBlock(),
        createBlock('div', null, [
            hoist1,
            createVNode('p', null, ctx.title, PatchFlags.TEXT)
        ])
    )
}
全屏

这样渲染函数重新执行时,就不会二次创建静态的虚拟节点了。
需要注意的是,静态提升是以树为单位的,如下模板:

Copy
<div>
    <section>
        <p>text</p>
    </section>
</div>
全屏

除了div会作为Block,其下的section元素及其子节点都会被提升。
另外,虽然动态节点本身不会被提升,但是如果动态节点上存在纯静态的属性,如下:

Copy
<div>
    <p foo="bar" a="b">{{ text }}</p>
</div>
全屏

那么纯静态的props会被提升到渲染函数之外:

Copy
const hoistProp = { foo: 'bar', a: 'b'}
function render() {
    return (
        openBlock(),
        createBlock('div', null, [
            createVNode('p', hoistProp, ctx.text, PatchFlags.TEXT)
        ])
    )
}
全屏

这也是同样能够减少创建虚拟DOM产生的开销以及内存占用。

<<上一篇vue的解析器
到底啦~~下一篇>>