渲染器之挂载与更新

讲解渲染器的核心功能:挂载与更新。

1、挂载子节点和元素的属性

当 vnode.children 的值是字符串类型时,会把它设置为元素的文本内容。一个元素除了具有文本子节点外,还可以包含其他元素子节点,并且子节点可以是很多个。为了描述元素的子节点,我们需要将 vnode.children 定义为数组:

01 const vnode = {
02   type: 'div',
03   children: [
04     {
05       type: 'p',
06       children: 'hello'
07     }
08   ]
09 }

上面这段代码描述的是“一个 div 标签具有一个子节点,且子节点是 p 标签”。可以看到,vnode.children 是一个数组,它的每一个元素都是一个独立的虚拟节点对象。这样就形成了树型结构,即虚拟 DOM 树。

为了完成子节点的渲染,我们需要修改 mountElement 函数,如下面的代码所示:

01 function mountElement(vnode, container) {
02   const el = createElement(vnode.type)
03   if (typeof vnode.children === 'string') {
04     setElementText(el, vnode.children)
05   } else if (Array.isArray(vnode.children)) {
06     // 如果 children 是数组,则遍历每一个子节点,并调用 patch 函数挂载它们
07     vnode.children.forEach(child => {
08       patch(null, child, el)
09     })
10   }
11   insert(el, container)
12 }

在上面这段代码中,我们增加了新的判断分支。使用Array.isArray 函数判断 vnode.children 是否是数组,如果是数组,则循环遍历它,并调 patch 函数挂载数组中的虚拟节点。在挂载子节点时,需要注意以下两点:

  • 传递给 patch 函数的第一个参数是 null。因为是挂载阶段,没有旧 vnode,所以只需要传递 null 即可。这样,当 patch 函数执行时,就会递归地调用 mountElement 函数完成挂载。
  • 传递给 patch 函数的第三个参数是挂载点。由于我们正在挂载的子元素是 div 标签的子节点,所以需要把刚刚创建的 div 元素作为挂载点,这样才能保证这些子节点挂载到正确位置。

完成了子节点的挂载后,我们再来看看如何用 vnode 描述一个标签的属性,以及如何渲染这些属性。我们知道,HTML 标签有很多属性,其中有些属性是通用的,例如 id、class 等,而有些属性是特定元素才有的,例如 form 元素的 action 属性。实际上,渲染一个元素的属性比想象中要复杂,不过我们仍然秉承一切从简的原则,先来看看最基本的属性处理。

为了描述元素的属性,我们需要为虚拟 DOM 定义新的vnode.props 字段,如下面的代码所示:

01 const vnode = {
02   type: 'div',
03   // 使用 props 描述一个元素的属性
04   props: {
05     id: 'foo'
06   },
07   children: [
08     {
09       type: 'p',
10       children: 'hello'
11     }
12   ]
13 }

vnode.props 是一个对象,它的键代表元素的属性名称,它的值代表对应属性的值。这样,我们就可以通过遍历 props 对象的方式,把这些属性渲染到对应的元素上,如下面的代码所示:

01 function mountElement(vnode, container) {
02   const el = createElement(vnode.type)
03   // 省略 children 的处理
04
05   // 如果 vnode.props 存在才处理它
06   if (vnode.props) {
07     // 遍历 vnode.props
08     for (const key in vnode.props) {
09       // 调用 setAttribute 将属性设置到元素上
10       el.setAttribute(key, vnode.props[key])
11     }
12   }
13
14   insert(el, container)
15 }

在这段代码中,我们首先检查了 vnode.props 字段是否存在,如果存在则遍历它,并调用 setAttribute 函数将属性设置到元素上。实际上,除了使用 setAttribute 函数为元素设置属性之外,还可以通过 DOM 对象直接设置:

01 function mountElement(vnode, container) {
02   const el = createElement(vnode.type)
03   // 省略 children 的处理
04
05   if (vnode.props) {
06     for (const key in vnode.props) {
07       // 直接设置
08       el[key] = vnode.props[key]
09     }
10   }
11
12   insert(el, container)
13 }

在这段代码中,我们没有选择使用 setAttribute 函数,而是直接将属性设置在 DOM 对象上,即 el[key] =vnode.props[key]。实际上,无论是使用 setAttribute 函数,还是直接操作 DOM 对象,都存在缺陷。如前所述,为元素设置属性比想象中要复杂得多。不过,在讨论具体有哪些缺陷之前,我们有必要先搞清楚两个重要的概念:HTML Attributes和 DOM Properties。

2、HTML Attributes 与 DOM Properties

理解 HTML Attributes 和 DOM Properties 之间的差异和关联非常重要,这能够帮助我们合理地设计虚拟节点的结构,更是正确地为元素设置属性的关键。

我们从最基本的 HTML 说起。给出如下 HTML 代码:

01 <input id="my-input" type="text" value="foo" />

HTML Attributes 指的就是定义在 HTML 标签上的属性,这里指的就是 id=“my-input”、type=“text” 和 value=“foo”。当浏览器解析这段 HTML 代码后,会创建一个与之相符的 DOM 元素对象,我们可以通过 JavaScript 代码来读取该 DOM 对象:

01 const el = document.querySelector('#my-input')

这个 DOM 对象会包含很多属性(properties),如下图所示:
渲染器之挂载与更新_第1张图片
这些属性就是所谓的 DOM Properties。很多 HTML Attributes 在 DOM 对象上有与之同名的 DOM Properties,例如 id=“my-input” 对应 el.id,type=“text” 对应 el.type,value=“foo” 对应 el.value 等。但 DOM Properties 与 HTML Attributes 的名字不总是一模一样的,例如:

01 <div class="foo"></div>

class=“foo” 对应的 DOM Properties 则是 el.className。另外,并不是所有 HTML Attributes 都有与之对应的 DOM Properties,例如:

01 <div aria-valuenow="75"></div>

aria-* 类的 HTML Attributes 就没有与之对应的 DOM Properties。

类似地,也不是所有 DOM Properties 都有与之对应的 HTML Attributes,例如可以用 el.textContent 来设置元素的文本内容,但并没有与之对应的 HTML Attributes 来完成同样的工作。

HTML Attributes 的值与 DOM Properties 的值之间是有关联的,例如下面的 HTML 片段:

01 <div id="foo"></div>

这个片段描述了一个具有 id 属性的 div 标签。其中,id="foo"对应的 DOM Properties 是 el.id,并且值为字符串 ‘foo’。我们把这种 HTML Attributes 与 DOM Properties 具有相同名称(即 id)的属性看作直接映射。但并不是所有 HTML Attributes 与 DOM Properties 之间都是直接映射的关系,例如:

01 <input value="foo" />

这是一个具有 value 属性的 input 标签。如果用户没有修改文本框的内容,那么通过 el.value 读取对应的 DOM Properties 的值就是字符串 ‘foo’。而如果用户修改了文本框的值,那么el.value 的值就是当前文本框的值。例如,用户将文本框的内容修改为 ‘bar’,那么:

01 console.log(el.value) // 'bar'

但如果运行下面的代码,会发生“奇怪”的现象:

01 console.log(el.getAttribute('value')) // 仍然是 'foo'
02 console.log(el.value) // 'bar'

可以发现,用户对文本框内容的修改并不会影响el.getAttribute(‘value’) 的返回值,这个现象蕴含着 HTML Attributes 所代表的意义。实际上,HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。一旦值改变,那么 DOM Properties 始终存储着当前值,而通过getAttribute 函数得到的仍然是初始值。

但我们仍然可以通过 el.defaultValue 来访问初始值,如下面的代码所示:

01 el.getAttribute('value') // 仍然是 'foo'
02 el.value // 'bar'
03 el.defaultValue // 'foo'

