13_第_8_章_挂载与更新
约 39714 字大约 132 分钟
2026-10-05
先给你一个全局视角。本章讲的是渲染器具体怎么干活,可以这样记:
| 动作 | 生活比方 | 干活的函数 |
|---|---|---|
| 挂载 mount | 照着图纸盖房子 | mountElement |
| 更新 patch | 改装修,能改多少改多少 | patchElement → patchProps / patchChildren |
| 卸载 unmount | 拆房子(还要断电断水、通知住户) | unmount |
| 调度 | 项目经理收发图纸、决定谁来干 | patch |
这四个词会在本章反复出现。先把它们当成“盖房子 / 改装修 / 拆房子 / 项目经理”这四个角色记住,后面读代码时就不会晕。
在第 7 章中,我们主要介绍了渲染器的基本概念和整体架构。本章,我们将讲解渲染器的核心功能:挂载与更新。
8.1 挂载子节点和元素的属性
第 7 章提到,当 vnode.children 的值是字符串类型时,会把它设置为元素的文本内容。一个元素除了具有文本子节点外,还可以包含其他元素子节点,并且子节点可以是很多个。为了描述元素的子节点,我们需要将 vnode.children 定义为数组:
const vnode = {
type: 'div',
children: [
{
type: 'p',
children: 'hello'
}
]
}这是全章第一张图纸,我们把它一层层拆开看。注意最外层的 children 是方括号(数组),最内层的 children 却是引号(字符串)——这一处差别,正是本章后面 8.9 节要花大力气规范化的东西。
const vnode = { ... }:整份图纸装进vnode这个变量。const表示之后不再改指向。type: 'div':图纸第一栏——“我是个div标签”。type是 vnode 最重要的字段,整个patch函数全靠它判断该走哪条路。children: [ ... ]:方括号 = 数组 = 房间里的家具清单。之所以要用数组,是因为一个元素可以有很多个子节点。{ type: 'p', children: 'hello' }:清单上的第一件家具——一个p标签。children: 'hello':引号 = 字符串 = 这件家具里只放了一段纯文字。注意这里没有方括号——一段文字用不着清单,一个位置就够了。}/]/}:三层收尾,对应上面三层开口。“一个套一个”的形状,这就是树。- ⚠️ 顺便记一笔:内层那个
p没有props字段。没写就等于没有,不会报错。
上面这段代码描述的是“一个 div 标签具有一个子节点,且子节点是 p 标签”。可以看到,vnode.children 是一个数组,它的每一个元素都是一个独立的虚拟节点对象。这样就形成了树型结构,即虚拟 DOM 树。
虚拟 DOM(Virtual DOM,又叫“虚拟节点树”):
用普通的 JavaScript 对象(不是真正的网页元素)来“假装”描述网页长什么样。比如我们不直接动手搭积木,而是先在纸上画一张“这里放个 div、里面再放个 p、p 上写着 hello”的图纸。前面章节已经完整讲过它,这里只做一次简短回顾。
打个生活比方:虚拟 DOM 就是装修前的设计图,真实 DOM 就是已经砌好的墙。
- 你不会在墙上乱敲,需要先在图纸上算一遍“这面墙要不要改”,算完了再照着改;
- 如果发现整面墙要拆了(内容完全不同),那就整面拆掉重砌,不必抠着原来的砖;
- 如果只是换个灯泡(内容几乎一样),那就只换灯泡,墙一根砖都不用动。
所以“虚拟”二字的意思是:它只是张图纸,页面上真正存在的是真实 DOM。本章讲的 mount(挂载)就是“照着图纸把墙砌出来”,patch(打补丁)就是“照着新图纸改旧墙”。
你可以把上面那个对象想象成一张图纸,从大到小一层层读下来:最外层是 div 房间,children 是“房间里的家具清单”,清单里那一个对象就是那张 p 桌子。清单用数组,是因为房间里的家具可能不止一件。
让 mountElement 支持子节点
这一节要反复用到三个词,先把它们的身份摆清楚:
| 词 | 一句话 | 补充 |
|---|---|---|
| 挂载 mount | 把“图纸上画着的东西”真的变成屏幕上能点能看的真实元素 | 就像照着图纸砌墙——图纸上是“这里有一面墙”,挂载就是真的把砖砌上去 |
container 容器 | 新元素要被放进去的那个“爹”元素,是渲染器做操作时认准的落脚点 | 就像砌墙时你得站在某个房间的地板上,才能把新墙砌在这个房间里 |
| patch 打补丁 | 拿旧图纸和新图纸对比,然后只改真正变了的地方 | 名字来自“打补丁”——衣服破了个洞,不用整件重做,缝上那一小块就行 |
Vue.js 源码里大量使用 patch 这个词。本章讲的 patch、mountElement、patchElement、patchProps、patchChildren 都是同一家族:前缀 mount 表示“从无到有地造出来”,前缀 patch 表示“在已有的基础上改”。
为了完成子节点的渲染,我们需要修改 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)
}这段代码在干的事,一句话就是:造出元素 → 往里面填内容 → 把它挂到容器上。顺序不能乱——内容没填好就挂上去,页面上会闪一下空壳;而且只有先填好内容,后面更新时才能准确知道“要改哪里”。
我们逐行看:
function mountElement(vnode, container):定义一个叫mountElement的函数,意思是“挂载一个元素”。它接收两个原料——vnode(要照着哪个图纸造)和container(造好了挂到哪儿)。函数名是从mount(挂载)+Element(元素)拼出来的。const el = createElement(vnode.type):createElement是渲染器选项里的一个函数,作用是“按vnode.type说的标签名,创建出一个真实的、但还没插进页面的 DOM 元素”。Vue.js 内部通常会调用浏览器的document.createElement。变量名el是 short for element(元素),不是 Vue.js 里的关键字,换成dom也一样能跑。if (typeof vnode.children === 'string'):先问一句“这个元素有没有纯文字内容”。typeof是 JS 里问“这是个什么类型”的关键字,字符串的类型名就是'string'。setElementText(el, vnode.children):setElementText(设置元素文本)也是渲染器选项里的函数,作用是“把这个元素的文字内容整体替换成这句话”。它内部通常就是el.textContent = text。else if (Array.isArray(vnode.children)):不是文字,那就是“有一堆子节点”。Array.isArray(判断是否为数组)是 JS 用来精确回答“这个东西到底是不是数组”的函数。vnode.children.forEach(child => { patch(null, child, el) }):把子节点清单一个一个拿出来,递归地交给patch去处理。这里传null作为旧 vnode,意思是“这次是从无到有地挂载,没有旧图纸可比”,于是patch内部会走挂载分支。insert(el, container):最后一步,把造好的元素插进容器里。insert(插入)是渲染器选项,浏览器里对应parent.insertBefore(el, null),意思是“插到这个爹的最后面”。
⚠️ 这里的顺序还藏着一个细节:子节点是先挂在
el内部(挂到el里),然后el自己才被挂到container里。先装修好房间,再把房间接进大楼——这样插进页面的一瞬间,看到的就是装修完的样子。
挂载子节点时,需要注意两点:
- 传递给
patch函数的第一个参数是null。因为是挂载阶段,没有旧vnode,所以只需要传递null即可。这样,当patch函数执行时,就会递归地调用mountElement函数完成挂载。递归(recursion):函数自己调用自己。就像拆快递:拆开外面的大箱子,里面又是一个小箱子,再拆,直到最里面没有箱子。 - 传递给
patch函数的第三个参数是挂载点。由于我们正在挂载的子元素是div标签的子节点,所以需要把刚刚创建的div元素作为挂载点,这样才能保证这些子节点挂载到正确位置。
上面这两点连起来看,就是渲染器处理层级的办法:每一层只管自己这一圈,把下一层整体丢给 patch,而 patch 看到“没有旧 vnode”又会把活儿丢回给 mountElement。一来一回,深度多深的嵌套都能自动铺开,代码量不随嵌套层数增长——这就是递归的好用之处。
用 vnode 描述元素的属性
完成了子节点的挂载后,我们再来看看如何用 vnode 描述一个标签的属性,以及如何渲染这些属性。
HTML 标签有很多属性,其中有些是通用的,例如 id、class 等,而有些是特定元素才有的,例如 form 元素的 action 属性。实际上,渲染一个元素的属性比想象中要复杂,不过我们仍然秉承一切从简的原则,先来看看最基本的属性处理。
为了描述元素的属性,我们需要为虚拟 DOM 定义新的 vnode.props 字段:
const vnode = {
type: 'div',
// 使用 props 描述一个元素的属性
props: {
id: 'foo'
},
children: [
{
type: 'p',
children: 'hello'
}
]
}const vnode = { ... }:const表示“这个变量之后不再改指向”,右边是这次要画的整张图纸。type: 'div':图纸第一栏写“这是什么”,这里是div标签。props: { id: 'foo' }:props是新加的一栏,专门写“装修清单”。props(properties,属性)指这个要被造出来的元素身上要带哪些属性。比喻一下——children是房间里的家具,props是这间房的装修配置单(刷什么漆、装什么锁)。这一栏里id是配置项的名字,'foo'是配置项的值。children: [ { type: 'p', children: 'hello' } ]:房间里的家具清单。注意那个p元素自己没有props,这表示它不需要任何属性。属性是“逐个元素”各自描述的,不会自动从父元素继承。
为什么非要单独开一个 props 字段,而不直接跟 type、children 平铺在一起?因为 type 和 children 是渲染器内核要用的骨架信息(靠它们才能把元素建出来),而 props 是要往真实 DOM 上写的外部信息。把两者分开,渲染器以后想换一套“怎么写属性”的策略(就像下一节要做的),只要盯住 props 这一栏就行,骨架部分一点不用动。
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)
}这段代码在干的事:先照图纸造出空元素,然后拿着“装修配置单”一条条往这个元素上贴,最后再挂到页面上。
function mountElement(vnode, container):还是那个“挂载一个元素”的函数,这版多了“贴配置单”这一步。const el = createElement(vnode.type):照图纸造出一个还没插进页面的真实元素。// 省略 children 的处理:这是一句注释,告诉读者“上一节讲过的填内容那部分这里不重复写了”,代码本身被删掉了。注释(comment)就是写给人看的说明文字,程序不会执行它。if (vnode.props):加一层保险——万一这张图纸压根没写props(比如刚才那个p元素),就直接跳过贴配置单这一步,不然后面遍历会报错。这种写法在 JS 里很常见:对象是空对象{}时算“真”,不写这一栏(值为undefined)才算“假”。for (const key in vnode.props):把配置单从头到尾翻一遍。for ... in是遍历对象里所有“自己可枚举的键”的循环写法,key就是当前这一项的名字。写成for (const key in ...)而不是for (const key of ...),是因为要遍历的是键名。vnode.props[key]:用刚才拿到的键名去配置单里取对应的值。el.setAttribute(key, vnode.props[key]):setAttribute(设置属性)是浏览器原生 API,作用是“往这个 HTML 标签的字面上写一条key="值"的记录”。insert(el, container):贴完配置单,最后把元素挂到容器上。
⚠️ 注意这里的顺序是先贴属性、后挂载。原因是如果先挂载再贴属性,用户在那一瞬间可能看到一个“没样式”的光秃秃元素(类名还没贴上),然后样式才补上——这叫“样式闪烁”。先把内部装修全部完工,再开门迎客。
实际上,除了使用 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)
}这版只改了一行,但思路换了个频道:
for (const key in vnode.props):还是遍历配置单,但这次遍历出来的key要被当成“对象属性名”来用。el[key] = vnode.props[key]:不再调用任何函数,而是直接用方括号给元素赋值。这里的el是一个普通对象(DOM 对象),el[key] = 值等价于写el.id = 值这种点号写法,只是方括号里的key是变量,所以必须用方括号。效果上相当于直接修改 DOM 对象的字段。
两条路线碰到的根本不是同一个东西:
| 路线 | 写法 | 碰到的到底是 | 比喻 |
|---|---|---|---|
| HTML Attributes 路线 | el.setAttribute(key, value) | 标签上的字面属性 | 把配置写在贴在机器外壳上的标签纸上,要经过一层“翻译” |
| DOM Properties 路线 | el[key] = value | 真实的 DOM 对象字段 | 直接拧机器内部的旋钮,碰到的是真正的部件 |
那么,哪种更好?答案是两种都有坑——正是 8.2 节要讲清楚的原因。
实际上,无论是使用 setAttribute 函数,还是直接操作 DOM 对象,都存在缺陷。如前所述,为元素设置属性比想象中要复杂得多。不过,在讨论具体有哪些缺陷之前,我们有必要先搞清楚两个重要的概念:HTML Attributes 和 DOM Properties。
8.2 HTML Attributes 与 DOM Properties
理解 HTML Attributes 和 DOM Properties 之间的差异和关联非常重要,这能够帮助我们合理地设计虚拟节点的结构,更是正确地为元素设置属性的关键。
我们从最基本的 HTML 说起。给出如下 HTML 代码:
<input id="my-input" type="text" value="foo" /><input>:HTML 里“输入框”这个标签的名字。id="my-input":给这个输入框起一个唯一名字,页面上叫它 “my-input”。type="text":指定它的种类是“普通单行文本框”。value="foo":预先往框里放好的文字是foo。/>:这是 HTML 的写法,表示“这个标签我一次写完了,不用再单独写个</input>来闭合”。自闭合标签(self-closing tag)指像img、br、input这种本身就没有“内部内容”的概念的标签,直接在开头就结束的写法。
querySelector(查询选择器):
浏览器帮你在整个页面里按“选择条件”找到某个元素的函数。这里的条件写成 CSS 选择器的语法,#my-input 的意思是“找 id 等于 my-input 的元素”。
就像在人群里喊“穿蓝衣服、站在门口那个”,浏览器听到这句描述就帮你把人指出来。
HTML Attributes 指的就是定义在 HTML 标签上的属性,这里指的就是 id="my-input"、type="text" 和 value="foo"。当浏览器解析这段 HTML 代码后,会创建一个与之相符的 DOM 元素对象,我们可以通过 JavaScript 代码来读取该 DOM 对象:
const el = document.querySelector('#my-input')document:整个网页这个“文档”的代名词。每个网页打开后,浏览器都会给你一个document对象,你通过它就能操作页面上的东西。document.querySelector('#my-input'):在整份文档里按id="my-input"这个条件查找,找到就返回那个元素对象。const el = ...:把找到的元素存进变量el,之后写el.id就是在读这个输入框的id了。
这个 DOM 对象会包含很多属性(properties),如图 8-1 所示。

