共计 6379 个字符,预计需要花费 16 分钟才能阅读完成。
Vue 卷了一代又一代,可谓是越卷越快,从 Vue2 到 Vue3 更是完成了一个大的飞跃!而 Vue3 之所以这么快,很大一部分归功于它做的一系列 编译优化 策略。
我们今天就来看看它究竟修炼了什么秘籍来提升速度。
在讲解编译优化之前,我们先来盘一盘 Vue 是如何把 我们写的模板变成页面上的真实 DOM 的。
下面是一个组件对应的模板:
template>
div>
h1>我是静态文本h1>
h2>{{dynamicText}}h2>
div>
template>
模板编译
在 Vue2 时代,这段模板会被编译器编译成类似下面这样的渲染函数:
// 渲染函数
export function render(ctx) {
return (createElementVNode("div", null, [
createElementVNode("h1", null, "我是静态文本"),
// 新增一个参数 1
createElementVNode("h2", null, ctx.dynamicText, 1 /* TEXT */)
]))
}
这里的 createElementVNode
方法简单来说就是:创建并返回虚拟 DOM 的方法:
function createElementVNode(tag, props, children) {
const vnode = {
tag,
props,
children,
key: props.key
}
return vnode
}
运行时
接下来,如果我们的代码是在浏览器环境运行的话,上面的渲染函数会被交给浏览器;
这时候就是 Vue 运行时 的地盘了,上面的渲染函数会被执行最终生成如下的虚拟 DOM:
// 旧 vnode
const vnode = {
tag: 'div',
children: [
{
tag: 'h1',
children: '我是静态文本'
},
{
tag: 'h2',
children: ctx.dynamicText
}
]
}
而 Vue 的 渲染器 (renderer) 会负责调用浏览器平台相关的 API,把 虚拟 DOM 渲染成真实的 DOM 元素。
而当 dynamicText
变量对应的内容发生了改变之后,由于这是个响应式数据;它会导致这个 ** 组件的渲染函数被再次执行,生成新的虚拟 DOM **;
这时候 Vue 的 diff 算法 开始发挥作用,通过层层比较新旧虚拟 DOM 节点,diff 算法 找到了差异部分,也就是变量 dynamicText
所在的部分。
最后,渲染器 (renderer) 再次调用浏览器相关 API 将差异的部分更新到页面上。
这么一盘,不知道细心的小伙伴们有没有发现问题所在?
在我们给出的这段模板中,实际上 只有 dynamicText
所在的部分是可能发生变化的,但是在进行 diff 算法 时,却需要层层向下进行比较;
如果模板中有大量静态的节点,只有一个动态的部分,这样逐层的比较无疑是十分浪费性能的。
那 Vue3 中是如何解决这个问题的呢?
Vue3 的解决思路是:给模板中动态的部分加上标识,并将它们提取出来存放在虚拟 DOM 中独立的一块区域。
比如像这样:
const vnode = {
tag: 'div',
children: [
{tag: 'h1', children: '我是静态文本' },
{
tag: 'h2',
children: ctx.dynamicText,
// 增加一个 patchFlag 标识
patchFlag: 1
}
],
// 专门用于存放动态节点
dynamicChildren: [
{tag: 'h2', children: ctx.dynamicText, patchFlag: 1 }
]
}
这里的 patchFlag
是个 number
类型,不同的数值代表了不同的含义:
const patchFlags = {
TEXT: 1, // 表示动态文本内容
CLASS: 2, // 表示动态的 class
STYLE: 3, // 表示动态的 style
PROPS: 4 // 表示动态的 props
// 省略...
}
这样一来,在渲染器进行 diff 对比新旧 vnode 时就 只需要对比存放了动态节点的 dynamicChildren
数组就可以了;
同时由于动态节点上存在着 patchFlag
标识,明确指出了当前节点需要更新的部分,从而达到 靶向更新 的效果。
在具体实现上,Vue 首先在 编译模板时就会收集相关的动态节点信息,将动态节点的相关信息体现在最终生成的渲染函数上:
export function render(ctx) {
return (createElementVNode("div", null, [
createElementVNode("h1", null, "我是静态文本"),
// 新增一个参数 1
createElementVNode("h2", null, ctx.dynamicText, 1 /* TEXT */)
]))
}
可以看到,新的渲染函数中给 createElementVnode
方法传递第 4 个参数:数字 1
;它表示这个节点是一个有动态文本内容的节点;
现在已经有了动态节点的标识 patchFlag
,接下来就要考虑如何将这些动态节点收集到根节点 div
的 vnode
下的 dynamicChildren
数组中了。
这里有一点需要注意 ——
上面渲染函数是 由内向外执行 的,也就是说 h1
、h2
节点的 createElementVnode
会先执行,最后才到 div
节点对应的 createElementVnode
方法;
当 div
的 createElementVNode
执行时,动态节点 h2
的 createElementVNode
方法已经执行完毕,那么 div
就无法将对应的节点收集到自身的 dynamicChildren
数组中。
为了解决这个问题,Vue3 使用了一个 栈的结构 配合 openBlock
方法来 临时储存内层的动态节点:
// 创建一个临时栈以及 openBlock 方法
// 临时栈 用于还原渲染函数执行时的层级关系
const dynamicChildrenStack = []
// 用于储存当前的动态节点
let currentDynamicChildren = null
function openBlock() {
const currentDynamicChildren = []
dynamicChildrenStack.push(currentDynamicChildren)
}
同时修改 createElementVNode
方法:
// 增加第四个入参 patchFlags
function createElementVNode(tag, props, children, patchFlags) {
const vnode = {
tag,
props,
children,
key: props.key
}
// 如果当前节点是个动态节点,就将其 push 进 currentDynamicChildren 中
if (typeof patchFlags !== 'undefined' && currentDynamicChildren) {
currentDynamicChildren.push(vnode)
}
return vnode
}
针对那些需要收集动态节点的 vnode
,对应的创建虚拟节点的方法也需要改变:
// 增加一个 closeBlock 方法,用于将当前的动态节点集合从栈中弹出
function closeBlock() {
currentDynamicChildren = dynamicChildrenStack.pop()
}
// 创建一个带有 dynamicChildren 的 vnode
function createElementBlock() {
const block = createElementVnode()
block.dynamicChildren = currentDynamicChildren
// 最后调用 closeBlock 方法关闭当前动态节点的收集
closeBlock()
return block
}
最后修改渲染函数,在创建 vnode
之前先调用 openBlock
方法,并将 div
节点对应的 createElementVNode
方法修改为 createElementBlock
:
export function render(ctx) {
// 先调用 openBlock 方法来创建临时栈
// 并将原本 div 的 createElementVNode 方法修改为 createElementBlock
return (openBlock(), createElementBlock("div", null, [
createElementVNode("h1", null, "我是静态文本"),
// 新增一个参数 1
createElementVNode("h2", null, ctx.dynamicText, 1 /* TEXT */)
]))
}
这样一来就能够将 h2
对应的动态节点正确的收集到根节点 div
的 dynamicChildren
中了。
而这种 带有 dynamicChildren
的 vnode
则被称为块 (Block)。
说完了 Vue3 是如何提取动态内容来实现靶向更新,我们再来看看 静态提升。
还是原先那段模板,正常情况下它对应的渲染函数如下:
export function render(ctx) {
return (openBlock(), createElementBlock("div", null, [
createElementVNode("h1", null, "我是静态文本"),
// 新增一个参数 1
createElementVNode("h2", null, ctx.dynamicText, 1 /* TEXT */)
]))
}
而这个渲染函数中,h1
标签对应的 createElementVNode
逻辑是不会发生变化的;
因此,我们可以 将这段逻辑抽离到 render
函数之外,避免由于渲染函数重新执行导致这段逻辑再次执行,从而造成浪费:
// 这里将创建 vnode 的逻辑提取到 render 函数之外
const hoist1 = createElementVNode("h1", null, "我是静态文本")
export function render(ctx) {
return (openBlock(), createElementBlock("div", null, [
hoist1, // 这里使用 h1 创建 vnode 逻辑的引用
createElementVNode("h2", null, ctx.dynamicText, 1 /* TEXT */)
]))
}
除了针对整个节点的静态提升之外,如果节点的内容是动态的,但是 属性是纯静态的,也可以进行属性的静态提升。
如下模板:
template>
h2
class="hello"
style="color:red"
id="1"
>
{{dynamicText}}
h2>
template>
静态提升后的渲染函数:
// 针对属性进行静态提升
const hoist1 = {
class: "hello",
style: {"color":"red"},
id: "1"
}
export function render(ctx) {
return (openBlock(), createElementBlock("template", null, [
// 这里的第二个参数变为静态引用
createElementVNode("h2", hoist1, ctx.dynamicText, 1 /* TEXT */)
]))
}
预字符串化
基于静态提升 Vue3 还做了更进一步的优化 —— 预字符串化。
所谓预字符串化就是:将静态提升后的节点预先序列化为字符串。
比如下面这段模板:
template>
h1>texth1>
// ... 中间省略 8 个 h1>texth1>
h1>texth1>
template>
当上面这样的 连续的静态内容超过了一定数量(10 个),那么 Vue3 就会将这些节点合并为一个静态 vnode
调用:
// 将 text
合并成一个静态节点的创造
const hoist1 = /*#__PURE__*/createStaticVNode("text
//... text
", 10)
const hoist11 = [
hoist1
]
export function render(_ctx) {
return (openBlock(), createElementBlock("template", null, hoist11))
}
这样一来就可以 避免创建虚拟 DOM 带来的开销,同时这部分静态内容可以通过 innerHTML
直接设置,对性能也有一定的提升。
除了对静态节点的提升,Vue3 还对于 内联事件 做了缓存。
上面这段模板,在没有对内联事件进行缓存时,编译后的渲染函数如下:
export function render(ctx) {
return (openBlock(), createElementBlock("template", null, [
createElementVNode("button", {
// 创建一个内联函数
onClick: $event => (ctx.num++)
}, "点我", 8 /* PROPS */, ["onClick"])
]))
}
很显然,每次 render
函数重新执行都会重新创建一个 onClick
函数;
这样一来,由于 onClick
函数前后的指针不一致,渲染器会认为 button
的属性被更新了,从而去更新 button
按钮,造成额外的开销。
因此 Vue3 中 针对内联事件进行了缓存。
具体做法就是:将这些内联事件缓存在一个数组 cache
中,并渲染函数执行时优先从缓存中读取事件处理函数:
// 给渲染函数新增一个 cache 参数
export function render(ctx, cache) {
return (openBlock(), createElementBlock("template", null, [
createElementVNode("button", {
// 优先从缓存中读取事件处理函数
onClick: cache[0] || (cache[0] = $event => (ctx.num++))
}, "点我")
]))
这里传给 render
函数的第二个参数 cache
就是用于缓存内联事件的数组,它与 ctx
一样被挂在对应的 组件实例 上。
如果模板中使用了 v-once
指令,Vue3 还可以实现对虚拟 DOM 的缓存。
在 Vue 中 v-once
是用于 将元素或组件标记为只渲染一次的静态内容。
假设有如下模板,在模板上使用了 v-once
指令:
template>
div v-once>{{text}}div>
template>
上面的模板编译成渲染函数后:
export function render(ctx, cache) {
return (openBlock(), createElementBlock("template", null, [
cache[0] || (
// 阻止 Block 追踪
setBlockTracking(-1),
// 创建对应虚拟节点并缓存
cache[0] = createElementVNode("div", null, [
createTextVNode(ctx.text, 1 /* TEXT */)
]),
// 恢复 Block 追踪
setBlockTracking(1),
// 从缓存中读取对应虚拟节点并返回
cache[0]
)
]))
}
从上面的代码中可以看出,虚拟 DOM 的缓存思路与内联事件的类似 —— 都是 优先从缓存中读取结果,没有读取到的情况下再进行重新创建。
而值得一提的是:由于这段虚拟节点已经被缓存了是不会变化的,因此在父节点进行 块(Block) 收集时 它不应该被收集到 dynamicChildren
中。
所以可以看到,代码中使用了一个 setBlockTracking(-1)
方法来阻止它被收集,并在其创建虚拟 DOM 的逻辑完成后,使用 setBlockTracking(1)
方法来恢复收集。
以上就是 Vue3 针对编译优化所做的全部内容。
各位小伙伴还可以戳这里来帮助理解编译优化:vue3 模板在线编译结果;
这个网站可以很直观的看到经过静态提升、内联事件缓存等优化后的编译结果。
That’s all 🌋🌋🌋