这说明一个 HTML Attributes 可能关联多个 DOM Properties。例如在上例中,value=“foo” 与 el.value 和el.defaultValue 都有关联。

虽然我们可以认为 HTML Attributes 是用来设置与之对应的DOM Properties 的初始值的,但有些值是受限制的,就好像浏览器内部做了默认值校验。如果你通过 HTML Attributes 提供的默认值不合法,那么浏览器会使用内建的合法值作为对应DOM Properties 的默认值,例如:

01 <input type="foo" />

我们知道,为 标签的 type 属性指定字符串 ‘foo’ 是不合法的,因此浏览器会矫正这个不合法的值。所以当我们尝试读取 el.type 时,得到的其实是矫正后的值,即字符串’text’,而非字符串 ‘foo’:

01 console.log(el.type) // 'text'

从上述分析来看,HTML Attributes 与 DOM Properties 之间的关系很复杂,但其实我们只需要记住一个核心原则即可:HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值

3、正确地设置元素属性

上一节我们详细讨论了 HTML Attributes 和 DOM Properties 相关的内容,因为 HTML Attributes 和 DOM Properties 会影响 DOM 属性的添加方式。对于普通的 HTML 文件来说,当浏览器解析 HTML 代码后,会自动分析 HTML Attributes 并设置合适的 DOM Properties。但用户编写在 Vue.js 的单文件组件中的模板不会被浏览器解析,这意味着,原本需要浏览器来完成的工作,现在需要框架来完成。

我们以禁用的按钮为例,如下面的 HTML 代码所示:

01 <button disabled>Button</button>

浏览器在解析这段 HTML 代码时,发现这个按钮存在一个叫作disabled 的 HTML Attributes,于是浏览器会将该按钮设置为禁用状态,并将它的 el.disabled 这个 DOM Properties 的值设置为 true,这一切都是浏览器帮我们处理好的。但同样的代码如果出现在 Vue.js 的模板中,则情况会有所不同。首先,这个 HTML 模板会被编译成 vnode,它等价于:

01 const button = {
02   type: 'button',
03   props: {
04     disabled: ''
05   }
06 }

注意,这里的 props.disabled 的值是空字符串,如果在渲染器中调用 setAttribute 函数设置属性,则相当于:

01 el.setAttribute('disabled', '')

这么做的确没问题,浏览器会将按钮禁用。但考虑如下模板:

01 <button :disabled="false">Button</button>

它对应的 vnode 为:

01 const button = {
02   type: 'button',
03   props: {
04     disabled: false
05   }
06 }

用户的本意是“不禁用”按钮,但如果渲染器仍然使用setAttribute 函数设置属性值,则会产生意外的效果,即按钮被禁用了:

01 el.setAttribute('disabled', false)

在浏览器中运行上面这句代码,我们发现浏览器仍然将按钮禁用了。这是因为使用 setAttribute 函数设置的值总是会被字符串化,所以上面这句代码等价于:

01 el.setAttribute('disabled', 'false')

对于按钮来说,它的 el.disabled 属性值是布尔类型的,并且它不关心具体的 HTML Attributes 的值是什么,只要 disabled 属性存在,按钮就会被禁用。所以我们发现,渲染器不应该总是使用 setAttribute 函数将 vnode.props 对象中的属性设置到元素上。那么应该怎么办呢?一个很自然的思路是,我们可以优先设置 DOM Properties,例如:

01 el.disabled = false

这样是可以正确工作的,但又带来了新的问题。还是以上面给出的模板为例:

01 <button disabled>Button</button>

这段模板对应的 vnode 是:

01 const button = {
02   type: 'button',
03   props: {
04     disabled: ''
05   }
06 }

我们注意到,在模板经过编译后得到的 vnode 对象中,props.disabled 的值是一个空字符串。如果直接用它设置元素的 DOM Properties,那么相当于:

01 el.disabled = ''

由于 el.disabled 是布尔类型的值,所以当我们尝试将它设置为空字符串时,浏览器会将它的值矫正为布尔类型的值,即false。所以上面这句代码的执行结果等价于:

01 el.disabled = false

这违背了用户的本意,因为用户希望禁用按钮,而 el.disabled = false 则是不禁用的意思。

这么看来,无论是使用 setAttribute 函数,还是直接设置元素的 DOM Properties,都存在缺陷。要彻底解决这个问题,我们只能做特殊处理,即优先设置元素的 DOM Properties,但当值为空字符串时,要手动将值矫正为 true。只有这样,才能保证代码的行为符合预期。下面的 mountElement 函数给出了具体的实现:

01 function mountElement(vnode, container) {
02   const el = createElement(vnode.type)
03   // 省略 children 的处理
04
05   if (vnode.props) {
06     for (const key in vnode.props) {
07       // 用 in 操作符判断 key 是否存在对应的 DOM Properties
08       if (key in el) {
09         // 获取该 DOM Properties 的类型
10         const type = typeof el[key]
11         const value = vnode.props[key]
12         // 如果是布尔类型,并且 value 是空字符串,则将值矫正为 true
13         if (type === 'boolean' && value === '') {
14           el[key] = true
15         } else {
16           el[key] = value
17         }
18       } else {
19         // 如果要设置的属性没有对应的 DOM Properties,则使用 setAttribute 函数设置属性
20         el.setAttribute(key, vnode.props[key])
21       }
22     }
23   }
24
25   insert(el, container)
26 }

如上面的代码所示,我们检查每一个 vnode.props 中的属性,看看是否存在对应的 DOM Properties,如果存在,则优先设置 DOM Properties。同时,我们对布尔类型的 DOM Properties 做了值的矫正,即当要设置的值为空字符串时,将其矫正为布尔值 true。当然,如果 vnode.props 中的属性不具有对应的 DOM Properties,则仍然使用 setAttribute 函数完成属性的设置。

但上面给出的实现仍然存在问题,因为有一些 DOM Properties 是只读的,如以下代码所示:

01 <form id="form1"></form>
02 <input form="form1" />

在这段代码中,我们为 标签设置了 form 属性(HTML Attributes)。它对应的 DOM Properties 是el.form,但 el.form 是只读的,因此我们只能够通过setAttribute 函数来设置它。这就需要我们修改现有的逻辑:

01 function shouldSetAsProps(el, key, value) {
02   // 特殊处理
03   if (key === 'form' && el.tagName === 'INPUT') return false
04   // 兜底
05   return key in el
06 }
07
08 function mountElement(vnode, container) {
09   const el = createElement(vnode.type)
10   // 省略 children 的处理
11
12   if (vnode.props) {
13     for (const key in vnode.props) {
14       const value = vnode.props[key]
15       // 使用 shouldSetAsProps 函数判断是否应该作为 DOM Properties 设置
16       if (shouldSetAsProps(el, key, value)) {
17         const type = typeof el[key]
18         if (type === 'boolean' && value === '') {
19           el[key] = true
20         } else {
21           el[key] = value
22         }
23       } else {
24         el.setAttribute(key, value)
25       }
26     }
27   }
28
29   insert(el, container)
30 }

如上面的代码所示,为了代码的可读性,我们提取了一个shouldSetAsProps 函数。该函数会返回一个布尔值,代表属性是否应该作为 DOM Properties 被设置。如果返回 true,则代表应该作为 DOM Properties 被设置,否则应该使用setAttribute 函数来设置。在 shouldSetAsProps 函数内,我们对 进行特殊处理,即 标签的 form 属性必须使用 setAttribute 函数来设置。实际上,不仅仅是 标签,所有表单元素都具有 form 属性,它们都应该作为 HTML Attributes 被设置。

