渲染器是 Vue.js 中非常重要的一部分。在 Vue.js 中,很多功能依赖渲染器来实现,例如 Transition 组件、Teleport 组件、Suspense 组件,以及 template ref 和自定义指令等。
渲染器也是框架性能的核心,渲染器的实现直接影响框架的性能。Vue.js 3的渲染器不仅仅包含传统的 Diff 算法,它还独创了快捷路径的更新方式,能够充分利用编译器提供的信息,大大提升了更新性能。
# 渲染器与响应系统的结合
顾名思义,渲染器是用来执行渲染任务的。在浏览器平台上,用它来渲染其中的真实 DOM 元素。渲染器不仅能够渲染真实 DOM 元素,它还是框架跨平台能力的关键。因此,在设计渲染器的时候一定要考虑好可自定义的能力。
既然渲染器用来渲染真实 DOM 元素,那么严格来说,下面的函数就是一个合格的渲染器:
function renderer(domString, container) {
container.innerHTML = domString
}
//我们可以如下所示使用它:
renderer('<h1>Hello</h1>', document.getElementById('app'))
2
3
4
5
如果页面中存在 id 为 app 的 DOM 元素,那么上面的代码就会将<h1>hello</h1>
插入到该 DOM 元素内。
当然,我们不仅可以渲染静态的字符串,还可以渲染动态拼接的 HTML 内容,如下所示:
let count = 1
renderer(`<h1>${count}</h1>`, document.getElementById('app'))
2
利用响应系统,我们可以让整个渲染过程自动化:
const count = ref(1)
effect(() => {
renderer(`<h1>${count.value}</h1>`, document.getElementById('app'))
})
count.value++
2
3
4
5
这样,副作用函数执行完毕后,会与响应式数据建立响应联系。当我们修改 count.value 的值时,副作用函数会重新执行,完成重新渲染。
这就是响应系统和渲染器之间的关系。我们**利用响应系统的能力,自动调用渲染器完成页面的渲染和更新。**这个过程与渲染器的具体实现无关,在上面给出的渲染器的实现中,仅仅设置了元素的 innerHTML 内容。
@vue/reactivity 提供了 IIFE 模块格式,因此我们可以直接通过 <script>
标签引用到页面中使用:
<script src="https://unpkg.com/@vue/reactivity@3.0.5/dist/reactivity.global.js"></script>
它暴露的全局 API 名叫 VueReactivity,因此上述内容的完整代码如下:
const { effect, ref } = VueReactivity
function renderer(domString, container) {
container.innerHTML = domString
}
const count = ref(1)
effect(() => {
renderer(`<h1>${count.value}</h1>`, document.getElementById('app'))
})
count.value++
2
3
4
5
6
7
8
9
10
11
12
13
可以看到,我们通过 VueReactivity 得到了 effect 和 ref 这两个 API。
# 渲染器的基本概念
renderer 表示渲染器, render 表示渲染。**渲染器**的作用是把虚拟 DOM 渲染为特定平台上的真实元素。在浏览器平台上,渲染器会把虚拟 DOM 渲染为真实 DOM 元素。虚拟 DOM 的一个作用是脱离平台,为跨平台做准备。
**虚拟 DOM** 通常用英文 virtual DOM 来表达,有时会简写成 vdom。虚拟 DOM 和真实 DOM 的结构一样,都是由一个个节点组成的树型结构。我们经常能听到“**虚拟节点**”这样的词,即 virtual node,有时会简写成 vnode。虚拟DOM 是树型结构,这棵树中的任何一个 vnode 节点都可以是一棵子树,因此 vnode 和 vdom 有时可以替换使用。
渲染器把虚拟 DOM 节点渲染为真实 DOM 节点的过程叫作挂载,通常用英文 mount 来表达。例如 Vue.js 组件中的 mounted 钩子就会在挂载完成时触发。这就意味着,在 mounted 钩子中可以访问真实 DOM 元素。自定义组件就是虚拟 DOM。
其实渲染器并不知道应该把真实 DOM 挂载到哪里。因此,渲染器通常需要接收一个挂载点作为参数,用来指定具体的挂载位置。这里的“挂载点”其实就是一个 DOM 元素,渲染器会把该 DOM 元素作为容器元素,并把内容渲染到其中。我们通常用英文 container 来表达容器。
例如以下代码所示:
function createRenderer() {
function render(vnode, container) {
// ...
}
return render
}
2
3
4
5
6
7
其中 createRenderer 函数用来创建一个渲染器。调用 createRenderer 函数会得到一个 render 函数,该 render 函数会以 container为挂载点,将 vnode 渲染为真实 DOM 并添加到该挂载点下。
为什么需要一个 createRenderer 函数呢?渲染器与渲染是不同的。渲染器是更加宽泛的概念,它包含渲染。渲染器不仅可以用来渲染,还可以用来激活已有的 DOM 元素,这个过程通常发生在同构渲染的情况下,如以下代码所示:
function createRenderer() {
function render(vnode, container) {
// ...
}
function hydrate(vnode, container) {
// ...
}
return {
render,
hydrate
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
可以看到,当调用 createRenderer 函数创建渲染器时,渲染器不仅包含 render函数,还包含 hydrate 函数。渲染器的内容非常广泛,而用来把 vnode 渲染为真实 DOM 的 render 函数只是其中一部分。实际上,在 Vue.js 3 中,甚至连创建应用的createApp 函数也是渲染器的一部分。
有了渲染器,我们就可以用它来执行渲染任务了,首先调用 createRenderer 函数创建一个渲染器,接着调用渲染器的 renderer.render 函数执行渲染:
const renderer = createRenderer()
// 首次渲染
renderer.render(vnode, document.querySelector('#app'))
2
3
当首次调用 renderer.render 函数时,只需要创建新的 DOM 元素即可,这个过程只涉及挂载。
而当多次在同一个 container 上调用 renderer.render 函数进行渲染时,渲染器除了要执行挂载动作外,还要执行更新动作。例如:
const renderer = createRenderer()
// 首次渲染
renderer.render(oldVNode, document.querySelector('#app'))
// 第二次渲染
renderer.render(newVNode, document.querySelector('#app'))
2
3
4
5
多次执行 renderer.render 函数时,不是完全的把 dom 全部换掉(浏览器过多的操作 DOM 影响性能),而是需要 patch。
由于首次渲染时已经把 oldVNode 渲染到 container 内了,所以当再次调用 renderer.render 函数并尝试渲染 newVNode 时,就不能简单地执行挂载动作了。在这种情况下,渲染器会使用 newVNode 与上一次渲染的 oldVNode 进行比较,试图找到并更新变更点。这个过程叫作“打补丁”(或更新),英文通常用 patch 来表达。
但实际上,挂载动作本身也可以看作一种特殊的打补丁,它的特殊之处在于旧的 vnode 是不存在的。所以我们不必过于纠结“挂载”和“打补丁”这两个概念。
render 函数的基本实现如下:
function createRenderer() {
function render(vnode, container) {
if (vnode) {
// 新 vnode 存在,将其与旧 vnode 一起传递给 patch 函数,进行打补丁
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
// 旧 vnode 存在,且新 vnode 不存在,说明是卸载(unmount)操作
// 只需要将 container 内的 DOM 清空即可
container.innerHTML = ''
}
}
// 把 vnode 存储到 container._vnode 下,即后续渲染中的旧 vnode
container._vnode = vnode
}
return {
render
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
假设我们连续三次调用renderer.render 函数来执行渲染:
const renderer = createRenderer()
// 首次渲染
renderer.render(vnode1, document.querySelector('#app'))
// 第二次渲染
renderer.render(vnode2, document.querySelector('#app'))
// 第三次渲染
renderer.render(null, document.querySelector('#app'))
2
3
4
5
6
7
首次渲染:渲染器会将 vnode1 渲染为真实 DOM。渲染完成后,vnode1会存储到容器元素的
container._vnode
属性中,它会在后续渲染中作为旧 vnode使用。第二次渲染:旧 vnode 存在,此时渲染器会把 vnode2 作为新 vnode,并将新旧 vnode 一同传递给 patch 函数进行打补丁。
第三次渲染:新 vnode 的值为 null,即什么都不渲染。但此时容器中渲染的是 vnode2 所描述的内容,所以渲染器需要清空容器。
从上面的代码中可以看出,我们使用
container.innerHTML = ''
来清空容器。需要注意的是,这样清空容器是有问题的,不过这里我们暂时使用它来达到目的。(这只是 dom 表现上的清理,实际上还有很多 JS 逻辑上的副作用要清理)
另外,在上面给出的代码中,我们注意到 patch 函数的签名:
patch(container._vnode, vnode, container)
虽然我们并没有给出
patch()
函数的具体实现,但从上面的代码中,仍然可以窥探patch()
函数的部分细节。
实际上,patch 函数是整个渲染器的核心入口,它承载了最重要的渲染逻辑。
patch 函数至少接收三个参数:
function patch(n1, n2, container) {
// ...
}
2
3
第一个参数 n1:旧 vnode。
第二个参数 n2:新 vnode。
第三个参数 container:容器。
在首次渲染时,容器元素的 container._vnode
属性是不存在的,即 undefined。这意味着,在首次渲染时传递给 patch 函数的第一个参数 n1 也是 undefined。这时,patch 函数会执行挂载动作,它会忽略 n1,并直接将 n2 所描述的内容渲染到容器中。从这一点可以看出,patch 函数不仅可以用来完成打补丁,也可以用来执行挂载。
打补丁和挂载本来就是一码事,其实就是不同情况下的不同叫法。
# 自定义渲染器
渲染器不仅能够把虚拟 DOM 渲染为浏览器平台上的真实 DOM,通过将渲染器设计为可配置的“通用”渲染器,即可实现渲染到任意目标平台上。
不同的平台传过来的 API 有所不同,这样就实现了当把虚拟 DOM 转换为真实 DOM 或者渲染出真实页面时的逻辑抽象,实现真实渲染的解耦。
我们从渲染一个普通的 <h1>
标签开始。可以使用如下 vnode 对象来描述一个<h1>
标签:
const vnode = {
type: 'h1',
children: 'hello'
}
2
3
4
使用 type 属性来描述一个 vnode 的类型,不同类型的 type 属性值可以描述多种类型的 vnode。当 type 属性是字符串类型值时,可以认为它描述的是普通标签,并使用该 type 属性的字符串值作为标签的名称。其他类型表明是组件类型(函数组件或普通组件)。
对于这样一个 vnode,我们可以使用 render 函数渲染它:
const vnode = {
type: 'h1',
children: 'hello'
}
// 创建一个渲染器
const renderer = createRenderer()
// 调用 render 函数渲染该 vnode
renderer.render(vnode, document.querySelector('#app'))
2
3
4
5
6
7
8
为了完成渲染工作,我们需要补充 patch 函数:
function createRenderer() {
function patch(n1, n2, container) {
// 在这里编写渲染逻辑
}
function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
container.innerHTML = ''
}
}
container._vnode = vnode
}
return {
render
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
patch 函数的代码如下:
function patch(n1, n2, container) {
// 如果 n1 不存在,意味着挂载,则调用 mountElement 函数完成挂载
if (!n1) {
mountElement(n2, container)
} else {
// n1 存在,意味着打补丁,暂时省略
}
}
2
3
4
5
6
7
8
第一个参数 n1 代表旧 vnode,第二个参数 n2 代表新 vnode。当 n1 不存在时,意味着没有旧 vnode,此时只需要执行挂载即可。
path 判断是增删改查,如果是新增直接 mountElement 挂载新元索。
这里我们调用 mountElement 完成挂载,它的实现如下:
function mountElement(vnode, container) {
// 创建 DOM 元素
const el = document.createElement(vnode.type)
// 处理子节点,如果子节点是字符串,代表元素具有文本节点
if (typeof vnode.children === 'string') {
// 因此只需要设置元素的 textContent 属性即可
el.textContent = vnode.children
}
// 将元素添加到容器中
container.appendChild(el)
}
2
3
4
5
6
7
8
9
10
11
首先调用 document.createElement 函数,以 vnode.type 的值作为标签名称创建新的DOM 元素。
接着处理 vnode.children,如果它的值是字符串类型,则代表该元素具有文本子节点,这时只需要设置元素的 textContent 即可。
最后调用appendChild 函数将新创建的 DOM 元素添加到容器元素内。这样,我们就完成了一个 vnode 的挂载。
接下来,我们分析这段代码存在的问题。
我们的目标是设计一个不依赖于浏览器平台的通用渲染器(跨平台通用渲染器),但很明显,mountElement 函数内调用了大量依赖于浏览器的 API。想要设计通用渲染器,第一步要做的就是将这些浏览器特有的 API 抽离。
怎么做呢?我们可以将这些用于操作 DOM 的 API 作为配置项,该配置项可以作为 createRenderer 函数的参数:
// 在创建 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)
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这样,在 mountElement 等函数内就可以通过配置项来取得操作 DOM 的 API 了:
function createRenderer(options) {
// 通过 options 得到操作 DOM 的 API
const {
createElement,
insert,
setElementText
} = options
// 在这个作用域内定义的函数都可以访问那些 API
function mountElement(vnode, container) {
// ...
}
function patch(n1, n2, container) {
// ...
}
function render(vnode, container) {
// ...
}
return {
render
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
接着,我们就可以使用从配置项中取得的 API 重新实现 mountElement 函数:
function mountElement(vnode, container) {
// 调用 createElement 函数创建元素
const el = createElement(vnode.type)
if (typeof vnode.children === 'string') {
// 调用 setElementText 设置元素的文本节点
setElementText(el, vnode.children)
}
// 调用 insert 函数将元素插入到容器内
insert(el, container)
}
2
3
4
5
6
7
8
9
10
重构后的 mountElement 函数在功能上没有任何变化,但是它不再直接依赖于浏览器的特有 API 了。这意味着,只要传入不同的配置项,就能够完成非浏览器环境下的渲染工作。(与渲染 API 解耦)
为了展示这一点,我们可以实现一个用来打印渲染器操作流程的自定义渲染器:
const renderer = createRenderer({
createElement(tag) {
console.log(`创建元素 ${tag}`)
return { tag }
},
setElementText(el, text) {
console.log(`设置 ${JSON.stringify(el)} 的文本内容:${text}`)
el.textContent = text
},
insert(el, parent, anchor = null) {
console.log(`将 ${JSON.stringify(el)} 添加到 ${JSON.stringify(parent)} 下`)
parent.children = el
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
在 createElement 内,我们不再调用浏览器的 API,而是仅仅返回一个对象 { tag },并将其作为创建出来的“DOM 元素”。同样,在 setElementText 以及 insert 函数内,我们也没有调用浏览器相关的 API,而是自定义了一些逻辑,并打印信息到控制台。
这样,我们就实现了一个自定义渲染器,可以用下面这段代码来检测它的能力:
const vnode = {
type: 'h1',
children: 'hello'
}
// 使用一个对象模拟挂载点
const container = { type: 'root' }
renderer2.render(vnode, container)
2
3
4
5
6
7
由于上面实现的自定义渲染器不依赖浏览器特有的 API,所以这段代码不仅可以在浏览器中运行,还可以在 Node.js 中运行。
虚拟DOM的好处,根据不同平台更换对应的渲染方法。
自定义渲染器并不是“黑魔法”,它只是通过抽象的手段,让核心代码不再依赖平台特有的 API,再通过支持个性化配置的能力来实现跨平台。
# 总结
- 利用响应系统的能力,我们可以做到,当响应式数据变化时自动完成页面更新(或重新渲染)。这与渲染器的具体实现无关。
- 渲染器的作用是把虚拟 DOM 渲染为特定平台上的真实元素,我们用英文 renderer 来表达渲染器。
- 虚拟 DOM 通常用英文 virtual DOM 来表达,有时会简写成 vdom 或 vnode。
- 渲染器会执行挂载和打补丁操作,对于新的元素,渲染器会将它挂载到容器内;对于新旧 vnode 都存在的情况,渲染器则会执行打补丁操作,即对比新旧 vnode,只更新变化的内容。
- 在浏览器平台上,渲染器可以利用 DOM API 完成 DOM 元素的创建、修改和删除。
- 为了让渲染器不直接依赖浏览器平台特有的 API,我们将这些用来创建、修改和删除元素的操作抽象成可配置的对象。用户可以在调用 createRenderer 函数创建渲染器的时候指定自定义的配置对象,从而实现自定义的行为。
- 意味着不仅可以开发web应用,还可以开发基于 nodejs 的环境,比如说桌面端、移动端,便于服务端渲染。