图 8-1 DOM 对象下的属性
这些属性就是所谓的 DOM Properties。
先分清两个概念
HTML Attributes(HTML 属性):
写在 HTML 标签上的“字面属性”,比如 <input id="my-input"> 里的 id="my-input"。它就像衣服上的吊牌——只在你买的时候看一眼。
DOM Properties(DOM 属性):
浏览器把 HTML 解析成 DOM 之后,对象上的 JavaScript 属性,比如 el.id、el.type、el.value。它就像衣服本身的尺码——是会变的(比如用户改了 input 的值)。
很多 HTML Attributes 在 DOM 对象上有与之同名的 DOM Properties,例如 id="my-input" 对应 el.id,type="text" 对应 el.type,value="foo" 对应 el.value 等。但 DOM Properties 与 HTML Attributes 的名字不总是一模一样的,例如:
<div class="foo"></div><div class="foo">:一个div标签,上面贴了class="foo"这个类名。class可以理解为“这个元素参加哪些样式分组”的名字。class="foo":class的值是foo,意思是“它属于名叫 foo 的样式分组”。
class="foo" 对应的 DOM Properties 则是 el.className。另外,并不是所有 HTML Attributes 都有与之对应的 DOM Properties,例如:
<div aria-valuenow="75"></div>aria-valuenow="75":这是无障碍(accessibility,帮视障用户用屏幕阅读器读懂页面)相关的一类属性,叫aria-*。它不是给普通视觉用户看的,而是给读屏软件看的。- 注意:浏览器把它原样记在标签上,但不会在 DOM 对象上造一个叫
ariaValueNow的字段给你写。
aria-* 类的 HTML Attributes 就没有与之对应的 DOM Properties。
类似地,也不是所有 DOM Properties 都有与之对应的 HTML Attributes,例如可以用 el.textContent 来设置元素的文本内容,但并没有与之对应的 HTML Attributes 来完成同样的工作。
把上面提到的对应关系摆成一张速查表:
| HTML Attributes | 对应的 DOM Properties | 关系 |
|---|---|---|
id="my-input" | el.id | 直接映射 |
type="text" | el.type | 直接映射 |
value="foo" | el.value | 直接映射,存“当前值” |
value="foo" | el.defaultValue | 同一个 HTML Attributes 关联的第二个 DOM Properties,存“初始值” |
class="foo" | el.className | 名字不一样 |
aria-valuenow="75" | 没有 | 没有对应的 DOM Properties |
| 没有 | el.textContent | 没有对应的 HTML Attributes |
它们的关系:HTML Attributes 是初始值
HTML Attributes 的值与 DOM Properties 的值之间是有关联的:
<div id="foo"></div><div id="foo">:一个div标签,id是foo。id在一个页面里必须唯一,相当于身份证号。
这个片段描述了一个具有 id 属性的 div 标签。其中,id="foo" 对应的 DOM Properties 是 el.id,并且值为字符串 'foo'。我们把这种 HTML Attributes 与 DOM Properties 具有相同名称(即 id)的属性看作直接映射。但并不是所有 HTML Attributes 与 DOM Properties 之间都是直接映射的关系,例如:
<input value="foo" /><input value="foo" />:一个输入框,标签上写着“初始值是foo”。注意这里只是说“初始”,框里的字之后是可以被改的。
这是一个具有 value 属性的 input 标签。如果用户没有修改文本框的内容,那么通过 el.value 读取对应的 DOM Properties 的值就是字符串 'foo'。而如果用户修改了文本框的值,那么 el.value 的值就是当前文本框的值。例如,用户将文本框的内容修改为 'bar',那么:
console.log(el.value) // 'bar'el.value:读输入框此刻真实显示的内容。用户已经改过了,所以读到'bar'。console.log(...):把括号里的值打印到浏览器的开发者控制台上,方便我们看结果。控制台(console)就是浏览器自带的调试窗口,按 F12 就能打开。
但如果运行下面的代码,会发生“奇怪”的现象:
console.log(el.getAttribute('value')) // 仍然是 'foo'
console.log(el.value) // 'bar'el.getAttribute('value'):getAttribute(读取属性)是setAttribute的“反义操作”——它不是读元素当前状态,而是回到 HTML 源码里,把当初写在标签上的那串字面量抠出来。- 为什么还是
'foo':因为标签上写的那行字从来没被改过。用户打字改的是“框里的内容”,不是“标签上的那行字”。 - 两行对比着看:同一时刻、同一个元素,
getAttribute给'foo'(出厂设置),value给'bar'(当前设置)。这正是理解本节的钥匙。
可以发现,用户对文本框内容的修改并不会影响 el.getAttribute('value') 的返回值。这个现象蕴含着 HTML Attributes 所代表的意义。实际上,HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。一旦值改变,那么 DOM Properties 始终存储着当前值,而通过 getAttribute 函数得到的仍然是初始值。
生活比方:HTML Attributes 就是商品吊牌上印的“标准配置”,DOM Properties 是这台机器现在实际的配置。你把机器的内存从 8G 换成 16G,机器现在跑的是 16G(DOM Properties 变了),但吊牌上还印着“出厂 8G”(HTML Attributes 没变)。
但我们仍然可以通过 el.defaultValue 来访问初始值:
el.getAttribute('value') // 仍然是 'foo'
el.value // 'bar'
el.defaultValue // 'foo'el.defaultValue:专门用来读“出厂值”的那个字段。可以记成 “default(默认)Value(值)”。- 为什么要多开一个字段:因为一个 HTML Attributes 可能同时关联好几个 DOM Properties,而每个 DOM Properties 存的东西不一样——
el.value存“当前值”,el.defaultValue存“初始值”。浏览器干脆把初始值单独开一格存着,省得每次都去解析标签字符串。
这说明一个 HTML Attributes 可能关联多个 DOM Properties。例如在上例中,value="foo" 与 el.value 和 el.defaultValue 都有关联。
浏览器会“矫正”默认值
虽然我们可以认为 HTML Attributes 是用来设置与之对应的 DOM Properties 的初始值的,但有些值是受限制的,就好像浏览器内部做了默认值校验。如果你通过 HTML Attributes 提供的默认值不合法,那么浏览器会使用内建的合法值作为对应 DOM Properties 的默认值,例如:
<input type="foo" />type="foo":想把这个输入框的种类写成foo。但浏览器认识的输入框种类只有text、password、email、submit这些,foo不在名单上。
我们知道,为 <input/> 标签的 type 属性指定字符串 'foo' 是不合法的,因此浏览器会矫正这个不合法的值。所以当我们尝试读取 el.type 时,得到的其实是矫正后的值,即字符串 'text',而非字符串 'foo':
console.log(el.type) // 'text'- 为什么是
'text':因为text是浏览器给<input>内建的默认种类——你什么都不写时它就是文本框。既然你写的foo它不认识,它就退回自己的默认值。 - 关键在于“它没有报错”:浏览器不是把你的错别字标红拒绝执行,而是默默替你改成一个合理值。这就叫矫正(sanitization / fallback)。
⚠️ 这一点对框架作者极重要:你没法假设“我写进去的什么值,读出来就一定是什么值”。这一节的坑,就是 8.3 节要处理的。
记住一条核心原则
从上述分析来看,HTML Attributes 与 DOM Properties 之间的关系很复杂,但其实我们只需要记住一个核心原则即可:
结论:HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。
记住这一条就够应付接下来的理解了。
8.3 正确地设置元素属性
上一节我们详细讨论了 HTML Attributes 和 DOM Properties 相关的内容,因为它们会影响 DOM 属性的添加方式。对于普通的 HTML 文件来说,当浏览器解析 HTML 代码后,会自动分析 HTML Attributes 并设置合适的 DOM Properties。但用户编写在 Vue.js 的单文件组件中的模板不会被浏览器解析。这意味着,原本需要浏览器来完成的工作,现在需要框架来完成。
我们以禁用的按钮为例:
<button disabled>Button</button>浏览器在解析这段 HTML 代码时,发现这个按钮存在一个叫作 disabled 的 HTML Attributes,于是浏览器会将该按钮设置为禁用状态,并将它的 el.disabled 这个 DOM Properties 的值设置为 true。这一切都是浏览器帮我们处理好的。
但同样的代码如果出现在 Vue.js 的模板中,则情况会有所不同。首先,这个 HTML 模板会被编译成 vnode,它等价于:
const button = {
type: 'button',
props: {
disabled: ''
}
}type: 'button':图纸上写着“这是个button标签”。props: { disabled: '' }:装修清单上写着“要禁用这个按钮”,而值是空字符串。为什么是空字符串?因为你在模板里写的是<button disabled>,光秃秃一个disabled、后面没跟=值,编译器没有值可填,只能填一个空串。编译(compile)就是指把.vue文件里你写的模板,变成渲染器能用的 JS 对象的那个过程。
注意,这里的 props.disabled 的值是空字符串。如果在渲染器中调用 setAttribute 函数设置属性,则相当于:
el.setAttribute('disabled', '')el.setAttribute('disabled', ''):setAttribute会把第二个参数一律变成文本再写上去,所以效果是给按钮的标签上补了一句disabled=""。而 HTML 规则规定:disabled这个属性只要“在场”,按钮就是禁用的,浏览器不关心它等于什么字符串。
这么做的确没问题,浏览器会将按钮禁用。
但考虑如下模板:
<button :disabled="false">Button</button>:disabled="false"里的冒号:是 Vue.js 模板的语法糖,意思是“后面引号里的不是固定文本,而是一个变量/表达式,请算出它的值再给我”。所以这里的真实意图是“把按钮的禁用状态设成false”。
它对应的 vnode 为:
const button = {
type: 'button',
props: {
disabled: false
}
}disabled: false:这次值是布尔值false,不再是空字符串。因为模板里明确写了false,编译时就原样保留成了布尔类型。
用户的本意是“不禁用”按钮,但如果渲染器仍然使用 setAttribute 函数设置属性值,则会产生意外的效果,即按钮被禁用了:
el.setAttribute('disabled', false)在浏览器中运行上面这句代码,我们发现浏览器仍然将按钮禁用了。这是因为使用 setAttribute 函数设置的值总是会被字符串化,所以上面这句代码等价于:
el.setAttribute('disabled', 'false')- 为什么会“字符串化”:
setAttribute只认文本。它把你传进去的值一律转成文本再写上。字符串化(stringify)就是指把任何值变成文本:数字123变'123',布尔false变'false',对象{}变'[object Object]'。 - 于是闹出了乌龙:
'false'这个字符串看起来是“假”,但 HTML 只认这个属性在不在,不认它的内容。属性在场 = 禁用,这就是明明传了false,按钮却还是被禁用的真正原因。
对于按钮来说,它的 el.disabled 属性值是布尔类型的,并且它不关心具体的 HTML Attributes 的值是什么,只要 disabled 属性存在,按钮就会被禁用。
所以我们发现,渲染器不应该总是使用 setAttribute 函数将 vnode.props 对象中的属性设置到元素上。那么应该怎么办呢?
一个很自然的思路是,我们可以优先设置 DOM Properties,例如:
el.disabled = falseel.disabled = false:这次不写标签,直接改按钮这个对象自己身上的开关。因为el.disabled本身就是布尔字段,写false就是“不禁用”,语义完全对得上,不经过字符串化这一道坎。
这样是可以正确工作的,但又带来了新的问题。还是以上面给出的模板为例:
<button disabled>Button</button>这段模板对应的 vnode 是:
const button = {
type: 'button',
props: {
disabled: ''
}
}type: 'button':button标签。disabled: '':又是空字符串。因为模板里写的是<button disabled>——属性名后面没跟=值,编译器取不到值,只能填空串。
我们注意到,在模板经过编译后得到的 vnode 对象中,props.disabled 的值是一个空字符串。如果直接用它设置元素的 DOM Properties,那么相当于:
el.disabled = ''由于 el.disabled 是布尔类型的值,所以当我们尝试将它设置为空字符串时,浏览器会将它的值矫正为布尔类型的值,即 false。所以上面这句代码的执行结果等价于:
el.disabled = false这违背了用户的本意,因为用户希望禁用按钮,而 el.disabled = false 则是不禁用的意思。
这么看来,无论是使用 setAttribute 函数,还是直接设置元素的 DOM Properties,都存在缺陷。要彻底解决这个问题,我们只能做特殊处理,即优先设置元素的 DOM Properties,但当值为空字符串时,要手动将值矫正为 true。只有这样,才能保证代码的行为符合预期。
上面这四种写法的后果并排放在一起,disabled 这个例子的全貌就在这张表里了:
| 写法 | 浏览器实际做了什么 | 按钮的结果 | 符不符合用户本意 |
|---|---|---|---|
el.setAttribute('disabled', '') | 标签上补了一句 disabled="" | 禁用 | 符合,模板写的就是 <button disabled> |
el.setAttribute('disabled', false) | 值被字符串化,等价于 el.setAttribute('disabled', 'false') | 仍然被禁用 | 不符合,本意是“不禁用” |
el.disabled = false | 直接把布尔字段写成 false,不经过字符串化 | 不禁用 | 符合 |
el.disabled = '' | 浏览器把空字符串矫正为布尔值 false | 不禁用 | 不符合,本意是“禁用” |
下面给出 mountElement 函数的具体实现:
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)
}我们逐行看:
if (key in el):用in操作符判断el对象上是否有key这个属性(即同名 DOM Properties)。in操作符就是检查某个键是否在对象里,比如"id" in el返回true表示el上有id。const type = typeof el[key]:拿到那个 DOM Properties 的类型(boolean/string/number等)。const value = vnode.props[key]:拿到要设置的值。if (type === 'boolean' && value === ''):如果属性类型是布尔,且值为空字符串,就把空字符串矫正为true。el[key] = true或el[key] = value:把值真正写到 DOM Properties 上。else { el.setAttribute(...) }:否则(没有同名 DOM Properties),就用setAttribute兜底。
如上面的代码所示,我们检查每一个 vnode.props 中的属性,看看是否存在对应的 DOM Properties,如果存在,则优先设置 DOM Properties。同时,我们对布尔类型的 DOM Properties 做了值的矫正。
只读属性的坑
但上面给出的实现仍然存在问题,因为有一些 DOM Properties 是只读的,如以下代码所示:
<form id="form1"></form>
<input form="form1" /><form id="form1"></form>:页面上有一个空的表单,它的身份证号是form1。注意它放在前面——顺序有意义:input要“认亲”就得靠form1这个名字找到它。<input form="form1" />:一个输入框,写着“我不属于自己家,我属于form1这个表单”。这是 HTML 规范提供的标准能力,一个输入框完全可以放在表单外面,却仍然算作那个表单的一部分(提交表单时会一起被提交)。- ⚠️ 关键点在这里:这段 HTML 完全合法,但它对应的 DOM Properties
el.form是只读的——因为“我属于哪个表单”是浏览器根据这个标签算出来的结论,不是你能直接填的字段。这就是为什么下面必须为它打一个特殊补丁。
在这段代码中,我们为 <input/> 标签设置了 form 属性(HTML Attributes)。它对应的 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 这个函数(“该不该当成属性来写”),逐行看:
function shouldSetAsProps(el, key, value):定义一个判断函数,它的职责只有一个——回答“这个key到底该走 DOM Properties 这条路,还是该走setAttribute那条路”。返回true走前者,返回false走后者。should(应该)加上AsProps(作为属性来设置),名字本身就自解释了。// 特殊处理:一句注释,说明下面几行是在打补丁——专门对付那些“规则之外”的怪情况。if (key === 'form' && el.tagName === 'INPUT') return false:先看两个条件——属性名是不是form?这个元素是不是<input>?两个都成立就立刻返回false,意思是“这个form千万别用 DOM Properties 的方式写”。el.tagName读元素的标签名,浏览器里返回的是大写的字符串,比如INPUT、DIV。&&(逻辑与)表示两边都为真时整个条件才为真。// 兜底:兜底(fallback)是“万不得已的最后一道防线”。上面那些特殊规则都没命中时,走这条通用规则。return key in el:用in操作符检查元素的 DOM Properties 里有没有同名的key——有,说明浏览器认识这个属性,那就走更快更准的 DOM Properties 路线。
这个判断函数目前只有两条规则,一特殊一兜底:
| 位置 | 条件 | 结果 | 走哪条路 |
|---|---|---|---|
| 特殊处理 | key === 'form' && el.tagName === 'INPUT' | 返回 false | 只能 setAttribute,因为 el.form 是只读的 |
| 兜底 | return key in el | 有同名 DOM Properties 返回 true,否则返回 false | 优先 DOM Properties,否则 setAttribute |
mountElement 里那段循环,逐行看(前半部分和上一节一样,聚焦这次的变化):
const value = vnode.props[key]:先把值取出来存到value变量。存下来而不是每次现取,是因为下面两个分支都要用它,取一次就够。if (shouldSetAsProps(el, key, value)):把判断活儿整个外包给shouldSetAsProps。注意这里传了三个参数,而上一版是把判断逻辑直接写在mountElement里的。这么拆的好处是:以后再遇到新的怪情况,只改shouldSetAsProps这一个函数就够了。const type = typeof el[key]/if (type === 'boolean' && value === '')/el[key] = true/el[key] = value:这三行和上一节完全一样,负责“布尔 + 空字符串”的矫正。} else { el.setAttribute(key, value) }:判断为假时走兜底路线,用setAttribute写标签。for (const key in vnode.props)/insert(el, container):还是遍历配置单、最后挂载,不变。
为什么 el.form 一定要特殊处理?因为它是“只读”的。
只读属性(read-only property):
你只能读、不能赋值的字段。对只读字段执行 el.form = 'form1' 不会报错,但什么也不会发生——值纹丝不动。浏览器内部是这么设计的:el.form 表达的意思是“根据标签上的 form="xxx",我去找那个 id 为 xxx 的表单,返回它”,这个答案是算出来的,不是你能直接填的。所以想设置它,只能老老实实去改标签。
生活比方:只读属性就像微波炉上“剩余时间”那个显示屏——你能看它是 02:00,但没法伸手进去把它设成 05:00。要改时间,只能按“启动/停止”键让它自己重算。
实际上,不仅仅是 <input/> 标签,所有表单元素都具有 form 属性,它们都应该作为 HTML Attributes 被设置。
这里还有一个特殊例子:<input form="xxx"/>。除了它,还有一些其他类似的需要特殊处理的情况。我们不会把所有情况都列举出来逐个讲解,因为掌握处理问题的思路更加重要。另外,我们也不可能把所有需要特殊处理的地方都记住,更何况有时我们根本不知道在什么情况下才需要特殊处理。
所以,上述解决方案本质上是经验之谈。不要惧怕写出不完美的代码,只要在后续迭代过程中“见招拆招”,代码就会变得越来越完善,框架也会变得越来越健壮。
把属性设置也变成可配置的
最后,我们需要把属性的设置也变成与平台无关,因此需要把属性设置相关操作也提取到渲染器选项中:
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)
}
}
})渲染器选项(renderer options):
渲染器核心不认识浏览器的那部分能力,全被装进一个“配置对象”里传进来。
生活中的比喻:就像一台通用机床本身不会配钻头,钻头是外部配上去的。这套做法叫依赖注入(dependency injection)——核心只提要求,具体的活儿由外面送进来的东西干。这样同一套核心代码既能在浏览器里跑,也能在 Node.js、小程序等别的环境里跑。
这个大括号 { ... } 里的每一项,就是一个“能力接口”。逐个看:
const renderer = createRenderer({ ... }):createRenderer(创建渲染器)拿一整包“能力”造出一个渲染器实例,存进renderer。createElement(tag):能力一,“按标签名造元素”。return document.createElement(tag)把任务交回给浏览器的原生 API。tag就是标签名,比如'div'。setElementText(el, text):能力二,“设置元素文本”。浏览器里就是el.textContent = text——textContent(文本内容)直接读写这个元素“里面所有的文字”,中间不管有没有别的标签。insert(el, parent, anchor = null):能力三,“把元素插到某个位置”。anchor = null里的=是 ES6 的默认参数语法,意思是“如果调用时没传第三个参数,就用null”。parent.insertBefore(el, anchor):浏览器原生 API,意思是“把el插到parent里面、放在anchor这个元素的前面”。当anchor是null时,表示“放在最末尾”。
anchor(锚点):
钉在墙上的一颗“钉子”,用来说明“新东西要插在这颗钉子的前面”。
为什么要用它?因为渲染时常常需要把新元素插到已有兄弟节点的正中间,光说“加进这个爹里面”是不够的——得有个参照物告诉它“插在哪张皮前面”。
生活比方:你要把一张新便利贴贴到一排便利贴中间,你会说“贴在写着‘周三’那张的前面”,而不是“贴在墙上”。anchor 就是那块写着“周三”的参照贴;传 null 则相当于说“贴在最后面”。
patchProps(el, key, prevValue, nextValue):能力四,也是本节的主角——“设置/更新一个属性”。注意它的四个参数比前面几个都多。
| 参数 | 含义 |
|---|---|
el | 要改哪个元素 |
key | 改哪个属性 |
prevValue | 旧值。挂载时旧值是 null(此前什么都没有),更新时必须拿旧值和新值比对 |
nextValue | 新值。判断走哪条路只看新值 |
提前把 prevValue 准备好,是为后面几节的事件更新埋下的伏笔——更新时比如事件更新就要用旧值去解绑。
if (shouldSetAsProps(el, key, nextValue)):先问一句“这个属性该走哪条路”。注意这里传的是nextValue(新值)而不是prevValue。const type = typeof el[key]/if (type === 'boolean' && nextValue === '')/el[key] = true/el[key] = nextValue:处理“布尔属性 + 空字符串”这个经典坑,把它矫正成true。} else { el.setAttribute(key, nextValue) }:走不通 DOM Properties 路线时,用setAttribute兜底。
注意 patchProps 这个名字里的 Props 是复数——它一次只处理一个属性,之所以叫复数,是因为你会在循环里把整包属性一个一个交给它。这个名字是从 Vue.js 真实源码沿用下来的。
而在 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)
}const el = createElement(vnode.type):向“配置包”要一个造元素的能力,造出元素。if (typeof vnode.children === 'string')/setElementText(...):向配置包要“设置文本”的能力,填文字。vnode.children.forEach(child => { patch(null, child, el) }):子节点依然交给patch递归处理,不变。for (const key in vnode.props):还是遍历配置单。patchProps(el, key, null, vnode.props[key]):这行是本节的重点。它把“要改哪个元素(el)、哪个属性(key)、旧值是多少(null)、新值是多少(vnode.props[key])”这四样东西一起交给patchProps去办。第二个参数位置上的null就是在说“这是首次设置,之前没有旧值”——正是刚才patchProps需要的那个prevValue参数。insert(el, container):向配置包要“插入”的能力,挂到容器上。
这样,我们就把属性相关的渲染逻辑从渲染器的核心中抽离了出来:逻辑一行都没少,变的只是分工——mountElement 不再自己写判断,只负责发号施令,具体怎么干全听 patchProps 的。
现在渲染器的分派关系长这样,我们先画出来,后面几节会不断往这张图上加东西:
8.4 class 的处理
在上一节中,我们讲解了如何正确地把 vnode.props 中定义的属性设置到 DOM 元素上。但在 Vue.js 中,仍然有一些属性需要特殊处理,比如 class 属性。为什么需要对 class 属性进行特殊处理呢?这是因为 Vue.js 对 class 属性做了增强。
在 Vue.js 中为元素设置类名有以下几种方式。
方式一:指定 class 为一个字符串值。
<p class="foo bar"></p>这段模板对应的 vnode 是:
const vnode = {
type: 'p',
props: {
class: 'foo bar'
}
}type: 'p':一个p标签(段落)。class: 'foo bar':类名是一整串文本'foo bar'。中间那个空格是分隔符,表示“这个元素同时属于foo和bar两个样式分组”。CSS 里的.foo和.bar两条规则会同时作用到它身上。
方式二:指定 class 为一个对象值。
<p :class="cls"></p>:class="cls":冒号表示“去算cls这个变量的值”,把结果当作 class 用。
假设对象 cls 的内容如下:
const cls = { foo: true, bar: false }const cls = { foo: true, bar: false }:用一个变量cls装下这份“穿搭清单”。注意模板里写的是:class="cls",引号里是一个名字,运行时才去取这个变量的值。- 为什么非要绕一个变量? 直接写
:class="{ foo: true }"也能用。绕变量的好处是“内容可以随时换”——cls可以是写死的,也可以是从别的数据算出来的,甚至可以是响应式的。 foo: true/bar: false:键是类名,值是“要不要它”。三个词记住这个形状就行:哪个类 / 要不要。- 生活比方:这就像你的穿搭清单——“外套:穿 / 帽子:不穿”。
那么,这段模板对应的 vnode 是:
const vnode = {
type: 'p',
props: {
class: { foo: true, bar: false }
}
}type: 'p':p标签,老样子。class: { foo: true, bar: false }:把上面那个cls变量的内容整个搬进了class字段。编译做的事就是“照着模板,把值填进去”——模板里写的是:class="cls",所以这里填的就是cls当时的值。- ⚠️ 和方式一对比着看最清楚:方式一的
class是一串文字'foo bar',方式二的class是一个对象。字段名一模一样,装的东西却完全不同——这就是“Vue.js 对class做了增强”的第一个体现:同一个class字段,能装三种完全不同的数据类型。渲染器必须在赋值之前先把它们统一(这就是后面要讲的normalizeClass)。
方式三:class 是包含上述两种类型的数组。
<p :class="arr"></p>这个数组可以是字符串值与对象值的组合:
const arr = [
// 字符串
'foo bar',
// 对象
{
baz: true
}
]['foo bar', { baz: true }]:一个数组,里面第一个元素是字符串、第二个元素是对象。数组的作用是“把好几份清单叠起来”——两份清单都生效。- 生活比方:你出门前写了两张纸条,一张写“穿外套”,一张写“戴帽子”,一起交上去,结果就是两样都穿上了。数组 = 把多张纸条合并成一张。
- 数组里还能继续嵌套数组(Vue.js 真实实现支持),本章为了讲清楚原理只写到这个程度。
那么,这段模板对应的 vnode 是:
const vnode = {
type: 'p',
props: {
class: [
'foo bar',
{ baz: true }
]
}
}class: [ 'foo bar', { baz: true } ]:把上面那个数组整个塞进class字段。经过渲染器归一化之后,它等价于'foo bar baz'。
把 class 值归一化
可以看到,因为 class 的值可以是多种类型,所以我们必须在设置元素的 class 之前将值归一化为统一的字符串形式,再把该字符串作为元素的 class 值去设置。
归一化(normalize):
把乱七八糟的数据“统一”成同一种格式,方便后续处理。
生活中的比喻:就好比不管你买的是大米还是小米,最后都得煮成米饭才能吃。
为什么必须归一化? 因为真正要执行的那句赋值(el.className = ???)只接受字符串。如果不做归一化,就得为字符串、对象、数组各写一套赋值逻辑,代码会迅速膨胀成三份。把“消灭差异”这件事集中到入口处一次做完,下游就永远只面对一种类型。这是处理“多类型输入”最常用的一招。
因此,我们需要封装 normalizeClass 函数,用它来将不同类型的 class 值归一化为字符串,例如:
const vnode = {
type: 'p',
props: {
// 使用 normalizeClass 函数对值进行序列化
class: normalizeClass([
'foo bar',
{ baz: true }
])
}
}normalizeClass(...):normalize(归一化)加上Class(类名),名字就是“把类名归一化”。这里是在画图纸的阶段就把它算好了,所以图纸上的class已经是一串干净的文本。- 注意它的位置:包在
class:的值上,等于说“这份图纸交付之前,装修清单里的 class 那一栏已经打印成一张白纸了”。归一化发生在数据侧,赋值发生在 DOM 侧,两边职责分得清清楚楚。
最后的结果等价于:
const vnode = {
type: 'p',
props: {
// 序列化后的结果
class: 'foo bar baz'
}
}class: 'foo bar baz':三个类名合并成了一串。'foo bar'里的foo、bar原样留下,对象里的baz: true被提取出来补在后面,用空格连起来。- ⚠️ 别被顺序骗了:这里
foo出现一次(来自字符串),数组里那个{ baz: true }也贡献了baz。至于类名之间的先后顺序其实不影响 CSS 生效——CSS 靠“选择器的特异度”决定谁赢,不靠写在class里的先后。
至于 normalizeClass 函数的实现,这里我们不会做详细讲解,因为它本质上就是一个数据结构转换的小算法,实现起来并不复杂。
三种设置 class 的方式,谁最快?
假设现在我们已经能够对 class 值进行归一化了。接下来,我们将讨论如何将归一化后的 class 值设置到元素上。其实,我们目前实现的渲染器已经能够完成 class 的渲染了。
观察前文中函数的代码,由于 class 属性对应的 DOM Properties 是 el.className,所以表达式 "class" in el 的值将会是 false,因此,patchProps 函数会使用 setAttribute 函数来完成 class 的设置。但是我们知道,在浏览器中为一个元素设置 class 有三种方式,即使用 setAttribute、el.className 或 el.classList。那么哪一种方法的性能更好呢?
图 8-2 对比了这三种方式为元素设置 1000 次 class 的性能。