当然, 是一个特殊的例子,还有一些其他类似于这种需要特殊处理的情况。我们不会列举所有情况并一一讲解,因为掌握处理问题的思路更加重要。另外,我们也不可能把所有需要特殊处理的地方都记住,更何况有时我们根本不知道在什么情况下才需要特殊处理。所以,上述解决方案本质上是经验之谈。不要惧怕写出不完美的代码,只要在后续迭代过程中“见招拆招“,代码就会变得越来越完善,框架也会变得越来越健壮。

最后,我们需要把属性的设置也变成与平台无关,因此需要把属性设置相关操作也提取到渲染器选项中,如下面的代码所示:

01 const renderer = createRenderer({
02   createElement(tag) {
03     return document.createElement(tag)
04   },
05   setElementText(el, text) {
06     el.textContent = text
07   },
08   insert(el, parent, anchor = null) {
09     parent.insertBefore(el, anchor)
10   },
11   // 将属性设置相关操作封装到 patchProps 函数中,并作为渲染器选项传递
12   patchProps(el, key, prevValue, nextValue) {
13     if (shouldSetAsProps(el, key, nextValue)) {
14       const type = typeof el[key]
15       if (type === 'boolean' && nextValue === '') {
16         el[key] = true
17       } else {
18         el[key] = nextValue
19       }
20     } else {
21       el.setAttribute(key, nextValue)
22     }
23   }
24 })

而在 mountElement 函数中,只需要调用 patchProps 函数,并为其传递相关参数即可:

01 function mountElement(vnode, container) {
02   const el = createElement(vnode.type)
03   if (typeof vnode.children === 'string') {
04     setElementText(el, vnode.children)
05   } else if (Array.isArray(vnode.children)) {
06     vnode.children.forEach(child => {
07       patch(null, child, el)
08     })
09   }
10
11   if (vnode.props) {
12     for (const key in vnode.props) {
13       // 调用 patchProps 函数即可
14       patchProps(el, key, null, vnode.props[key])
15     }
16   }
17
18   insert(el, container)
19 }

这样,我们就把属性相关的渲染逻辑从渲染器的核心中抽离了出来。

4、class 的处理

在上一节中,我们讲解了如何正确地把 vnode.props 中定义的属性设置到 DOM 元素上。但在 Vue.js 中,仍然有一些属性需要特殊处理,比如 class 属性。为什么需要对 class 属性进行特殊处理呢?这是因为 Vue.js 对 calss 属性做了增强。在 Vue.js 中为元素设置类名有以下几种方式:

方式一:指定 class 为一个字符串值:

01 <p class="foo bar"></p>

这段模板对应的 vnode 是:

01 const vnode = {
02   type: 'p',
03   props: {
04     class: 'foo bar'
05   }
06 }

方式二:指定 class 为一个对象值:

01 <p :class="cls"></p>

假设对象 cls 的内容如下:

01 const cls = { foo: true, bar: false }

那么,这段模板对应的 vnode 是:

01 const vnode = {
02   type: 'p',
03   props: {
04     class: { foo: true, bar: false }
05   }
06 }

方式三:class 是包含上述两种类型的数组:

01 <p :class="arr"></p>

这个数组可以是字符串值与对象值的组合:

01 const arr = [
02   // 字符串
03   'foo bar',
04   // 对象
05   {
06     baz: true
07   }
08 ]

那么,这段模板对应的 vnode 是:

01 const vnode = {
02   type: 'p',
03   props: {
04     class: [
05       'foo bar',
06       { baz: true }
07     ]
08   }
09 }

可以看到,因为 class 的值可以是多种类型,所以我们必须在设置元素的 class 之前将值归一化为统一的字符串形式,再把该字符串作为元素的 class 值去设置。因此,我们需要封装normalizeClass 函数,用它来将不同类型的 class 值正常化为字符串,例如:

01 const vnode = {
02   type: 'p',
03   props: {
04     // 使用 normalizeClass 函数对值进行序列化
05     class: normalizeClass([
06       'foo bar',
07       { baz: true }
08     ])
09   }
10 }

最后的结果等价于:

01 const vnode = {
02   type: 'p',
03   props: {
04     // 序列化后的结果
05     class: 'foo bar baz'
06   }
07 }

至于 normalizeClass 函数的实现,这里我们不会做详细讲解,因为它本质上就是一个数据结构转换的小算法,实现起来并不复杂。

假设现在我们已经能够对 class 值进行正常化了。接下来,我们将讨论如何将正常化后的 class 值设置到元素上。其实,我们目前实现的渲染器已经能够完成 class 的渲染了。观察前文中函数的代码,由于 class 属性对应的 DOM Properties 是el.className,所以表达式 ‘class’ in el 的值将会是 false,因此,patchProps 函数会使用 setAttribute 函数来完成 class 的设置。但是我们知道,在浏览器中为一个元素设置 class 有三种方式,即使用 setAttribute、el.className 或 el.classList。那么哪一种方法的性能更好呢?下图对比了这三种方式为元素设置 1000 次 class 的性能:
渲染器之挂载与更新_第2张图片
可以看到,el.className 的性能最优。因此,我们需要调整patchProps 函数的实现,如下面的代码所示:

01 const renderer = createRenderer({
02   // 省略其他选项
03
04   patchProps(el, key, prevValue, nextValue) {
05     // 对 class 进行特殊处理
06     if (key === 'class') {
07       el.className = nextValue || ''
08     } else if (shouldSetAsProps(el, key, nextValue)) {
09       const type = typeof el[key]
10       if (type === 'boolean' && nextValue === '') {
11         el[key] = true
12       } else {
13         el[key] = nextValue
14       }
15     } else {
16       el.setAttribute(key, nextValue)
17     }
18   }
19 })

从上面的代码中可以看到,我们对 class 进行了特殊处理,即使用 el.className 代替 setAttribute 函数。其实除了 class 属性之外,Vue.js 对 style 属性也做了增强,所以我们也需要对style 做类似的处理。

通过对 class 的处理,我们能够意识到,vnode.props 对象中定义的属性值的类型并不总是与 DOM 元素属性的数据结构保持一致,这取决于上层 API 的设计。Vue.js 允许对象类型的值作为 class 是为了方便开发者,在底层的实现上,必然需要对值进行正常化后再使用。另外,正常化值的过程是有代价的,如果需要进行大量的正常化操作,则会消耗更多性能。

5、卸载操作

前文主要讨论了挂载操作。接下来,我们将会讨论卸载操作。卸载操作发生在更新阶段,更新指的是,在初次挂载完成之后,后续渲染会触发更新,如下面的代码所示:

01 // 初次挂载
02 renderer.render(vnode, document.querySelector('#app'))
03 // 再次挂载新 vnode,将触发更新
04 renderer.render(newVNode, document.querySelector('#app'))

更新的情况有几种,我们逐个来看。当后续调用 render 函数渲染空内容(即 null)时,如下面的代码所示:

01 // 初次挂载
02 renderer.render(vnode, document.querySelector('#app'))
03 // 新 vnode 为 null,意味着卸载之前渲染的内容
04 renderer.render(null, document.querySelector('#app'))

首次挂载完成后,后续渲染时如果传递了 null 作为新 vnode,则意味着什么都不渲染,这时我们需要卸载之前渲染的内容。回顾前文实现的 render 函数,如下:

01 function render(vnode, container) {
02   if (vnode) {
03     patch(container._vnode, vnode, container)
04   } else {
05     if (container._vnode) {
06       // 卸载,清空容器
07       container.innerHTML = ''
08     }
09   }
10   container._vnode = vnode
11 }

