为了提高英语水平和保持技术成长,开始按计划翻译一些短篇和博客,有问题欢迎讨论👻
原文:Rendering Mechanism
正文
Vue 是如何将 template 转换为真实的DOM节点?又是如何高效地更新这些节点?接下来我们会通过研究Vue内部的渲染机制,来解释这些问题。
Virtual DOM
你也许听过”虚拟DOM”这个概念,Vue的渲染就是基于它实现的
虚拟DOM(VDOM)是一个编程概念,他将用户所需的界面通过虚拟节点(对象结构)保存在内存中,并与真实的DOM节点同步。这个概念是由 React 首创,并被Vue在内的许多其他框架以不同的实现方式采用
虚拟DOM更像是一种模式,而不是一种特定的技术,因此不存在某种标准的实现方式,我们可以通过一个简单的例子来说明:
const vnode = {
type: 'div',
props: {
id: 'hello'
},
children: [
/* more vnodes */
]
}
在上面这个例子中,vnode 是一个纯 JavaScript 对象,表示一个 <div>
元素。它包含了我们创建真实DOM所需的所有信息。它还包含了更多的子节点,这就使它成为了虚拟DOM树的根节点。
然后一个运行时的渲染器(runtime renderer)遍历这个虚拟DOM树,并使其构建为一个真实的DOM树,这一过程称为挂载(mount)。
如果我们有两个虚拟DOM树,渲染器也可以对这个树进行遍历和对比,找出不同之处。然后将这些不同之处的更改应用到真实DOM上。这个过程称为 patch,也可以称为”diffing”或者”reconciliation”
虚拟DOM的主要优势在于,可以让开发人员以声明的方式,创建、检查和组合所需的用户界面结构,而真实DOM的操作就留给渲染器去实现
Render Pipeline
从高层次来看,挂载Vue组件时会发生以下几件事:
Compile:Vue 模版会被编译成渲染函数:渲染函数会返回虚拟DOM树,这一步可以是在构建之前完成,也可以在运行时渲染器即时完成的。
Mount:运行时渲染器会调用渲染函数,然后遍历返回的虚拟DOM树,并以此创建真实DOM节点。这一步是在响应式副作用中执行,因此它会跟踪所有使用过的响应式依赖项。
Patch:当挂载期间使用过的依赖数据发生变化时,副作用会重新运行。这时,会创建一个新的并且经过更新的虚拟DOM树。然后运行时渲染器会遍历新的DOM树,并与旧的DOM树进行对比,并将必要的更新应用到真实DOM上。
Templates vs. Render Functions
Vue模版会被编译成虚拟DOM渲染函数。另外Vue也提供了一些API可以跳过模版编译阶段直接编写渲染函数。当你在处理高度动态的逻辑时,直接编写渲染函数比模版更加灵活,因为你可以使用javascript的全部功能来处理 vnodes
那为什么Vue默认推荐使用模版呢?有几个原因:
模版更接近真实的HTML。这样就更容易的复用现有的HTML代码段,并有更好的可读性,更能方便地使用CSS样式,并且设计师更容易理解和修改。
模版的语法更加确定,因此更容易进行静态分析。这就可以让Vue的模版编译器在编译时有更多的优化,以提高虚拟DOM的性能(这个我们会在下面讨论)。
在实践中,模版足以满足应用中的大多用例。渲染函数通常只需要处理高度动态渲染逻辑的可重用组件。如果想了解更多渲染函数的使用,可以去 Render Functions & JSX 中继续阅读
Compiler-Informed Virtual DOM
React 中的虚拟DOM和大多数虚拟DOM实现都是单纯的运行时实现:reconciliation 算法无法预知传入的虚拟DOM树,所以它必须完全遍历,并对比每个vnode的props,以确保正确性。另外 ,即使DOM树的其中一部分从未改变,每次重新渲染时也会为它们创建新的vnode,从而造成不必要的内存压力。这也是虚拟DOM最为人诟病的地方之一:粗暴的更新过程牺牲了效率,却换来了代码的声明性和正确性
但其实不必如此。在Vue中,框架同时控制了编译器和运行时。这可以让我们实现许多编译时的优化,只有编译时和渲染时紧密耦合才能利用这些优化。编译器可以静态分析模版,并在生成的代码中留下提示,这样运行时就可能通过这些提示走捷径。同时,我们仍然保留了用户在某些边界情况想要使用底层渲染函数的能力。我们将这种混合实现称为带编译时信息的虚拟 DOM (Compiler-Informed Virtual DOM)
在下面,我们将讨论Vue编译器为提高虚拟DOM运行时性能而进行的几项主要优化。
Static Hoisting
在模版中经常会出现不包含任何动态绑定的部分:
<div>
<div>foo</div> <!-- hoisted -->
<div>bar</div> <!-- hoisted -->
<div>{{ dynamic }}</div>
</div>
// ==> after compile
import { createElementVNode as _createElementVNode, createCommentVNode as _createCommentVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
const _hoisted_1 = /*#__PURE__*/_createElementVNode("div", null, "foo", -1 /* HOISTED */)
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "bar", -1 /* HOISTED */)
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", null, [
_hoisted_1,
_createCommentVNode(" hoisted "),
_hoisted_2,
_createCommentVNode(" hoisted "),
_createElementVNode("div", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
]))
}
foo
和 bar
的div
元素是静态的,因此在每次重新渲染时重新创建vnode并进行diffing处理是没必要的。Vue编译器会自动将静态部分从渲染函数中移出到外面,并在每次渲染时重复使用相同的vnode。当渲染器发现旧的vnode和新的vnode是同一个时,它还可以完全跳过它们的diffing处理
此外,如果有足够多的连续静态元素,它们将组合成一个”静态vnode”,其中包含这些节点的纯HTML字符串。然后这些vnode可以直接通过innerHTML
来加载。在初始挂载时,它们也会缓存相应的DOM节点,如果同一内容在应用的其他地方重复使用后,就会通过本地的cloneNode()
创建新的DOM节点,这样做会非常高效。
Patch Flags
对于动态绑定的单个元素,我们也可以在编译时从中推断出很多信息:
<!-- class binding only -->
<div :class="{ active }"></div>
<!-- id and value bindings only -->
<input :id="id" :value="value">
<!-- text children only -->
<div>{{ dynamic }}</div>
// ==> after compile
import { normalizeClass as _normalizeClass, createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock(_Fragment, null, [
_createElementVNode("div", {
class: _normalizeClass({ active: _ctx.active })
}, null, 2 /* CLASS */),
_createElementVNode("input", {
id: _ctx.id,
value: _ctx.value
}, null, 8 /* PROPS */, ["id", "value"]),
_createElementVNode("div", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
], 64 /* STABLE_FRAGMENT */))
}
在为这些元素生成渲染函数代码时,Vue会在vnode创建调用中直接编码每个元素所需的更新类型:
createElementVNode("div", {
class: _normalizeClass({ active: _ctx.active })
}, null, 2 /* CLASS */)
最后一个参数2是patch flag。一个元素可以有多个flag,这些flag会合并成一个数字。然后,运行时渲染器可以通过位运算检查这些flag,用于确定是否需要执行某些更新操作:
if (vnode.patchFlag & PatchFlags.CLASS /* 2 */) {
// update the element's class
}
位运算检查速度极快。有了patch flags,Vue就能通过最少的更新操作来更新具有动态绑定的元素
Vue还会给vnode的子节点类型进行编码。例如,具有多个根节点的模版被表示为一个片段。在大多数情况下,我们可以确定这些根节点的顺序永远不会改变,所以可以将这个信息通过标记提供给运行时
export function render() {
return (_openBlock(), _createElementBlock(_Fragment, null, [
/* children */
], 64 /* STABLE_FRAGMENT */))
}
所以,运行时可以完全跳过根片段的子序列顺序的协调过程
Tree Flattening
再看一下上一个示例中生成的代码,你会发现返回的虚拟DOM树的根是通过一个特殊的 createElementBlock()
调用创建的:
export function render() {
return (_openBlock(), _createElementBlock(_Fragment, null, [
/* children */
], 64 /* STABLE_FRAGMENT */))
}
从概念上看,“block”是模版中具有稳定内部结构的一部分。在这个示例中,整个模版只有一个block,因为它不包含任何结构指令,如v-if
和v-for
每个block都会追踪任何有patch flags的后代节点(不只是直接子节点)。例如:
<div> <!-- root block -->
<div>...</div> <!-- not tracked -->
<div :id="id"></div> <!-- tracked -->
<div> <!-- not tracked -->
<div>{{ bar }}</div> <!-- tracked -->
</div>
</div>
这个结果是一个只包含动态子节点的扁平化数组:
div (block root)
- div with :id binding
- div with {{ bar }} binding
当组件需要重新渲染时,它只需要遍历这个扁平化的树而不是整棵树。这就所谓的 Tree Flattening,它大大减少了虚拟DOM协调过程中需要遍历的节点数量。模版中的任何静态部分都会高效略过。
v-if
和v-for
会创建新的block节点:
<div> <!-- root block -->
<div>
<div v-if> <!-- if block -->
...
<div>
</div>
</div>
子block会在父block的动态后代数组中进行追踪。这就为父block保留了一个稳定的结构。
Impact on SSR Hydration
patch flags 和 tree flattening 也会提高Vue的 SSR Hydration 性能:
- 单个元素的 hydration 可以根据 vnode 的 path flag 走更快的捷径。
- 只有block节点和其动态子节点在进行 hydration 时需要进行遍历,从而在模板级别实现部分 hydration。