vue.js 3设计与实现 -- 渲染器的设计


  在 vue.js 3设计与实现 – Vue.js 3 的设计思路 这章中,我们初步讨论了虚拟 DOM 和渲染器的工作原理,并尝试编写了一个微型的渲染器。从本章开始,我们将详细讨论渲染器的实现细节。在这个过程中,你将认识到渲染器是 Vue.js 中非常重要的一部分。在 Vue.js 中,很多功能依赖渲染器来实现,例如 Transition 组件、 Teleport 组件、Suspense 组件,以及 template ref 和自定义指令等。 另外,渲染器也是框架性能的核心,渲染器的实现直接影响框架的性能。Vue.js 3 的渲染器不仅仅包含传统的 Diff 算法,它还独创了快捷路径的更新方式,能够充分利用编译器提供的信息, 大大提升了更新性能。 渲染器的代码量非常庞大,需要合理的架构设计来保证可维护性,不过它的实现思路并不复杂。接下来,我们就从讨论渲染器如何与响应系统结合开始,逐步实现一个完整的渲染器。

一、渲染器与响应系统的结合

  顾名思义,渲染器是用来执行渲染任务的。在浏览器平台上,用它来渲染其中的真实 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 的值时,副作用函数会重新执行,完成重新渲染。所以上面的代码运行完毕后,最终渲染到页面的内容是 <h1>2</h1>。

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

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

<script src="https://unpkg.com/@vue/reactivity@3.0.5/dist/reactivity.global.js"></script>

它暴露的全局 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 得到了 effect 和 ref 这两个 API。

二、渲染器的基本概念

  理解渲染器所涉及的基本概念,有利于理解后续内容。因此,本节我们会介绍渲染器所涉及的术语及其含义,并通过代码来举例说明。

  我们通常使用英文 renderer 来表达“渲染器”。千万不要把 renderer 和 render 弄混了,前者代表渲染器,而后者是动词,表示“渲染”。渲染器的作用是把虚拟 DOM 渲染为特定平台上的真实元素。在浏览器平台上,渲染器会把虚拟 DOM 渲染为真实 DOM 元素。

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

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

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

  上文分别阐述了渲染器、虚拟 DOM(或虚拟节点)、挂载以及容器等概念。为了便于理解, 下面举例说明:

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 函数。关于 hydrate 函数,介绍服务端渲染时会详细讲解。这个例子说明,渲染器的内容非常广泛,而用来把 vnode 渲染为真实 DOM 的 render 函数只是其中一部分。实际上,在 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) { 
               // 新 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 函数的实现思路。假设我们连续三次调用 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。渲染完成后,vnode1 会存储到容 器元素的 container._vnode 属性中,它会在后续渲染中作为旧 vnode 使用。

  • 在第二次渲染时,旧 vnode 存在,此时渲染器会把 vnode2 作为新 vnode,并将新旧 vnode 一同传递给 patch 函数进行打补丁。

  • 在第三次渲染时,新 vnode 的值为 null,即什么都不渲染。但此时容器中渲染的是 vnode2 所描述的内容,所以渲染器需要清空容器。从上面的代码中可以看出,我们使用 container.innerHTML = ’ ’ 来清空容器。需要注意的是,这样清空容器是有问题的,不过这里我们暂时使用它来达到目的。

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

patch(container._vnode, vnode, container)

  我们并没有给出 patch 的具体实现,但从上面的代码中,仍然可以窥探 patch 函数的部分细节。实际上,patch 函数是整个渲染器的核心入口,它承载了最重要的渲染逻辑,我们会花费大量篇幅来详细讲解它,但这里仍有必要对它做一些初步的解释。patch 函数至少接收三个参数:

function patch(n1, n2, container) { 
	// ... 
} 
  • 第一个参数 n1:旧 vnode。

  • 第二个参数 n2:新 vnode。

  • 第三个参数 container:容器。

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

三、自定义渲染器

​  正如我们一直强调的,渲染器不仅能够把虚拟 DOM 渲染为浏览器平台上的真实 DOM。通 过将渲染器设计为可配置的“通用”渲染器,即可实现渲染到任意目标平台上。本节我们将以浏览器作为渲染的目标平台,编写一个渲染器,在这个过程中,看看哪些内容是可以抽象的,然后通过抽象,将浏览器特定的 API 抽离,这样就可以使得渲染器的核心不依赖于浏览器。在此基础上,我们再为那些被抽离的 API 提供可配置的接口,即可实现渲染器的跨平台能力。 我们从渲染一个普通的 <h1> 标签开始。可以使用如下 vnode 对象来描述一个 <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')) 

为了完成渲染工作,我们需要补充 patch 函数:

function createRenderer() { 
       function patch(n1, n2, container) { 
           // 在这里编写渲染逻辑
       } 

       function render(vnode, container) { 
           if (vnode) { 
                patch(container._vnode, vnode, container) 
           } else { 
                if (container._vnode) { 
                    container.innerHTML = '' 
                } 
           } 
           container._vnode = vnode 
       } 

       return { render } 
} 

​  如上面的代码所示,我们将 patch 函数也编写在 createRenderer 函数内。在后续的讲解中, 如果没有特殊声明,我们编写的函数都定义在 createRenderer 函数内。

​  patch 函数的代码如下:

function patch(n1, n2, container) { 
       // 如果 n1 不存在,意味着挂载,则调用 mountElement 函数完成挂载
       if (!n1) { 
           mountElement(n2, container) 
       } else { 
           // n1 存在,意味着打补丁,暂时省略
       } 
} 

​  在上面这段代码中,第一个参数 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) 
}

  上面这段代码我们并不陌生, vue.js 3设计与实现 – Vue.js 3 的设计思路 这章中曾初步讲解过渲染器的相关内容。首先调用 document.createElement 函数,以 vnode.type 的值作为标签名称创建新的 DOM 元素。接着处理 vnode.children,如果它的值是字符串类型,则代表该元素具有文本子节点,这时只需要设置元素的 textContent 即可。最后调用 appendChild 函数将新创建的 DOM 元素添加到容器元素内。这样, 我们就完成了一个 vnode 的挂载。

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