图 8-2 el.className、setAttribute 和 el.classList 的性能比较
可以看到,el.className 的性能最优。因此,我们需要调整 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)
}
}
})注意这个 if / else if / else 的四路分派——一个属性进来,先看它属于哪一类,再决定用哪套手法:
patchProps(el, key, prevValue, nextValue):还是“设置/更新一个属性”这个能力,参数没变。// 省略其他选项:注释,告诉读者createElement、insert等其他能力这里不重复贴。if (key === 'class'):第一优先级。只要属性名叫class,立刻走专属路线,后面的判断一个都不做。为什么不能省掉这一步?因为"class" in el是false(DOM 上没有叫class的字段,只有className),它会被甩到最后那个setAttribute兜底分支去——而图 8-2 恰恰告诉我们setAttribute是三者里最慢的。所以必须把它提到最前面抢答。el.className = nextValue || '':用最快的className赋值。||是逻辑或:左边是“假值”(undefined、null、''、0、false)时,就取右边的''。为什么要兜一个空串?因为空字符串是合法值——nextValue可能是undefined(新图纸没写class),而el.className = undefined在浏览器里会得到字面的字符串"undefined",让元素莫名其妙挂上一个叫undefined的类。|| ''就是把它翻译成“没有类名”这件合法的事。} else if (shouldSetAsProps(el, key, nextValue)):第二优先级。不是class,那就回到上一节的通用判断:能不能用更快的 DOM Properties 路线写。const type = typeof el[key]/if (type === 'boolean' && nextValue === '')/el[key] = true/el[key] = nextValue:DOM Properties 路线内部的老三样,一字未改。} else {:最后的兜底。既不是class,也不适合走 DOM Properties,那就老老实实setAttribute。
把四条路线摆在一起看,就是这张分派图(后面几节我们还会继续往 patchProps 里加分支):
这张图的价值在于:它解释了 patchProps 为什么要写成“一串 if/else”而不是一堆互相独立的判断。
从上面的代码中可以看到,我们对 class 进行了特殊处理,即使用 el.className 代替 setAttribute 函数。其实除了 class 属性之外,Vue.js 对 style 属性也做了增强,所以我们也需要对 style 做类似的处理。
通过对 class 的处理,我们能够意识到,vnode.props 对象中定义的属性值的类型并不总是与 DOM 元素属性的数据结构保持一致,这取决于上层 API 的设计。Vue.js 允许对象类型的值作为 class 是为了方便开发者,在底层的实现上,必然需要对值进行归一化后再使用。另外,归一化值的过程是有代价的,如果需要进行大量的归一化操作,则会消耗更多性能。
8.5 卸载操作
前文主要讨论了挂载操作。接下来,我们将会讨论卸载操作。卸载操作发生在更新阶段,更新指的是,在初次挂载完成之后,后续渲染会触发更新,如下面的代码所示:
// 初次挂载
renderer.render(vnode, document.querySelector('#app'))
// 再次挂载新 vnode,将触发更新
renderer.render(newVNode, document.querySelector('#app'))renderer.render(vnode, container):渲染器的对外唯一入口。你只要告诉它“把这份图纸画到这个容器里”,剩下的挂载、更新它自己搞定。render(渲染)就是指把虚拟 DOM 变成真实 DOM 的那个动作。- 第一行
// 初次挂载:注释。初次挂载(initial mount)指的是容器里还什么都没有时的第一次绘制。 document.querySelector('#app'):找出页面上id="app"的那个元素当容器。同一个容器贯穿始终——这是关键,下一次render才知道“该拿谁跟谁比”。- 第二行
// 再次挂载新 vnode,将触发更新:注释。第二次调用render时容器里已经有东西了,所以走的是更新这条路。注意这里是“再次挂载”这个词有点误导——它实际上是更新,原书沿用了这个说法。 - 新传进来的
newVNode:新图纸。渲染器会拿它和“上次画的那张”做对比。
所以你可以这样理解 render 的身份:它是项目经理。你每次只管递一份新图纸上去,它自己去找旧图纸、算差异、安排工人施工。
更新的情况有几种,我们逐个来看。当后续调用 render 函数渲染空内容(即 null)时,就意味着卸载之前渲染的内容:
// 初次挂载
renderer.render(vnode, document.querySelector('#app'))
// 新 vnode 为 null,意味着卸载之前渲染的内容
renderer.render(null, document.querySelector('#app'))renderer.render(null, ...):递上去一份空图纸。它的意思是“这里什么都不要了”——不是“画个空的东西”,而是“把之前画的拆掉”。- 为什么用
null而不是空字符串:因为null在 JS 里表示“根本没有值”,跟“值是空”是两码事。渲染器拿到null才有理由认为这是一次拆除指令。
首次挂载完成后,后续渲染时如果传递了 null 作为新 vnode,则意味着什么都不渲染,这时我们需要卸载之前渲染的内容。回顾前文实现的 render 函数:
function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
// 卸载,清空容器
container.innerHTML = ''
}
}
container._vnode = vnode
}function render(vnode, container):项目经理函数本体。if (vnode):先问“这次有图纸吗?”有图纸就干活,没图纸就走拆除流程。patch(container._vnode, vnode, container):这一行是整个渲染器的记忆所在。container._vnode(下划线开头表示“这是我们自己挂上去的私有字段”,不是浏览器自带的)存着上次画的那张图纸。把它当“旧图纸”、vnode当“新图纸”,一起丢给patch去比对。没有这个字段,渲染器就失忆了,每次都只能重新盖一遍房子。} else {:没有新图纸 → 走拆除分支。if (container._vnode):先确认“之前确实画过东西”。要是从来没画过(container._vnode是undefined),就没东西可拆,直接跳过。container.innerHTML = '':innerHTML(内部 HTML)是浏览器提供的一个属性,一读就拿到这个元素内部的全部 HTML 源码,一写就把内部内容全部清空重建。这里是把它设成空串,等于“一把扫帚扫干净”。container._vnode = vnode:无论走了哪个分支,最后都要把这次的图纸存起来。这是渲染器“记住自己上次干了啥”的唯一手段。注意这一行在if/else外面——拆除时也要存,因为存进去的就是null,正好表示“现在容器是空的”。
可以看到,当 vnode 为 null,并且容器元素的 container._vnode 属性存在时,我们直接通过 innerHTML 清空容器。但这么做是不严谨的,原因有三点:
| 缺陷 | 说明 |
|---|---|
| 容器的内容可能是由某个或多个组件渲染的 | 当卸载操作发生时,应该正确地调用这些组件的 beforeUnmount、unmounted 等生命周期函数 |
| 即使内容不是由组件渲染的,有的元素存在自定义指令 | 我们应该在卸载操作发生时正确执行对应的指令钩子函数 |
使用 innerHTML 清空容器元素内容不会移除绑定在 DOM 元素上的事件处理函数 | 比如你之前给某个按钮绑了 click 事件,innerHTML = "" 之后虽然按钮没了,但事件处理函数还在内存里——会造成内存泄漏 |
内存泄漏(memory leak):
你曾经用过的东西(变量、对象、函数),你以为已经删了,但其实还在内存里待着、占着空间。就像你以为扔掉的旧衣服,其实还塞在衣柜角落里,时间久了衣柜就满了。
正确的卸载方式
卸载(unmount,卸载):
把已经造出来的真实 DOM 元素从页面上摘下来,并且顺手把它该做的收尾工作做掉。
生活比方:本章把渲染器比作装修队,那么挂载 = 盖房子,更新 = 改装修,卸载 = 拆房子。而 innerHTML = '' 相当于推土机直接推平——墙是没了,但它不知道那房子里原来住过谁:谁的水电表还没抄、谁的快递还挂在门口,它一概不管。正规拆房是要先断电断水、通知住户、再一砖一瓦拆的。
正如上述三点原因,我们不能简单地使用 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)
}const el = vnode.el = createElement(vnode.type):这一行是整个 8.5 节的地基。它是一次赋值,两个变量——先算出createElement(...)的结果,然后同时赋给el和vnode.el。改写成两行就是const el = createElement(...)再vnode.el = el。写成一行是为了省事,不是因为语法特殊。// 让 vnode.el 引用真实 DOM 元素:注释,点明这行的用意。引用(reference)不是拷贝一份元素,而是存一个“指向”它的小纸条。所以改一下el指向别处,vnode.el记的还是原来那个。
vnode.el:vnode 上的一个字段,专门用来存放“我这份图纸对应的那面真实墙壁”。
生活比方:图纸背面写了这面墙的门牌号。以后要拆这面墙、或者要在这面墙上钉东西(比如挂事件监听器),不用满屋找,只要翻出图纸看门牌号就行。没有这个门牌号,图纸就只是一张纸——你根本不知道它对应页面上哪一块。
if (typeof vnode.children === 'string')/setElementText(...):和之前一样,填文字。vnode.children.forEach(child => { patch(null, child, el) }):子节点递归挂载。子节点也会各自给自己写上门牌号(各自mountElement里的vnode.el),所以将来拆房子时,子节点也是能独立找到的。for (const key in vnode.props)/patchProps(el, key, null, vnode.props[key]):贴配置单。insert(el, container):挂到容器上。
⚠️ 注意
vnode.el是在元素创建的第一时间就写好的,比贴属性、比插入页面都早。这样一来,任何一步出错或被中断,这个图纸都还指着它的墙,不会出现“图纸和墙失联”的情况。
可以看到,当我们调用 createElement 函数创建真实 DOM 元素时,会把真实 DOM 元素赋值给 vnode.el 属性。这样,在 vnode 与真实 DOM 元素之间就建立了联系,我们可以通过 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
}const el = container._vnode.el:连着点两次取出真实元素。先拿“上次画的图纸”,再用图纸上的门牌号换出墙。这就是vnode.el存在的全部意义。const parent = el.parentNode:parentNode(父节点)是 DOM 对象自带的一个属性,指向“装着我的那个元素”。拆除必须告诉爹,所以要先找到爹。if (parent) parent.removeChild(el):removeChild(移除子节点)是浏览器原生 API,意思是“把我这个儿子从爹身上摘下来”。if (parent)是一层防御:万一这个元素已经被别人摘走了(parentNode是null),就别再喊爹了,否则removeChild会对null报错。
结论:拆除操作一定要能重复执行而不炸。
如上面的代码所示,其中 container._vnode 代表旧 vnode,即要被卸载的 vnode。然后通过 container._vnode.el 取得真实 DOM 元素,并调用 removeChild 函数将其从父元素中移除即可。
封装 unmount 函数
由于卸载操作是比较常见且基本的操作,所以我们应该将它封装到 unmount 函数中,以便后续代码可以复用它:
function unmount(vnode) {
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}function unmount(vnode):定义一个叫unmount的函数,只接收一个参数——要拆的那份图纸。un(撤销)加上mount(挂载)就是撤掉挂载。注意它不接收container参数——因为不需要,图纸上的el本身就带着“我在哪儿”的信息。const parent = vnode.el.parentNode:翻出门牌号找到墙,再找到墙的爹。if (parent):同样的防御——墙可能早就不在了。parent.removeChild(vnode.el):把墙从爹身上摘下来。- 注意没有
insert的对称操作:因为 DOM 元素被摘掉之后,浏览器会自动释放它,不需要手动“回收”。这一点和 JS 里的对象不同,所以初学者容易困惑。
unmount 函数接收一个虚拟节点作为参数,并将该虚拟节点对应的真实 DOM 元素从父元素中移除。现在 unmount 函数的代码还非常简单,后续我们会慢慢充实它,让它变得更加完善。有了 unmount 函数后,就可以直接在 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(container._vnode):一行替换掉了原先那三行(取el、取parent、removeChild)。这就是“封装”的意义:把同一段操作打包成一个名字,以后 8.11 节要支持 Fragment 卸载时,只改unmount一个函数就够了,不用满世界找removeChild在哪。// 调用 unmount 函数卸载 vnode:注释。container._vnode = vnode:还是最后那句“记账”,不变。
最后,将卸载操作封装到 unmount 中,还能够带来两点额外的好处:
- 在
unmount函数内,我们有机会调用绑定在 DOM 元素上的指令钩子函数,例如beforeUnmount、unmounted等。 - 当
unmount函数执行时,我们有机会检测虚拟节点vnode的类型。如果该虚拟节点描述的是组件,则我们有机会调用组件相关的生命周期函数。
8.6 区分 vnode 的类型
在上一节中我们了解到,当后续调用 render 函数渲染空内容(即 null)时,会执行卸载操作。如果在后续渲染时,为 render 函数传递了新的 vnode,则不会进行卸载操作,而是会把新旧 vnode 都传递给 patch 函数进行打补丁操作。回顾前文实现的 patch 函数:
function patch(n1, n2, container) {
if (!n1) {
mountElement(n2, container)
} else {
// 更新
}
}function patch(n1, n2, container):patch是渲染器的总调度员。参数也最好认:n1= 旧 vnode(number 1,取“第一个、新旧对比里的旧的那个”),n2= 新 vnode,container= 落脚点容器。这个n1 / n2的命名是 Vue.js 源码沿用下来的,读源码时见到它不用慌,就当 old / new。if (!n1):!是“取反”。!n1的意思是“没有旧 vnode”。没有旧图纸 = 只能新建 = 挂载。mountElement(n2, container):从无到有,走挂载路线。} else { // 更新 }:else(否则)分支——存在旧图纸,说明可以打补丁了。但这里暂时是空的,因为我们还没搞清“什么情况下能打补丁”,下一小节就解决它。
其中 n1 代表旧 vnode,n2 代表新 vnode。如果旧 vnode 存在,则需要在新旧 vnode 之间打补丁。但在具体执行打补丁操作之前,我们需要保证新旧 vnode 所描述的内容相同。这是什么意思呢?举个例子,假设初次渲染的 vnode 是一个 p 元素:
const vnode = {
type: 'p'
}
renderer.render(vnode, document.querySelector('#app'))type: 'p':图纸上写“这是个段落标签”。renderer.render(...):把它画到#app里。此时页面上有一个<p>元素。
后续又渲染了一个 input 元素:
const vnode = {
type: 'input'
}
renderer.render(vnode, document.querySelector('#app'))type: 'input':新图纸换成了“这是个输入框”。名字虽然也叫vnode(同名的const重新赋值),但它已经是全新的对象了。
这就会造成新旧 vnode 所描述的内容不同,即 vnode.type 属性的值不同。对于上例来说,p 元素和 input 元素之间不存在打补丁的意义,因为对于不同的元素来说,每个元素都有特有的属性,例如:
<p id="foo" />
<!-- type 属性是 input 标签特有的,p 标签则没有该属性 -->
<input type="submit" />打比方:你没法“把一张床改成一张书桌”——床有床的构造(床架、床垫),书桌有书桌的构造(抽屉、桌腿)。两张图纸说的是两种完全不同的东西,中间没有“一点点差别”,只有“完全不是一回事”。这时候正确做法是拆了旧的重砌,而不是尝试改造。
在这种情况下,正确的更新操作是,先将 p 元素卸载,再将 input 元素挂载到容器中。因此我们需要调整 patch 函数的代码:
function patch(n1, n2, container) {
// 如果 n1 存在,则对比 n1 和 n2 的类型
if (n1 && n1.type !== n2.type) {
// 如果新旧 vnode 的类型不同,则直接将旧 vnode 卸载
unmount(n1)
n1 = null
}
if (!n1) {
mountElement(n2, container)
} else {
// 更新
}
}if (n1 && n1.type !== n2.type):两个条件同时成立才进这个分支——①有旧图纸(n1存在);②新旧图纸说的不是同一个东西(type不相等,!==是“不等于”,严格比较,数字1和字符串'1'它也认为不等)。unmount(n1):类型都不一样了,没什么好比的,先把旧的拆掉。拆之前用旧的n1,不是新的n2——要拆的是墙上那间。n1 = null:这一步是全函数最容易被忽略、也最关键的一行。拆完之后,必须假装“从来就没有过旧图纸”。为什么?因为下面的if (!n1)才是决定“走挂载还是走更新”的总开关,n1不置空的话,这个开关还停在“更新”上,新元素就永远挂不上去。摘掉门牌号,才能让后面的流程以为这是第一次盖房。if (!n1) { mountElement(n2, container) }:现在n1已经被置空了,条件成立,走挂载。} else { // 更新 }:只有“旧图纸存在且类型相同”时,才会落到这里——这才是真正可以打补丁的情形。
如上面的代码所示,在真正执行更新操作之前,我们优先检查新旧 vnode 所描述的内容是否相同,如果不同,则直接调用 unmount 函数将旧 vnode 卸载。这里需要注意的是,卸载完成后,我们应该将参数 n1 的值重置为 null,这样才能保证后续挂载操作正确执行。
还要进一步确认 type 相同
即使新旧 vnode 描述的内容相同,我们仍然需要进一步确认它们的类型是否相同。我们知道,一个 vnode 可以用来描述普通标签,也可以用来描述组件,还可以用来描述 Fragment 等。对于不同类型的 vnode,我们需要提供不同的挂载或打补丁的处理方式。
vnode 的类型(vnode type):
由 vnode.type 这个字段的值决定——它是字符串就描述普通标签,是对象就描述组件,是 Symbol 就描述没有标签名的特殊节点。
生活中的比喻:图纸右上角那一栏写的是“这是什么”。同一批图纸,可能是“这里砌一堵墙”、也可能是“这里放一个反复复用的模子”、还可能只是“用胶带把几张纸粘成一叠”。渲染器进门先看这一栏,再决定派谁去施工。
所以,我们需要继续修改 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
}
}if (n1 && n1.type !== n2.type) { unmount(n1); n1 = null }:类型不符的旧货处理,和上一版一模一样。// 代码运行到这里,证明 n1 和 n2 所描述的内容相同:注释。能走到这一行,说明拆除已经办完了——要么本来就没旧货,要么旧的已经拆干净了。const { type } = n2:解构(destructuring)——ES6 语法,一句话从n2这个对象里把type这个字段抠出来存成同名变量。等价于const type = n2.type,只是更短。后面反复要用n2.type,抠出来存一份省得一直写。if (typeof type === 'string'):第一类:普通标签。type是字符串,就说明它就是标签名本身('div'、'p'……)。if (!n1) { mountElement(n2, container) }:普通标签 + 没有旧货 = 挂载。} else { patchElement(n1, n2) }:普通标签 + 有旧货且类型相同 = 更新。patchElement(给元素打补丁):专管“同一个元素改了哪儿”的函数——本节还没写它,8.9 节会实现。} else if (typeof type === 'object'):第二类:组件。type是一个对象,说明这个 vnode 描述的不是标签,而是一个组件对象。这里先留空,注释说“如果 n2.type 的值的类型是对象,则它描述的是组件”。} else if (type === 'xxx'):第三类:其他类型。xxx只是个占位符,代表将来要加的各种特殊类型。placeholder(占位符):先占个位置、以后再填真实内容。8.10 和 8.11 节会往这里填上Text、Comment、Fragment的真身。
现在 patch 函数的骨架就搭起来了。整条流程画出来是这样(这张图从 8.6 开始会一直有效,后面几节只是往里加分支):
读这张图时抓住两条主线就够了:
- 横向一条:先验货(
n1.type !== n2.type吗?)——不一样就拆旧货,这一步保证接下来比的都是同类; - 纵向一条:再分诊(
type是什么类型?)——字符串走元素路线、对象走组件路线、Symbol 走特殊节点路线,每种类型各有各的挂载法和更新法。
patch 函数之所以长得越来越长,全是因为它是唯一的总入口:所有类型都得在这里报到、先统一验货,再分流到各自的专员手上。
实际上,在前文的讲解中,我们一直假设 vnode 的类型是普通标签元素。但严谨的做法是根据 vnode.type 进一步确认它们的类型是什么,从而使用相应的处理函数进行处理。落到代码上就是下面这张分派表:
n2.type 的值 | 它描述的是什么 | 挂载走哪个 | 更新走哪个 |
|---|---|---|---|
字符串('div'、'p'……) | 普通标签元素,值就是标签名 | mountElement(n2, container) | patchElement(n1, n2) |
| 对象 | 组件 | mountComponent | patchComponent(这两个函数要到讲组件的章节才会真正展开) |
Symbol(Text、Comment、Fragment) | 没有标签名的特殊节点 | 8.10 节、8.11 节往这个占位符里填 | 同左 |
8.7 事件的处理
本节我们将讨论如何处理事件,包括如何在虚拟节点中描述事件、如何把事件添加到 DOM 元素上,以及如何更新事件。
在 vnode 中描述事件
我们先来解决第一个问题,即如何在虚拟节点中描述事件。事件可以视作一种特殊的属性,因此我们可以约定,在 vnode.props 对象中,凡是以字符串 on 开头的属性都视作事件。例如:
const vnode = {
type: 'p',
props: {
// 使用 onXxx 描述事件
onClick: () => {
alert('clicked')
}
},
children: 'text'
}onClick: () => { ... }:on开头的属性一律当成“事件线路”来解读。名字里的on读作 “on”(当……的时候),Click读作 “click”(点击)——合起来就是“被点击的时候”。() => { ... }:箭头函数(arrow function),ES6 语法,一份“被调用时才执行的代码”。这里可以理解成“一个还没触发、随时待命的动作”。alert('clicked'):浏览器原生的弹窗函数,alert读作“警告”,弹出一小窗显示文字。children: 'text':这个p里还有一段文字。
为什么事件也塞进 props 里? 因为属性和事件本质是同一类东西——“挂在这个元素身上的额外信息”。属性是静态的说明书,事件是动态的开关线路,都属于“这面墙附带什么”。约定俗成(convention)指“没有强制规定、但大家都这么用”的规矩:这里约定凡是 on 开头就归事件管,好处是一个新事件不用改渲染器代码,自动就支持了。
这里 onClick 就是描述“点击事件”的属性。
把事件绑定到 DOM 上
解决了事件在虚拟节点层面的描述问题后,我们再来看看如何将事件添加到 DOM 元素上。这非常简单,只需要在 patchProps 中调用 addEventListener 函数来绑定事件即可:
patchProps(el, key, prevValue, nextValue) {
// 匹配以 on 开头的属性,视其为事件
if (/^on/.test(key)) {
// 根据属性名称得到对应的事件名称,例如 onClick ---> click
const name = key.slice(2).toLowerCase()
// 绑定事件,nextValue 为事件处理函数
el.addEventListener(name, nextValue)
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}if (/^on/.test(key)):正则判断属性名是不是on开头。拆开看:/^on/是一个正则表达式——正则(regular expression,缩写regex或regexp)就是“一个用来检查字符串长相的模具”,这个模具的意思是“o、n开头,后面随便”。.test(key)拿这个模具去套key,返回true(长得像)或false(不像)。^是正则里的“开头”符号($则表示“结尾”)。const name = key.slice(2).toLowerCase():把on这两个字母削掉,再全转小写。key.slice(2):slice(切片)从下标 2 开始截,'onClick'.slice(2)得到'Click';.toLowerCase()把它变成'click'。两行代码合起来完成“从属性名到事件名”的翻译。el.addEventListener(name, nextValue):addEventListener(添加事件监听器)是浏览器原生 API,意思是“给这个元素接一根线路:'click' 这个信号一来,就执行这个函数”。nextValue就是图纸里写的那份待命动作,到这儿才真正被接上线。} else if (key === 'class') { // 省略部分代码 }:8.4 节讲的 class 专属处理,这里不重复贴。注意它在事件分支的后面——顺序无所谓,因为class和onXxx不会同时匹配。} else if (shouldSetAsProps(...)) { ... }和} else { ... }:8.3 节的 DOM Properties 路线和setAttribute兜底路线,同样不重复贴。
翻译规则就是一张对照表:
| vnode.props 里的属性名 | key.slice(2).toLowerCase() 得到的事件名 |
|---|---|
onClick | click |
onContextmenu | contextmenu |
三步走总结:
- 认出是事件:
/^on/.test(key) - 翻译出事件名:
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 {
// 省略部分代码
}
}const name = key.slice(2).toLowerCase():老规矩,翻译出事件名。prevValue && el.removeEventListener(name, prevValue):prevValue(旧值)在这里第一次派上用场——它就是上一次绑定的那个函数。removeEventListener(移除事件监听器)是addEventListener的“反义操作”。el.addEventListener(name, nextValue):接上新函数。注意这里接的是nextValue本身,不是任何包装。
⚠️ 前面的
prevValue &&是什么?这是短路求值(short-circuit evaluation):&&会先看左边,左边是“假值”就直接返回左边、根本不执行右边。所以首次挂载时prevValue是null,右边那句removeEventListener压根不会跑——没有旧函数可移除,强行去移除反而会出问题。一行代码同时干掉了“首次挂载”和“更新”两种情况。
⚠️ 这里藏着一个大坑:
removeEventListener必须传入和当初addEventListener时“完全同一个函数对象”才能摘掉。每次更新都造一个新函数(哪怕干的事一模一样),旧的那根就永远摘不掉——而 DOM 元素上可以挂无数个同名监听器,于是点一下触发 N 次。这就是下面invoker模式要解决的问题。
这么做代码能够按照预期工作,但其实还有一种性能更优的方式来完成事件更新。
invoker 模式:性能更优的更新方式
在绑定事件时,我们可以绑定一个伪造的事件处理函数 invoker,然后把真正的事件处理函数设置为 invoker.value 属性的值。这样当更新事件的时候,我们将不再需要调用 removeEventListener 函数来移除上一次绑定的事件,只需要更新 invoker.value 的值即可:
invoker(invoker,调用者 / 触发者):
一个“传话筒”函数。你接在墙上的线不是干活的本人,而是一台“转发器”——你按这个转发器,它替你转手喊真正的工人去干活。
生活中的比喻:总机的分机。员工入职时,桌上电话不变,但分机号指向的号码换了。所以“换人”只是改一个号码,电话线一根都不用重新接。
换成技术语言:addEventListener 绑上去的永远是同一个 invoker 函数对象,而真正干活的函数被塞进 invoker.value。更新时只改 invoker.value 的指向,invoker 本人一动不动。这既避免了“新旧函数对不上号摘不掉”的老坑,又省掉一次 removeEventListener 调用。
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 {
// 省略部分代码
}
}let invoker = el._vei:el._vei是元素身上专门存 invoker 的柜子。let而不是const,是因为下一行可能要给这个变量重新赋值,而重新赋值不会改变el._vei里存的东西——这一点是本模式成立的关键。// vei 是 vue event invoker 的首字母缩写:注释。vei= Vue Event Invoker。const name = key.slice(2).toLowerCase():翻译事件名,不变。if (nextValue):有新的事件函数要绑吗?这一层是“要不要接线”的总开关。if (!invoker):柜子是空的吗?空 = 首次绑定,得从头造一根线。invoker = el._vei = (e) => { invoker.value(e) }:造一根传话筒。(e) => { ... }里的e是事件对象——点击发生的那一刻,浏览器会把“谁被点了、鼠标在哪个位置”这些信息打包成e递给它。函数体只有一句invoker.value(e):自己不干活,把e转手递给invoker.value指着的那个真函数。注意invoker.value(e)里的e就是这个参数,一层层往下传。invoker.value = nextValue:把真正的事件处理函数存进invoker.value这个字段。注意它是存函数本身,不是调用函数——这里没有括号。el.addEventListener(name, invoker):接上线,但接的是传话筒invoker,不是nextValue。记住这一行,8.8 节的关键推理全靠它。} else { invoker.value = nextValue }:柜子里已经有一根线了 → 说明这是更新。只换传话筒指向的号码,一根线都不用动。这就是“性能更优”的全部秘密:省掉了removeEventListener+addEventListener这一对操作。} else if (invoker) { el.removeEventListener(name, invoker) }:新图纸上没写事件了(nextValue是空),而柜子里有旧线 → 该拆线了。if (invoker)又是一层防御:没线就别拆。
把这条“点击 → 执行”的链路画出来,就一目了然了:
观察上面的代码,事件绑定主要分为两个步骤:
- 先从
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'))onClick和onContextmenu并排:contextmenu是“右键菜单事件”(context menu = 右键弹出的菜单)。- 两个都是
on开头,所以都会走事件分支。问题就出在这儿。
当渲染器尝试渲染这上面代码中给出的 vnode 时,会先绑定 click 事件,然后再绑定 contextmenu 事件。后绑定的 contextmenu 事件的处理函数将覆盖先绑定的 click 事件的处理函数。
为什么“先绑的”会被“后绑的”顶掉?因为上一版我们把 invoker 存在 el._vei 这个单一格子里。绑定 click 时,格子里存的是 click 的 invoker;绑定 contextmenu 时,整个格子被重新赋值,click 的 invoker 记录当场丢失。一个柜子只放得下一条线,第二条进来就把第一条挤走了。
生活比方:你只有一个贴在门上的便签格,先写“送外卖的在这敲门”,再写“收快递的在这敲门”——后写的把先写的盖住了。结果两拨人都不认识门。柜子太小,是根子上的毛病。
为了解决事件覆盖的问题,我们需要重新设计 el._vei 的数据结构。我们应该将 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 {
// 省略部分代码
}
}结论:这一版只动了“柜子”的结构,逻辑一行没重写。 逐行看:
if (/^on/.test(key)):还是老规矩,先认事件。const invokers = el._vei || (el._vei = {}):本次修改的核心一行。含义是“柜子存在就拿来用,不存在就当场造一个空的{}存进去”。||的短路特性让这一行同时完成了“读”和“初始化”两件事——巧妙,但也容易看懵:注意const声明的invokers是局部变量,而真正存进元素身上的是el._vei(那个{}对象)。- 为什么要这么绕?因为柜子只该造一次。如果每次都写
el._vei = {},那第二来一个事件就把柜子清空了,等于白改。||恰好保证“已存在就不动它”。
- 为什么要这么绕?因为柜子只该造一次。如果每次都写
let invoker = invokers[key]:按名字从柜子里取这根线。key是'onClick'、'onContextmenu'这样的完整属性名——用完整名字做键,就绝不会撞车。取到就是更新,取不到就是新增。const name = key.slice(2).toLowerCase():翻译出给浏览器用的真事件名(click、contextmenu)。注意key和name长得像但角色不同:key是柜子上的标签(onClick),name是电线上的型号(click)。柜子用长的、接线用短的。if (nextValue):有事件函数要绑。if (!invoker):这根线柜子里没有 → 新建一根。invoker = el._vei[key] = (e) => { invoker.value(e) }:造传话筒,并且这次是存进“柜子的某一格”(el._vei[key]),而不是直接存柜子本身。这一改就是“事件不覆盖”的全部秘密:click 住第 1 格,contextmenu 住第 2 格,互不干涉。invoker.value = nextValue:把真函数塞进这根线的value字段。el.addEventListener(name, invoker):接线不变。} else { invoker.value = nextValue }:这根线已存在 → 更新,只换value,不动线。} else if (invoker) { el.removeEventListener(name, invoker) }:事件被删掉了 → 拆线。拆的时候传的是invoker(当初接上去的那根),所以一定对得上号,不会留下“摘不掉的僵尸监听器”。- 三个
else分支:class、shouldSetAsProps、setAttribute,老样子。
改完之后柜子的长相是这样的:
三版演变的差别全在“柜子”这一处:
| 版本 | el._vei 的结构 | 后果 |
|---|---|---|
| 最初 | 直接存一个 invoker(一个格子) | 同一时刻只能缓存一个事件处理函数,click 会被 contextmenu 顶掉 |
| 改为对象 | { [key]: invoker },键是完整属性名 | 多种事件各占一格,互不覆盖 |
value 允许数组 | invoker.value 可以是函数数组 | 同一事件的多个处理函数按数组顺序依次执行 |
结论:el._vei 从一个格子变成一个按事件名分格的柜子,问题就解决了。 invoker 模式的老规矩(更新只改 value)一点没动。
同一事件的多个处理函数
另外,一个元素不仅可以绑定多种类型的事件,对于同一类型的事件而言,还可以绑定多个事件处理函数。我们知道,在原生 DOM 编程中,当多次调用 addEventListener 函数为元素绑定同一类型的事件时,多个事件处理函数可以共存,例如:
el.addEventListener('click', fn1)
el.addEventListener('click', fn2)el:还是那个元素。'click':事件名,两行完全一样。fn1/fn2:两个不同的函数对象——这才是两行之间唯一的区别。- 对比一下上一节的
el._vei:那里的柜子只有一格,后写的把先写的顶掉了。浏览器自己的列表却能挂无限个——它内部维护的是一份“监听器列表”,click这一栏下面挂着fn1和fn2两个成员。这正是我们要在 vnode 里用数组的原因:既然浏览器支持“多个并存”,Vue.js 就应该让你也这么写。
当点击元素时,事件处理函数 fn1 和 fn2 都会执行。因此,为了描述同一个事件的多个事件处理函数,我们需要调整 vnode.props 对象中事件的数据结构:
const vnode = {
type: 'p',
props: {
onClick: [
// 第一个事件处理函数
() => {
alert('clicked 1')
},
// 第二个事件处理函数
() => {
alert('clicked 2')
}
]
},
children: 'text'
}
renderer.render(vnode, document.querySelector('#app'))onClick: [ fn1, fn2 ]:onClick的值从“一个函数”变成了“两个函数装在数组里”。点击一下,fn1和fn2会按数组顺序依次执行。- 注意语义变化:从前“一个事件 = 一个函数”,现在“一个事件 = 一串函数”。这是加法(多个人干同一件事),不是覆盖(新人顶掉老人)。
在上面这段代码中,我们使用一个数组来描述事件,数组中的每个元素都是一个独立的事件处理函数,并且这些事件处理函数都能够正确地绑定到对应元素上。为了实现此功能,我们需要修改 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 {
// 省略部分代码
}
}结论:这一版相对上一版只动了那个箭头函数体里的三行(就是“事件到了之后该干什么”那一小块),外面的取柜子、翻译名字、接线拆线全部原封不动。逐行看:
if (/^on/.test(key)):认事件,不变。const invokers = el._vei || (el._vei = {}):拿柜子(或当场造一个),不变。let invoker = invokers[key]:按名字取这根线,不变。const name = key.slice(2).toLowerCase():翻译事件名,不变。if (nextValue):有事件要绑(nextValue现在可能是一个数组),不变。if (!invoker):柜子里没这根线 → 新建。invoker = el._vei[key] = (e) => { ... }:传话筒本体。它的函数体就是这次的全部改动。if (Array.isArray(invoker.value)):本次修改的关键一行。问一句“我手上这个value是不是数组?”注意这里判断的是invoker.value(真正的那个值),而不是nextValue——因为传话筒执行时,图纸早就传走了,它只能看自己value字段此刻装的是什么。这一笔让同一个传话筒既能扛单个函数、又能扛函数数组。invoker.value.forEach(fn => fn(e)):数组路线——挨个叫一遍,每个都把同一个事件对象e递过去。forEach就是“挨个执行一遍”的循环工具,fn => fn(e)是“拿到一个函数fn,立刻用e调它”。} else {:不是数组,那就是单个函数的写法。invoker.value(e):单个函数路线——直接调。这是上一版就有的老代码,本版保留,用来兼容“一个事件一个函数”的写法。}:else块结束。invoker.value = nextValue:把新的(可能是数组、可能是单个函数)真值存进去。它是什么类型,交给传话筒执行时自己判断——存储层不挑食,执行层才分类。el.addEventListener(name, invoker):接上传话筒,注意接的仍然是invoker,不是nextValue,所以“更新只改value”的省时特性也一并保留到了数组场景。} else { invoker.value = nextValue }:更新场景,只换value,数组也照存不误。比如从“两个函数”改成“一个函数”,只要把value整个换成单个函数即可。} else if (invoker) { el.removeEventListener(name, invoker) }:事件被删 → 拆线,不变。- 三个
else分支:class、shouldSetAsProps、setAttribute,老样子。
为什么一定要用数组,而不是简单地 addEventListener 调 N 次?因为那样就回到了 8.7 节开头的老问题——每次更新都要摘旧线、接新线。用数组装、交给同一个 invoker,才既支持“多个处理函数”,又保住“更新只改一个字段”的性能优势。
在这段代码中,我们修改了 invoker 函数的实现。当 invoker 函数执行时,在调用真正的事件处理函数之前,要先检查 invoker.value 的数据结构是否是数组,如果是数组则遍历它,并逐个调用定义在数组中的事件处理函数。
8.8 事件冒泡与更新时机问题
在上一节中,我们介绍了基本的事件处理。本节我们将讨论事件冒泡与更新时机相结合所导致的问题。
先看一个“奇怪”的例子
事件冒泡(event bubbling):
一个事件被触发后,会自动一层层往上传给所有祖先元素。
生活中的比喻:就像往水池里扔颗石子,涟漪会一圈圈荡开——你只在最中心的 p 上点,但 div、外层的容器……全都会收到“click 发生了”这个通知。
本节例子还要用到两个前面章节介绍过的东西:effect(() => { ... }) 是副作用函数,作用是“把一段代码登记成‘会自动重跑的任务’”,你在里面读到的响应式数据一旦变化,这段代码就被重新执行一遍(第 4 章专门讲过);ref 是用来造响应式数据的工具,bol.value 就是读它当前的值、改它就触发更新。
为了更清晰地描述问题,我们需要构造一个小例子:
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'))
})整段代码就干了两件事:造一个会变的开关 bol,然后登记一份“只要 bol 变就重画”的活儿。我们把关键行拆开看:
const { effect, ref } = VueReactivity:解构从响应式模块里取出两个工具函数。{ }左边写你要拿的名字、右边写对象,中间用:分隔。const bol = ref(false):创建一个响应式数据bol,初始值为false。bol是 boolean 的简写,不过这里只是个普通变量名,叫什么都行。注意:数据变了会“自动”重跑effect。effect(() => { ... }):把一整段代码登记成副作用函数。() => { ... }就是“当bol变了就重跑一遍的活儿”——注意是整段重跑,每次都从零画一张新图纸。// 创建 vnode/const vnode = { type: 'div', ... }:这份 vnode 描述的是一个div元素,它有一个p元素作为子节点。每次重跑都会重新造一份全新的 vnode 对象。旧的那份会作为container._vnode留下来,新旧对比就是靠它们。props: bol.value ? { onClick: ... } : {}:全例子的机关。bol.value为真 → 给一个带onClick的对象;为假 → 给空对象{}。“有没有 click 线路”这件事,完全由bol的值决定。三元表达式(ternary expression,形状是条件 ? 成立时的值 : 不成立时的值)——就像“如果天晴就带伞,否则不带”这一句里的“带伞/不带伞”。children: [ { type: 'p', ... } ]:一个p子节点。注意p的props是写死的——它永远有一个onClick,不随bol变化。onClick: () => { bol.value = true }:p的特点:它有click事件,点击时会设置bol.value = true。它是一根“引线”,一点就点燃父元素的变化。// 渲染 vnode/renderer.render(vnode, document.querySelector('#app')):把刚造好的图纸画到#app里。每次effect重跑,这行就执行一次,于是旧图纸被顶替、新图纸上位。
先把这个例子的页面结构画出来,后面分析全靠这张图:
问题:为什么点击子元素会触发父元素的事件?
结合上述特点,我们来思考一个问题:当首次渲染完成后,用鼠标点击 p 元素,会触发父级 div 元素的 click 事件的事件处理函数执行吗?
答案其实很明显,在首次渲染完成之后,由于 bol.value 的值为 false,所以渲染器并不会为 div 元素绑定点击事件。当用鼠标点击 p 元素时,即使 click 事件可以从 p 元素冒泡到父级 div 元素,但由于 div 元素没有绑定 click 事件的事件处理函数,所以什么都不会发生。
用图说话:下面把“我们的预期”和“实际发生的”并排画出来——左边是事件从 p 一路冒泡上去,而 div 这一格是空的(涟漪传到了没人接的墙上);右边是真实发生的三步,问题的形状就露出来了,注意“更新”和“冒泡”这两件事的先后:
但事实是,当你尝试运行上面这段代码并点击 p 元素时,会发现父级 div 元素的 click 事件的事件处理函数竟然执行了。为什么会发生如此奇怪的现象呢?这其实与更新机制有关。
原因分析
我们来逐步分析当点击 p 元素时,到底发生了什么:
- 当点击
p元素时,绑定到它身上的click事件处理函数会执行,于是bol.value的值被改为true。 - 由于
bol是一个响应式数据,所以当它的值发生变化时,会触发副作用函数重新执行。由于此时的bol.value已经变成了true,所以在更新阶段,渲染器会为父级div元素绑定click事件处理函数。 - 当更新完成之后,点击事件才从
p元素冒泡到父级div元素。由于此时div元素已经绑定了click事件的处理函数,因此就发生了上述“奇怪”的现象。
图 8-3 给出了当点击 p 元素后,整个更新和事件触发的流程图。
根据图 8-3 我们能够发现,之所以会出现上述奇怪的现象,是因为更新操作发生在事件冒泡之前,即为 div 元素绑定事件处理函数发生在事件冒泡之前。
结论:用户只点了一次 p,div 却被“连累”了。 按理说“刚接上的线路”不该立刻响——用户那一次点击发生在接线路之前,接线路只是碰巧赶上了同一波冒泡。问题不是“线路接错了”,而是“接得太及时了”。
那如何避免这个问题呢?一个很自然的想法是,能否将绑定事件的动作挪到事件冒泡之后?但这个想法不可靠,因为我们无法知道事件冒泡是否完成,而且即使可以,事件冒泡过程的某些节点是会变化的,这些都无法避免这个问题。
微任务队列(microtask queue):
浏览器里“排队等一等再执行”的一小格待办清单。JS 代码不是想插队就插队的——你不能在事件处理函数执行到一半的时候强行插一段自己的代码进去。微任务(microtask)就是浏览器给这类“必须排在当前代码之后、但不用等到下一次事件循环”的活儿留的便签。Vue.js 的更新就排在这个队列里。
这里还有一个自然的追问:Vue.js 的更新不是排进异步的微任务队列了吗?把绑定动作放进微任务里,不就“等冒泡走完”了吗?其实不然。换句话说,微任务会穿插在由事件冒泡触发的多个事件处理函数之间被执行。因此,即使把绑定事件的动作放到微任务中,也无法避免这个问题。
这个“穿插”是本章最难的一个点,我们再展开说说。事件从 p 往上冒泡,不是一口气冲到 div,而是一层一层、逐个元素地派发。假设中间还有 span、section、article……每经过一个绑了 click 的元素,那个元素的处理函数就要同步跑一遍。而微任务队列就卡在这些处理函数之间的缝隙里被清空了:
所以“把活儿推到微任务里”只是把抢跑时间点往后挪了一点点,并没有跳出事件派发这条流水线。要根治,就得换个思路:不去管“绑定排在第几位”,而是在执行时做一次自查。
那应该如何解决呢?其实,仔细观察图 8-3 就会发现,触发事件的时间与绑定事件的时间之间是有联系的,如图 8-4 所示。

