30 秒速记
- 核心算法与宿主节点操作解耦
- 宿主提供创建、插入、移除和属性更新能力
- 调度器决定何时执行,渲染器决定怎样应用
- 异步批处理不等于自动可中断
- 引用与生命周期要在正确完成边界触发
自定义渲染器把怎样操作平台这件事抽出来,让界面协调逻辑能复用。 浏览器实现用节点接口,其他宿主可以提供自己的创建、插入和属性操作,只要满足渲染器约定。调度是另一层问题,它决定什么时候刷新;把工作延后一次不代表长更新就能中途暂停。设计时还要保证引用、生命周期和清理看到的是一致的已完成状态。
版本校准: 本文出现 Object.defineProperty、Dep、Watcher 和双端 Diff 时,主要描述 Vue 2 实现;Vue 3 使用 Proxy、effect 与新的渲染器路径。Vue 2 已于 2023-12-31 结束维护,新项目应以 Vue 3 为基线,旧项目参考 Vue 2 EOL 官方说明 制定迁移与安全策略。
在本章之前,我们花费了很大的篇幅全面的讲解了一个普通渲染器的实现原理,它可以将
Virtual DOM渲染为 Web 平台的真实 DOM。本章我们将在上一章的基础上讲解更加高级的渲染器:自定义渲染器(Custom renderer)以及异步渲染。
# 自定义渲染器的原理
渲染器是围绕 Virtual DOM 而存在的,在 Web 平台下它能够把 Virtual DOM 渲染为浏览器中的真实 DOM 对象,通过前面几章的讲解,相信你已经能够认识到渲染器的实现原理,为了能够将 Virtual DOM 渲染为真实 DOM,渲染器内部需要调用浏览器提供的 DOM 编程接口,下面罗列了在出上一章中我们曾经使用到的那些浏览器为我们提供的 DOM 编程接口:
document.createElement / createElementNS:创建标签元素。document.createTextNode:创建文本元素。el.nodeValue:修改文本元素的内容。el.removeChild:移除 DOM 元素。el.insertBefore:插入 DOM 元素。el.appendChild:追加 DOM 元素。el.parentNode:获取父元素。el.nextSibling:获取下一个兄弟元素。document.querySelector:挂载Portal类型的VNode时,用它查找挂载点。
这些 DOM 编程接口完成了 Web 平台(或者说浏览器)下对 DOM 的增加、删除、查找的工作,它是 Web 平台独有的,所以如果渲染器自身强依赖于这些方法(函数),那么这个渲染器也只能够运行在浏览器中,它不具备跨平台的能力。换句话说,如果想要实现一个平台无关的渲染器,那么渲染器自身必须不能强依赖于任何一个平台下特有的接口,而是应该提供一个抽象层,将 “DOM” 的增加、删除、查找等操作使用抽象接口实现,具体到某个平台下时,由开发者决定如何使用该平台下的接口实现这个抽象层,这就是自定义渲染器的本质。
TIP
在下文中,我们将使用 “元素” 一词指代所有平台中的元素对象,例如在 Web 平台下 “元素” 一词指的就是 DOM 元素。
渲染器除了负责对元素的增加、删除、查找之外,它还负责修改某个特定元素自身的属性/特性,例如 Web 平台中元素具有 id、href 等属性/特性。在上一章中,我们使用 patchData 函数来完成元素自身属性/特性的更新,如下代码用于修改一个元素的类名列表(class):
// patchData.js
case 'class':
el.className = nextValue
break
这段代码同样也只能运行在浏览器中,为了渲染器能够跨平台,那么修改一个元素自身的属性/特性的工作也应该作为可自定义的一部分才行,因此,一个跨平台的渲染器应该至少包含两个可自定义的部分:可自定义元素的增加、删除、查找等操作、可自定义元素自身属性/特性的修改操作。这样对于任何一个元素来说,它的增删改查都已经变成了可自定义的部分,我们只需要“告知”渲染器在对元素进行增删改查时应该做哪些具体的操作即可。
接下来我们就着手将一个普通渲染器修改为拥有自定义能力的渲染器,在之前的讲解中,我们将渲染器的代码存放在了 render.js 文件中,如下是整个 render.js 文件的核心代码:
// 导出渲染器
export default function render(vnode, container) { /* ... */ }
// ========== 挂载 ==========
function mount(vnode, container, isSVG, refNode) { /* ... */ }
function mountElement(vnode, container, isSVG, refNode) { /* ... */ }
function mountText(vnode, container) { /* ... */ }
function mountFragment(vnode, container, isSVG) { /* ... */ }
function mountPortal(vnode, container) { /* ... */ }
function mountComponent(vnode, container, isSVG) { /* ... */ }
function mountStatefulComponent(vnode, container, isSVG) { /* ... */ }
function mountFunctionalComponent(vnode, container, isSVG) { /* ... */ }
// ========== patch ==========
function patch(prevVNode, nextVNode, container) { /* ... */ }
function replaceVNode(prevVNode, nextVNode, container) { /* ... */ }
function patchElement(prevVNode, nextVNode, container) { /* ... */ }
function patchChildren(
prevChildFlags,
nextChildFlags,
prevChildren,
nextChildren,
container
) { /* ... */ }
function patchText(prevVNode, nextVNode) { /* ... */ }
function patchFragment(prevVNode, nextVNode, container) { /* ... */ }
function patchPortal(prevVNode, nextVNode) { /* ... */ }
function patchComponent(prevVNode, nextVNode, container) { /* ... */ }
// https://en.wikipedia.org/wiki/Longest_increasing_subsequence
function lis(arr) { /* ... */ }
观察如上代码结构,可以发现一个渲染器由两部分组成:mount 和 patch。在 mount 和 patch 中都会调用浏览器提供的 DOM 编程接口来完成真正的渲染工作。为了将浏览器提供的 DOM 编程接口与渲染器的代码分离,我们可以将如上代码封装到一个叫做 createRenderer 的函数中,如下代码所示:
export default function createRenderer(options) {
function render(vnode, container) { /* ... */ }
// ========== 挂载 ==========
function mount(vnode, container, isSVG, refNode) { /* ... */ }
function mountElement(vnode, container, isSVG, refNode) { /* ... */ }
function mountText(vnode, container) { /* ... */ }
function mountFragment(vnode, container, isSVG) { /* ... */ }
function mountPortal(vnode, container) { /* ... */ }
function mountComponent(vnode, container, isSVG) { /* ... */ }
function mountStatefulComponent(vnode, container, isSVG) { /* ... */ }
function mountFunctionalComponent(vnode, container, isSVG) { /* ... */ }
// ========== patch ==========
function patch(prevVNode, nextVNode, container) { 