渲染器的设计
在 Vue.js 中,很多功能依赖渲染器来实现,例如 Transition 组件、Teleport 组件、Suspense 组件,以及 template ref 和自定义指令等。
渲染器也是框架性能的核心,渲染器的实现直接影响框架的性能。Vue.js 3 的渲染器不仅仅包含传统的 Diff 算法,它还独创了快捷路径的更新方式,能够充分利用编译器提供的信息,大大提升了更新性能。
渲染器与响应系统的结合
Vue.js 利用渲染器与响应系统的结合,实现了当响应式数据变化时,自动完成页面更新(或重新渲染)的能力。
渲染器的基本概念
渲染器的英文名为 renderer ,作用是把虚拟 DOM 渲染为特定平台上的真实元素。
虚拟 DOM 的英文名为 virtual DOM ,可简写为 vdom 、vnode 。虚拟 DOM 和真实 DOM 的结构一样,都是由一个个节点组成的树型结构。
渲染器把虚拟 DOM 节点渲染为真实 DOM 节点的过程叫作挂载,用英文 mount 来表达。
渲染器除了会执行挂载,还会执行打补丁操作,即对比新旧虚拟 DOM ,只更新变化的内容。
自定义渲染器
为了实现跨平台的渲染器,不能在实现渲染器的代码中直接使用浏览器的 API ,而是要把浏览器的 API 作为配置项传入创建渲染器的函数中。关键代码如下:
// 在创建 renderer 时传入配置项
const renderer = createRenderer({
// 用于创建元素
createElement(tag) {
return document.createElement(tag)
},
// 用于设置元素的文本节点
setElementText(el, text) {
el.textContent = text
},
// 用于在给定的 parent 下添加指定元素
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor)
}
})
挂载与更新
挂载子节点和元素的属性
Vue.js 通过遍历子节点数组,然后调用 patch 函数完成挂载。
function mountElement(vnode, container) {
const el = createElement(vnode.type)
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children)
} else if (Array.isArray(vnode.children)) {
// 如果 children 是数组,则遍历每一个子节点,并调用 patch 函数挂载它们
vnode.children.forEach(child => {
patch(null, child, el)
})
}
insert(el, container)
}
节点的属性比想象中的复杂,因为它涉及两个重要概念:HTML Attributes 和 DOM Properties。
HTML Attributes 与 DOM Properties
HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。
正确地设置元素属性
为元素设置属性时,我们不能总是使用 setAttribute 函数,也不能总是通过元素的 DOM Properties 来设置。至于如何正确地为元素设置属性,取决于被设置属性的特点。例如,表单元素的 el.form 属性是只读的,因此只能使用 setAttribute 函数来设置。
在设置元素属性的时候,不可能一次性处理完所有特殊的情况,因此掌握处理问题的思路更加重要。不要惧怕写出不完美的代码,只要在后续迭代过程中“见招拆招“,代码就会变得越来越完善,框架也会变得越来越健壮。
class 处理
通过对比 setAttribute、el.className 与 el.classList 三种方法设置 class 的性能,发现 el.className 性能是最优的。
因此 Vue.js 采用了 el.className 的方式来处理 class 。
通过对 class 的处理,我们发现虚拟 dom props 对象中定义的属性值的类型并不总是与 DOM 元素属性的数据结构保持一致,这取决于上层 API 的设计。Vue.js 允许对象类型的值作为 class 是为了方便开发者,在底层的实现上,必然需要对值进行正常化后再使用。另外,正常化值的过程是有代价的,如果需要进行大量的正常化操作,则会消耗更多性能。具体取决于框架设计者的权衡取舍。
卸载操作
卸载操作发生在更新阶段,更新指的是,在初次挂载完成之后,后续渲染会触发更新。
Vue.js 通过封装 unmount 函数完成协作,这样做有两个好处:
-
在 unmount 函数内,我们有机会调用绑定在 DOM 元素上的指令钩子函数,例如 beforeUnmount、unmounted 等。
-
当 unmount 函数执行时,我们有机会检测虚拟节点 vnode 的类型。如果该虚拟节点描述的是组件,则我们也有机会调用组件相关的生命周期函数。
区分 vnode 的类型
渲染器在执行更新时,会优先检查新旧 vnode 所描述的内容是否相同。只有当它们所描述的内容相同时,才有打补丁的必要。另外,即使它们描述的内容相同,我们也需要进一步检查它们的类型,即检查 vnode.type 属性值的类型,据此判断它描述的具体内容是什么。如果类型是字符串,则它描述的是普通标签元素,这时我们会调用 mountElement 和 patchElement 来完成挂载和打补丁;如果类型是对象,则它描述的是组件,这时需要调用 mountComponent 和 patchComponent 来完成挂载和打补丁。
事件的处理
要完成事件的处理,首先要解决的问题就是如何在虚拟节点中描述事件 。
事件可以视作一种特殊的属性,我们可以约定,在 vnode.props 对象中。凡是以字符串 on 开头的属性都视作事件。
Vue.js 中使用原生的 addEventListener 函数来绑定事件,使用 removeEventListener 函数来移除绑定的事件。
同时,Vue.js 为了提升性能,Vue.js 伪造了一个事件处理函数 invoker 。然后把真正的事件处理函数设置为 invoker.value 属性的值。在绑定事件时,绑定的是 invoker ,当更新事件的时候,我们将不再需要调用 removeEventListener 函数来移除上一次绑定的事件,只需要更新 invoker.value 的值即可。这样,在更新事件时可以避免一次 removeEventListener 函数的调用,从而提升了性能。
事件冒泡与更新时机问题
当更新操作发生在事件冒泡之前,即为 dom 元素绑定事件处理函数发生在事件冒泡之前时,会导致不该执行的事件被提前执行的情况。
我们可以利用事件处理函数被绑定到 DOM 元素的时间与事件触发时间之间的差异来解决该问题。
当事件触发的时间早于事件处理函数被绑定的时间时,意味着该事件触发时,目标元素上还没有绑定相关的事件处理函数。因此我们可以屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行来解决此问题。
更新子节点
元素的子节点会有以下三种情况:
-
字符串类型:代表元素具有文本子节点。
-
数组类型:代表元素具有一组子节点。
-
null:代表元素没有子节点。
因此在更新时,新旧 vnode 的子节点都有可能是以上三种情况之一,所以在执行更新时一共要考虑九种可能。
当新旧虚拟 dom (vnode)都具有一组子节点时,Vue.js 会通过 Diff 算法比较新旧两组子节点,试图最大程度复用 DOM 元素。
文本节点和注释节点
在虚拟 dom 中,使用 symbol 类型来表示文本节点和注释节点的类型。
export const Text = Symbol(__DEV__ ? 'Text' : undefined)
export const Comment = Symbol(__DEV__ ? 'Comment' : undefined)
Fragment
Fragment(片断)是 Vue.js 3 中新增的一个 vnode 类型。用于支持有多个根节点的组件。片段 | Vue 3 迁移指南
<!-- Layout.vue -->
<template>
<header>...</header>
<main v-bind="$attrs">...</main>
<footer>...</footer>
</template>
上面的代码在 Vue.js 2 中是不允许的。
渲染器渲染 Fragment 的方式类似于渲染普通标签,不同的是,Fragment 本身并不会渲染任何 DOM 元素。所以,只需要渲染一个 Fragment 的所有子节点即可。
拓展阅读
简单 Diff 算法
简单来说,当新旧 vnode 的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫作 Diff 算法。在 JS 中操作 DOM 的性能开销通常比较大,而渲染器的核心 Diff 算法就是为了解决这个问题而诞生的。
简单 Diff 算法简单来说就是通过双重 for 循环,遍历新旧两组 DOM 节点来找出差异。
减少 DOM 操作的性能开销
在新旧虚拟节点都存在一组子节点的情况,新的子节点的数量可能跟旧的子节点数量一样多,新的子节点的数量可能比旧的子节点数量少,新的子节点的数量也可能比旧的节点数量多。
如何设计 Diff 算法才能够减少 DOM 操作的性能开销呢?答案是遍历新旧两组子节点中数量较少的那一组,并逐个调用 patch 函数进行打补丁,然后比较新旧两组子节点的数量,如果新的一组子节点数量更多,说明有新子节点需要挂载;否则说明在旧的一组子节点中,有节点需要卸载。
最终实现如下:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
// 旧的一组子节点的长度
const oldLen = oldChildren.length
// 新的一组子节点的长度
const newLen = newChildren.length
// 两组子节点的公共长度,即两者中较短的那一组子节点的长度
const commonLength = Math.min(oldLen, newLen)
// 遍历 commonLength 次
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i], container)
}
// 如果 newLen > oldLen,说明有新子节点需要挂载
if (newLen > oldLen) {
for (let i = commonLength; i < newLen; i++) {
patch(null, newChildren[i], container)
}
} else if (oldLen > newLen) {
// 如果 oldLen > newLen,说明有旧子节点需要卸载
for (let i = commonLength; i < oldLen; i++) {
unmount(oldChildren[i])
}
}
} else {
// 省略部分代码
}
}
DOM 复用与 key 的作用
虚拟节点中 key 属性的作用就像虚拟节点的“身份证号”。在更新时,渲染器通过 key 属性找到可复用的节点,然后尽可能地通过 DOM 移动操作来完成更新,避免过多地对 DOM 元素进行销毁和重建。
找到需要移动的元素
当新旧两组子节点的节点顺序不变时,就不需要额外的移动操作。
简单 Diff 算法寻找需要移动的节点的核心逻辑是:拿新的一组子节点中的节点去旧的一组子节点中寻找可复用的节点。如果找到了,则记录该节点的位置索引。我们把这个位置索引称为最大索引。在整个更新过程中,如果一个节点的索引值小于最大索引,则说明该节点对应的真实 DOM 元素需要移动。
关键的代码实现逻辑如下:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
// 用来存储寻找过程中遇到的最大索引值
let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
for (let j = 0; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container)
if (j < lastIndex) {
// 如果当前找到的节点在旧 children 中的索引小于最大索引值lastIndex,
// 说明该节点对应的真实 DOM 需要移动
} else {
// 如果当前找到的节点在旧 children 中的索引不小于最大索引值,
// 则更新 lastIndex 的值
lastIndex = j
}
break // 这里需要 break
}
}
}
} else {
// 省略部分代码
}
}
如何移动元素
移动元素的思路如下:
- 获取真实 DOM 节点的引用
function patchElement(n1, n2) {
// 新的 vnode 也引用了真实 DOM 元素
const el = n2.el = n1.el
// 省略部分代码
}
-
遍历新的一组子节点,在旧的一组子节点中寻找是否存在可以复用的节点,并记录此节点在旧的一组子节点中的索引,我们把这个索引叫最大索引。
-
当后续的遍历中发现存在索引比最大索引小的节点,则说明该 DOM 节点需要移动。
-
找到需要移动的 DOM 节点后,则调用浏览器原生的 insertBefore 函数完成节点的移动
添加新元素
Diff 算法中找到需要添加新元素的思路很简单:
-
遍历一组新的子节点,如果该子节点无法在一组旧的子节点找到可复用的子节点,则该子节点为此次 Diff 中需要添加的新元素
-
找到了新增节点后,只需将新增节点挂载到正确位置即可。
移除不存在的元素
移除不存在的元素的思路也很简单,就是先找到需要删除的节点,然后将该节点删除即可。
如何找到需要删除的节点呢?很简单,就是遍历,遍历一遍旧的一组子节点,然后拿旧子节点去新的一组子节点中寻找具有相同 key 值的节点,如果没有找到具有相同 key 值的节点,则说明需要删除该节点。
双端 Diff 算法
双端 Diff 算法指的是,在新旧两组子节点的四个端点之间分别进行比较,并试图找到可复用的节点。相比简单 Diff 算法,双端 Diff 算法的优势在于,对于同样的更新场景,执行的 DOM 移动操作次数更少。
Vue2 采用的就是双端 Diff 算法,性能还不错。
双端比较的原理
双端比较的原理是新旧两组子节点的头尾指针向中间移动的同时,对比头头、尾尾、头尾、尾头是否可以复用,如果可以的话就移动对应的 dom 节点。
双端比较的优势
双端比较的优势是可以减少 DOM 操作的次数。
非理想状况的处理方式
非理想状况即是新旧两组子节点的头尾两端均不可复用的情况。这个时候需要尝试看看非头部、非尾部的节点能否复用,具体做法是拿新的子节点在一组旧节点数组中查找,找到了就把该可复用的旧子节点移动到正确的位置,并把原位置置为 undefined 。
添加新元素
当旧的一组子节点都处理完成后,还剩下新的子节点的话,则这些子节点为需要新增的子节点,具体做法是遍历这组新的子节点,完成挂载操作。
移除不存在的元素
当旧的一组子节点中存在未被处理的节点,应该将其移除。与处理新增节点类似,通过遍历剩余的旧子节点,完成卸载操作。
快速 Diff 算法
快速 Diff 算法在实测中性能最优。它借鉴了文本 Diff 中的预处理思路,先处理新旧两组子节点中相同的前置节点和相同的后置节点。当前置节点和后置节点全部处理完毕后,如果无法简单地通过挂载新节点或者卸载已经不存在的节点来完成更新,则需要根据节点的索引关系,构造出一个最长递增子序列。最长递增子序列所指向的节点即为不需要移动的节点。
快速 Diff 算法也是 Vue.js 3 采用的 Diff 算法。
相同的前置元素和后置元素
快速 Diff 算法借鉴了文本 Diff 预处理思路,先更新相同的前置元素,再更新相同的后置元素,在新的一组子元素中剩余的未处理的元素则是需要新增的元素,走批量挂载的流程。在旧的一组子元素中剩余的未处理的元素为需要删除的元素,走批量卸载的流程。
判断是否需要进行 DOM 移动操作
快速 Diff 算法判断是否需要进行 DOM 移动操作的思路是:
- 遍历旧的一组子节点,获得该节点在新的一组子节点中的位置,并记录下来叫最大索引,如果下一轮遍历中获得的最大索引小于上一次记录的最大索引,则该节点需要移动。
如何移动元素
快速 Diff 算法中移动元素要先构造一个最长递增子序列 。
最长递增子序列:在给定的一个序列中,有多个递增的序列,其中最长的那个称为最长递增子序列,要注意的是子序列的元素在原序列中不一定连续。同时,同一个序列中的最长递增子序列有可能有多个。
给定序列:[0, 8, 4, 12] 最长递增子序列有:[0, 8, 12]、[0, 4, 12]
在最长递增子序列中的元素都不需要移动。构造完最长递增子序列后,会遍历新的一组子节点中的元素,如果遍历到的元素在最长递增子序列中,则不需移动该元素,否则将该元素移动到正确的位置。