图 8-3 更新和事件触发的流程

图 8-4 触发事件的时间与绑定事件的时间之间的联系
由图 8-4 可以发现,事件触发的时间要早于事件处理函数被绑定的时间。这意味着当一个事件触发时,目标元素上还没有绑定相关的事件处理函数,我们可以根据这个特点来解决问题:
结论:屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行。
这个思路的妙处在于:它不试图“抢时间”,而是“验明正身”。事件来了,invoker 不再二话不说直接转交,而是先问一句“你这事件是什么时候发生的?”——如果发现事件发生时我还没接上线,那说明我属于事后才接的那根线,和这一次点击无关,于是当场闪开。真用户后来再点,那时的 e.timeStamp 一定大于 invoker.attached,自然就放行了。
生活比方:门卫看进出记录。你规定“八点以后才入职的员工,不算在今早九点那次集合里”——不去阻止集合,也不调整入职时间,只是在点名时按“入职时间 vs 集合时间”核一下。核得过就应到,核不过就说明自己来晚了。这就是 e.timeStamp 和 invoker.attached 的关系。
方案要落到两个时间字段上:
| 字段 | 它记的是什么 | 从哪来 |
|---|---|---|
e.timeStamp | 事件发生的时间 | 浏览器递给监听器的事件对象 e |
invoker.attached | 事件处理函数被绑定的时间 | performance.now(),浏览器的高精度计时器,返回当前时刻(毫秒) |
基于此,我们可以调整 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 {
// 省略部分代码
}
}结论:相对上一版只多了两行(if (e.timeStamp < invoker.attached) return 和 invoker.attached = performance.now()),其余全部原封不动。逐行看:
invoker = el._vei[key] = (e) => { ... }:还是那台传话筒。注意它的参数还是e——e是浏览器递给监听器的事件对象,这正是我们能拿到“事件发生时间”的唯一入口。// e.timeStamp 是事件发生的时间:注释,解释e.timeStamp这个字段的含义。timeStamp(时间戳):记录时间的一个数字。e是“事件对象”,浏览器在事件发生时顺手塞给它的一包资料(谁被点了、鼠标坐标、发生时刻)。if (e.timeStamp < invoker.attached) return:本次修改的核心一行。拿“事件发生时间”和“我被绑定的时刻”比一比:事件比我还早 → 说明我迟到 → 直接return走人。return后面不带值表示“函数到此结束,什么都不返回”,于是后面的真正处理函数一行都不会执行。注意这个return必须放在最前面——闸门必须在门口,过了这关才谈上干活。if (Array.isArray(invoker.value))/invoker.value.forEach(fn => fn(e)):数组路线,8.7 节的代码原封不动。} else { invoker.value(e) }:单个函数路线,同样没动。换句话说,“多个人干”和“一个人干”两种写法都被这道闸门统一管住了。invoker.value = nextValue:存真函数,不变。invoker.attached = performance.now():第二处新增。这个attached(附着的、贴上去的时刻)字段在invoker函数创建之后、正式接线之前就被填好了——填的就是“这一根线是此刻贴上去的”这个事实。顺序很重要:必须先记时间再接线,不然记的就不是线的时间了。el.addEventListener(name, invoker):接线。现在线上挂着的不只是一根线,还附了一张“上线时间卡”(attached)。} else { invoker.value = nextValue }:更新时不会刷新attached。这是有意为之:线还是同一根线(从没被摘过),它的“出生时间”理应是第一次接线的那一刻,不该因为换了接线人而重写。} else if (invoker) { el.removeEventListener(name, invoker) }:拆线,不变。- 三个
else分支:class、shouldSetAsProps、setAttribute,老样子。
把整个防误触发机制画出来(这是本节的核心图):
如上面的代码所示,我们在原来的基础上只添加了两行代码。首先,我们为伪造的事件处理函数添加了 invoker.attached 属性,用来存储事件处理函数被绑定的时间。然后,在 invoker 执行的时候,通过事件对象的 e.timeStamp 获取事件发生的时间。最后,比较两者,如果事件处理函数被绑定的时间晚于事件发生的时间,则不执行该事件处理函数。
这里有必要指出的是,在关于时间的存储和比较方面,我们使用的是高精时间,即 performance.now。但根据浏览器的不同,e.timeStamp 的值也会有所不同。它既可能是高精时间,也可能是非高精时间。因此,严格来讲,这里需要做兼容处理。不过在 Chrome 49、Firefox 54、Opera 36 以及之后的版本中,e.timeStamp 的值都是高精时间。
为什么要对“时间”这么较真?因为这次比的不是“先后顺序”这种大方向,而是两个数字直接比大小——精度不够就可能把“其实比对方大一点”判成“更小”,闸门就误开或误关了。
8.9 更新子节点
前几节我们讲解了元素属性的更新,包括普通标签属性和事件。接下来,我们将讨论如何更新元素的子节点。
首先,回顾一下元素的子节点是如何被挂载的:
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)
}const el = vnode.el = createElement(vnode.type):造元素 + 记门牌号(vnode.el),老规矩。// 挂载子节点,首先判断 children 的类型/// 如果是字符串类型,说明是文本子节点:两句注释,点明接下来这段代码的任务是“先看清楚子节点是哪种类型,再对号入座”。if (typeof vnode.children === 'string')/setElementText(el, vnode.children):文字路线——整个内容就是一段纯文字,直接整体写进去。} else if (Array.isArray(vnode.children))/vnode.children.forEach(child => { patch(null, child, el) }):清单路线——挨个把子节点交给patch递归处理。因为是首次挂载,旧节点一律传null。// 如果是数组,说明是多个子节点:注释。for (const key in vnode.props)/patchProps(el, key, null, vnode.props[key]):贴配置单,旧值依然传null(首次挂载嘛)。insert(el, container):挂到容器。
⚠️ 注意这里没有第三个
else:children为null(压根没子节点)时,两个条件都不成立,什么都不做,直接往下走。“空房间”不需要任何处理。
在挂载子节点时,首先要区分其类型:如果 vnode.children 是字符串,则说明元素具有文本子节点;如果 vnode.children 是数组,则说明元素具有多个子节点。
这里需要思考的是,为什么要区分子节点的类型呢?其实这是一个规范性的问题,因为只有子节点的类型是规范化的,才有利于我们编写更新逻辑。因此,在具体讨论如何更新子节点之前,我们有必要先规范化 vnode.children。
为什么要“规范化”? 因为更新逻辑要写很多个 if,而每个 if 都得先知道“我手里的 children 到底是哪种”。如果不事先约定好格式,写代码的人就得处理无穷多种可能——“单个元素怎么办?”“字符串和元素混着排怎么办?”“空字符串怎么办?”规范化的作用,就是把这些无穷多种收拢成三种,让后面的 if 数量是固定的、可数的。先定标准,再写代码,这是所有工程里都在用的招数。
vnode.children 的三种合法类型
那应该设定怎样的规范呢?为了搞清楚这个问题,我们需要先搞清楚在一个 HTML 页面中,元素的子节点都有哪些情况:
<!-- 没有子节点 -->
<div></div>
<!-- 文本子节点 -->
<div>Some Text</div>
<!-- 多个子节点 -->
<div>
<p/>
<p/>
</div><div></div>:空房间,什么家具都没有。<div>Some Text</div>:只有一段文字。<div><p/><p/></div>:摆着两件家具(两个p标签)。- 还有第四种情况原文故意没列出来:“就一件家具”,比如
<div><p/></div>。请留意——它属于下面要说的“其他情况”,用数组装。
对于一个元素来说,它的子节点无非有以下三种情况:
| 情况 | vnode.children 的值 | 例子 |
|---|---|---|
| 没有子节点 | null | <div></div> |
| 具有文本子节点 | 字符串,代表文本的内容 | <div>Some Text</div> |
| 其他情况(单个元素子节点,或多个子节点、可能是文本和元素的混合) | 数组 | [ { type: 'p' }, 'Some Text' ] |
注意第三条的括号里那句“单个元素子节点也用数组”——这正是“规范化”的价值所在。本来“一件家具”和“两件家具”是两种情况,规范化之后一律用数组([一件] 和 [一件, 一件]),if 分支就少了一个。这是用一点点内存换代码简单的典型取舍。
// 没有子节点
vnode = {
type: 'div',
children: null
}
// 文本子节点
vnode = {
type: 'div',
children: 'Some Text'
}
// 其他情况,子节点使用数组表示
vnode = {
type: 'div',
children: [
{ type: 'p' },
'Some Text'
]
}children: null:第一种。null是 JS 里表示“什么也没有”的值。children: 'Some Text':第二种。一段直接的文字,不是数组。children: [ { type: 'p' }, 'Some Text' ]:第三种。数组里可以既有元素对象、又有裸字符串——“文字和家具混着摆”是合法的,数组能装下。- 三段都是
vnode = { ... }:因为前面用了const vnode,这里三次重新赋值是不行的,所以实际代码里会写成let vnode或分成三条独立的语句;这里只是为了把三种形状并排展示,方便对照。
现在,我们已经规范化了 vnode.children 的类型。
新旧子节点的九种情况
既然一个 vnode 的子节点可能有三种情况,那么当渲染器执行更新时,新旧子节点都分别是三种情况之一。所以,我们可以总结出更新子节点时全部九种可能,如图 8-5 所示。
3 × 3 = 9 种组合。我们把它列成一张表(横轴是旧子节点,纵轴是新子节点):
| 旧 \ 新 | null(不存在) | 文本(字符串) | 数组(一组子节点) |
|---|---|---|---|
null(不存在) | ① 什么都不做 | ④ 直接 setElementText 写新文字 | ⑦ 先 setElementText(container, '') 清空,再逐个 patch |
| 文本(字符串) | ② setElementText(container, '') 清空 | ⑤ setElementText 整体覆盖,连遍历都省了 | ⑧ 同 ⑦:先清空,再逐个 patch |
| 数组(一组子节点) | ③ 逐个 unmount | ⑥ 先逐个 unmount,再 setElementText | ⑨ 本章先用“全拆全建”,第 9 章用 Diff 算法 |
这张表最值得盯住的是三个位置:
- ⑤(文本 → 文本):最简单的一格。旧的是文字、新的是文字,直接整段覆盖就行,连遍历都省了。这是实际开发中最常遇到的一格。
- ⑨(数组 → 数组):最复杂的一格,也是唯一一个“两边都是一堆东西”的格子。它逼着你去想“这 5 个旧节点和这 4 个新节点,谁对应谁?”——这个问题就是 Diff 算法的全部起因。本章先用“全拆全建”这种笨办法顶上,第 9 章会正式攻克它。
- ③、⑥、⑧、⑨ 里都出现了“逐个卸载”(
forEach → unmount):说明只要新子节点和旧子节点“对不上形状”,第一步永远是先把旧的清干净,绝不能一边留着一半一边往上加。