可以看到,当 vnode 为 null,并且容器元素的container._vnode 属性存在时,我们直接通过 innerHTML 清空容器。但这么做是不严谨的,原因有三点:

  • 容器的内容可能是由某个或多个组件渲染的,当卸载操作发生时,应该正确地调用这些组件的 beforeUnmount、unmounted 等生命周期函数。
  • 即使内容不是由组件渲染的,有的元素存在自定义指令,我们应该在卸载操作发生时正确执行对应的指令钩子函数。
  • 使用 innerHTML 清空容器元素内容的另一个缺陷是,它不会移除绑定在 DOM 元素上的事件处理函数。

正如上述三点原因,我们不能简单地使用 innerHTML 来完成卸载操作。正确的卸载方式是,根据 vnode 对象获取与其相关联的真实 DOM 元素,然后使用原生 DOM 操作方法将该 DOM 元素移除。为此,我们需要在 vnode 与真实 DOM 元素之间建立联系,修改 mountElement 函数,如下面的代码所示:

01 function mountElement(vnode, container) {
02   // 让 vnode.el 引用真实 DOM 元素
03   const el = vnode.el = createElement(vnode.type)
04   if (typeof vnode.children === 'string') {
05     setElementText(el, vnode.children)
06   } else if (Array.isArray(vnode.children)) {
07     vnode.children.forEach(child => {
08       patch(null, child, el)
09     })
10   }
11
12   if (vnode.props) {
13     for (const key in vnode.props) {
14       patchProps(el, key, null, vnode.props[key])
15     }
16   }
17
18   insert(el, container)
19 }

可以看到,当我们调用 createElement 函数创建真实 DOM 元素时,会把真实 DOM 元素赋值给 vnode.el 属性。这样,在vnode 与真实 DOM 元素之间就建立了联系,我们可以通过vnode.el 来获取该虚拟节点对应的真实 DOM 元素。有了这些,当卸载操作发生的时候,只需要根据虚拟节点对象vnode.el 取得真实 DOM 元素,再将其从父元素中移除即可:

01 function render(vnode, container) {
02   if (vnode) {
03     patch(container._vnode, vnode, container)
04   } else {
05     if (container._vnode) {
06       // 根据 vnode 获取要卸载的真实 DOM 元素
07       const el = container._vnode.el
08       // 获取 el 的父元素
09       const parent = el.parentNode
10       // 调用 removeChild 移除元素
11       if (parent) parent.removeChild(el)
12     }
13   }
14   container._vnode = vnode
15 }

如上面的代码所示,其中 container._vnode 代表旧 vnode,即要被卸载的 vnode。然后通过 container._vnode.el 取得真实 DOM 元素,并调用 removeChild 函数将其从父元素中移除即可。

由于卸载操作是比较常见且基本的操作,所以我们应该将它封装到 unmount 函数中,以便后续代码可以复用它,如下面的代码所示:

01 function unmount(vnode) {
02   const parent = vnode.el.parentNode
03   if (parent) {
04     parent.removeChild(vnode.el)
05   }
06 }

unmount 函数接收一个虚拟节点作为参数,并将该虚拟节点对应的真实 DOM 元素从父元素中移除。现在 unmount 函数的代码还非常简单,后续我们会慢慢充实它,让它变得更加完善。有了 unmount 函数后,就可以直接在 render 函数中调用它来完成卸载任务了:

01 function render(vnode, container) {
02   if (vnode) {
03     patch(container._vnode, vnode, container)
04   } else {
05     if (container._vnode) {
06       // 调用 unmount 函数卸载 vnode
07       unmount(container._vnode)
08     }
09   }
10   container._vnode = vnode
11 }

最后,将卸载操作封装到 unmount 中,还能够带来两点额外的好处:

  • 在 unmount 函数内,我们有机会调用绑定在 DOM 元素上的指令钩子函数,例如 beforeUnmount、unmounted 等。
  • 当 unmount 函数执行时,我们有机会检测虚拟节点 vnode 的类型。如果该虚拟节点描述的是组件,则我们有机会调用组件相关的生命周期函数。

6、区分 vnode 的类型

在上一节中我们了解到,当后续调用 render 函数渲染空内容(即 null)时,会执行卸载操作。如果在后续渲染时,为render 函数传递了新的 vnode,则不会进行卸载操作,而是会把新旧 vnode 都传递给 patch 函数进行打补丁操作。回顾前文实现的 patch 函数,如下面的代码所示:

01 function patch(n1, n2, container) {
02   if (!n1) {
03     mountElement(n2, container)
04   } else {
05     // 更新
06   }
07 }

其中,patch 函数的两个参数 n1 和 n2 分别代表旧 vnode 与新 vnode。如果旧 vnode 存在,则需要在新旧 vnode 之间打补丁。但在具体执行打补丁操作之前,我们需要保证新旧vnode 所描述的内容相同。这是什么意思呢?举个例子,假设初次渲染的 vnode 是一个 p 元素:

01 const vnode = {
02   type: 'p'
03 }
04 renderer.render(vnode, document.querySelector('#app'))

后续又渲染了一个 input 元素:

01 const vnode = {
02   type: 'input'
03 }
04 renderer.render(vnode, document.querySelector('#app'))

这就会造成新旧 vnode 所描述的内容不同,即 vnode.type 属性的值不同。对于上例来说,p 元素和 input 元素之间不存在打补丁的意义,因为对于不同的元素来说,每个元素都有特有的属性,例如:

01 <p id="foo" />
02 <!-- type 属性是 input 标签特有的,p 标签则没有该属性 -->
03 <input type="submit" />

在这种情况下,正确的更新操作是,先将 p 元素卸载,再将input 元素挂载到容器中。因此我们需要调整 patch 函数的代码:

01 function patch(n1, n2, container) {
02   // 如果 n1 存在,则对比 n1 和 n2 的类型
03   if (n1 && n1.type !== n2.type) {
04     // 如果新旧 vnode 的类型不同,则直接将旧 vnode 卸载
05     unmount(n1)
06     n1 = null
07   }
08
09   if (!n1) {
10     mountElement(n2, container)
11   } else {
12     // 更新
13   }
14 }

如上面的代码所示,在真正执行更新操作之前,我们优先检查新旧 vnode 所描述的内容是否相同,如果不同,则直接调用unmount 函数将旧 vnode 卸载。这里需要注意的是,卸载完成后,我们应该将参数 n1 的值重置为 null,这样才能保证后续挂载操作正确执行。

即使新旧 vnode 描述的内容相同,我们仍然需要进一步确认它们的类型是否相同。我们知道,一个 vnode 可以用来描述普通标签,也可以用来描述组件,还可以用来描述 Fragment 等。对于不同类型的 vnode,我们需要提供不同的挂载或打补丁的处理方式。所以,我们需要继续修改 patch 函数的代码以满足需求,如下面的代码所示:

01 function patch(n1, n2, container) {
02   if (n1 && n1.type !== n2.type) {
03     unmount(n1)
04     n1 = null
05   }
06   // 代码运行到这里,证明 n1 和 n2 所描述的内容相同
07   const { type } = n2
08   // 如果 n2.type 的值是字符串类型,则它描述的是普通标签元素
09   if (typeof type === 'string') {
10     if (!n1) {
11       mountElement(n2, container)
12     } else {
13       patchElement(n1, n2)
14     }
15   } else if (typeof type === 'object') {
16     // 如果 n2.type 的值的类型是对象,则它描述的是组件
17   } else if (type === 'xxx') {
18     // 处理其他类型的 vnode
19   }
20 }

实际上,在前文的讲解中,我们一直假设 vnode 的类型是普通标签元素。但严谨的做法是根据 vnode.type 进一步确认它们的类型是什么,从而使用相应的处理函数进行处理。例如,如果 vnode.type 的值是字符串类型,则它描述的是普通标签元素,这时我们会调用 mountElement 或 patchElement 完成挂载和更新操作;如果 vnode.type 的值的类型是对象,则它描述的是组件,这时我们会调用与组件相关的挂载和更新方法。

