渲染机制
Vue 如何将模板转换为实际的 DOM 节点?Vue 如何高效地更新这些 DOM 节点?在这里,我们将通过深入了解 Vue 的内部渲染机制来尝试解答这些问题。
虚拟 DOM
你可能已经听说过“虚拟 DOM”这个术语,Vue 的渲染系统就是基于这个概念。
虚拟 DOM(VDOM)是一种编程概念,其中在内存中保持 UI 的理想或“虚拟”表示,并与“真实”DOM 保持同步。这个概念由 React 领先提出,并被许多其他框架采用,包括 Vue。
虚拟 DOM 更像是一种模式,而不是一项具体的技术,因此没有一种官方的实现。我们可以用一个简单的例子来说明这个概念
js
const vnode = {
type: 'div',
props: {
id: 'hello'
},
children: [
/* more vnodes */
]
}
在这里,vnode
是一个普通的 JavaScript 对象(一个“虚拟节点”),代表一个 <div>
元素。它包含创建实际元素所需的所有信息。它还包含更多的子 vnode,这使得它成为虚拟 DOM 树的根节点。
运行时渲染器可以遍历虚拟 DOM 树,并从中构建一个实际的 DOM 树。这个过程被称为 挂载。
如果我们有两个虚拟DOM树的副本,渲染器也可以遍历并比较这两个树,找出差异,并将这些更改应用到实际的DOM上。这个过程被称为补丁,也称为“差异”或“协调”。
虚拟DOM的主要好处是它为开发者提供了以声明性方式程序化创建、检查和组合所需UI结构的能力,同时将直接DOM操作留给渲染器。
渲染管道
从高层次来说,当Vue组件挂载时会发生这样的事情
编译:Vue模板被编译成返回虚拟DOM树的渲染函数:函数。这一步可以通过构建步骤提前完成,或者通过使用运行时编译器即时完成。
挂载:运行时渲染器调用渲染函数,遍历返回的虚拟DOM树,并根据它创建实际的DOM节点。这一步作为一个响应式效果执行,因此它会跟踪所有使用的响应式依赖项。
补丁:当挂载期间使用的依赖项发生变化时,效果会重新运行。这次,创建了一个新的、更新的虚拟DOM树。运行时渲染器遍历新树,将其与旧树进行比较,并将必要的更新应用到实际的DOM上。
模板与渲染函数
Vue模板被编译成虚拟DOM渲染函数。Vue还提供了API,允许我们跳过模板编译步骤并直接编写渲染函数。当处理高度动态的逻辑时,渲染函数比模板更灵活,因为你可以使用JavaScript的全部功能来处理vnode。
那么为什么Vue默认推荐使用模板?有几个原因
模板更接近实际的HTML。这使得它更容易重用现有的HTML片段,应用无障碍性最佳实践,使用CSS进行样式化,以及让设计师理解并修改。
由于它们的语法更具确定性,模板更容易进行静态分析。这使得Vue的模板编译器可以对虚拟DOM(我们将在下面讨论)应用许多编译时优化以提高性能。
在实践中,模板对于应用中的大多数用例来说都是足够的。渲染函数通常只在需要处理高度动态渲染逻辑的通用组件中使用。渲染函数的使用在渲染函数 & JSX中更详细地讨论。
编译器知情虚拟DOM
React和大多数其他虚拟DOM实现中的虚拟DOM实现完全是运行时:协调算法无法对传入的虚拟DOM树做出任何假设,因此它必须完全遍历树并比较每个vnode的props以确保正确性。此外,即使树的一部分从未改变,在每次重新渲染时也会为它们创建新的vnode,这会导致不必要的内存压力。这是虚拟DOM最被批评的方面之一:这种有点蛮力的协调过程以牺牲效率为代价换取了声明性和正确性。
但不必如此。在Vue中,框架控制编译器和运行时。这使我们能够实施许多只有紧密耦合的渲染器才能利用的编译时优化。编译器可以静态分析模板,并在生成的代码中留下提示,以便运行时可以在可能的情况下采取捷径。同时,我们仍然保留了用户在边缘情况中下降到渲染函数层的直接控制能力。我们称这种混合方法为编译器知情虚拟DOM。
下面,我们将讨论Vue模板编译器为了提高虚拟DOM的运行时性能所做的一些主要优化。
静态提升
模板中经常会包含一些不包含任何动态绑定的部分
template
<div>
<div>foo</div> <!-- hoisted -->
<div>bar</div> <!-- hoisted -->
<div>{{ dynamic }}</div>
</div>
《foo》和《bar》的div是静态的 - 在每次重新渲染时重新创建vnode和比较它们是不必要的。Vue编译器会自动将vnode创建调用提升出渲染函数,并在每次渲染时重用相同的vnode。当渲染器发现旧vnode和新vnode是同一个时,它还能完全跳过它们的比较。
此外,当有足够的连续静态元素时,它们将被压缩成一个包含所有这些节点纯HTML字符串的“静态vnode”(示例)。这些静态vnode通过直接设置innerHTML来挂载。它们还缓存了初始挂载时的相应DOM节点 - 如果同一内容在应用程序的其它地方被重用,则会创建新的DOM节点,使用原生的cloneNode()
,这非常高效。
补丁标志
对于单个具有动态绑定的元素,我们也可以在编译时从它那里推断出很多信息
template
<!-- class binding only -->
<div :class="{ active }"></div>
<!-- id and value bindings only -->
<input :id="id" :value="value">
<!-- text children only -->
<div>{{ dynamic }}</div>
当为这些元素生成渲染函数代码时,Vue会直接在vnode创建调用中编码每个元素所需的更新类型
js
createElementVNode("div", {
class: _normalizeClass({ active: _ctx.active })
}, null, 2 /* CLASS */)
最后一个参数2
是一个补丁标志。一个元素可以有多个补丁标志,这些标志将合并成一个数字。运行时渲染器可以通过位操作来检查标志,以确定是否需要执行某些工作
js
if (vnode.patchFlag & PatchFlags.CLASS /* 2 */) {
// update the element's class
}
位操作非常快。有了补丁标志,Vue能够在更新具有动态绑定的元素时做最少的必要工作。
Vue还会编码vnode的子节点类型。例如,具有多个根节点的模板表示为片段。在大多数情况下,我们知道这些根节点的顺序永远不会改变,因此这些信息也可以作为补丁标志提供给运行时
js
export function render() {
return (_openBlock(), _createElementBlock(_Fragment, null, [
/* children */
], 64 /* STABLE_FRAGMENT */))
}
因此,运行时可以完全跳过根片段的子节点顺序的协调。
树扁平化
再次查看上一个示例生成的代码,你会注意到返回的虚拟DOM树的根是通过特殊的createElementBlock()
调用创建的
js
export function render() {
return (_openBlock(), _createElementBlock(_Fragment, null, [
/* children */
], 64 /* STABLE_FRAGMENT */))
}
在概念上,“块”是模板中具有稳定内部结构的部分。在这种情况下,整个模板只有一个块,因为它不包含任何结构指令,如v-if
和v-for
。
每个块跟踪任何具有补丁标志的后代节点(不仅是直接子节点),例如
template
<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
当此组件需要重新渲染时,它只需要遍历扁平化的树,而不是完整的树。这被称为 树扁平化,它在虚拟DOM的重新同步过程中大大减少了需要遍历的节点数量。模板中的任何静态部分都有效地被跳过。
v-if
和 v-for
指令将创建新的块节点
template
<div> <!-- root block -->
<div>
<div v-if> <!-- if block -->
...
</div>
</div>
</div>
子块在父块动态子代数组中被跟踪。这保留了父块的稳定结构。
对SSR同步的影响
补丁标志和树扁平化也极大地提高了Vue的 SSR同步 性能
单元素同步可以根据相应的vnode的补丁标志采取快速路径。
在同步过程中,只需要遍历块节点及其动态子代,从而在模板级别上实现部分同步。