本系列以vue.js3为基础解析vue框架的设计原理。 本文是 vue.js的设计与实现 的第 3 章:渲染器。
- 第1章 框架设计
- 第2章 组件的实现
- 第3章 渲染器
1. 初识渲染器
我们知道虚拟dom是用javaScript对象来描述真实的dom,那么虚拟dom是如何变成真实dom渲染到页面上的呢? 我们需要设计一个渲染器来实现:
graph TD
虚拟Dom --> 渲染器 --> 真实DOM
2. 设计一个简单的渲染器renderer
const vnode = {
tag: 'div', // tag代表标签名称
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
渲染如上虚拟DOM对象,我们设计一个renderer函数
function renderer(vnode, container) {
// 1. 创建元素
const el = document.createElement(vnode.tag)
// 2. 为元素添加属性和事件
for(const key in vnode.props) {
if (/^on/.test(key)) {
// 如果 key 以 on 开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名称 onClick ---> click
vnode.props[key] // 事件处理函数
)
}
}
// 3. 处理children
if (typeof vnode.children === 'string') {
// 文本节点
const text = document.createTextNode(vnode.children)
el.appendChild(text)
} else if (vnode.children) {
// 数组 递归
vnode.children.forEach((child) => renderer(child, el))
container.appendChild(el)
}
}
这样,我们可以调用 renderer 函数:
renderer(vnode, document.body) // body 作为挂载点
以上,我们实现一个简单的渲染器,仅仅用于创建节点。以帮助我们理解渲染器。我们接下来解析vue渲染器的实现。
3. vue3渲染器的设计
3.1 渲染器的基本概念
渲染器的作用是把虚拟 DOM 渲染为特定平台上的真实元素。在浏览器平台上,渲染器会把虚拟 DOM vdom渲染为真实 DOM 元素。
渲染器把虚拟 DOM 节点渲染为真实 DOM 节点的过程叫作挂载,通常用英文 mount 来表达。例如 Vue.js 组件中的 mounted 钩子就会在挂载完成时触发。这就意味着,在 mounted 钩子中可以访问真实DOM 元素。理解这些名词有助于我们更好地理解框架的 API 设计。
渲染器把真实 DOM 挂载到哪里呢?渲染器通常需要接收一个挂载点作为参数,用来指定具体的挂载位置。
为了便于理解,下面举例说明:
function createRenderer() {
function render(vdom, container) {
// ...
}
return render
}
我们会疑惑为什么还需要createRenderer,其实渲染器不仅用来渲染,还可以用来激活已有的 DOM 元素,个过程 通常发生在同构渲染的情况下。
function createRenderer() {
function render(vdom, container) {
// ...
}
function hydrate(vdom, container) {
// ...
}
return {
render,
hydrate
}
}
关于 hydrate 函数,介绍服务端渲染时会详细讲解。
const renderer = createRenderer()
// 首次渲染
renderer.render(vDom, document.querySelector('#app'))
当首次调用 renderer.render 函数时,只需要创建新的 DOM 元素即可,这个过程只涉及挂载。而当多次在同一个 container 上调用 renderer.render 函数进行渲染时,渲染器除了要执行挂载动作外,还要执行更新动作。
3.2 挂载和更新
const renderer = createRenderer()
// 挂载:首次渲染
renderer.render(oldVDom, document.querySelector('#app'))
// 更新:第二次渲染
renderer.render(newVDom, document.querySelector('#app'))
// ...
// 卸载:传递了 null 作为新 vnode
renderer.render(newVDom, document.querySelector('#app'))
在这种情况下,渲染器会使用 newVNode 与上一次渲染的 oldVNode 进行比较,试图找到并更新变更点。这个过程叫作“打补丁”(或更新),英文通常用patch 来表达。实际上,初次挂载也可以用patch 来表达,也就是旧vDom不存在的情况。卸载也同理。
function createRenderer() {
function render(vDom, container) {
if(vDom){
// vDom存在 新旧dom对比
patch(container._vdom, vDom, container)
} else {
// 旧Dom存在,卸载
if (container._vdom) {
// ...
}
}
// 把vDom存在container._vDom下,作为后续渲染中的旧vDom
container._vDom = vDom
}
return render
}
我们不能简单地使用 innerHTML 来完成卸载操作,因为
以上并没有给出patch 函数的实现细节,其实,patch 函数是整个渲染器的核心入口,它承载了最重要的渲染逻辑。他接受三个参数,旧VDOM,新VDOM,容器节点。
3.3 更新的近一步解析
patch 函数的两个参数 n1 和 n2 分别代表旧 vdom 与新 vdom。我在这里vdom可以表示一个节点,也可以是一个组件(一组虚拟dom节点),这里vue3组件的实现可以了解vue3的设计与实现原理深度解析第2章:组件的实现,我们需要提供不同的挂载或打补丁的处理方式。
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
// 代码运行到这里,证明 n1 和 n2 所描述的内容相同
const { type } = n2
// 如果 n2.type 的值是字符串类型,则它描述的是普通标签元素
if (typeof type === 'string') {
if (!n1) {
// 挂载函数
mountElement(n2, container)
} else {
// 打补丁
patchElement(n1, n2)
}
} else if (typeof type === 'object') {
// 如果 n2.type 的值的类型是对象,则它描述的是组件,可以参考上一章节
} else if (type === 'xxx') {
// 处理其他类型的 vnode
}
}
比如新旧vdom描述的内容不同,旧节点是p标签,新节点是h标签,那么也就没有打补丁的必要,直接执行卸载旧节点,挂载新节点。
3.4 实现渲染器的跨平台能力
正如我们一直强调的,渲染器不仅能够把虚拟 DOM 渲染为浏览器平台上的真实 DOM。通过将渲染器设计为可配置的“通用”渲染器,即可实现渲染到任意目标平台上。将浏览器特定的 API 抽离,这样就可以使得渲染器的核心不依赖于浏览器。在此基础上,我们再为那些被抽离的 API 提供可配置的接口,即可实现渲染器的跨平台能力。
我们来实现一下patch 函数的代码如下:
// 假设如下更新节点结构
const n2 = {
type: 'h1',
children: 'hello',
props: {}
}
function createRenderer() {
// 挂载、更新、卸载
function patch(n1, n2, container) {
// n1:旧vdom, n2:新vdom,container:容器节点
if(!n1) {
// 旧vdom不存在,则执行挂载
mountElement(n2, container)
} else if(n1 && !n2) {
// 调用 unmount 函数卸载 vdom
unmount(container._vdom)
} else {
// 意味着要进行打补丁
}
}
// 挂载函数
function mountElement(n2, container) {
// 创建dom
const el = document.createElement(vnode.type)
if(typeof n2.children === 'string') {
// 文本节点
el.textContent = n2.children
}
// ...
container.appendChild(el)
}
// 卸载函数
function unmount(vdom) {
const parent = vdom.el.parentNode
if (parent) {
parent.removeChild(vdom.el)
}
}
function render() {
// ...
}
}
mountElement 函数内调用了大量依赖于浏览器的 API,例如 document.createElement、el.textContent 以及 appendChild 等。想要设计通用渲染器,第一步要做的就是将这些浏览器特有的 API 抽离。我们可以将这些操作 DOM 的API 作为配置项,该配置项可以作为 createRenderer 函数的参数。
// 在创建 renderer 时传入配置项
const renderer = createRenderer({
// 用于创建元素
createElement(tag){
}
// 用于设置元素的文本节点
setElementText(el, text) {
el.textContent = text
}
// 用于在给定的 parent 下添加指定元素
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor)
}
// 比如将属性设置相关操作封装到 patchProps 函数中,并作为渲染器选项传递,这里不再细述。
patchProps() {
// ...
}
})
function createRenderer(options) {
// 通过 options 得到操作 DOM 的 API
const { createElement } = options
// 在这个作用域内定义的函数都可以访问那些 API
function mountElement(vdom, container) {
// 调用 createElement 函数创建元素
const el = createElement(vdom.type)
if (typeof vdom.children === 'string') {
// 调用 setElementText 设置元素的文本节点
setElementText(el, vdom.children)
} else if (Array.isArray(vdom.children)){
// 如果 children 是数组,则遍历每一个子节点,并调用 patch 函数挂载它
vdom.children.forEach(child => patch(null, child, el))
}
if (vnode.props) {
for (const key in vnode.props) {
patchProps() // 这里不再细述。
}
}
// 调用 insert 函数将元素插入到容器内
insert(el, container)
}
// ...
}
mountElement不再直接依赖于浏览器的特有 API 了。
3.5 事件的处理
上面我们解析了dom节点的渲染,那么如果是如下结构:
const n2 = {
type: 'div',
children: 'click me',
props: {
onClick: () => {
alert('clicked')
}
}
}
patchProps(el, key, prevValue, nextValue) {
// 匹配以 on 开头的属性,视其为事件
if (/^on/.test(key)) {
// 根据属性名称得到对应的事件名称,例如 onClick ---> click
const name = key.slice(2).toLowerCase()
// 更新情况下:移除上一次绑定的事件处理函数
prevValue && el.removeEventListener(name, prevValue)
// 绑定事件,nextValue 为事件处理函数
el.addEventListener(name, nextValue)
} else {
// 其他属性暂时不写了
}
}
那接下来近一步去优化性能。在绑定事件时,我们可以绑定一个伪造的事件处理函数 invoker,然后把真正的事件处理函数设置为 invoker.value 属性的值。这样当更新事件的时候,我们将不再需要调用 removeEventListener 函数来移除上一次绑定的事件,只需要更新 invoker.value 的值即可。
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
// 获取为该元素伪造的事件处理函数 invoker
let invoker = el._vei
const name = key.slice(2).toLowerCase()
if (nextValue) {
if(!invoker) {
// 如果没有 invoker,则将一个伪造的 invoker 缓存到 el._vei 中
invoker = el._vei = (e) => {
// 当伪造的事件处理函数执行时,会执行真正的事件处理函数
invoker.value(e)
}
invoker.value = nextValue
// 绑定 invoker 作为事件处理函数
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
}
prevValue && el.removeEventListener(name, prevValue)
el.addEventListener(name, nextValue)
} else {
// 其他属性暂时不写了
}
}
那么,如果一个元素同时绑定了多种事件,将会出现事件覆盖的现象。例如同时给元素绑定 click 和 contextmenu 事件。所以我们更改下el._vei的数据结构设计,设计为一个对象,键为事件名称,值为事件的处理函数。
props: {
onClick: () => {
alert('clicked')
},
onContextmenu: () => {
alert('contextmenu')
}
},
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const invokers = el._vei || (el._vei = {})
let invoker = invokers[key]
// ...
}
}
这样解决了一个元素绑定多个事件的问题,那么对于同一类型事件,可以绑定多个事件处理函数,比如:
el.addEventListener('click', fn1)
el.addEventListener('click', fn2)
我们需要调整 vnode.props 对象中事件的数据结构。
props: {
onClick: [
() => {
alert('clicked1')
},
() => {
alert('clicked2')
},
],
}
为了实现此功能,我们需要修改 patchProps 函数中事件处理相关的代码。
// 如果 invoker.value 是数组,则遍历它并逐个调用事件处理函数
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
if (nextValue) {
if(!invoker) {
invoker = el._vei = (e) => {
// 如果 invoker.value 是数组,则遍历它并逐个调用事件处理函数
if (Array.isArray(invoker.value)){
invoker.value.forEach(fn => fn(e))
} else {
invoker.value(e)
}
}
}
// ...
}
}
}
这样,我们就已经支持同一节点绑定不同类型事件和同一类型绑定多个事件的处理。以上只是简单实现事件处理的原理。当然当数据复杂时还有事件冒泡与更新时机等问题,这个我们后面再详细解析。
3.6 更新子节点
上面我们已经知道了子节点是怎么挂载的,对于一个元素来说,它的子节点无非有以下三种情况。
- 没有子节点,此时 vdom.children 的值为 null。
- 具有文本子节点,此时 vdom.children 的值为字符串,代表文本的内容。
- 其他情况,无论是单个元素子节点,还是多个子节点(可能是文本和元素的混合),都可以用数组来表示。
那么当渲染器执行更新时,新旧子节点都分别是三种情况之一,就会有九种可能,这里我们不再列举。
- 如果没有旧子节点或者旧子节点的类型是文本子节点,那么只需要将新的文本内容(或遍历数组子节点添加)设置给容器元素;
- 如果新子节点是null或者文本,清空旧节点(旧子节点数组遍历卸载),将新节点内容渲染到容器中。
- 如果新旧子节点存在,且都是一组子节点。这里就涉及我们常说的 Diff 算法。 当然,除了以上几种节点类型,vue还有注释节点,vue3新增Fragment(片段)节点类型。
了解一下Fragment
我们知道vue2不支持多根节点模板,而Vue.js 3 支持多根节点模板,那么,Vue.js 3 是如何用 vnode 来描述多根节点模板的呢?答案就是使用Fragment。当渲染器渲染 Fragment 类型的虚拟节点时,由于 Fragment 本
身并不会渲染任何内容,所以渲染器只会渲染 Fragment 的子节点,卸载也同理,所以只需要遍历它的 children 数组,并将其中的节点逐个卸载即可。
4. diff算法
我们知道,操作 DOM 的性能开销通常比较大,这里,我们将介绍为了解决这个问题而产生的,也就是渲染器的核心Diff 算法。简单来说,当新旧 vnode 的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫作 Diff 算法。
4.1 简单diff算法
我们先简单实现两组子节点在长度不同时的更新逻辑patchChildren函数:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// ...
} else if (Array.isArray(n2.children)) {
// 新旧 children
const oldChildren = n1.children
const newChildren = n1.children
// 新旧的一组子节点的长度
const oldLen = oldChildren.length
const newLen = newChildren.length
// 两组子节点的公共长度,即两者中较短的那一组子节点的长度
const commonLength = Math.min(oldLen, newLen)
// 遍历 commonLength 次
// 遍历旧的 children
for (let i = 0; i < commonLength; i++) {
// 调用 patch 函数逐个更新子节点
patch(oldChildren[i], newChildren[i], container)
}
// 如果 newLen > oldLen,说明有新子节点需要挂载
if (newLen > oldLen) {
for (let i = commonLength; i < newLen; i++) {
patch(null, newChildren[i], container)
}
} else if (oldLen > newLen) {
// 如果 oldLen > newLen,说明有旧子节点需要卸载
for (let i = commonLength; i < oldLen; i++) {
unmount(oldChildren[i])
}
}
}
}
4.2 DOM 复用与 key 的作用
很容易发现,上述算法只有在顺序相同且只是队尾变更的情况才能实现最大复用。那如果顺序不同呢,所以最优的处理方式是,通过 DOM 的移动来完成子节点的更新,但是,如果想要通过移动更新dom,那我们就要知道是否有能移动的节点,那么怎么判断呢,我们会想到用vDom.type,但是在一组子节点vDom.type都相同的情况下,我们就无法知道新旧节点的映射关系。这时,我们就需要引入额外的 key 来作为 vdom 的标识。key 属性就像虚拟节点的“身份证”号,只要两个虚拟节点的 type 属性值和 key 属性值都相同,那么我们就认为它们是相同的,即可以进行 DOM 的复用。我们根据子节点的 key 属性,能够明确知道新子节点在旧子节点中的位置,这样就可以进行相应的 DOM 移动操作了。
DOM 可复用并不意味着不需要更新。如下面的两个虚拟节点所示:
const oldVDom = { type: 'p', key: 1, children: 'text 1' }
const newVDom = { type: 'p', key: 1, children: 'text 2' }
这意味着,在更新时可以复用 DOM 元素,即只需要通过移动操作来完成更新。但仍需要对这两个虚拟节点进行打补丁操作,因为新的虚拟节点文本子节点的内容已经改变了。
-----持续更新中,喜欢请关注-----