7、事件的处理

本节我们将讨论如何处理事件,包括如何在虚拟节点中描述事件,如何把事件添加到 DOM 元素上,以及如何更新事件。

我们先来解决第一个问题,即如何在虚拟节点中描述事件。事件可以视作一种特殊的属性,因此我们可以约定,在vnode.props 对象中,凡是以字符串 on 开头的属性都视作事件。例如:

01 const vnode = {
02   type: 'p',
03   props: {
04     // 使用 onXxx 描述事件
05     onClick: () => {
06       alert('clicked')
07     }
08   },
09   children: 'text'
10 }

解决了事件在虚拟节点层面的描述问题后,我们再来看看如何将事件添加到 DOM 元素上。这非常简单,只需要在patchProps 中调用 addEventListener 函数来绑定事件即可,如下面的代码所示:

01 patchProps(el, key, prevValue, nextValue) {
02   // 匹配以 on 开头的属性,视其为事件
03   if (/^on/.test(key)) {
04     // 根据属性名称得到对应的事件名称,例如 onClick ---> click
05     const name = key.slice(2).toLowerCase()
06     // 绑定事件,nextValue 为事件处理函数
07     el.addEventListener(name, nextValue)
08   } else if (key === 'class') {
09     // 省略部分代码
10   } else if (shouldSetAsProps(el, key, nextValue)) {
11     // 省略部分代码
12   } else {
13     // 省略部分代码
14   }
15 }

那么,更新事件要如何处理呢?按照一般的思路,我们需要先移除之前添加的事件处理函数,然后再将新的事件处理函数绑定到 DOM 元素上,如下面的代码所示:

01 patchProps(el, key, prevValue, nextValue) {
02   if (/^on/.test(key)) {
03     const name = key.slice(2).toLowerCase()
04     // 移除上一次绑定的事件处理函数
05     prevValue && el.removeEventListener(name, prevValue)
06     // 绑定新的事件处理函数
07     el.addEventListener(name, nextValue)
08   } else if (key === 'class') {
09     // 省略部分代码
10   } else if (shouldSetAsProps(el, key, nextValue)) {
11     // 省略部分代码
12   } else {
13     // 省略部分代码
14   }
15 }

这么做代码能够按照预期工作,但其实还有一种性能更优的方式来完成事件更新。在绑定事件时,我们可以绑定一个伪造的事件处理函数 invoker,然后把真正的事件处理函数设置为invoker.value 属性的值。这样当更新事件的时候,我们将不再需要调用 removeEventListener 函数来移除上一次绑定的事件,只需要更新 invoker.value 的值即可,如下面的代码所示:

01 patchProps(el, key, prevValue, nextValue) {
02   if (/^on/.test(key)) {
03     // 获取为该元素伪造的事件处理函数 invoker
04     let invoker = el._vei
05     const name = key.slice(2).toLowerCase()
06     if (nextValue) {
07       if (!invoker) {
08         // 如果没有 invoker,则将一个伪造的 invoker 缓存到 el._vei 中
09         // vei 是 vue event invoker 的首字母缩写
10         invoker = el._vei = (e) => {
11           // 当伪造的事件处理函数执行时,会执行真正的事件处理函数
12           invoker.value(e)
13         }
14         // 将真正的事件处理函数赋值给 invoker.value
15         invoker.value = nextValue
16         // 绑定 invoker 作为事件处理函数
17         el.addEventListener(name, invoker)
18       } else {
19         // 如果 invoker 存在,意味着更新,并且只需要更新 invoker.value 的值即可
20         invoker.value = nextValue
21       }
22     } else if (invoker) {
23       // 新的事件绑定函数不存在,且之前绑定的 invoker 存在,则移除绑定
24       el.removeEventListener(name, invoker)
25     }
26   } else if (key === 'class') {
27     // 省略部分代码
28   } else if (shouldSetAsProps(el, key, nextValue)) {
29     // 省略部分代码
30   } else {
31     // 省略部分代码
32   }
33 }

观察上面的代码,事件绑定主要分为两个步骤:

  • 先从 el._vei 中读取对应的 invoker,如果 invoker 不存在,则将伪造的 invoker 作为事件处理函数,并将它缓存到 el._vei 属性中。
  • 把真正的事件处理函数赋值给 invoker.value 属性,然后把伪造的 invoker 函数作为事件处理函数绑定到元素上。可以看到,当事件触发时,实际上执行的是伪造的事件处理函数,在其内部间接执行了真正的事件处理函数 invoker.value(e)。

当更新事件时,由于 el._vei 已经存在了,所以我们只需要将invoker.value 的值修改为新的事件处理函数即可。这样,在更新事件时可以避免一次 removeEventListener 函数的调用,从而提升了性能。实际上,伪造的事件处理函数的作用不止于此,它还能解决事件冒泡与事件更新之间相互影响的问题,下文会详细讲解。

但目前的实现仍然存在问题。现在我们将事件处理函数缓存在el._vei 属性中,问题是,在同一时刻只能缓存一个事件处理函数。这意味着,如果一个元素同时绑定了多种事件,将会出现事件覆盖的现象。例如同时给元素绑定 click 和 contextmenu 事件:

01 const vnode = {
02   type: 'p',
03   props: {
04     onClick: () => {
05       alert('clicked')
06     },
07     onContextmenu: () => {
08       alert('contextmenu')
09     }
10   },
11   children: 'text'
12 }
13 renderer.render(vnode, document.querySelector('#app'))

当渲染器尝试渲染这上面代码中给出的 vnode 时,会先绑定click 事件,然后再绑定 contextmenu 事件。后绑定的contextmenu 事件的处理函数将覆盖先绑定的 click 事件的处理函数。为了解决事件覆盖的问题,我们需要重新设计 el._vei 的数据结构。我们应该将 el._vei 设计为一个对象,它的键是事件名称,它的值则是对应的事件处理函数,这样就不会发生事件覆盖的现象了,如下面的代码所示:

01 patchProps(el, key, prevValue, nextValue) {
02   if (/^on/.test(key)) {
03     // 定义 el._vei 为一个对象,存在事件名称到事件处理函数的映射
04     const invokers = el._vei || (el._vei = {})
05     //根据事件名称获取 invoker
06     let invoker = invokers[key]
07     const name = key.slice(2).toLowerCase()
08     if (nextValue) {
09       if (!invoker) {
10         // 将事件处理函数缓存到 el._vei[key] 下,避免覆盖
11         invoker = el._vei[key] = (e) => {
12           invoker.value(e)
13         }
14         invoker.value = nextValue
15         el.addEventListener(name, invoker)
16       } else {
17         invoker.value = nextValue
18       }
19     } else if (invoker) {
20       el.removeEventListener(name, invoker)
21     }
22   } else if (key === 'class') {
23     // 省略部分代码
24   } else if (shouldSetAsProps(el, key, nextValue)) {
25     // 省略部分代码
26   } else {
27     // 省略部分代码
28   }
29 }

另外,一个元素不仅可以绑定多种类型的事件,对于同一类型的事件而言,还可以绑定多个事件处理函数。我们知道,在原生 DOM 编程中,当多次调用 addEventListener 函数为元素绑定同一类型的事件时,多个事件处理函数可以共存,例如:

01 el.addEventListener('click', fn1)
02 el.addEventListener('click', fn2)

当点击元素时,事件处理函数 fn1 和 fn2 都会执行。因此,为了描述同一个事件的多个事件处理函数,我们需要调整vnode.props 对象中事件的数据结构,如下面的代码所示:

