精读 《Vuejs设计与实现》第 8 章(挂载与更新)
本章将深入渲染器的核心功能:挂载与更新。
8.1 挂载子节点与属性
在第七章中,我们讨论了当 vnode.children 是字符串时,它会被设为元素的文本内容。然而,一个元素可能有多个子节点,包括文本和其他元素节点。为描述子节点,我们将 vnode.children 定义为数组:
const vnode = {
type: 'div',
children: [
{
type: 'p',
children: 'hello'
}
]
};
上述代码描述的是 "一个 div 元素有一个 p 元素作为子节点"。vnode.children 是个数组,其中每个元素都是独立的虚拟节点,形成虚拟 DOM 树。为完成子节点渲染,需要修改 mountElement 函数:
function mountElement(vnode, container) {
const el = createElement(vnode.type)
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children)
} else if (Array.isArray(vnode.children)) {
// 如果 children 是数组,则遍历每一个子节点,并调用 patch 函数挂载它们
vnode.children.forEach(child => {
patch(null, child, el)
})
}
insert(el, container)
}
在这段代码中,我们判断了 vnode.children 是否是数组,如果是,循环挂载虚拟节点。挂载子节点时,patch 函数的第一个参数是 null,因为是挂载阶段没有旧 vnode。当 patch 函数执行时,会递归调用 mountElement 函数完成挂载。第三个参数是挂载点,子节点需要把刚刚创建的 div 标签作为挂载点。
接下来,我们讨论如何描述渲染元素的属性。HTML 标签有各种属性,有通用属性如 id、class,也有特定元素属性如 form 的 action。为描述元素的属性,我们定义新的 vnode.props 字段:
const vnode = {
type: 'div',
// 使用 props 描述一个元素的属性
props: {
id: 'foo'
},
children: [
{
type: 'p',
children: 'hello'
}
]
}
vnode.props 是个对象,键代表属性名称,值代表对应属性的值。我们可以遍历 props 对象,把这些这些属性渲染到对应的元素上:
function mountElement(vnode, container) {
const el = createElement(vnode.type)
// 省略 children 的处理
// 如果 vnode.props 存在才处理它
if (vnode.props) {
// 遍历 vnode.props
for (const key in vnode.props) {
// 调用 setAttribute 将属性设置到元素上
el.setAttribute(key, vnode.props[key])
}
}
insert(el, container)
}
上述代码,我们检查了 vnode.props 是否存在,如果存在则遍历它,并调用 setAttribute 函数将属性设置到元素上。除了使用 setAttribute 函数设置元素属性,我们还可以直接在 DOM 对象上设置:
function mountElement(vnode, container) {
const el = createElement(vnode.type);
// 省略 children 的处理
if (vnode.props) {
for (const key in vnode.props) {
// 直接设置
el[key] = vnode.props[key];
}
}
insert(el, container);
}
在此,我们直接将属性设置在 DOM 对象上,即 el[key] = vnode.props[key]
。无论使用 setAttribute 函数还是直接操作 DOM 对象,都有其限制。设置元素属性实际上比预想的复杂。在讨论具体缺陷之前,我们需要理解两个重要概念:HTML Attributes 和 DOM Properties。
8.2 HTML Attributes 与 DOM Properties
理解 HTML 属性(Attributes)与 DOM 属性(Properties)的关系和区别是非常重要的,这能帮助我们高效地创建虚拟节点结构,并正确设置元素属性。下面我们从基础的 HTML 开始讲解:
<input id="my-input" type="text" value="foo" />
HTML Attributes 是定义在 HTML 标签上的属性,如此处的 id="my-input",type="text" 和 value="foo"。当浏览器解析此 HTML 代码后,会创建一个相应的 DOM 元素对象,我们可以通过 JavaScript 来访问该 DOM 对象:
const el = document.querySelector('#my-input')
这个 DOM 对象包含许多属性,这些属性称为 DOM 属性。许多 HTML 属性 在 DOM 对象中有同名的 DOM 属性,例如 id="my-input" 对应 el.id,type="text" 对应 el.type,value="foo" 对应 el.value 等。但 HTML 属性与 DOM 属性并不总是完全对应,如:
<div class="foo"></div>
HTML属性 class="foo" 对应的DOM属性是 el.className。并且,并非所有 HTML 属性都有对应的 DOM 属性,反之亦然。例如下面:
<div aria-valuenow="75"></div>
aria-* 类的 HTML 属性并没有与之对应的 DOM 属性。例如可以用 DOM 属性 el.textContent 来设置元素的文本内容, 但并没有与之对应的 HTML 属性来完成同样的工作。
HTML 属性与 DOM 属性之间是有关联的。例如:
<div id="foo"></div>
HTML 属性 id="foo" 对应DOM属性 el.id,值为字符串 'foo'。这种相同名词的情况我们称其为直接映射。但并非所有 HTML 属性与 DOM 属性都是直接映射,例如:
<input value="foo" />
HTML 属性 value="foo" 对应 DOM 属性 el.value。如果用户未修改文本框内容,则 el.value 值为字符串 'foo'。如果用户修改了文本框的值,el.value 值为当前文本框的值。例如,用户将文本框内容修改为 'bar':
console.log(el.value) // 'bar'
console.log(el.getAttribute('value')) // 'foo'
上述代码,说明用户对文本框内容的修改并不影响 el.getAttribute('value') 的返回值。事实上,HTML 属性的作用是设置对应 DOM 属性的初始值。一旦值改变,DOM 属性会持续保存当前值,而 getAttribute 函数得到的仍然是初始值。我们还可以通过 el.defaultValue 来访问初始值,如下:
console.log(el.getAttribute('value')) // 'foo'
console.log(el.defaultValue) // 'foo'
console.log(el.value) // 'bar'
上述代码说明一个 HTML 属性可能与多个 DOM 属性有关联。虽然 HTML 属性是用来设置对应 DOM 属性的初始值,但有些值是受限制的,如果你通过 HTML 属性设置的默认值不合法,浏览器会使用内建的合法值作为对应的 DOM 属性。例如:
<input type="foo" />
在这里,为 <input/>
标签的 type 属性指定字符串 'foo' 是不合法的,因此浏览器会自动纠正这个非法值。所以当我们试图读取 el.type 时,得到的实际上是纠正后的值,即字符串 'text',而非 'foo':
console.log(el.type) // 'text'
总结来看,HTML 属性与 DOM 属性之间的关系复杂。但要记住一个核心原则就足够了:HTML 属性的主要作用是为对应的 DOM 属性设置初始值。这样,即使在面对复杂的属性映射和值变化时,我们也可以清楚地理解其运作原理。
8.3 设置元素属性的正确方式
在上一节中,我们讨论了 HTML Attributes 和 DOM Properties 的关系以及它们如何影响 DOM 属性的添加方式。通常情况,在普通 HTML 文件中,浏览器在解析 HTML 代码后会自动分析 HTML 属性并设置合适的 DOM 属性。但是,在 Vue.js 的单文件组件中的模板,浏览器并不会解析这些代码,所以这个任务就需要由框架来完成。以一个禁用的按钮为例:
<button disabled>Button</button>
浏览器在解析这段 HTML 代码时,会发现这个按钮有一个 disabled 的 HTML 属性,因此会把这个按钮设置为禁用状态,并且设置 DOM 属性 el.disabled为 true。但是,如果相同的代码在 Vue.js 的模板中,情况就会变得不同。这段 HTML 模板会被编译成一个 vnode,如下:
const button = {
type: 'button',
props: {
disabled: ''
}
}
这里 props.disabled 的值是空字符串。如果在渲染器中调用 setAttribute 函数设置属性,相当于:
el.setAttribute('disabled', '')
这么做没有问题,浏览器会禁用按钮。但如果我们有以下模板:
<button :disabled="false">Button</button>
它对应的vnode为:
const button = {
type: 'button',
props: {
disabled: false
}
}
这个代码的意图是“不禁用”按钮,但如果渲染器仍然使用 setAttribute 函数设置属性值:
el.setAttribute('disabled', false)
浏览器运行上述代码后,按钮仍然会被禁用。这是因为使用 setAttribute 函数设置的值总会被转化为字符串,所以上面的代码等效于:
el.setAttribute('disabled', 'false')
对于按钮来说,它的 el.disabled 属性值是布尔类型的,只要 disabled 属性存在,无论其值是什么,按钮都会被禁用。因此,渲染器不应总是使用 setAttribute 函数将 vnode.props 对象中的属性设置到元素上。一种更自然的思路是,我们可以优先设置 DOM 属性,例如:
el.disabled = false
这样可以正确工作,但带来了新的问题。考虑以下模板:
<button disabled>Button</button>
这段模板对应的 vnode 是:
const button = {
type: 'button',
props: {
disabled: ''
}
}
这里 props.disabled 的值是空字符串。如果我们直接用这个值设置元素的 DOM 属性,那么相当于:
el.disabled = ''
由于 el.disabled 是布尔类型的值,所以当我们尝试将它设置为空字符串时,浏览器会将它的值矫正为布尔类型的值,即 false。 所以上面这句代码的执行结果等价于:
el.disabled = false
这违背了用户的本意,因为用户希望禁用按钮,而 el.disabled = false 则是不禁用的意思。
这样看来,无论是使用 setAttribute 函数,还是直接设置元素 的 DOM 属性,都存在缺陷。解决这个问题,我们做些特殊处理,优先设置元素的 DOM 属性,但当值为空字符串时,要手动将值矫正为 true。只有这样,才能保证代码的行为符合预 期,具体实现如下:
function mountElement(vnode, container) {
const el = createElement(vnode.type)
// 省略 children 的处理
if (vnode.props) {
for (const key in vnode.props) {
// 用 in 操作符判断 key 是否存在对应的 DOM Properties
if (key in el) {
// 获取该 DOM Properties 的类型
const type = typeof el[key]
const value = vnode.props[key]
// 如果是布尔类型,并且 value 是空字符串,则将值矫正为 true
if (type === 'boolean' && value === '') {
el[key] = true
} else {
el[key] = value
}
} else {
// 如果要设置的属性没有对应的 DOM Properties,则使用 setAttribute 函数设置属性
el.setAttribute(key, vnode.props[key])
}
}
}
insert(el, container)
}
但上面实现仍有问题,因为有一些 DOM 属性是只读的,,比如 <input form="form1" />
中的 form 属性,它对应的 DOM Properties 是 el.form,但 el.form 是只读的,因此我们只能够通过 setAttribute 函数来设置它。我们修改现有逻辑:
function shouldSetAsProps(el, key, value) {
// 特殊处理
if (key === 'form' && el.tagName === 'INPUT') return false
// 兜底
return key in el
}
function mountElement(vnode, container) {
const el = createElement(vnode.type)
// 省略 children 的处理
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key]
// 使用 shouldSetAsProps 函数判断是否应该作为 DOM Properties 设置
if (shouldSetAsProps(el, key, value)) {
const type = typeof el[key]
if (type === 'boolean' && value === '') {
el[key] = true
} else {
el[key] = value
}
} else {
el.setAttribute(key, value)
}
}
}
insert(el, container)
}
上述代码,我们提取了 shouldSetAsProps 函数,用于判断属性是否应该作为 DOM 属性设置。对于 <input form="xxx" />
这种特殊情况,我们需要使用 setAttribute 函数来设置 form 属性。事实上会有很多类似这种特殊情况要处理。
最后,我们需要将属性设置与平台无关,将相关操作提取到渲染器选项中:
const renderer = createRenderer({
createElement(tag) {
return document.createElement(tag)
},
setElementText(el, text) {
el.textContent = text
},
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor)
},
// 将属性设置相关操作封装到 patchProps 函数中,并作为渲染器选项传递
patchProps(el, key, prevValue, nextValue) {
if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key]
if (type === 'boolean' && nextValue === '') {
el[key] = true
} else {
el[key] = nextValue
}
} else {
el.setAttribute(key, nextValue)
}
}
})
而在 mountElement 函数中,只需要调用 patchProps 函数, 并为其传递相关参数即可:
function mountElement(vnode, container) {
const el = createElement(vnode.type)
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children)
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
patch(null, child, el)
})
}
if (vnode.props) {
for (const key in vnode.props) {
// 调用 patchProps 函数即可
patchProps(el, key, null, vnode.props[key])
}
}
insert(el, container)
}
这样,我们成功地将属性相关的渲染逻辑从渲染器的核心中分离出来。在这个过程中,我们遵循了一种持续迭代优化的思路,只有这样具体问题具体分析,一步步解决,才能逐渐使代码更健壮。
8.4 处理 class 属性
Vue.js 中的某些属性,如 class 需要特殊处理,因为 Vue.js 对 class 属性做了增强,提供了几种设定元素类名的方式。方式一: 直接将 class 设为字符串:
<p class="foo bar"></p>
对应的 vnode 是:
const vnode = {
type: 'p',
props: {
class: 'foo bar'
}
}
方式二: 将 class 设为一个对象,假设对象 cls = { foo: true, bar: false }:
<p :class="cls"></p>
对应的 vnode 是:
const vnode = {
type: 'p',
props: {
class: { foo: true, bar: false }
}
}
方式三: 将 class 设为包含上述两种类型的数组:
<p :class="arr"></p>
这个数组可以是字符串值与对象值的组合:
const arr = [
'foo bar',
{ baz: true }
]
对应的 vnode 是:
const vnode = {
type: 'p',
props: {
class: [
'foo bar',
{ baz: true }
]
}
}
因为 class 的值可以是多种类型,我们可以使用 normalizeClass 函数,在设置元素的 class 之前需要将其标准化为字符串形式:
const vnode = {
type: 'p',
props: {
// 使用 normalizeClass 函数对值进行序列化
class: normalizeClass([
'foo bar',
{ baz: true }
])
}
}
标准化后的结果等价于:
const vnode = {
type: 'p',
props: {
class: 'foo bar baz'
}
}
关于 normalizeClass 简单的转换就不做过多阐述,假设此时已经完成标准化 class 值,我们如何设置到元素之上?在浏览器中,我们有三种方式为元素设置:setAttribute、el.className 、 el.classList:比较每秒执行数,我们会发现 el.className 的性能最优。由于 class 属性对应的 DOM 属性是 el.className,所以 'class' in el 的值将会是 false,patchProps 函数会使用 setAttribute 函数来完成 class 的设置。因此,我们需要调整 patchProps 函数的实现:
const renderer = createRenderer({
// 省略其他选项
patchProps(el, key, prevValue, nextValue) {
// 对 class 进行特殊处理
if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key]
if (type === 'boolean' && nextValue === '') {
el[key] = true
} else {
el[key] = nextValue
}
} else {
el.setAttribute(key, nextValue)
}
}
})
上述代码,我们对 class 进行特殊处理,即用 el.className 代替 setAttribute 函数。实际上,除了 class 属性之外,Vue.js 对 style 属性也进行了增强,我们也需要对 style 做类似的处理。通过 class 我们意识到 vnode.props 对象中定义的属性值的类型并不总是与 DOM 元素属性的数据结构一致,这取决于上层 API 的设计。上层允许使用 class 对象类型使用,在底层实现中,我们就必须先对值进行标准化再设置,注意这种标准化也会有一定的性能代价。
8.5 卸载操作
现在让我们来看看卸载操作。卸载操作通常会在更新阶段发生,更新指的是,在初次挂载完成之后,后续渲染会触发更新,如下面的代码所示:
// 初次挂载
renderer.render(vnode, document.querySelector('#app'))
// 再次挂载新 vnode,将触发更新
renderer.render(newVNode, document.querySelector('#app'))
有几种不同的更新情况,比如我们传递新 vnode 为 null,代表我们不要渲染任何内容了,这时就需要卸载先前的内容:
// 初次挂载
renderer.render(vnode, document.querySelector('#app'))
// 新 vnode 为 null,意味着卸载之前渲染的内容
renderer.render(null, document.querySelector('#app'))
我们之前实现的 render 函数,当 vnode 为 null 且容器元素的 container._vnode 属性存在时,我们直接通过 innerHTML 来清空容器。然而,这种做法并不严谨,因为:
- 容器的内容可能是由一个或多个组件渲染的,当卸载操作发生时,我们应该正确地调用这些组件的 beforeUnmount、unmounted 等生命周期函数。
- 即使内容不是由组件渲染的,有的元素存在自定义指令,我们应该在卸载操作发生时正确执行对应的指令钩子函数。
- 使用 innerHTML 清空容器元素内容的另一个缺点是,它不会移除绑定在 DOM 元素上的事件处理函数。
因此,我们不能简单地使用 innerHTML 来完成卸载操作。正确的方式是根据 vnode 对象获取其关联的真实 DOM 元素,然后使用原生 DOM 操作方法将 该 DOM 元素移除。我们需要在 vnode 与真实 DOM 元素之间建立联系。修改 mountElement 函数如下:
function mountElement(vnode, container) {
// 让 vnode.el 引用真实 DOM 元素
const el = vnode.el = createElement(vnode.type)
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children)
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
patch(null, child, el)
})
}
if (vnode.props) {
for (const key in vnode.props) {
patchProps(el, key, null, vnode.props[key])
}
}
insert(el, container)
}
上述代码,我们在创建真实 DOM 元素时,将其赋值给 vnode.el 属性。这样,我们可以通过 vnode.el 来获取该虚拟节点对应的真实 DOM 元素。
当卸载操作发生时,我们只需获取 vnode.el 对应的真实 DOM 元素,然后将其从父元素中移除:
function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
// 根据 vnode 获取要卸载的真实 DOM 元素
const el = container._vnode.el
// 获取 el 的父元素
const parent = el.parentNode
// 调用 removeChild 移除元素
if (parent) parent.removeChild(el)
}
}
container._vnode = vnode
}
上述代码,container._vnode 代表旧的 vnode,即需要卸载的 vnode。我们通过 container._vnode.el 获取真实 DOM 元素,并用 removeChild 函数将其从父元素中移除。
卸载操作是一种常见且基本的操作,因此我们应该将其封装到一个 unmount 函数中,以便复用:
function unmount(vnode) {
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}
上述 unmount 函数接受一个虚拟节点作为参数,然后将该虚拟节点对应的真实 DOM 元素从父元素中移除,我们可以在 render 函数中直接调用它来完成卸载任务:
function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
// 调用 unmount 函数卸载 vnode
unmount(container._vnode)
}
}
container._vnode = vnode
}
最后,将卸载操作封装到 unmount 函数中还有两个额外的好处:
- 在 unmount 函数内,我们可以调用绑定在 DOM 元素上的指令钩子函数,例如 beforeUnmount、unmounted 等。
- 当 unmount 函数执行时,我们可以检查虚拟节点 vnode 的类型。如果该虚拟节点描述的是一个组件,我们就可以调用组件相关的生命周期函数。
8.6 区分 vnode 类型
前文中,我们知道在渲染空内容(null)时,会执行卸载操作。若向 render 函数传入新的 vnode,不会执行卸载,而是通过 patch 函数将新旧 vnode 进行比较和更新:
function patch(n1, n2, container) {
if (!n1) {
mountElement(n2, container)
} else {
// 更新
}
}
这里,n1 和 n2 分别代表旧 vnode 与新 vnode。若旧 vnode 存在,我们就在新旧 vnode 之间打补丁。但是打补丁也需要保证新旧 vnode 描述的内容相同,例如 vnode 是一个 p 元素,然后我们渲染了一个 input 元素,这时新旧 vnode 描述的内容不同,即 vnode.type 属性的值不同,此时不存在打补丁的意义,因为每个元素都有自己特有属性。
<p id="foo" />
<input type="submit" />
正确的操作是先卸载 p 元素,再将 input 元素挂载到容器中。因此我们需要调整 patch 函数:
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
if (!n1) {
mountElement(n2, container)
} else {
// 更新
}
}
上述代码,在执行更新操作前,我们首先检查新旧 vnode 是否相同,若不同,则直接卸载旧 vnode。卸载后,应该将 n1 重置为 null,以确保后续挂载操作正确执行。
即使新旧 vnode 描述的内容相同,我们还需要确认它们的类型是否相同。一个 vnode 可以描述普通标签,也可以描述组件,还可以描述 Fragment 等。对于不同类型的 vnode,我们需要提供不同的处理方式。因此,我们需要进一步修改 patch 函数:
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
}
}
实际应用中,我们需要根据 vnode.type 进一步确认它们的类型,以使用相应的处理函数。如果 vnode.type 是字符串类型,则它描述的是普通标签元素,我们会调用 mountElement 或 patchElement 进行挂载和更新操作。如果 vnode.type 是对象类型,则它描述的是组件,我们会调用与组件相关的挂载和更新方法。
8.7 事件处理优化
事件可被视为特殊属性,因此我们约定:在 vnode.props 对象中,以 on 开头的字符串属性均被视为事件,如下所示:
const vnode = {
type: 'p',
props: {
onClick: () => {
alert('clicked')
}
},
children: 'text'
}
接下来,我们来探究如何绑定事件到 DOM 元素,我们可以在 patchProps 中调用 addEventListener 来绑定事件:
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const name = key.slice(2).toLowerCase()
el.addEventListener(name, nextValue)
}
// 其他属性处理代码省略...
}
如何更新事件?一般来说,需要先移除旧的事件处理器,然后将新的处理器绑定到 DOM 元素上:
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const name = key.slice(2).toLowerCase()
// 移除上一次绑定的事件处理函数
prevValue && el.removeEventListener(name, prevValue)
// 绑定新的事件处理函数
el.addEventListener(name, nextValue)
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}
但是,我们可以使用一个更高效的方式来更新事件。我们可以绑定一个伪事件处理函数 invoker,并将真正的处理函数设为 invoker.value。这样在更新事件时,我们只需要更新 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 中
// vei 是 vue event invoker 的首字母缩写
invoker = el._vei = e => {
// 当伪造的事件处理函数执行时,会执行真正的事件处理函数
invoker.value(e)
}
// 将真正的事件处理函数赋值给 invoker.value
invoker.value = nextValue
// 绑定 invoker 作为事件处理函数
el.addEventListener(name, invoker)
} else {
// 如果 invoker 存在,意味着更新,并且只需要更新 invoker.value 的值即可
invoker.value = nextValue
}
} else if (invoker) {
// 新的事件绑定函数不存在,且之前绑定的 invoker 存在,则移除绑定
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}
上面的代码,事件绑定主要分为两个步骤:
- 先从 el._vei 中读取对应的 invoker,如果 invoker 不存在,则将伪造的 invoker 作为事件处理函数,并将它缓存到 el._vei 属性中。
- 把真正的事件处理函数赋值给 invoker.value 属性,然后把伪造的 invoker 函数作为事件处理函数绑定到元素上。可以看到,当事件触发时,实际上执行的是伪造的事件处理函数,在其内部间接执行了真正的事件处理函数 invoker.value(e)。
当更新事件时,由于 el._vei 已经存在了,所以我们只需要将 invoker.value 的值修改为新的事件处理函数即可。这样,在更新事件时可以避免一次removeEventListener 函数的调用,从而提升了性能。实际上,伪造的事件处理函数的作用不止于此,它还能解决事件冒泡与事件更新之间相互影响的问题,下文会详细讲解。上述实现会存在一个问题,我们将事件处理函数缓存在 el._vei 属性中,只能缓存一个事件处理函数。如果一个元素同时绑定多种事件,将会覆盖先绑定的事件。例如,我们给元素同时绑定 click 和 contextmenu 事件,后绑定的事件将覆盖先绑定的事件。
const vnode = {
type: 'p',
props: {
onClick: () => {
alert('clicked')
},
onContextmenu: () => {
alert('contextmenu')
}
},
children: 'text'
}
renderer.render(vnode, document.querySelector('#app'))
后绑定的 contextmenu 事件的处理函数将覆盖先绑定的 click 事件的处理函数。为解决这个问题,我们可以将 el._vei 设计为一个对象,其键为事件名称,值为相应的事件处理函数。这样就不会覆盖事件,代码如下:
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
// 定义 el._vei 为一个对象,存在事件名称到事件处理函数的映射
const invokers = el._vei || (el._vei = {})
//根据事件名称获取 invoker
let invoker = invokers[key]
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
// 将事件处理函数缓存到 el._vei[key] 下,避免覆盖
invoker = el._vei[key] = e => {
invoker.value(e)
}
invoker.value = nextValue
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}
再进一步,我们还可以为同一类型的事件绑定多个处理函数。例如,我们可以多次调用 addEventListener 为元素绑定同一类型的事件,每个处理函数都会执行。因此,我们需要修改 vnode.props 中的事件数据结构以描述同一事件的多个处理函数。代码如下:
const vnode = {
type: 'p',
props: {
onClick: [
// 第一个处理函数
() => {
alert('clicked 1')
},
// 第二个处理函数
() => {
alert('clicked 2')
}
]
},
children: 'text'
}
renderer.render(vnode, document.querySelector('#app'))
上述代码,我们使用数组来描述事件,数组中的每个元素都是一个独立的事件处理函数。这些处理函数都将正确地绑定到对应元素上。为了实现此功能,我们需要修改 patchProps 函数中的事件处理相关代码,如下:
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const invokers = el._vei || (el._vei = {})
let invoker = invokers[key]
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
invoker = el._vei[key] = e => {
// 如果 invoker.value 是数组,则遍历它并逐个调用事件处理函数
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
// 否则直接作为函数调用
invoker.value(e)
}
}
invoker.value = nextValue
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}
上述代码,我们修改了 invoker 函数的实现。在调用真正的事件处理函数之前,我们先检查 invoker.value 是否为数组。如果是数组,我们遍历它,并逐一调用数组中定义的事件处理函数。
8.8 事件冒泡与更新时机问题
本节我们将讨论事件冒泡与更新时机相结合所导致的问题,我们先构造一个例子:
const { effect, ref } = VueReactivity
const bol = ref(false)
effect(() => {
// 创建 vnode
const vnode = {
type: 'div',
props: bol.value
? {
onClick: () => {
alert('父元素 clicked')
}
}
: {},
children: [
{
type: 'p',
props: {
onClick: () => {
bol.value = true
}
},
children: 'text'
}
]
}
// 渲染 vnode
renderer.render(vnode, document.querySelector('#app'))
})
该 vnode 对象,它描述了一个 div 元素,并且该 div 元素具有一个 p 元素作为子节点。div 元素
- 它的 props 对象的值是由一个三元表达式决定的。在首次渲染时,由于 bol.value 的值为 false,所以它的 props 的值是一个空对象。
p 元素
- 它具有 click 点击事件,并且当点击它时,事件处理函数会将 bol.value 的值设置为 true。
现在,当首次渲染完成后,用鼠标点击 p 元素,会触发父级 div 元素的 click 事件的事件处理函数执行吗?首次渲染完成之后,由于 bol.value 的值为 false,所以渲染器并不会为 div 元素绑定点击事件,然后点击 p 冒泡到 div 应该是什么都不会发生,事实却是 div 元素的 click 事件的事件处理函数竟然执行了。因为点击 p 元素时,绑定到它身上的 click 事件处理函数会执行, bol.value 的值被改为 true。由于 bol 是一个响应式数据,所以当它的值发生变化时,会触发副作用函数重新执行。在更新阶段,因为 bol 为 true,渲染器会为父级 div 元素绑定 click 事件处理函数。可以发现,事件触发的时间要早于事件处理函数被绑定的时间。意味着当一个事件触发时,目标元素还没有绑定事件。我们可以根据这个特点解决问题:屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行。调整 patchProps 函数中关于事件的代码:
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const invokers = el._vei || (el._vei = {})
let invoker = invokers[key]
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
invoker = el._vei[key] = e => {
// e.timeStamp 是事件发生的时间
// 如果事件发生的时间早于事件处理函数绑定的时间,则不执行事件处理函数
if (e.timeStamp < invoker.attached) return
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
invoker.value(e)
}
}
invoker.value = nextValue
// 添加 invoker.attached 属性,存储事件处理函数被绑定的时间
invoker.attached = performance.now()
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}
我们在原来的基础上添加了两行代码。首先,我们为伪造的事件处理函数添加了 invoker.attached 属性,用来存储事件处理函数被绑定的时间。然后,在 invoker 执行的时候,通过事件对象的 e.timeStamp获取事件发生的时间最后,比较两者,如果事件处理函数被绑定的时间晚于事件发生的时间,则不执行该事件处理函数。performance.now 是高精度时间,e.timeStamp 的值也会有所不同。它既可能是高精时间,也可能是非高精时间,严格来讲,这里需要做兼容处理。
8.9 子节点的更新
我们先回顾一下如何将子节点挂载到元素上,如下所示的 mountElement 函数:
function mountElement(vnode, container) {
const el = vnode.el = createElement(vnode.type)
// 挂载子节点,需要判断 children 的类型
if (typeof vnode.children === 'string') {
// 如果是字符串,表示文本子节点
setElementText(el, vnode.children)
} else if (Array.isArray(vnode.children)) {
// 如果是数组,表示多个子节点
vnode.children.forEach(child => {
patch(null, child, el)
})
}
if (vnode.props) {
for (const key in vnode.props) {
patchProps(el, key, null, vnode.props[key])
}
}
insert(el, container)
}
挂载节点,我们需要根据子节点的类型,做不同的处理:
- 如果 vnode.children 是字符串,表示元素有文本子节点;
- 如果 vnode.children 是数组,表示元素有多个子节点。我们需要规范子节点的类型以便编写更新逻辑
在讨论如何更新子节点之前,我们先规范化 vnode.children。
- 没有子节点:此时 vnode.children 的值为 null。
- 有文本子节点:此时 vnode.children 的值为字符串,代表文本的内容。
- 其他情况,例如单个元素子节点或多个子节点(可能是文本和元素的混合):可以用数组来表示。
// 没有子节点
vnode = {
type: 'div',
children: null
}
// 文本子节点
vnode = {
type: 'div',
children: 'Some Text'
}
// 其他情况,子节点使用数组表示
vnode = {
type: 'div',
children: [
{ type: 'p' },
'Some Text'
]
}
现在规范化了 vnode.children 的类型的三种情况,我们可以总结出更新子节点时全部九种可能:然而,在实际编码中,我们发现并非所有九种情况都需要考虑,下面我们优化 patchElement 函数:
function patchElement(n1, n2) {
const el = n2.el = n1.el;
const oldProps = n1.props;
const newProps = n2.props;
// 第一步:更新 props
for (const key in newProps) {
if (newProps[key] !== oldProps[key]) {
patchProps(el, key, oldProps[key], newProps[key]);
}
}
for (const key in oldProps) {
if (!(key in newProps)) {
patchProps(el, key, oldProps[key], null);
}
}
// 第二步:更新 children
patchChildren(n1, n2, el);
}
上述代码,对最后的 patchChildren 函数封装传入新旧 vnode 以及当前正在更新的 DOM 元素 el:
function patchChildren(n1, n2, container) {
// 判断新子节点是否文本节点
if (typeof n2.children === 'string') {
// 当旧子节点是一组节点,需要逐个卸载
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c))
}
// 将最新文本节点内容设置给容器元素
setElementText(container, n2.children)
} else if (Array.isArray(n2.children)) {
if (Array.isArray(n1.children)) {
// 代码运行到这里,说明新旧子节点都是一组子节点,设计到核心的 Diff 算法
// 暂且做这样的处理:将旧一组子节点全部卸载,新的一组子节点全部挂载
n1.children.forEach(c => unmount(c))
n2.children.forEach(c => patch(null, c, container))
} else {
// 到这里,旧字节点要么是文本子节点,要么不存在,但无论哪种情况,我们都需要将容器情况,然后将新的一组子节点逐个挂载
setElementText(container, '')
n2.children.forEach(c => patch(null, c, container))
}
} else {
// 代码运行到这里,代表新子节点不存在
// 旧子节点是一组子节点,只需逐个卸载即可
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c))
} else if (typeof n1.children === 'string') {
// 旧子节点是文本子节点,清空内容接口
setElementText(container, '')
}
// 如果没有旧子节点,什么都不做
}
}
上述代码,首先会检查新子节点的类型。如果新子节点是字符串(即文本子节点),那么我们将首先卸载所有的旧子节点,然后将新的文本内容设置到容器中。如果新子节点是一组子节点(即一个数组),我们会进一步检查旧子节点的类型。如果旧子节点也是一组子节点,我们会先卸载旧子节点,然后逐个挂载新子节点。如果旧子节点不是一组子节点(可能是文本节点或者不存在),我们则直接清空容器并逐个挂载新子节点。最后,如果新子节点不存在,我们则需要处理旧子节点。如果旧子节点是一组子节点,我们将逐个卸载;如果旧子节点是文本节点,我们将清空容器内容;如果旧子节点也不存在,我们则不需要做任何操作。这种处理方式虽然可行,但并不高效。在后续章节中,我们将介绍 Diff 算法,它可以更高效地处理新旧子节点的变化。
8.10 文本节点和注释节点
我们将探讨如何用虚拟 DOM 描述文本节点和注释节点,这两种节点类型在 HTML 中非常常见。假设我们有一个 vnode 来描述普通的 HTML 标签,如下:
const vnode = {
type: 'div'
}
在这个 vnode 中,我们使用 vnode.type 来表示元素的名称。然而,注释节点和文本节点与普通标签节点有所不同,它们并不具有标签名称。因此,我们需要创建一些唯一的标识来描述这两种节点类型。我们可以使用 symbol 类型的值来表示这些类型,如下:
// 文本节点的 type 标识
const Text = Symbol()
const newVNode = {
type: Text,
children: '我是文本内容'
}
// 注释节点的 type 标识
const Comment = Symbol()
const newVNode = {
type: Comment,
children: '我是注释内容'
}
我们分别为文本节点和注释节点创建了 symbol 类型的值,并将其作为 vnode.type 属性的值,这样就能够用 vnode 来描述文本节点和注释节点了。由于文本节点和注释节点只关心文本内容,所以我们用 vnode.children 来存储它们对应的文本内容。
有了 vode 对象,我们就可以使用渲染器来渲染它们了,渲染它们之前我们需要封装两个操作 DOM 的 API 封装到渲染器的选项:
const renderer = createRenderer({
createElement(tag) {
// 省略部分代码
},
setElementText(el, text) {
// 省略部分代码
},
insert(el, parent, anchor = null) {
// 省略部分代码
},
createText(text) {
return document.createTextNode(text)
},
setText(el, text) {
el.nodeValue = text
},
patchProps(el, key, prevValue, nextValue) {
// 省略部分代码
},
})
上述代码,我们在调用 createRenderer 函数创建渲染器时,传递的选项参数中封装了 createText 函数和 setText 函数。这两个函数分别用来创建文本节点和设置文本节点的内容。有了这两个 vnode 对象和操作 DOM 的方法,我们就可以使用渲染器来渲染它们了,如下所示:
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
if (!n1) {
mountElement(n2, container)
} else {
patchElement(n1, n2)
}
} else if (type === Text) {
// 如果新 vnode 的类型是 Text,则说明该 vnode 描述的是文本节点
// 如果没有旧节点,则进行挂载
if (!n1) {
// 使用 createText 函数创建文本节点
const el = (n2.el = createText(n2.children))
// 将文本节点插入到容器中
insert(el, container)
} else {
// 如果旧 vnode 存在,只需要使用新文本节点的文本内容更新旧文本节点即可
const el = (n2.el = n1.el)
if (n2.children !== n1.children) {
// 调用 setText 函数更新文本节点的内容
setText(el, n2.children)
}
}
}
}
上述代码,我们增加了一个判断条件,即判断表达式 type === Text 是否成立,如果成立,则说明要处理的节点是文本节点。接着,还需要判断旧的虚拟节点(n1)是否存在,如果不存在,则直接挂载新的虚拟节点(n2)。如果旧的虚拟节点(n1)存在,则需要更新文本内容。
8.11 片段(Fragment)
Vue.js 3 中,引入了一种新的 vnode 类型——片段(Fragment)。这是为了解决特定的组件封装问题,让组件模板支持多根节点:
const Fragment = Symbol()
const vnode = {
type: Fragment,
children: [
{ type: 'li', children: 'text 1' },
{ type: 'li', children: 'text 2' },
{ type: 'li', children: 'text 3' }
]
}
与文本节点和注释节点类似,片段也没有标签名称,因此我们也需要为其创建唯一标识,即 Fragment。对于类型为 Fragment 的 vnode,它的 children 便是模板中的所有根节点。当我们渲染类型为 Fragment 的 vnode 时,由于 Fragment 本身并不会渲染任何内容,因此只需渲染其子节点即可:
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
// 省略部分代码
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) { // 处理 Fragment 类型的 vnode
if (!n1) {
// 如果旧 vnode 不存在,则只需要将 Fragment 的 children 逐个挂载即可
n2.children.forEach(c => patch(null, c, container))
} else {
// 如果旧 vnode 存在,则只需要更新 Fragment 的 children 即可
patchChildren(n1, n2, container)
}
}
}
然而,需要注意的是,卸载函数 unmount 也需要支持 Fragment 类型的 vnode 卸载,如下面 unmount 函数的代码所示:
function unmount(vnode) {
// 如果卸载的 vnode 类型为 Fragment,则需要卸载其 children
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c))
return
}
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}
当卸载 Fragment 类型的虚拟节点时,由于 Fragment 本身并不会渲染任何真实 DOM,所以只需要遍历它的 children 数组,并将其中的节点逐个卸载即可。
8.12 总结
在这个章节,我们深入探讨了如何挂载子节点,以及如何处理节点的属性。我们认识到,挂载子节点只需递归地调用 patch 函数。另一方面,节点的属性处理要复杂得多,涉及到 HTML Attributes 和 DOM Properties 两个重要概念。根据被设置属性的特点,我们在设置元素属性时,不能一概而论地使用 setAttribute 函数或直接通过元素的 DOM Properties 来设置。接下来,我们讨论了特殊属性的处理,如 class 属性。Vue.js 对 class 属性进行了增强,它可以接受不同类型的值。在设置这些值给 DOM 元素前,需要对这些值进行正常化。我们也讨论了元素设置 class 的三种方式及其性能情况,其中 el.className 的性能最佳,因此我们在 patchProps 函数中使用 el.className 完成 class 属性的设置。同样,Vue.js 对 style 属性也进行了增强,因此 style 属性也需要类似的处理。接着,我们讨论了卸载操作。一开始,我们使用 innerHTML 来清空容器元素,但这引发了许多问题,比如无法正确地调用卸载组件的生命周期函数,无法执行自定义指令的钩子函数,以及无法移除绑定在 DOM 元素上的事件处理函数。因此,我们引入了 unmount 函数,它基于虚拟节点 vnode 的维度来完成卸载,调用原生 DOM API 完成 DOM 元素的卸载,并在卸载过程中有机会调用指令钩子函数和组件生命周期函数。然后,我们探讨了如何区分 vnode 类型。在执行更新时,首先需要确认新旧 vnode 描述的内容是否相同。如果相同,我们还需要进一步检查 vnode 的类型,即 vnode.type 属性值的类型,来决定它描述的是普通标签元素还是组件,然后调用相应的函数来完成挂载和打补丁。接下来,我们详细讲解了事件的处理,包括如何在虚拟节点中描述事件,如何绑定和更新事件,以及如何处理事件与更新时机的问题。为了提升性能,我们使用了伪造的 invoker 函数,并把真正的事件处理函数存储在 invoker.value 属性中。我们还讨论了子节点的更新。我们对虚拟节点中的 children 属性进行了规范化,规定 vnode.children 属性只能有字符串类型(代表元素具有文本子节点)、数组类型(代表元素具有一组子节点)和 null(代表元素没有子节点)这三种类型。更新时,新旧 vnode 的子节点可能出现以上三种情况,所以我们需要考虑九种可能的情况。在更新时,如果新旧 vnode 都有一组子节点,我们会通过 Diff 算法比较新旧两组子节点,试图最大程度地复用 DOM 元素。此外,我们还探讨了如何使用虚拟节点来描述文本节点和注释节点。我们利用了 symbol 类型值的唯一性,为文本节点和注释节点分别创建唯一标识,并将其作为 vnode.type 属性的值。最后,我们讨论了 Fragment 及其用途。渲染器渲染 Fragment 的方式与渲染普通标签类似,但是 Fragment 本身并不渲染任何 DOM 元素,因此只需渲染一个 Fragment 的所有子节点即可。
转载自:https://juejin.cn/post/7268189174015983679