渲染器是 Vue.js 中非常重要的一部分,很多功能依赖渲染器来实现。渲染器也是框架性能的核心,其实现直接影响框的性能。

本章我们使用 @vue/reactivity 包提供的响应式 API VueReactivity进行讲解,以下是一个基本的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 <script 
src="https://unpkg.com/@vue/reactivity@3.0.5/dist/reactivity.global
.js"></script>

<body>
<div id="app"></div>
<script>
const { ref, effect } = VueReactivity;
function render(domString,container){
container.innerHTML = domString;
}
const count = ref(0);
effect(() => {
console.log(count.value);
render(`<h1>${count.value}</h1>`, document.getElementById('app'));
});
count.value++; // 控制台打印 1
</script>
</body>

渲染器基本概念

renderer

**渲染器(renderer)**的作用是把虚拟 DOM 渲染为特定平台上的真实元素。在浏览器平台上,渲染器会把虚拟 DOM 渲染为真实 DOM 元素。

虚拟 DOM 即 virtual DOM (vdom),和真实 DOM 结构一样,都是由节点组成的树型结构。这个树的节点叫做虚拟节点 virtual node (vnode)。由于任何一个 vnode 节点都可以是一棵子树,vnode 和 vdom 可以替换使用。原书统一使用 vnode。

渲染器把虚拟 DOM 节点渲染为真实 DOM 节点的过程叫作挂载 (mount)。渲染器通常需要接收一个挂载点作为参数,用来指定具体的挂载位置。这里的“挂载点”是一个 DOM 元素,渲染器会把该 DOM 元素作为**容器元素(container)**,并把内容渲染到其中。

渲染器与渲染不同,在实际运用中渲染器要做的不仅是渲染而已。以下是一个创建渲染器的createRenderer函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function createRenderer(){
function render(vnode,container){
// ...
}
function hydrate(container,component){
// ...
}
return {
render,
hydrate
}
}

// 调用渲染器
const renderer = createRenderer();
// 首次渲染
renderer.render(`<h1>${count.value}</h1>`, document.getElementById('app'));

可以看到渲染器不仅包含render函数,还有hydrate等其他功能的函数。关于其他功能我们在其他章节讲解。

patch

当在同一个容器上多次调用render渲染时,渲染器除了挂载还会执行更新动作,渲染器会比较新旧节点并更新变更点,这个过程叫作打补丁(更新/patch)。挂载也可以看作没有旧节点的打补丁。

我们render渲染时,调用patch函数进行节点更新。节点更新可以简单分为三种情况:初次挂载、更新节点、卸载节点。对应的,patch函数至少需要三个参数:旧节点n1、新节点n2、容器container:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function patch(n1,n2,container){...}

function render(vnode,container){
if(vnode){
// 新节点存在,更新节点
patch(container._vnode,vnode,container)
}else{
// 新节点不存在,卸载操作
if(container._vnode){
patch(container._vnode,null,container)
}
}
// 将渲染后的新节点存储为旧节点
container._vnode = vnode;
}

自定义渲染器

我们的渲染器不止于把虚拟dom渲染为浏览器的真实dom,还应该作为通用渲染器,可以渲染到任意平台上。但不同平台的对应API不同,为了进行不同平台的渲染兼容,我们应该将渲染时涉及到针对平台的API提取出来,并提供可配置的接口,实现跨平台能力。

比如我们在创建元素时,浏览器api是这样的:

1
const el = document.createElement(vnode.type)

但不同平台创建元素的操作可能就不同,我们应该将createElement这个方法提取出来,并单独配置。

我们可以将这些操作 DOM 的 API 作为配置项,该配置项可以作为 createRenderer 函数的参数。我们利用这些配置项进行渲染器的内容添加与完善:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function createRenderer(options){
// 解构获取操作API
const { createElement, insert, setElementText } = options;
// mountElement函数用于挂载节点 vnode-子节点 container-父容器
function mountElement(vnode,container){
// 调用 createElement 创建元素
const el = createElement(vnode.type);
if(typeof vnode.children === 'string'){
// 调用 setElementText 设置文本节点
setElementText(el,vnode.children);
}
// 将元素插入容器
insert(el,container);
}

function patch(n1,n2,container){
if(!n1){
// 旧节点不存在,进行元素挂载
mountElement(n2,container)
}else{
...
}
}
function render(vnode,container){
...
}
...
return {
render
}
}

我们在创建渲染器时将操作DOM的API传入配置项:

1
2
3
4
5
6
7
8
9
10
11
const renderer = createRenderer({
createElement(tag){
return document.createElement(tag);
},
insert(el,parent,anchor = null){
parent.insertBefore(el,anchor);
},
setElementText(el,text){
el.textContent = text;
}
});

这样createRenderer就可以通过配置项获取浏览器DOM操作的API了。只要传入不同的配置项,就能够完成非浏览器环境下的渲染工作,比如以下这段console模拟的渲染操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
}
})

const vnode = { type: 'h1', children: 'hello' }
const container = { type: 'root' } // 模拟父容器
renderer.render(vnode, container)