渲染器的设计

渲染器是 vue.js 中非常重要的一部分。在 vue.js 中,很多功能依赖渲染器实现,例如 Transition 组件、Teloport 组件、Suspense 组件,以及 template ref 和自定义指令等。

渲染器是框架性能的核心,需要合理的架构设计来保证可维护性,不过它的实现思路并不复杂。

渲染器与响应系统的结合

顾名思义,渲染器是用来执行渲染任务的。在浏览器平台上,用它来渲染其中的真实 DOM 元素。渲染器不仅能够渲染真实 DOM 匀速,它还是框架跨平台能力的关键。因此,在设计渲染器的时候一定要考虑好可自定义的能力。

我们暂时将渲染器限定在 DOM 平台。既然渲染器用来渲染真实 DOM 元素,那么严格来说,下面的函数就是一个合格的渲染器。

function renderer (domString, container) { container.innerHTML = domString; }

我们可以这样使用它:

renderer('<h1>hello</h1>', document.getElementById('app'));

如果页面中存在 id 为 app 的 DOM 元素,那么上面的代码就会将 <h1>hello</h1> 插入到该 DOM 元素中。

当然,我们不仅可以渲染静态字符串,还可以渲染动态拼接的 HTML 内容。

let count = 1; renderer(`<h1>${ count }</h1>`, document.getElementById('app'));

这样,最终渲染出来的内容将会是 <h1>1</h1> 。但是如果上面这段代码中的变量 count 是一个响应式数据,会怎么样?

利用响应系统,我们可以让整个渲染函数过程自动化。

const count = ref(1); effect(() => { renderer(`<h1>${ count.value }</h1>`, document.getElementById('app')); }); count.value++;

这段代码中,我们首先定义了一个响应式数据 count,它是一个 ref,然后在副作用函数内调用 renderer 函数执行渲染。副作用函数执行完毕后,会与响应式数据建立响应联系。当我们修改 count.value 的值时,副作用函数会重新执行,完成重新渲染。

这就是相应系统和渲染器之间的关系。我们利用响应系统的能力,自动调用渲染器完成页面的渲染和更新。这个过程与渲染器的具体首先无关,在上面给出的渲染器的实现中,仅仅设置了元素的 innerHTML 内容。

我们将使用 @vue/reactivity 包提供的响应式 API 进行讲解。@vue/reactivity 提供了 IIFE 模块格式,因此我们可以直接通过 <script> 标签引用到页面中使用。

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Renderer</title> </head> <body> <div id="app"></div> <script src="https://unpkg.com/@vue/reactivity@3.2.31/dist/reactivity.global.js"></script> <script src="./index01.js"></script> </body> </html>

它暴露的全局 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++;

可以看到,我们通过 VueReactivity 得到了 effectref 这两个 API。

渲染器的基本概念

理解渲染器所涉及的基本概念,有利于理解后续内容。

我们通常使用英文 renderer 来表达 “渲染器”。rendererrender 含义并不相同,前者代表渲染器,后者是动词,表示 渲染。渲染器的作用是把虚拟 DOM 渲染为特定平台上的真实元素。在浏览器平台上,渲染器会把虚拟 DOM 渲染为真实 DOM 元素。

虚拟 DOM 通常用英文 virtual DOM 来表达,可以简写为 vdom。虚拟 DOM 和真实 DOM 的结构一样,都是由一个个节点组成的树形结构。所以,我们经常能听到 “虚拟节点” 这样的词,即 vritual node,可以简写为 vnode。虚拟 DOM 是树型结构,这棵树中的任何一个 vnode 节点都可以是一颗子树,因此 vnodevdom 有时可以替换使用。本篇文章中将统一使用 vnode

浏览器把虚拟 DOM 节点渲染为真实 DOM 节点的过程叫做挂载,通常用英文 mount 来表达。例如 vue.js 组件中的 mounted 钩子就会在挂载完成时触发。这就意味着,在 mounted 钩子中可以访问真实 DOM 元素。理解这些名词有助于我们更好地理解框架的 API 设计。