01 const vnode = {
02   type: 'p',
03   props: {
04     onClick: [
05       // 第一个事件处理函数
06       () => {
07         alert('clicked 1')
08       },
09       // 第二个事件处理函数
10       () => {
11         alert('clicked 2')
12       }
13     ]
14   },
15   children: 'text'
16 }
17 renderer.render(vnode, document.querySelector('#app'))

在上面这段代码中,我们使用一个数组来描述事件,数组中的每个元素都是一个独立的事件处理函数,并且这些事件处理函数都能够正确地绑定到对应元素上。为了实现此功能,我们需要修改 patchProps 函数中事件处理相关的代码,如下面的代码所示:

01 patchProps(el, key, prevValue, nextValue) {
02   if (/^on/.test(key)) {
03     const invokers = el._vei || (el._vei = {})
04     let invoker = invokers[key]
05     const name = key.slice(2).toLowerCase()
06     if (nextValue) {
07       if (!invoker) {
08         invoker = el._vei[key] = (e) => {
09           // 如果 invoker.value 是数组,则遍历它并逐个调用事件处理函数
10           if (Array.isArray(invoker.value)) {
11             invoker.value.forEach(fn => fn(e))
12           } else {
13             // 否则直接作为函数调用
14             invoker.value(e)
15           }
16         }
17         invoker.value = nextValue
18         el.addEventListener(name, invoker)
19       } else {
20         invoker.value = nextValue
21       }
22     } else if (invoker) {
23       el.removeEventListener(name, invoker)
24     }
25   } else if (key === 'class') {
26     // 省略部分代码
27   } else if (shouldSetAsProps(el, key, nextValue)) {
28     // 省略部分代码
29   } else {
30     // 省略部分代码
31   }
32 }

在这段代码中,我们修改了 invoker 函数的实现。当 invoker 函数执行时,在调用真正的事件处理函数之前,要先检查invoker.value 的数据结构是否是数组,如果是数组则遍历它,并逐个调用定义在数组中的事件处理函数。

8、事件冒泡与更新时机问题

在上一节中,我们介绍了基本的事件处理。本节我们将讨论事件冒泡与更新时机相结合所导致的问题。为了更清晰地描述问题,我们需要构造一个小例子:

01 const { effect, ref } = VueReactivity
02
03 const bol = ref(false)
04
05 effect(() => {
06   // 创建 vnode
07   const vnode = {
08     type: 'div',
09     props: bol.value ? {
10       onClick: () => {
11         alert('父元素 clicked')
12       }
13     } : {},
14     children: [
15       {
16         type: 'p',
17         props: {
18           onClick: () => {
19             bol.value = true
20           }
21         },
22         children: 'text'
23       }
24     ]
25   }
26   // 渲染 vnode
27   renderer.render(vnode, document.querySelector('#app'))
28 })

这个例子比较复杂。在上面这段代码中,我们创建一个响应式数据 bol,它是一个 ref,初始值为 false。接着,创建了一个effect,并在副作用函数内调用 renderer.render 函数来渲染vnode。这里的重点在于该 vnode 对象,它描述了一个 div 元素,并且该 div 元素具有一个 p 元素作为子节点。我们再来详细看看 div 元素以及 p 元素的特点:

  • div 元素:它的 props 对象的值是由一个三元表达式决定的。在首次渲染时,由于 bol.value 的值为 false,所以它的 props 的值是一个空对象。
  • p 元素:它具有 click 点击事件,并且当点击它时,事件处理函数会将bol.value 的值设置为 true。

结合上述特点,我们来思考一个问题:当首次渲染完成后,用鼠标点击 p 元素,会触发父级 div 元素的 click 事件的事件处理函数执行吗?

答案其实很明显,在首次渲染完成之后,由于 bol.value 的值为false,所以渲染器并不会为 div 元素绑定点击事件。当用鼠标点击 p 元素时,即使 click 事件可以从 p 元素冒泡到父级 div 元素,但由于 div 元素没有绑定 click 事件的事件处理函数,所以什么都不会发生。但事实是,当你尝试运行上面这段代码并点击 p 元素时,会发现父级 div 元素的 click 事件的事件处理函数竟然执行了。为什么会发生如此奇怪的现象呢?这其实与更新机制有关,我们来分析一下当点击 p 元素时,到底发生了什么。

当点击 p 元素时,绑定到它身上的 click 事件处理函数会执行,于是 bol.value 的值被改为 true。接下来的一步非常关键,由于 bol 是一个响应式数据,所以当它的值发生变化时,会触发副作用函数重新执行。由于此时的 bol.value 已经变成了true,所以在更新阶段,渲染器会为父级 div 元素绑定 click 事件处理函数。当更新完成之后,点击事件才从 p 元素冒泡到父级 div 元素。由于此时 div 元素已经绑定了 click 事件的处理函数,因此就发生了上述奇怪的现象。下图给出了当点击 p 元素后,整个更新和事件触发的流程图:
渲染器之挂载与更新_第3张图片
根据上图我们能够发现,之所以会出现上述奇怪的现象,是因为更新操作发生在事件冒泡之前,即为 div 元素绑定事件处理函数发生在事件冒泡之前。那如何避免这个问题呢?一个很自然的想法是,能否将绑定事件的动作挪到事件冒泡之后?但这个想法不可靠,因为我们无法知道事件冒泡是否完成,以及完成到什么程度。你可能会想,Vue.js 的更新难道不是在一个异步的微任务队列中进行的吗?那是不是自然能够避免这个问题了呢?其实不然,换句话说,微任务会穿插在由事件冒泡触发的多个事件处理函数之间被执行。因此,即使把绑定事件的动作放到微任务中,也无法避免这个问题。

那应该如何解决呢?其实,仔细观察上图就会发现,触发事件的时间与绑定事件的时间之间是有联系的,如下图所示:
渲染器之挂载与更新_第4张图片
由上图可以发现,事件触发的时间要早于事件处理函数被绑定的时间。这意味着当一个事件触发时,目标元素上还没有绑定相关的事件处理函数,我们可以根据这个特点来解决问题:屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行。基于此,我们可以调整 patchProps 函数中关于事件的代码,如下:

01 patchProps(el, key, prevValue, nextValue) {
02   if (/^on/.test(key)) {
03     const invokers = el._vei || (el._vei = {})
04     let invoker = invokers[key]
05     const name = key.slice(2).toLowerCase()
06     if (nextValue) {
07       if (!invoker) {
08         invoker = el._vei[key] = (e) => {
09           // e.timeStamp 是事件发生的时间
10           // 如果事件发生的时间早于事件处理函数绑定的时间,则不执行事件处理函数
11           if (e.timeStamp < invoker.attached) return
12           if (Array.isArray(invoker.value)) {
13             invoker.value.forEach(fn => fn(e))
14           } else {
15             invoker.value(e)
16           }
17         }
18         invoker.value = nextValue
19         // 添加 invoker.attached 属性,存储事件处理函数被绑定的时间
20         invoker.attached = performance.now()
21         el.addEventListener(name, invoker)
22       } else {
23         invoker.value = nextValue
24       }
25     } else if (invoker) {
26       el.removeEventListener(name, invoker)
27     }
28   } else if (key === 'class') {
29     // 省略部分代码
30   } else if (shouldSetAsProps(el, key, nextValue)) {
31     // 省略部分代码
32   } else {
33     // 省略部分代码
34   }
35 }

如上面的代码所示,我们在原来的基础上只添加了两行代码。首先,我们为伪造的事件处理函数添加了 invoker.attached 属性,用来存储事件处理函数被绑定的时间。然后,在 invoker 执行的时候,通过事件对象的 e.timeStamp 获取事件发生的时间。最后,比较两者,如果事件处理函数被绑定的时间晚于事件发生的时间,则不执行该事件处理函数。