function createRenderer(options) { 
       // 通过 options 得到操作 DOM 的 API 
       const { 
           createElement, 
           insert, 
           setElementText 
       } = options 

       // 在这个作用域内定义的函数都可以访问那些 API 
       function mountElement(vnode, container) { 
           // ... 
       } 

       function patch(n1, n2, container) { 
           // ... 
       } 

       function render(vnode, container) { 
           // ... 
       } 

       return { render } 
} 

接着,我们就可以使用从配置项中取得的 API 重新实现 mountElement 函数:

function mountElement(vnode, container) { 
       // 调用 createElement 函数创建元素
       const el = createElement(vnode.type) 
       if (typeof vnode.children === 'string') { 
           // 调用 setElementText 设置元素的文本节点
           setElementText(el, vnode.children) 
       } 
       // 调用 insert 函数将元素插入到容器内
       insert(el, container) 
} 

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

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 
	} 
})

  观察上面的代码,在调用 createRenderer 函数创建 renderer 时,传入了不同的配置项。在 createElement 内,我们不再调用浏览器的 API,而是仅仅返回一个对象 { tag },并将其作为创建出来的“ DOM 元素”。同样,在 setElementText 以及 insert 函数内,我们也没有调用浏览器相关的 API,而是自定义了一些逻辑,并打印信息到控制台。这样,我们就实现了一个自定义渲染器,可以用下面这段代码来检测它的能力:

const vnode = { 
       type: 'h1', 
       children: 'hello' 
} 
// 使用一个对象模拟挂载点
const container = { type: 'root' } 
renderer2.render(vnode, container)

​  需要指出的是,由于上面实现的自定义渲染器不依赖浏览器特有的 API,所以这段代码不仅可以在浏览器中运行,还可以在 Node.js 中运行。图 3-1 给出了在浏览器中的运行结果。

在这里插入图片描述

图 3-1 渲染器的运行结果

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

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值