渲染器会把真实 DOM 挂载到哪里呢?其实渲染器并不知道应该把真实 DOM 挂载到哪里。因此,渲染器通常要接收一个挂载点作为参数,用来指定具体的挂载位置。这里的 挂载点 其实是一个 DOM 元素,渲染器会把该 DOM 元素作为容器元素,并把内容渲染到其中。我们通常使用英文 container 来表达容器。

function createRenderer () { function render (vnode, container) { // ... } return render; }

其中 createRenderer 函数用来创建一个渲染器。调用 createRenderer 函数会得到一个 render 函数,该 render 函数会以 container 为挂载点,将 vnode 渲染为真实 DOM 并添加到该挂载点下。

你可能会对这段代码产生疑惑,为什么需要 createRenderer 函数?直接定义 render 不就好了吗?
渲染器与渲染是不同的。渲染器是更加宽泛的概念,它包含渲染。渲染器不仅可以用来渲染,还可以用来激活已有的 DOM 元素,这个过程通常发生在通过渲染的情况下。例如下面的代码。

function createRenderer () { function render (vnode, container) { // ... } function hydrate (vnode, container) { // ... } return { render, hydrate }; }

createRenderer 函数创建渲染器时,渲染器不仅包含 render 函数,还包含 hydrate 函数。hydraye 函数与服务端渲染相关。

渲染器的内容非常广泛,用来把 vnode 渲染为真实 DOMrender 函数只是其中一部分。实际上,在 vue.js 3 中,甚至连创建应用的 createApp 函数也是渲染器的一部分。

有了渲染器,我们就可以用它来执行渲染任务了。

const renderer = createRenderer(); // 首次渲染 renderer.render(vnode, document.querySelector('#app'));

在上面这段代码中,我们首先调用 createRenderer 函数创建一个渲染器,接着调用渲染器的 renderer.render 函数执行渲染。当首次调用 renderer.render 函数时,只需要创建新的 DOM 元素即可,这个过程只涉及挂载。

而当多次在同一个 container 上调用 renderer.render 函数进行渲染时,渲染器除了要执行挂载动作外,还要执行更新动作。

const renderer = createRenderer(); // 首次渲染 renderer.render(oldVnode, document.querySelector('#app')); // 第二次渲染 renderer.render(newVnode, document.querySelector('#app'));

如上面的代码所示,由于首次渲染时已经把 oldVnode 渲染到 container 内,所以当再次调用 renderer.render 函数并尝试渲染 newVnode 时,就不能简单地执行挂载动作了。在这种情况下,渲染器会使用 newVnode 与上一次渲染的 oldVnode 进行比较。试图找到并更新变更点。这个过程叫做 “打补丁”(更新),英文通常用 patch 来表达。实际上,挂载工作本身也可以看作一种特殊的打补丁,它的特殊之处在于旧的 vnode 是不存在的。所以我们不必过于纠结 “挂载” 和 “打补丁” 这两个概念。

function createRenderer () { function render (vnode, container) { if (vnode) { // 新 node 存在,将其与旧 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; } function hydrate (vnode, container) { // ... } return { render, hydrate }; }

上面是 render 函数的基本实现。我们可以配合下面的代码分析其执行流程,从而更改地理解 render 函数的实现思路。假设我们连续三次调用 renderer.render 函数来执行渲染。

const renderer = createRenderer(); // 首次渲染 renderer.render(vnode1, document.querySelector('#app')); // 第二次渲染 renderer.render(vnode2, document.querySelector('#app')); // 第三次渲染 renderer.render(null, document.querySelector('#app'));
  • 首次渲染时,渲染器会将 vnode1 渲染为真实 DOM。渲染完成后,vnod1 会存储到容器元素的 container._vnode 属性中,它会在后续渲染中作为旧 vnode 使用;
  • 第二次渲染时,旧 vnode 存在,此时渲染器会把 vnode2 作为新 vnode,并将新旧 vnode 一同传递给 patch 函数打补丁;
  • 第三次渲染时,新 vnode 的值为 null,即什么都渲染。但此时容器中渲染的是 vnode2 所描述的内容,所以渲染器需要清空容器。从上面的代码中可以看出,我们使用 container.innerHTML = '' 来清空容器。需要注意的是,这样清空容器是有问题的,我们暂时使用它达到目的。

另外,在上面给出的代码中,我们注意 patch 函数的签名。

patch(container._vnode, vnode, container);

patch 函数是整个渲染器的核心入口,它承载了最重要的渲染逻辑,我们会花费大量时间会详细讲解它,这里对它做一些初步的解释。patch 函数至少接收三个参数。

function patch (n1, n2, container) { }
  • n1:旧 vnode;
  • n2:新 vnode;
  • container:容器。

首次渲染时,容器元素的 container._vnode 属性是不存在的,即 undefined。这意味着,在首次渲染时传递给 patch 函数的第一个参数 n1 也是 undefiend。这时,patch 函数会执行挂载动作,它会忽略 n1,并直接将 n2 所描述的内容挂载到容器中。从这一点可以看出,patch 函数不仅可以用来打补丁,也可以用来执行挂载。

自定义渲染器

渲染器不仅能够把虚拟 DOM 渲染为浏览器平台上的真实 DOM,还可以渲染到任意目标平台上,这需要我们把渲染器设计为可配置的 “通用” 渲染器。本节我们将以浏览器作为渲染的目标平台,编写一个渲染器,在这个过程中,通过抽象,将浏览器特定的 API 抽离,这样就可以使得渲染器的核心不依赖于浏览器。在此基础上,我们再为那些被抽离的 API 提供可配置的接口,即可实现渲染器的跨平台能力。

我们从渲染一个普通的 <h1> 标签开始。

const vnode = { type: 'h1', children: 'hello' };

观察上面的 vnode 对象。我们使用 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'));
function createRenderer () { function patch (n1, n2, container) { if (!n1) { // 如果 n1 不存在,意味着挂载,则调用 mountElement 函数完成挂载 mountElement(n2, container); } else { // n1 存在,意外着打补丁 TODO } } function render (vnode, container) { if (vnode) { // 新 node 存在,将其与旧 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; } function hydrate (vnode, container) { } return { render, hydrate }; }

我们在 createRenderer 函数内部定义了 patch 函数。第一个参数 n1 代表旧 vnode,第二个参数 n2 代表新 vnode。当 n1 不存在时,意味着没有旧 vnode,此时只需要执行挂载即可。我们使用 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); }

首先调用 document.createElement 函数,以 vnode.type 的值作为标签名称创建新的 DOM 元素。接着处理 vnode.children,如果它的值是字符串类型,则代表该元素具有文本子节点,这时只需要设置元素的 textContent 即可。最后调用 appendChild 函数将新创建的 DOM 元素添加到容器元素内。这样,我们就完成了 vnode 的挂载。

挂载一个普通元素的工作已经完成。接下来,我们分析这段代码存在的问题。我们的目的是设计一个不依赖于浏览器平台的通用渲染器,但很明显,mountElement 函数内调用了大量依赖于浏览器的 API,例如 document.createElement、el.textContent 以及 appendChild 等。想要设计通用渲染器,第一步要做的就是将这些浏览器特有的 API 抽离。我们可以将这些操作 DOM 的 API 作为配置项,该配置项可以作为 createRenderer 函数的参数。

// 创建渲染器 const renderer = createRenderer({ // 创建元素 creatElement (tag) { return document.creatElement(tag); }, // 设置元素的文本节点 setElementText (el, text) { el.textContent = text; }, // 给指定的 parent 下添加指定元素 insert (el, parent, anchor = null) { parent.insertBefore(el, anchor); } });

我们把用于操作 DOM 的 API 封装为一个对象,并把它传递给 createRenderer 函数。这样,在 mountElement 等函数内就可以通过配置项来获取操作 DOM 的 API 了。

function createRenderer (options) { const { createElement, insert, setElementText } = options; function mountElement (vnode, container) { // 调用 createElement 创建 DOM 元素 const el = createElement(vnode.type); // 处理子节点,如果子节点是字符串,代表元素具有文本节点 if (typeof vnode.children === 'string') { // 调用 setElementText 设置元素的文本节点 setElementText(el, vnode.children) } // 调用 insert 函数将元素插入到容器内 insert(el, container); } // ... return { render, hydrate }; }

重构后的 mountElement 函数在功能上没有任何变化。不同的时,它不再直接依赖于浏览器的特有 API。这意味着,只要传入不同的配置项,就能够完成非浏览器环境下的渲染工作。我们可以实现一个用来打印渲染器操作流程的自定义渲染器。

const vnode = { type: 'h1', children: 'hello' }; const container = { type: 'root' }; // 创建渲染器 const renderer = createRenderer({ createElement (tag) { console.log(`创建元素 ${ tag }`); return { tag }; }, setElementText (el, text) { console.log(`设置 ${ JSON.stringify(el) } 的文本内容:${ text }`); el.text = text; }, insert (el, parent, anchor = null) { console.log(`将 ${ JSON.stringify(el) } 添加到 ${ JSON.stringify(parent) } 下`); parent.children = el; } }); renderer.render(vnode, container); // 创建元素 h1 // 设置 {"tag":"h1"} 的文本内容:hello // 将 {"tag":"h1","text":"hello"} 添加到 {"type":"root"} 下

在调用 createRenderer 函数创建 renderer 时,传入了不同的配置项。在 createElement 内,我们不再调用浏览器的 API,而是仅仅返回一个对象 { tag } ,并将其作为创建出来的 “DOM 元素”。同样,在 setElementText 以及 insert 函数内,我们也没有调用浏览器相关 API ,而是自定义了一些逻辑,并打印信息到控制台。

上面的自定义渲染器不依赖于浏览器特有的 API ,所以这段代码不仅可以在浏览器中运行,还可以在 Node.js 中运行。

自定义渲染器并不是 ”黑魔法“ ,它只是通过抽象的手段,让核心代码不再依赖于平台特有的 API ,再通过支持个性化配置的能力来实现跨平台。

总结

我们首先介绍了渲染器与响应系统的关系。利用响应系统的能力,我们可以做到,当响应式数据变化时自动完成页面更新(重新渲染)。同时,这与渲染器的具体内容无关。我们实现了一个极简的渲染器,它只能利用 innerHTML 属性将给定的 HTML 字符串内容设置到容器中。

我们讨论了与渲染器相关的基本名词和概念。渲染器的作用是把虚拟 DOM 渲染为特定平台上的真实元素,我们用英文 renderer 来表达渲染器。虚拟 DOM 通常用英文 virtual DOM 来表达,可以简写成 vdomvnode。浏览器会执行挂载和打补丁操作,对于新的元素,渲染器会将它挂载到容器内;对于新旧 vnode 都存在的情况,渲染器则会执行打补丁操作,即对比新旧 vnode ,只更新变化的内容。

最后,我们讨论了自定义渲染器的实现。在浏览器平台上,渲染器可以利用 DOM API 完成 DOM 元素的创建、修改和删除。为了让渲染器不直接依赖浏览器平台特有的 API,我们将这些用来创建、修改和删除元素的操作抽象成可配置的对象。用户可以在调用 createRenderer 函数创建渲染器的时候指定自定义的配置对象,从而实现自定义的行为。我们实现了一个用来打印渲染操作流程的自定义渲染器,它不仅可以在浏览器中运行,还可以在 Node.js 中运行。