渲染器(一):渲染器的设计
1.前言:
接下来就开始详细讨论渲染器的实现细节了,这也是Vue.js中非常重要的一部分,很多功能依赖渲染器来实现,例如 Transition组件、Teleport组件、Suspense组件,以及template ref和自定义指令等。
并且它也是框架性能的核心,Vue3的渲染器不仅仅包括传统的diff算法,它还独创了快捷路径的更新方式,能够充分利用编译器提供的信息,大大提升了更新性能。
2.渲染器与响应系统的结合:
渲染器:用来执行渲染任务的。
在浏览器平台上,用它来渲染其中的真实DOM元素。渲染器不仅能够渲染真实的DOM元素,它还是框架跨平台能力的关键。所以在设计渲染器的时候一定要考虑好自定义的能力。
这节我们先将渲染器限定在dom平台。并且关于响应式的实现,在前面已经做过介绍,直接引入使用前面的即可。
来看利用响应系统,让整个渲染过程自动化:
import { ref } from '../reactivity/ref'
import { effect } from '../reactivity/effect';
export function renderer(domString, container) {
// app.innerHtml = <h1>Hello</h1>
container.innerHtml = domString;
}
const count = ref(1);
effect(() => {
renderer(`<h1>${count.value}</h1>`,
// 如果页面中存在id为app的DOM元素,就将上面的代码插入其中
document.getElementById('app'));
})
count.value++;
在上面这段代码中,先定义一个ref响应式数据count,然后在副作用函数内调用renderer
函数执行渲染。副作用函数执行完后,会与count建立响应联系。当执行 count.value++
,副作用函数重新执行,完整渲染。所以代码运行完,渲染到页面的内容是 <h1>2</h1>
。
这便是响应系统和渲染器之间的关系。利用响应系统的能力,自动调用渲染器完成页面的渲染和更新。这个过程与渲染器的具体实现无关。
3.渲染器的基本概念:
接下来先介绍渲染器所涉及的基本概念、术语和含义,有利于理解后续内容。
- renderer:渲染器,把虚拟DOM渲染为特定平台上的真实元素。
- 在浏览器平台上,渲染器把 虚拟DOM 渲染为 真实DOM。
- render:渲染,动词。
- 虚拟DOM:virtual DOM,简写为vdom。
- 虚拟DOM 和 真实DOM的结构一样,都是由一个个节点组成的树形结构。
- 虚拟DOM是树形结构,这棵树中的任何一个vnode节点都可以是一棵子树,因此vnode和vdom有时可以替换使用。(为了避免造成困惑,后续将统一使用vnode)
- 虚拟节点:virtual node,简写为vnode。
- 挂载:渲染器把 虚拟DOM节点 渲染为 真实DOM节点的过程,英文用mount来表达。
- 如:Vue中的
mounted
钩子就会在挂载完成时触发。(在mounted
中可以访问真实DOM元素)
那么问题来了,渲染器把真实DOM挂载到哪里?
- 这通过渲染器接收一个挂载点作为参数,用来指定具体的挂载位置。
- 这里的挂载点其实就是一个DOM元素,渲染器会把该DOM元素作为容器元素,并把内容渲染到其中。用container来表示容器。
如以下代码:createRenderer
用来创建一个渲染器,调用它就会得到一个render
函数,该render
函数以container
为挂载点,将vnode
渲染为真实DOM并添加到该挂载点下。
function createRenderer() {
function render(vnode, container) {
// ...
}
return render;
}
解释:需要 createRenderer
而不直接定义render,因为渲染器和渲染是不同的,渲染只是渲染器的其中一部分。
有了上面的代码后,就可以调用它来创建一个渲染器了,然后执行渲染。当首次调用renderer.render
函数时,只需要创建新的DOM元素即可,这个过程只涉及挂载:
const renderer = createRenderer();
// 首次渲染
renderer.render(vnode, document.querySelector('#app'));
当多次在一个container上调用 renderer.render
函数进行渲染时候,渲染器除了要执行挂载动作外,还要执行更新动作。
const renderer = createRenderer();
// 首次渲染
renderer.render(oldVnode, document.querySelector('#app'));
// 第二次渲染
renderer.render(newVNode, document.querySelector('#app'));
当首次渲染完成后,进行第二次渲染时,就不再简单地执行挂载动作了。在这种情况下,渲染器会使用 newVNode
与上一次渲染的 oldVNode
进行比较,试图找到并更新变更点。这个过程叫"打补丁"(或更新),英文用patch来表达。
- 特例:挂载本身也是一种特殊的打补丁,只不过旧VNode为空。
代码示例如下:
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;
}
上面代码给出了render的基本实现,我们可以配合下面代码分析其执行流程,从而更好理解render函数实现思路。
const renderer = createRenderer();
// 首次渲染
renderer.render(vnode1, document.querySelector('#app'));
// 第二次渲染
renderer.render(vnode2, document.querySelector('#app'));
// 第三次渲染
renderer.render(null, document.querySelector('#app'));
- 首次渲染时,渲染器会将vnode1渲染为真实DOM。渲染完成后,vnode1会存储到容器元素的
container._vnode
属性中,它会在后续渲染中作为旧vnode使用。 - 第二次渲染时,旧vnode存在,此时渲染器会把
vnode2
作为新的vnode,并将新旧vnode一同传递给patch函数进行更新。 - 第三次渲染时,新vnode的值为null,即不渲染,此时需要清空容器。(上面的清空容器代码是有问题的,先暂时用它来完成,后续完善)
上面中的patch函数的具体实现我们没有给出,实际上它是整个渲染器的核心入口,承载了最重要的渲染逻辑,后面将浓墨重彩地介绍它。
先初步介绍:
// n1:旧vnode, n2:新vnode, container:容器
function patch(n1, n2, container){
// ...
}
在首次渲染时,容器元素 container._vnode
属性是不存在的,即undefined。这意味着在首次渲染时传给patch
函数的第一个参数n1也是undefined
。这时patch函数会执行挂载,它会忽略n1,并直接将n2所描述的内容渲染到容器中。所以patch函数不仅可以用来更新,也可以用来执行挂载。
4.自定义渲染器:
正如前面所说,渲染器不仅能够把虚拟DOM渲染为浏览器平台上的真实DOM。通过渲染器设计可为配置的通用渲染器,即可实现渲染到任意目标平台上。本节将以浏览器作为渲染的目标平台,编写一个渲染器。
- 在这个过程中,可以看到哪些内容是可以抽象的,然后通过抽象,将浏览器特定的API抽离,这样就可以使得渲染器的核心不依赖于浏览器。在此基础上,为那些被抽离的API提供可配置的接口,就可以实现渲染器的跨平台能力了。
我们先从渲染一个普通的 <h1>
标签开始。使用如下vnode对象来描述:
const vnode = {
type: 'h1',
children: 'hello'
}
使用type属性类描述一个vnode类型,不同类型的type属性值可以描述多种类型的vnode。
- type属性是字符串类型值时,它描述的是普通标签,并使用该type属性的字符串值作为标签名。
const vnode = {
type: 'h1',
children: 'hello'
}
const renderer = createRenderer();
renderer.render(vnode, document.querySelector('#app'));
对上面的vnode进行渲染前,我们需要先补充patch函数:
function createRenderer() {
function patch(n1, n2, container) {
// 如果n1不存在,意味着挂载,调用 mountElement()完成挂载
if (!n1) {
mountElement(n2, container);
} else {
// n1存在,意味着更新,暂时省略
}
}
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;
}
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);
}
挂载一个普通标签元素的工作就完成了。但是回到我们最初想实现的目标,想要设计通用渲染器,但是这里面用到了大量依赖于浏览器的API,所以我们需要将这些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);
}
})
可以看到,我们用于操作DOM的API封装为一个对象,并把它传递给 createRenderer
函数。这样在 mountElement
等函数内就可以通过配置项来取得操作DOM的API了,并使用从配置项中取得的API重新实现 mountElement
函数:
function createRenderer(options) {
// 通过options得到操作DOM的API
const {
createElement,
insert,
setElementText
} = options
function mountElement(vnode, container) {
// 调用createElement函数创建元素
const el = createElement(vnode.type);
if (typeof vnode.children === 'string') {
// 调用setElementText 设置元素的文本节点
setElementText(el, vnode.children);
}
// 调用insert函数将元素插入到容器内
insert(el, container);
}
function patch(n1, n2, container) {
// 如果n1不存在,意味着挂载,调用 mountElement()完成挂载
if (!n1) {
mountElement(n2, container);
} else {
// n1存在,意味着更新,暂时省略
}
}
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 { mountElement, patch, render };
}
重构后的 mountElement
在功能是没有任何变化,但是它不再直接依赖于浏览器特有API了。
- 这意味着只要传入不同的配置项,就能够完成非浏览器下的渲染工作。自此,渲染器的初步设计就完成了。