渲染器(一):渲染器的设计

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了。

  • 这意味着只要传入不同的配置项,就能够完成非浏览器下的渲染工作。自此,渲染器的初步设计就完成了。