这里有必要指出的是,在关于时间的存储和比较方面,我们使用的是高精时间,即 performance.now。但根据浏览器的不同,e.timeStamp 的值也会有所不同。它既可能是高精时间,也可能是非高精时间。因此,严格来讲,这里需要做兼容处理。不过在 Chrome 49、Firefox 54、Opera 36 以及之后的版本中,e.timeStamp 的值都是高精时间。

9、更新子节点

前几节我们讲解了元素属性的更新,包括普通标签属性和事件。接下来,我们将讨论如何更新元素的子节点。首先,回顾一下元素的子节点是如何被挂载的,如下面 mountElement 函数的代码所示:

01 function mountElement(vnode, container) {
02   const el = vnode.el = createElement(vnode.type)
03
04   // 挂载子节点,首先判断 children 的类型
05   // 如果是字符串类型,说明是文本子节点
06   if (typeof vnode.children === 'string') {
07     setElementText(el, vnode.children)
08   } else if (Array.isArray(vnode.children)) {
09     // 如果是数组,说明是多个子节点
10     vnode.children.forEach(child => {
11       patch(null, child, el)
12     })
13   }
14
15   if (vnode.props) {
16     for (const key in vnode.props) {
17       patchProps(el, key, null, vnode.props[key])
18     }
19   }
20
21   insert(el, container)
22 }

在挂载子节点时,首先要区分其类型:

  • 如果 vnode.children 是字符串,则说明元素具有文本子节点;
  • 如果 vnode.children 是数组,则说明元素具有多个子节点。

这里需要思考的是,为什么要区分子节点的类型呢?其实这是一个规范性的问题,因为只有子节点的类型是规范化的,才有利于我们编写更新逻辑。因此,在具体讨论如何更新子节点之前,我们有必要先规范化 vnode.children。那应该设定怎样的规范呢?为了搞清楚这个问题,我们需要先搞清楚在一个HTML 页面中,元素的子节点都有哪些情况,如下面的 HTML 代码所示:

01 <!-- 没有子节点 -->
02 <div></div>
03 <!-- 文本子节点 -->
04 <div>Some Text</div>
05 <!-- 多个子节点 -->
06 <div>
07   <p/>
08   <p/>
09 </div>

对于一个元素来说,它的子节点无非有以下三种情况:

  • 没有子节点,此时 vnode.children 的值为 null。
  • 具有文本子节点,此时 vnode.children 的值为字符串,代表文本的内容。
  • 其他情况,无论是单个元素子节点,还是多个子节点(可能是文本和元素的混合),都可以用数组来表示。

如下面的代码所示:

01 // 没有子节点
02 vnode = {
03   type: 'div',
04   children: null
05 }
06 // 文本子节点
07 vnode = {
08   type: 'div',
09   children: 'Some Text'
10 }
11 // 其他情况,子节点使用数组表示
12 vnode = {
13   type: 'div',
14   children: [
15     { type: 'p' },
16     'Some Text'
17   ]
18 }

现在,我们已经规范化了 vnode.children 的类型。既然一个vnode 的子节点可能有三种情况,那么当渲染器执行更新时,新旧子节点都分别是三种情况之一。所以,我们可以总结出更新子节点时全部九种可能,如下图所示:
渲染器之挂载与更新_第5张图片
但落实到代码,我们会发现其实并不需要完全覆盖这九种可能。接下来我们就开始着手实现,如下面 patchElement 函数的代码所示:

01 function patchElement(n1, n2) {
02   const el = n2.el = n1.el
03   const oldProps = n1.props
04   const newProps = n2.props
05   // 第一步:更新 props
06   for (const key in newProps) {
07     if (newProps[key] !== oldProps[key]) {
08       patchProps(el, key, oldProps[key], newProps[key])
09     }
10   }
11   for (const key in oldProps) {
12     if (!(key in newProps)) {
13       patchProps(el, key, oldProps[key], null)
14     }
15   }
16
17   // 第二步:更新 children
18   patchChildren(n1, n2, el)
19 }

如上面的代码所示,更新子节点是对一个元素进行打补丁的最后一步操作。我们将它封装到 patchChildren 函数中,并将新旧 vnode 以及当前正在被打补丁的 DOM 元素 el 作为参数传递给它。

patchChildren 函数的实现如下:

01 function patchChildren(n1, n2, container) {
02   // 判断新子节点的类型是否是文本节点
03   if (typeof n2.children === 'string') {
04     // 旧子节点的类型有三种可能:没有子节点、文本子节点以及一组子节点
05     // 只有当旧子节点为一组子节点时,才需要逐个卸载,其他情况下什么都不需要做
06     if (Array.isArray(n1.children)) {
07       n1.children.forEach((c) => unmount(c))
08     }
09     // 最后将新的文本节点内容设置给容器元素
10     setElementText(container, n2.children)
11   }
12 }

如上面这段代码所示,首先,我们检测新子节点的类型是否是文本节点,如果是,则还要检查旧子节点的类型。旧子节点的类型可能有三种情况,分别是:没有子节点、文本子节点或一组子节点。如果没有旧子节点或者旧子节点的类型是文本子节点,那么只需要将新的文本内容设置给容器元素即可;如果旧子节点存在,并且不是文本子节点,则说明它的类型是一组子节点。这时我们需要循环遍历它们,并逐个调用 unmount 函数进行卸载。

如果新子节点的类型不是文本子节点,我们需要再添加一个判断分支,判断它是否是一组子节点,如下面的代码所示:

01 function patchChildren(n1, n2, container) {
02   if (typeof n2.children === 'string') {
03     // 省略部分代码
04   } else if (Array.isArray(n2.children)) {
05     // 说明新子节点是一组子节点
06
07     // 判断旧子节点是否也是一组子节点
08     if (Array.isArray(n1.children)) {
09       // 代码运行到这里,则说明新旧子节点都是一组子节点,这里涉及核心的 Diff 算法
10     } else {
11       // 此时:
12       // 旧子节点要么是文本子节点,要么不存在
13       // 但无论哪种情况,我们都只需要将容器清空,然后将新的一组子节点逐个挂载
14       setElementText(container, '')
15       n2.children.forEach(c => patch(null, c, container))
16     }
17   }
18 }

在上面这段代码中,我们新增了对 n2.children 类型的判断:检测它是否是一组子节点,如果是,接着再检查旧子节点的类型。同样,旧子节点也有三种可能:没有子节点、文本子节点和一组子节点。对于没有旧子节点或者旧子节点是文本子节点的情况,我们只需要将容器元素清空,然后逐个将新的一组子节点挂载到容器中即可。如果旧子节点也是一组子节点,则涉及新旧两组子节点的比对,这里就涉及我们常说的 Diff 算法。但由于我们目前还没有讲解 Diff 算法的工作方式,因此可以暂时用一种相对傻瓜式的方法来保证功能可用。这个方法很简单,即把旧的一组子节点全部卸载,再将新的一组子节点全部挂载,如下面的代码所示:

01 function patchChildren(n1, n2, container) {
02   if (typeof n2.children === 'string') {
03     if (Array.isArray(n1.children)) {
04       n1.children.forEach((c) => unmount(c))
05     }
06     setElementText(container, n2.children)
07   } else if (Array.isArray(n2.children)) {
08     if (Array.isArray(n1.children)) {
09       // 将旧的一组子节点全部卸载
10       n1.children.forEach(c => unmount(c))
11       // 再将新的一组子节点全部挂载到容器中
12       n2.children.forEach(c => patch(null, c, container))
13     } else {
14       setElementText(container, '')
15       n2.children.forEach(c => patch(null, c, container))
16     }
17   }
18 }

这样做虽然能够实现需求,但并不是最优解。现在,对于新子节点来说,还剩下最后一种情况,即新子节点不存在,如下面的代码所示:

01 function patchChildren(n1, n2, container) {
02   if (typeof n2.children === 'string') {
03     if (Array.isArray(n1.children)) {
04       n1.children.forEach((c) => unmount(c))
05     }
06     setElementText(container, n2.children)
07   } else if (Array.isArray(n2.children)) {
08     if (Array.isArray(n1.children)) {
09       //
10     } else {
11       setElementText(container, '')
12       n2.children.forEach(c => patch(null, c, container))
13     }
14   } else {
15     // 代码运行到这里,说明新子节点不存在
16     // 旧子节点是一组子节点,只需逐个卸载即可
17     if (Array.isArray(n1.children)) {
18       n1.children.forEach(c => unmount(c))
19     } else if (typeof n1.children === 'string') {
20       // 旧子节点是文本子节点,清空内容即可
21       setElementText(container, '')
22     }
23     // 如果也没有旧子节点,那么什么都不需要做
24   }
25 }

可以看到,如果代码走到了 else 分支,则说明新子节点不存在。这时,对于旧子节点来说仍然有三种可能:没有子节点、文本子节点以及一组子节点。如果旧子节点也不存在,则什么都不需要做;如果旧子节点是一组子节点,则逐个卸载即可;如果旧的子节点是文本子节点,则清空文本内容即可。

10、文本节点和注释节点

在前面的章节中,我们只讲解了一种类型的 vnode,即用于描述普通标签的 vnode,如下面的代码所示:

01 const vnode = {
02   type: 'div'
03 }

我们用 vnode.type 来描述元素的名称,它是一个字符串类型的值。

接下来,我们讨论如何用虚拟 DOM 描述更多类型的真实DOM。其中最常见的两种节点类型是文本节点和注释节点,如下面的 HTML 代码所示:

01 <div><!-- 注释节点 -->我是文本节点</div>

是元素节点,它包含一个注释节点和一个文本节点。那么,如何使用 vnode 描述注释节点和文本节点呢?

我们知道,vnode.type 属性能够代表一个 vnode 的类型。如果 vnode.type 的值是字符串类型,则代表它描述的是普通标签,并且该值就代表标签的名称。但注释节点与文本节点不同于普通标签节点,它们不具有标签名称,所以我们需要人为创造一些唯一的标识,并将其作为注释节点和文本节点的 type 属性值,如下面的代码所示:

01 // 文本节点的 type 标识
02 const Text = Symbol()
03 const newVNode = {
04   // 描述文本节点
05   type: Text,
06   children: '我是文本内容'
07 }
08
09 // 注释节点的 type 标识
10 const Comment = Symbol()
11 const newVNode = {
12   // 描述注释节点
13   type: Comment,
14   children: '我是注释内容'
15 }

可以看到,我们分别为文本节点和注释节点创建了 symbol 类型的值,并将其作为 vnode.type 属性的值。这样就能够用vnode 来描述文本节点和注释节点了。由于文本节点和注释节点只关心文本内容,所以我们用 vnode.children 来存储它们对应的文本内容。

有了用于描述文本节点和注释节点的 vnode 对象后,我们就可以使用渲染器来渲染它们了,如下面的代码所示:

01 function patch(n1, n2, container) {
02   if (n1 && n1.type !== n2.type) {
03     unmount(n1)
04     n1 = null
05   }
06
07   const { type } = n2
08
09   if (typeof type === 'string') {
10     if (!n1) {
11       mountElement(n2, container)
12     } else {
13       patchElement(n1, n2)
14     }
15   } else if (type === Text) { // 如果新 vnode 的类型是 Text,则说明该 vnode 描述的是文本节点
16     // 如果没有旧节点,则进行挂载
17     if (!n1) {
18       // 使用 createTextNode 创建文本节点
19       const el = n2.el = document.createTextNode(n2.children)
20       // 将文本节点插入到容器中
21       insert(el, container)
22     } else {
23       // 如果旧 vnode 存在,只需要使用新文本节点的文本内容更新旧文本节点即可
24       const el = n2.el = n1.el
25       if (n2.children !== n1.children) {
26         el.nodeValue = n2.children
27       }
28     }
29   }
30 }

观察上面这段代码,我们增加了一个判断条件,即判断表达式type === Text 是否成立,如果成立,则说明要处理的节点是文本节点。接着,还需要判断旧的虚拟节点(n1)是否存在,如果不存在,则直接挂载新的虚拟节点(n2)。这里我们使用createTextNode 函数来创建文本节点,并将它插入到容器元素中。如果旧的虚拟节点(n1)存在,则需要更新文本内容,这里我们使用文本节点的 nodeValue 属性完成文本内容的更新。

另外,从上面的代码中我们还能注意到,patch 函数依赖浏览器平台特有的 API,即 createTextNode 和 el.nodeValue。为了保证渲染器核心的跨平台能力,我们需要将这两个操作 DOM 的 API 封装到渲染器的选项中,如下面的代码所示:

01 const renderer = createRenderer({
02   createElement(tag) {
03     // 省略部分代码
04   },
05   setElementText(el, text) {
06     // 省略部分代码
07   },
08   insert(el, parent, anchor = null) {
09     // 省略部分代码
10   },
11   createText(text) {
12     return document.createTextNode(text)
13   },
14   setText(el, text) {
15     el.nodeValue = text
16   },
17   patchProps(el, key, prevValue, nextValue) {
18     // 省略部分代码
19   }
20 })

在上面这段代码中,我们在调用 createRenderer 函数创建渲染器时,传递的选项参数中封装了 createText 函数和 setText 函数。这两个函数分别用来创建文本节点和设置文本节点的内容。我们可以用这两个函数替换渲染器核心代码中所依赖的浏览器特有的 API,如下面的代码所示:

01 function patch(n1, n2, container) {
02   if (n1 && n1.type !== n2.type) {
03     unmount(n1)
04     n1 = null
05   }
06
07   const { type } = n2
08
09   if (typeof type === 'string') {
10     if (!n1) {
11       mountElement(n2, container)
12     } else {
13       patchElement(n1, n2)
14     }
15   } else if (type === Text) {
16     if (!n1) {
17       // 调用 createText 函数创建文本节点
18       const el = n2.el = createText(n2.children)
19       insert(el, container)
20     } else {
21       const el = n2.el = n1.el
22       if (n2.children !== n1.children) {
23         // 调用 setText 函数更新文本节点的内容
24         setText(el, n2.children)
25       }
26     }
27   }
28 }

注释节点的处理方式与文本节点的处理方式类似。不同的是,我们需要使用 document.createComment 函数创建注释节点元素。

11、Fragment

Fragment(片断)是 Vue.js 3 中新增的一个 vnode 类型。在具体讨论 Fragment 的实现之前,我们有必要先了解为什么需要 Fragment。请思考这样的场景,假设我们要封装一组列表组件:

01 <List>
02   <Items />
03 </List>

整体由两个组件构成,即 组件和 组件。其中 组件会渲染一个

    标签作为包裹层:

    01 <!-- List.vue -->
    02 <template>
    03   <ul>
    04     <slot />
    05   </ul>
    06 </template>
    

    组件负责渲染一组

  • 列表:

    01 <!-- Items.vue -->
    02 <template>
    03   <li>1</li>
    04   <li>2</li>
    05   <li>3</li>
    06 </template>
    

    这在 Vue.js 2 中是无法实现的。在 Vue.js 2 中,组件的模板不允许存在多个根节点。这意味着,一个 组件最多只能渲染一个

  • 标签:

    01 <!-- Item.vue -->
    02 <template>
    03   <li>1</li>
    04 </template>
    

    因此在 Vue.js 2 中,我们通常需要配合 v-for 指令来达到目的:

    01 <List>
    02   <Items v-for="item in list" />
    03 </List>
    

    类似的组合还有