图 8-5 新旧子节点的关系
但落实到代码,我们会发现其实并不需要完全覆盖这九种可能。接下来我们就开始着手实现 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)
}function patchElement(n1, n2):“给同一个元素打补丁”的专员。注意它只有两个参数,没有container——因为既然是更新同一个元素,它本来就在页面上了,不需要再告诉它“挂到哪儿去”。这是mountElement和patchElement在签名上最直观的区别。const el = n2.el = n1.el:复用真实元素的关键一行。类型既然已经确认相同(8.6 节验过货了),那就沿用墙上那一块,不重新造。同时把门牌号抄给新图纸n2——因为以后没人再记得旧图纸n1了,下一轮更新时n2就成了新的“旧图纸”。这个字段就是这样一轮一轮传下去的。const oldProps = n1.props/const newProps = n2.props:把新旧两份“装修配置单”各取一个短名字。接下来要拿它们做双向往返的比对,反复写n1.props太啰嗦。// 第一步:更新 props:注释。属性先改,子节点后改——顺序有讲究,见下面的说明。for (const key in newProps):第一趟:拿新配置单从头扫一遍。逻辑是“新配置单上有的,一个都不能漏”。if (newProps[key] !== oldProps[key]):核心过滤条件。只有新旧值真的不一样才动手。为什么非要比这一下?因为patchProps最后往往要落到真实 DOM 上,改一次 DOM 就可能触发一次样式重算。值没变的属性去改它,纯属白干活。这是本章贯彻始终的“能不改就不改”原则。patchProps(el, key, oldProps[key], newProps[key]):新旧两个值一起交出去。8.7 节事件更新要用到的prevValue就是从这里来的——那个参数从这一刻起才真正有了用武之地。for (const key in oldProps):第二趟:反向再扫一遍旧配置单。上一趟只保证了“新的都照顾到了”,可旧配置单上那些“新图纸里已经删掉”的属性呢?没人去清,它们会永远赖在墙上。if (!(key in newProps)):这个判断和上一句的!==完全是两码事,一定要分清:!==是“值变了”(同一个键,新旧值不同);!(key in newProps)是“这个键新图纸上压根没有了”;!套在in外面,in返回true/false,取反后就是“新配置单里查无此键”;- 一句话区分:一个问“东西变没变”,一个问“东西还在不在”。
patchProps(el, key, oldProps[key], null):把它删掉。注意新值传的是null而不是undefined,因为patchProps靠if (nextValue)判断“要不要绑”,null正好表示“不要了”——事件那边就会走removeEventListener拆线。// 第二步:更新 children:注释。属性搞定,再收拾子节点。patchChildren(n1, n2, el):把子节点这块差事整个外包出去。注意传进去的第三个参数是el(正在被打补丁的这个元素)——因为子节点要待在它自己爹的肚子里,不是待在容器里。这个参数名虽然叫container,但身份是“当前元素的内部”,容易看晕,记住这一层就明白了。
⚠️ 为什么“先改属性、后改子节点”? 因为属性会影响子节点的样子。比如先挂上
class="red"再挂子节点,子节点一进去就是红的;反过来先挂子节点,那一小段时间里它会没样式地闪一下。先打扮好房间,再请客人入座。
如上面的代码所示,更新子节点是对一个元素进行打补丁的最后一步操作。我们将它封装到 patchChildren 函数中,并将新旧 vnode 以及当前正在被打补丁的 DOM 元素 el 作为参数传递给它。
patchChildren 函数的实现:
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)
}
}function patchChildren(n1, n2, container):“处理子节点差事”的专员。container在这里是“被打补丁的那个元素自己”,不是外层容器。// 判断新子节点的类型是否是文本节点:注释。策略很清楚:先看新图纸要什么,再决定旧货怎么处理。为什么以“新”为准而不是“旧”?因为新图纸说了算——用户看到的永远是新的。if (typeof n2.children === 'string'):新的是一段文字(表格里的第 ④⑤⑥ 格)。先把旧的清理干净,再写新的。// 旧子节点的类型有三种可能:没有子节点、文本子节点以及一组子节点/// 只有当旧子节点为一组子节点时,才需要逐个卸载,其他情况下什么都不需要做:两句注释,但它们是这一小节的解题关键。展开说:- 旧的是
null(第 ④ 格)→ 不用清,直接写; - 旧的是文本(第 ⑤ 格)→ 不用清,
setElementText本来就是整体覆盖,新文字会把旧文字顶掉; - 旧的是数组(第 ⑥ 格)→ 必须清,否则旧元素会残留在页面上,新文字被挤到后面。
- 这就是“不需要完全覆盖九种可能”的原因:四种情况被同一个
setElementText一句话收拾掉了。能合并的判断就别硬拆成九段。
- 旧的是
if (Array.isArray(n1.children)):只有“旧的是数组”才进来。n1.children.forEach((c) => unmount(c)):挨个拆。c是每个子节点的临时名字(叫c是为了写得短,你叫child也一样);unmount(c)调的是 8.5 节封装好的那个拆除函数,一个子节点一份图纸、一个门牌号,拆得干净利落。// 最后将新的文本节点内容设置给容器元素:注释。setElementText(container, n2.children):把新文字写进去。这一句干两件事:写新的 + 顶掉旧的(因为是整体覆盖)。顺序上必须放在卸载之后——先把杂物清出屋,再摆新家具,反过来新家具会被当成“杂物”一起清掉。
如上面这段代码所示,首先,我们检测新子节点的类型是否是文本节点。如果是,则还要检查旧子节点的类型。旧子节点的类型可能有三种情况:没有子节点、文本子节点或一组子节点。如果没有旧子节点或者旧子节点的类型是文本子节点,那么只需要将新的文本内容设置给容器元素即可;如果旧子节点存在,并且不是文本子节点,则说明它的类型是一组子节点。这时我们需要循环遍历它们,并逐个调用 unmount 函数进行卸载。
如果新子节点的类型不是文本子节点,我们需要再添加一个判断分支,判断它是否是一组子节点:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
// 说明新子节点是一组子节点
// 判断旧子节点是否也是一组子节点
if (Array.isArray(n1.children)) {
// 代码运行到这里,则说明新旧子节点都是一组子节点,这里涉及核心的 Diff 算法
} else {
// 此时:
// 旧子节点要么是文本子节点,要么不存在
// 但无论哪种情况,我们都只需要将容器清空,然后将新的一组子节点逐个挂载
setElementText(container, '')
n2.children.forEach(c => patch(null, c, container))
}
}
}if (typeof n2.children === 'string') { // 省略部分代码 }:文本分支,上一小节刚写完,不重复贴。} else if (Array.isArray(n2.children)):新的是一组子节点(表格里的第 ⑦⑧⑨ 格)。// 说明新子节点是一组子节点:注释。// 判断旧子节点是否也是一组子节点/if (Array.isArray(n1.children)):关键分水岭。一旦新的是数组,就要回头看旧的:- 旧的也是数组 → 第 ⑨ 格,难的那一格;
- 旧的不是数组(是
null或文本)→ 第 ⑦⑧ 格,简单。
// 代码运行到这里,则说明新旧子节点都是一组子节点,这里涉及核心的 Diff 算法:这句注释是全章最重要的一句预告。它同时交代了两件事:①这就是 Diff 算法的战场;②但现在先不动它。} else {:旧的不是数组。// 此时:旧子节点要么是文本子节点,要么不存在/// 但无论哪种情况,我们都只需要将容器清空,然后将新的一组子节点逐个挂载:两句注释解释了“为什么可以合并处理”。因为setElementText(container, '')是整体清空——空的本来就是空的(旧=无,清了也白清,但无害),文字被清成空串正好符合我们要的“先腾空”。一句话同时解决了第 ⑦、⑧ 两格。setElementText(container, ''):先腾空。把元素的内部内容整体清成空串。这一步是“先清后建”三步里的第一步。n2.children.forEach(c => patch(null, c, container)):再逐个挂载。注意第三个参数传的是container(也就是当前元素自己)——子节点要进的是这个元素的肚子,不是外层容器。第二个参数null表示“没有旧图纸可对比,逐个新建”。- 这一格是“先清后建”三步里的第二步。 那第三步呢?是它自己——每个
patch内部最后都会insert到container里,位置自然就是追加到末尾,顺序也就对了。
在上面这段代码中,我们新增了对 n2.children 类型的判断:检测它是否是一组子节点,如果是,接着再检查旧子节点的类型。同样,旧子节点也有三种可能:没有子节点、文本子节点和一组子节点。
对于没有旧子节点或者旧子节点是文本子节点的情况,我们只需要将容器元素清空,然后逐个将新的一组子节点挂载到容器中即可。
Diff 算法(diffing algorithm,差异比对算法):
拿“旧的一份”和“新的一份”放在一起,找出哪些能沿用、哪些要动、哪些得扔,从这个过程里总结出来的一套步骤。
生活中的比喻一:改作文。老师说“第三段把'高兴'改成'开心',其余不动”——先把新旧两篇并排放好,逐句比对,只动真正不一样的地方。这就是 diff 的思路。改一行不用重抄全文。
生活中的比喻二:整理衣柜换季。你不会把衣服全倒出来重新分类一遍,而是只把“该换季的那几件”挑出来换位置。“哪些要动”这个判断过程,就是 diff。
生活中的比喻三:Excel 里两列数据找不同。左边一列旧名单、右边一列新名单,用“高亮显示不同项”功能一标,改动的几行立刻跳出来。
结论:为什么必须有 Diff 算法? 因为“新旧都是数组”这一格(第 ⑨ 格),根本没法用“清空重建”来蒙混过关——你会发现 99% 的内容其实没变。全拆全建意味着每次列表更新都要把整页 DOM 全部重建一遍:页面闪烁、输入框失焦、滚动位置归零、<video> 播放中断……而正确的做法是“只动那几个真的变了的”。
这就是 Vue.js 快的原因所在:它宁可花 CPU 去算差异,也不愿意傻乎乎地重建 DOM。第 9 章会给出这套算法的完整实现。
如果旧子节点也是一组子节点,则涉及新旧两组子节点的比对,这里就涉及我们常说的 Diff 算法。但由于我们目前还没有讲解 Diff 算法的工作方式,因此可以暂时用一种相对“傻瓜式”的方法来保证功能可用。这个方法很简单,即把旧的一组子节点全部卸载,再将新的一组子节点全部挂载:
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)) {
// 将旧的一组子节点全部卸载
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))
}
}
}if (typeof n2.children === 'string')…setElementText(container, n2.children):文本分支,省去了注释,但三行逻辑一个字没改。} else if (Array.isArray(n2.children)):新的是数组。if (Array.isArray(n1.children)):第 ⑨ 格(难的那格)。注意else分支(第 ⑦⑧ 格)已经写好了,这个if单独留着就是为了处理第 ⑨ 格。// 将旧的一组子节点全部卸载:注释。n1.children.forEach(c => unmount(c)):把旧清单整个拆光,一个都不留。这就是所谓的“傻瓜式”:明明 99% 的内容没变,它也照样推倒重来。// 再将新的一组子节点全部挂载到容器中:注释。n2.children.forEach(c => patch(null, c, container)):把新清单从零开始一个个新建。注意传null——因为旧的已经全拆了,对新节点而言确实不存在“旧节点”。} else { setElementText(container, ''); n2.children.forEach(c => patch(null, c, container)) }:第 ⑦⑧ 格,逻辑一个字没改。- 这段代码里有两个长得几乎一样的
forEach(一个调unmount、一个调patch),读的时候务必盯住调用的是哪个:unmount是拆,patch是建。
这样做虽然能够实现需求,但并不是最优解,我们将在下一章讲解如何使用 Diff 算法高效地更新两组子节点。
现在,对于新子节点来说,还剩下最后一种情况,即新子节点不存在:
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)) {
//
} 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 算法以后填这儿”。 } else {:前面的条件全不成立,只剩一种可能:n2.children既不是字符串、也不是数组,那就是null。新图纸写着“这个房间清空”。// 代码运行到这里,说明新子节点不存在:注释。// 旧子节点是一组子节点,只需逐个卸载即可/if (Array.isArray(n1.children))/n1.children.forEach(c => unmount(c)):旧的是一堆(第 ③ 格)→ 挨个拆。为什么必须逐个unmount、不能一句setElementText('')搞定?因为数组里的每一项都是独立的一份图纸,只有逐个调unmount才能让每份图纸各自完成它的收尾(触发它该触发的钩子)。setElementText('')是“一把扫帚”,扫得干净但不走流程。} else if (typeof n1.children === 'string'):旧的是文字(第 ② 格)→ 一句清空就够。对比一下:同样是“清空”,这里用setElementText而上面用forEach + unmount——差别就在于旧的是“一整段文字”还是“多个独立节点”。// 旧子节点是文本子节点,清空内容即可/setElementText(container, ''):把内部内容清成空串。用户看到的效果和全删一样。// 如果也没有旧子节点,那么什么都不需要做:注释,说的是第 ① 格。注意这里没有任何代码——“本来什么都没有,你要求它变成没有,它早就是那样了”,什么都不做就是最正确的做法。这也是九个格子里的第 ① 格,代码里一行都不用写。
可以看到,如果代码走到了 else 分支,则说明新子节点不存在。这时,对于旧子节点来说仍然有三种可能:没有子节点、文本子节点以及一组子节点。如果旧子节点也不存在,则什么都不需要做;如果旧子节点是一组子节点,则逐个卸载即可;如果旧的子节点是文本子节点,则清空文本内容即可。
至此,九个格子全部落到了代码上。我们把 patchChildren 的完整决策树画出来收尾:
读这张图时记住一句口诀就够了:先问“新的要什么”,再问“旧的是什么”,动作只有三个字——拆(unmount)、写(setElementText)、建(patch)。每一格都是这三个动作的某种组合。
8.10 文本节点和注释节点
在前面的章节中,我们只讲解了一种类型的 vnode,即用于描述普通标签的 vnode:
const vnode = {
type: 'div'
}const vnode = { type: 'div' }:一个“最小可用”的 vnode——只有type一个字段,连children和props都没有。type: 'div':说清楚“我是个div”,至于里面装什么、身上带什么,这份图纸一个字都没交代。
⚠️ 缺字段没事:渲染器读一个不存在的字段会拿到
undefined,而undefined既不满足typeof === 'string',也不满足Array.isArray,两道判断都不成立 → 什么都不做。所以“没写”就等于“没有”,不会报错。
我们用 vnode.type 来描述元素的名称,它是一个字符串类型的值。
接下来,我们讨论如何用虚拟 DOM 描述更多类型的真实 DOM。其中最常见的两种节点类型是文本节点和注释节点,如下面的 HTML 代码所示:
<div><!-- 注释节点 -->我是文本节点</div><div>:外层还是普通元素节点。<!-- 注释节点 -->:注释节点——它不会显示出来,也不影响排版,只是写给人看的备注。程序员在代码里留一句“这块是干什么用的”,编译后它仍会作为一个节点留在 DOM 里。我是文本节点:文本节点——页面上真正显示出来的那几个字。
<div> 是元素节点,它包含一个注释节点和一个文本节点。那么,如何使用 vnode 描述注释节点和文本节点呢?
我们知道,vnode.type 属性能够代表一个 vnode 的类型。如果 vnode.type 的值是字符串类型,则代表它描述的是普通标签,并且该值就代表标签的名称。但注释节点与文本节点不同于普通标签节点,它们不具有标签名称,所以我们需要人为创造一些唯一的标识,并将其作为注释节点和文本节点的 type 属性值:
// 文本节点的 type 标识
const Text = Symbol()
const newVNode = {
// 描述文本节点
type: Text,
children: '我是文本内容'
}
// 注释节点的 type 标识
const Comment = Symbol()
const newVNode = {
// 描述注释节点
type: Comment,
children: '我是注释内容'
}Symbol(符号):
ES6 新增的一种特殊数据类型。每调一次 Symbol(),就凭空造出一个全世界独一无二的值。没有两个 Symbol() 会相等,也拿不到第二个一样的来冒充。
生活中的比喻一:门牌号。你搬进新小区,管理员给你一个从没出现过的门牌号——绝不可能和楼里任何一家重号。你把号写在门上,别人看到就知道“这是你”。Text 和 Comment 就是文本节点和注释节点各自的门牌号。
生活中的比喻二:指纹。世界上几十亿人,没有两个指纹完全一样。Symbol 就是给对象“按一个指纹”,认准了就不可能认错。
⚠️ 为什么不直接用字符串?用字符串也能凑合,比如
type: 'Text'。但字符串是你自己起的名,万一起了'div'、'p'这种和标签重名的,就和普通标签混了。Symbol天然不可能和任何字符串相等,所以它是“绝不会撞名”的唯一选择。这就是本节“人为创造一些唯一的标识”的真正含义——把Symbol当作vnode.type,就保证了“文本节点的 type”和“注释节点的 type”绝对不会和别人撞名。
const Text = Symbol():给文本节点造一个专属标识。注意它是用const存起来的——必须存在某个地方反复引用(patch里要拿它做比较),不能每次现造一个(现造就永远不相等了)。const newVNode = { type: Text, children: '我是文本内容' }:用这个标识当type,用文字当children。为什么文字放children而不是别的字段?因为文本节点和注释节点“只关心内容”,它俩没有属性、没有子节点之分——一块文字,放哪儿都一样,就放在children这个已经现成的字段里,省得再开新字段。const Comment = Symbol()/{ type: Comment, children: '我是注释内容' }:给注释节点也造一个。和Text长得一模一样、做法一模一样——因为它们本来就是同一类东西:没有标签名的特殊节点。- 两处都叫
newVNode:这是原文的写法,意思是“又一份新 vnode”,先看Text那份、再看Comment那份,它们是并排的两个独立例子。
可以看到,我们分别为文本节点和注释节点创建了 symbol 类型的值,并将其作为 vnode.type 属性的值。这样就能够用 vnode 来描述文本节点和注释节点了。由于文本节点和注释节点只关心文本内容,所以我们用 vnode.children 来存储它们对应的文本内容。
| 节点类型 | type 标识 | children 存什么 | 浏览器里怎么造 |
|---|---|---|---|
| 文本节点 | const Text = Symbol() | 文本内容 | document.createTextNode |
| 注释节点 | const Comment = Symbol() | 注释内容 | document.createComment |
在 patch 函数里处理文本节点
有了用于描述文本节点和注释节点的 vnode 对象后,我们就可以使用渲染器来渲染它们了:
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) {
// 使用 createTextNode 创建文本节点
const el = n2.el = document.createTextNode(n2.children)
// 将文本节点插入到容器中
insert(el, container)
} else {
// 如果旧 vnode 存在,只需要使用新文本节点的文本内容更新旧文本节点即可
const el = n2.el = n1.el
if (n2.children !== n1.children) {
el.nodeValue = n2.children
}
}
}
}这一版只多了一个 else if 分支,前面两段一个字没改。逐行看:
if (n1 && n1.type !== n2.type) { unmount(n1); n1 = null }:老规矩,先验货。注意这一步对文本节点同样有效——旧的是p、新的是文字,类型不同照样拆旧建新。const { type } = n2:抠出类型,老规矩。if (typeof type === 'string') { if (!n1) mountElement(...) else patchElement(...) }:普通标签分支,老规矩。} else if (type === Text) { // 如果新 vnode 的类型是 Text,则说明该 vnode 描述的是文本节点:本次新增的分支。注释说得很清楚:type是不是那个专属Text标识,是的话说明“这是段文字,不是标签”。因为Text是Symbol,===只对那唯一那一个标识返回true。// 如果没有旧节点,则进行挂载/if (!n1):没有旧文字 → 新建。// 使用 createTextNode 创建文本节点/const el = n2.el = document.createTextNode(n2.children):createTextNode(创建文本节点)是浏览器原生 API,专门用来造出一段“纯粹文字”的节点。注意它和createElement分工不同:一个造标签,一个造文字。把文字作为参数传进去,出来的东西就装着这段文字。后面照例记上门牌号n2.el。// 将文本节点插入到容器中/insert(el, container):挂上去。和元素一样,最后都要insert。} else { // 如果旧 vnode 存在,只需要使用新文本节点的文本内容更新旧文本节点即可:有旧文字 → 更新。关键洞察在这句注释里:文字就是文字,“更新一段文字”不需要拆了重建,直接改内容就行。元素做不到这一点(标签不能从p变成div),但文字可以——因为所有文字节点在浏览器眼里长得一模一样,只差内容。const el = n2.el = n1.el:沿用墙上那一块,并把门牌号传给新图纸。和patchElement里那句一模一样。if (n2.children !== n1.children):又是“值没变就不动”。文字没改,就省掉一次 DOM 操作。el.nodeValue = n2.children:nodeValue(节点值)是 DOM 对象上所有节点都有的通用字段,读它就得到这个节点里的文字。对文本节点来说,它就是那块文字本身——所以改它等于改文字。注意这里没有用setElementText,因为那段 API 是给元素设文字用的(会连同所有子元素一起清空),文本节点只能靠nodeValue。
观察上面这段代码,我们增加了一个判断条件,即判断表达式 type === Text 是否成立,如果成立,则说明要处理的节点是文本节点。接着,还需要判断旧的虚拟节点(n1)是否存在,如果不存在,则直接挂载新的虚拟节点(n2)。这里我们使用 createTextNode 函数来创建文本节点,并将它插入到容器元素中。如果旧的虚拟节点(n1)存在,则需要更新文本内容,这里我们使用文本节点的 nodeValue 属性完成文本内容的更新。
另外,从上面的代码中我们还能注意到,patch 函数依赖浏览器平台特有的 API,即 createTextNode 和 el.nodeValue。为了保证渲染器核心的跨平台能力,我们需要将这两个操作 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) {
// 省略部分代码
}
})createElement(tag)/setElementText(el, text)/insert(el, parent, anchor = null):8.3 节就有的老三项,这里只留个壳子,注释写着“省略部分代码”,意思是“内容没变,就不重复贴了”。createText(text) { return document.createTextNode(text) }:新增的第一项能力。名字从createTextNode缩成了createText——平台相关的Node字样被剥掉了,对外只留下“造文字”这个动作。函数体还是那句原生 API。setText(el, text) { el.nodeValue = text }:新增的第二项能力。和上一项配套:一个负责“造”,一个负责“改”。注意它和setElementText不是一回事:setElementText管元素的全部文字,setText只管一个纯文字节点。patchProps(el, key, prevValue, nextValue):8.3 节的能力,老样子。- 这就是 8.3 节那张“分派关系图”要长大的地方:每碰到一个浏览器特有的 API,就往这个配置包里加一项。核心代码从此再也不直接碰
document。
在调用 createRenderer 函数创建渲染器时,传递的选项参数中封装了 createText 函数和 setText 函数。这两个函数分别用来创建文本节点和设置文本节点的内容。我们可以用这两个函数替换渲染器核心代码中所依赖的浏览器特有的 API:
| 渲染器选项 | 包装的浏览器 API | 职责 |
|---|---|---|
createText(text) | document.createTextNode(text) | 造出一个纯文字节点 |
setText(el, text) | el.nodeValue = text | 改一个纯文字节点的内容 |
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) {
if (!n1) {
// 调用 createText 函数创建文本节点
const el = n2.el = createText(n2.children)
insert(el, container)
} else {
const el = n2.el = n1.el
if (n2.children !== n1.children) {
// 调用 setText 函数更新文本节点的内容
setText(el, n2.children)
}
}
}
}// 调用 createText 函数创建文本节点/const el = n2.el = createText(n2.children):对比上一版,document.createTextNode换成了createText。document.这三个字符消失了——这就是“跨平台”四个字的含金量:这段patch代码现在不知道也不需要知道自己跑在浏览器里。insert(el, container):没变。const el = n2.el = n1.el/if (n2.children !== n1.children):没变。// 调用 setText 函数更新文本节点的内容/setText(el, n2.children):el.nodeValue = ...换成了setText(...)。同样,平台细节被彻底挡在了外面。- 整段代码里再也找不到
document、createTextNode、nodeValue这些词。 回头对比这两版你会发现:功能一模一样,改的全是“叫法”。这就是“把平台相关的部分抽出去”的好处——核心逻辑保持稳定,换平台只改外面那一层。
注释节点的处理方式与文本节点的处理方式类似。不同的是,我们需要使用 document.createComment 函数创建注释节点元素。
8.11 Fragment
Fragment(片断) 是 Vue.js 3 中新增的一个 vnode 类型。在具体讨论 Fragment 的实现之前,我们有必要先了解为什么需要 Fragment。请思考这样的场景,假设我们要封装一组列表组件:
为什么需要 Fragment
Fragment(片断 / 片段):
一个“只装东西、不自己占位置”的虚拟节点。
生活中的比喻一:透明胶带。你用它把三张纸粘成一叠——胶带自己看不见,也占不了地方,但没有它,三张纸就是散的。Fragment 就是这卷胶带。
生活中的比喻二:括号。( 1 + 2 + 3 ) 里的括号本身不是一个数,它只是告诉你“这三个要当成一整体看”。
结论:Fragment 自己不会产生任何真实 DOM(它没有 vnode.el),它只是一张“清单”,告诉渲染器“这几个要一起挂到这里”。所以下面处理它的代码会短得出奇——因为它根本不建东西,只管转交。
<List>
<Items />
</List><List>和<Items />:两个自定义标签,都是组件(组件就是“把一堆 HTML 打包起来复用”的东西,Vue.js 里写大写开头就当组件)。/>表示它自己不用闭合。- 整体结构:
Items写在List里面,意思是“List组件要负责把Items显示出来”。
整体由两个组件构成,即 <List> 组件和 <Items> 组件。其中 <List> 组件会渲染一个 <ul> 标签作为包裹层:
<!-- List.vue -->
<template>
<ul>
<slot />
</ul>
</template>而 <Items> 组件负责渲染一组 <li> 列表:
<!-- Items.vue -->
<template>
<li>1</li>
<li>2</li>
<li>3</li>
</template>这在 Vue.js 2 中是无法实现的。在 Vue.js 2 中,组件的模板不允许存在多个根节点。这意味着,一个 <Items> 组件最多只能渲染一个 <li> 标签:
<!-- Item.vue -->
<template>
<li>1</li>
</template>因此在 Vue.js 2 中,我们通常需要配合 v-for 指令来达到目的:
<List>
<Items v-for="item in list" />
</List>类似的组合还有 <select> 标签与 <option> 标签。
用 vnode 描述多根节点模板
而 Vue.js 3 支持多根节点模板,所以不存在上述问题。那么,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' }
]
}const Fragment = Symbol():和Text、Comment一模一样的造法——一个永远不会撞名的专属标识。这已经是本章第三次用这个套路了,说明“给特殊节点造个 Symbol”是渲染器里固定的一套手法。type: Fragment:图纸上写“我是个片断”。children: [ 三个 li ]:注意Fragment自己也用children存内容——但它存的不是文字,是一整串子节点。这就是“胶带”的含义:胶带上不写东西,它只是把三张纸粘在一起。Fragment和普通元素的children长得一样,区别在哪? 区别在type:普通元素type是标签名字符串('li'),Fragment的type是个Symbol。渲染器就是靠看type才知道“这个不该建东西,直接转交子节点”。
与文本节点和注释节点类似,片段也没有所谓的标签名称,因此我们也需要为片段创建唯一标识,即 Fragment。对于 Fragment 类型的 vnode 来说,它的 children 存储的内容就是模板中所有根节点。有了 Fragment 后,我们就可以用它来描述 Items.vue 组件的模板了:
<!-- Items.vue -->
<template>
<li>1</li>
<li>2</li>
<li>3</li>
</template>这段模板对应的虚拟节点是:
const vnode = {
type: Fragment,
children: [
{ type: 'li', children: '1' },
{ type: 'li', children: '2' },
{ type: 'li', children: '3' }
]
}type: Fragment:还是那个胶带标识。children: [ 三个 li ]:三个根节点整整齐齐排成一份清单。对照上面那个Items.vue模板——三个<li>就是清单上的三行,一一对应。这就是“用Fragment把多根节点模板描述出来”的完整含义。
类似地,对于如下模板:
<List>
<Items />
</List>我们可以用下面这个虚拟节点来描述它:
const vnode = {
type: 'ul',
children: [
{
type: Fragment,
children: [
{ type: 'li', children: '1' },
{ type: 'li', children: '2' },
{ type: 'li', children: '3' }
]
}
]
}type: 'ul':最外层是List组件渲染出来的<ul>包裹层。children: [ ... ]:<ul>里只放了一项,就是那个Fragment。type: Fragment/children: [ 三个 li ]:胶带夹在中间——把三个li当成一组交给<ul>。- 层级要数清楚:
ul→Fragment→ 三个li,一共三层。 明明页面上只有ul和三个li(一层多、零层胶带),图纸上却多了一层。这层多出来的,就是“胶带”本身。 - 为什么值得多这一层? 因为它把“三个并列的根节点”这件事本身表达成了数据。没有它,渲染器就无从知道“这三个
li是一组的、要一起增删”,只能把它们当成<ul>下的普通子节点。多花一层对象,换来一层结构信息。
和普通元素对比一下,Fragment 的特殊之处就全在 type 和“要不要真的建东西”这两点上:
| 对比项 | 普通元素 | Fragment |
|---|---|---|
type 的值 | 标签名字符串('li') | 一个 Symbol |
有没有 vnode.el | 有,指向真实 DOM | 没有,不产生任何真实 DOM |
children 装什么 | 文字,或一组子节点 | 一整串子节点(模板里的所有根节点) |
可以看到,vnode.children 数组包含一个类型为 Fragment 的虚拟节点。
在 patch 中处理 Fragment
当渲染器渲染 Fragment 类型的虚拟节点时,由于 Fragment 本身并不会渲染任何内容,所以渲染器只会渲染 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)
}
}
}if (n1 && n1.type !== n2.type) { unmount(n1); n1 = null }:老规矩,验货。const { type } = n2:抠出类型,老规矩。if (typeof type === 'string') { // 省略部分代码 }:普通标签分支,老样子。} else if (type === Text) { // 省略部分代码 }:文本分支,8.10 节刚写的。} else if (type === Fragment) { // 处理 Fragment 类型的 vnode:本次新增的分支。三连注释:“处理 Fragment 类型的 vnode”。if (!n1):没有旧的片断(首次渲染,或旧的类型不同已被拆掉)。// 如果旧 vnode 不存在,则只需要将 Fragment 的 children 逐个挂载即可/n2.children.forEach(c => patch(null, c, container)):这就是“透明”的全部含义。注意三件事:- 没有
insert调用,也没有“建一个 Fragment 自己”的动作——因为它压根没有真实 DOM 可建; - 直接用最外层的
container(不是某个新建的中间元素)——胶带不占地方,所以子节点直接挂到胶带本来要待的那个坑里; - 传
null——子节点也是从无到有,逐个新建。
- 没有
} else { // 如果旧 vnode 存在,则只需要更新 Fragment 的 children 即可/patchChildren(n1, n2, container):有旧片断 → 更新。又是 8.9 节那个patchChildren!也就是说——“更新一组子节点”这件苦力活,不需要为Fragment单独写一份。因为Fragment的children恰好也是一个数组,格式和普通元素的子节点一模一样,直接复用。这是“规范化”(8.9 节)带来的红利:格式统一了,逻辑就能通用。- 整段代码 23 行,新分支只有 9 行,而且没有一行涉及 DOM 操作。 这就是“Fragment 自己什么都不渲染”的回报。
观察上面这段代码,我们在 patch 函数中增加了对 Fragment 类型虚拟节点的处理。渲染 Fragment 的逻辑比想象中要简单得多,因为从本质上来说,渲染 Fragment 与渲染普通元素的区别在于,Fragment 本身并不渲染任何内容,所以只需要处理它的子节点即可。
unmount 也要支持 Fragment
但仍然需要注意一点,unmount 函数也需要支持 Fragment 类型的虚拟节点的卸载:
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)
}
}// 在卸载时,如果卸载的 vnode 类型为 Fragment,则需要卸载其 children:注释,点出了这次要补的洞。为什么必须补?因为 8.5 节的unmount是照着“元素”写的——它上来就vnode.el.parentNode。而Fragment没有vnode.el!不加这个判断,拆一个Fragment就会当场报错(读一个不存在的字段)。if (vnode.type === Fragment):新增的守卫判断。先问一句“我要拆的是不是胶带”。vnode.children.forEach(c => unmount(c)):是胶带 → 挨个拆里面的纸。注意这里调的是unmount而不是patch,因为是拆不是建。return:极其关键的一行。拆完纸就立刻收工,绝不能往下走。往下走那三行会去读vnode.el.parentNode——而Fragment压根没有el,一读就炸。这个return是“透明节点”必须配的保险。可以理解成“拆完胶带就走了,别再去找墙上有没有这个洞——因为胶带本来就没在墙上留洞”。const parent = vnode.el.parentNode/if (parent) { parent.removeChild(vnode.el) }:不是Fragment的老路数,一字未改。- 这里体现了 8.5 节那个“封装”决定的回报:因为拆除逻辑被收进了
unmount一个函数,8.11 节只需要改这一个函数。如果当初把removeChild抄在render里到处用,现在就得满世界去找着改。
当卸载 Fragment 类型的虚拟节点时,由于 Fragment 本身并不会渲染任何真实 DOM,所以只需要遍历它的 children 数组,并将其中的节点逐个卸载即可。
至此,patch 的分支可以画成最终版了。对照 8.6 节那张图,新长出来的就是 Text 和 Fragment 这两块:
本章小结
- 挂载子节点:用数组表示一组子节点,遍历调用
patch(null, child, el)递归挂载即可。递归是渲染器处理任意深嵌套的看家本领。 - HTML Attributes vs DOM Properties:HTML Attributes 是“初始值”(商品吊牌上的出厂配置),DOM Properties 是“当前值”(机器现在的实际配置)。两者名字不一定一样(
class↔className),不是所有都一一对应,浏览器还会对非法值做矫正。只要记住“Attributes 负责设置 DOM Properties 的初始值”这一条就够用了。 - 正确设置属性:优先用 DOM Properties(性能更好、布尔值支持更好),但要处理“布尔 + 空字符串”这个坑(矫正为
true);没有 DOM Properties 或者像el.form那样只读的,就用setAttribute兜底。判断逻辑统一收在shouldSetAsProps里,遇到新怪情况就往那儿加一条。 - 平台无关:把
createElement、insert、patchProps等浏览器相关能力全部装进渲染器选项传进createRenderer,核心代码从此不再直接碰document。 class处理:Vue.js 允许字符串、对象、数组三种形式,需要先用normalizeClass归一化成字符串;最终用最快的el.className设置(图 8-2 实测最优)。Vue.js 对style也做了增强,因此需要同类处理。- 卸载操作:不能直接
innerHTML = ""(会漏掉生命周期、漏掉指令钩子、还会造成事件不清理的内存泄漏)。正确做法是把vnode.el指向真实 DOM,再封装成unmount逐个拆。挂载 = 盖房子,更新 = 改装修,卸载 = 拆房子;而innerHTML相当于直接推土机推平,不管收尾。 - vnode 类型区分:
type是字符串就是普通标签,用mountElement/patchElement挂载和更新;type是对象就是组件,用mountComponent/patchComponent挂载和更新;type是Symbol就是文本 / 注释 /Fragment等特殊节点。patch作为总入口,先验货(类型不同就拆旧建新),再按类型分诊。组件那两个函数本章只留了位置(patch里的else if (typeof type === 'object')分支是空的),要到讲组件那一章才真正登场。 - 事件 invoker 模式:把
onXxx转成addEventListener,但用一个伪造的invoker传话筒函数中转,更新时只需替换invoker.value,省掉一次removeEventListener。el._vei存成按事件名分格的对象,才支持一个元素绑多种事件;invoker.value允许是函数数组,才支持同一事件的多个处理函数。 - 事件冒泡 vs 更新时机:微任务会穿插在事件冒泡触发的多个处理函数之间执行,所以推迟更新也躲不掉。Vue.js 的办法是对比“事件触发时间
e.timeStamp”和“事件绑定时间invoker.attached”,屏蔽“绑定晚于触发”的执行。这里要用高精度时间performance.now;e.timeStamp在 Chrome 49、Firefox 54、Opera 36 及之后版本中都是高精时间。 - 子节点规范化:
children只允许三种类型——null、字符串、数组。“单个元素子节点”也用数组装,这样if分支数才是固定的。新旧组合出九种情况,但靠setElementText的整体覆盖特性,实际不需要写满九段。 - Diff 算法:新旧子节点都是数组那一格(第 ⑨ 格)没法用“清空重建”蒙混过关,必须找出“哪些能复用、哪些要移动”。本章先用全拆全建的笨办法顶上,第 9 章会正式攻克。
- 文本/注释节点:它们没有标签名,所以用
Symbol()造两个保证不撞名的专属标识当type。文字节点更新时不必重建,直接改nodeValue就行。createTextNode和nodeValue同样要抽进渲染器选项。 Fragment:Vue.js 3 的多根节点模板靠它实现。它自己不产生任何真实 DOM(没有vnode.el),只是一张“清单”,所以挂载就是把children逐个转交、更新就是复用patchChildren、卸载就是逐个unmount后立刻return(绝不能再去读那个不存在的vnode.el)。
下一章预告:第 9 章我们会深入讲解渲染器的灵魂——简单 Diff 算法。怎么判断哪些节点需要移动?怎么高效地复用 DOM?看下去你就懂了。
