8.挂载与更新

2024/9/8 Vue挂载子节点和元素的属性

# 挂载子节点和元素的属性

mount() 步骤:

1、创建 vnode

2、挂载 appContext

3、vnode 和 rootContainer 传入render()

4、设置挂载标识 isMounted = true

5、其他属性挂载

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

 const vnode = {
   type: 'div',
   children: [
     {
       type: 'p',
       children: 'hello'
     }
   ]
 }
1
2
3
4
5
6
7
8
9

​ vnode.children 是一个数组,它的每一个元素都是一个独立的虚拟节点对象。这样就形成了树型结构,即虚拟 DOM 树。

​ 为了完成子节点的渲染,我们需要修改 mountElement 函数:

 function mountElement(vnode, container) {
   const el = createElement(vnode.type) // 创建 DOM 元素
   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) // 将元素添加到容器中
 }
1
2
3
4
5
6
7
8
9
10
11
12

使用 Array.isArray 函数判断 vnode.children 是否是数组,如果是数组,则循环遍历它,并调 patch 函数挂载数组中的虚拟节点。

​ 在挂载子节点时,需要注意以下两点:

1、传递给 patch 函数的第一个参数是 null。因为是挂载阶段,没有旧 vnode,所以只需要传递 null 即可。这样,当 patch 函数执行时,就会递归地调用mountElement 函数完成挂载。

2、传递给 patch 函数的第三个参数是挂载点。由于我们正在挂载的子元素是 div 标签的子节点,所以需要把刚刚创建的 div 元素作为挂载点,这样才能保证这些子节点挂载到正确位置。

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

​ 为了描述元素的属性,我们需要为虚拟 DOM 定义新的 vnode.props 字段:

 const vnode = {
   type: 'div',
   // 使用 props 描述一个元素的属性
   props: {
     id: 'foo'
   },
   children: [
     {
       type: 'p',
       children: 'hello'
     }
   ]
 }
1
2
3
4
5
6
7
8
9
10
11
12
13

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)
 }
1
2
3
4
5
6
7
8
9
10
11
12
13

我们首先检查了 vnode.props 字段是否存在,如果存在则遍历它,并调用 setAttribute 函数将属性设置到元素上。

​ 实际上,除了使用 setAttribute 函数为元素设置属性之外,还可以通过 DOM 对象直接设置:

 function mountElement(vnode, container) {
   const el = createElement(vnode.type)
   // 省略 children 的处理
   if (vnode.props) {
     for (const key in vnode.props) {
       // 直接设置
       el[key] = vnode.props[key]
     }
   }
   insert(el, container)
 }
1
2
3
4
5
6
7
8
9
10
11

​ 实际上,无论是使用 setAttribute 函数,还是直接操作 DOM 对象,都存在缺陷。为元素设置属性比想象中要复杂得多。在讨论缺陷之前,我们有必要先搞清楚两个重要的概念:HTML AttributesDOM Properties

# HTML Attributes 与 DOM Properties

​ HTML Attributes 即标签中传入的属性;DOM Properties 是 DOM 节点拥有的属性;HTML Attributes 的作用是为 DOM Properties 设置初始值。它们会影响 DOM 属性的添加方式。理解这两者的差异和关联非常重要,这能够帮助我们合理地设计虚拟节点的结构,更是正确地为元素设置属性的关键。

  • HTML 属性是在 HTML 代码中定义的,DOM 属性是在 JavaScript 中通过 DOM 对象访问的。
  • HTML 属性的值只能是字符串,而 DOM 属性的值可以是任何类型。
  • HTML 属性在网页加载时初始化,并且常常不能改变,而 DOM 属性可以在任何时候改变。
  • 不是所有的 HTML 属性都有对应的 DOM 属性,反之亦然。

​ 我们先从最基本的 HTML 说起:

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

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

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

​ 这个 DOM 对象会包含很多属性,这些属性就是所谓的 DOM Properties:

属性

​ 很多 HTML Attributes 在 DOM 对象上有与之同名的 DOM Properties,例如 id="my-input" 对应 el.idtype="text"对应 el.typevalue="foo" 对应 el.value 等。

​ 但 DOM Properties 与 HTML Attributes 的名字不总是一模一样的,例如:

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

class="foo" 对应的 DOM Properties 则是 el.className

​ 另外,并不是所有 HTML Attributes 都有与之对应的 DOM Properties,例如:

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

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

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

​ HTML Attributes 的值与 DOM Properties 的值之间是有关联的:

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

这个片段描述了一个具有 id 属性的 div 标签。其中,id="foo" 对应的 DOM Properties 是 el.id,并且值为字符串 'foo'。

​ 我们把这种 HTML Attributes 与 DOM Properties 具有相同名称(即 id)的属性看作直接映射。但并不是所有HTML Attributes 与 DOM Properties 之间都是直接映射的关系,例如:

<input value="foo" />
1

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

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

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

console.log(el.getAttribute('value')) // 仍然是 'foo'
console.log(el.value) // 'bar'
1
2

​ 实际上,HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值一旦值改变,那么 DOM Properties 始终存储着当前值,而通过 getAttribute 函数得到的仍然是初始值

相当于 Attributes 属于常量类型,渲染到页面中后不会再进行更新;而 Properties 则属于 JS 操作的 API,会引起 DOM 节点属性的更新。

​ 但我们仍然可以通过 el.defaultValue 来访问初始值:

el.getAttribute('value') // 仍然是 'foo'
el.value // 'bar'
el.defaultValue // 'foo'
1
2
3

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

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

<input type="foo" />
1

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

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

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

# 正确地设置元素属性

​ 对于普通的 HTML 文件来说,当浏览器解析 HTML 代码后,会自动分析 HTML Attributes 并设置合适的 DOM Properties。但用户编写在 Vue.js 的单文件组件中的模板不会被浏览器解析,这意味着,原本需要浏览器来完成的工作,现在需要框架来完成(解析 HTML文本,渲染成 Dom)。

​ 我们以禁用的按钮为例:

<button disabled>Button</button>
1

​ 浏览器在解析这段 HTML 代码时,发现这个按钮存在一个叫作 disabled 的HTML Attributes,于是浏览器会将该按钮设置为禁用状态,并将它的 el.disabled 这个 DOM Properties 的值设置为 true,这一切都是浏览器帮我们处理好的

# 使用 setAttribute 函数

​ 但同样的代码如果出现在 Vue.js 的模板中,则情况会有所不同。首先,这个 HTML 模板会被编译成 vnode,它等价于:

 const button = {
   type: 'button',
   props: {
     disabled: ''
   }
 }
1
2
3
4
5
6

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

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

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

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

​ 它对应的 vnode 为:

 const button = {
   type: 'button',
   props: {
     disabled: false
   }
 }
1
2
3
4
5
6

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

el.setAttribute('disabled', false)
1

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

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

​ 对于按钮来说,它的 el.disabled 属性值是布尔类型的,并且它不关心具体的 HTML Attributes 的值是什么,只要 disabled 属性存在,按钮就会被禁用。

# 直接设置元素的 DOM Properties

​ 所以我们发现,渲染器不应该总是使用 setAttribute 函数将 vnode.props 对象中的属性设置到元素上。那么应该怎么办呢?一个很自然的思路是,我们可以优先设置DOM Properties,例如:

el.disabled = false
1

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

<button disabled>Button</button>
1

​ 这段模板对应的 vnode 是:

 const button = {
   type: 'button',
   props: {
     disabled: ''
   }
 }
1
2
3
4
5
6

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

el.disabled = ''
1

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

el.disabled = false
1

​ 这么看来,无论是使用 setAttribute 函数,还是直接设置元素的 DOMProperties,都存在缺陷。

# 解决

​ 要彻底解决这个问题,我们只能做特殊处理,即优先设置元素的 DOM Properties,但当值为空字符串时,要手动将值矫正为 true。只有这样,才能保证代码的行为符合预期:

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

我们检查每一个 vnode.props 中的属性,看看是否存在对应的 DOM Properties,如果存在,则优先设置 DOM Properties

同时,我们对布尔类型的 DOM Properties 做了值的矫正,即当要设置的值为空字符串时,将其矫正为布尔值 true

当然,如果 vnode.props 中的属性不具有对应的 DOM Properties,则仍然使用 setAttribute 函数完成属性的设置。

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

<form id="form1"></form>
<input form="form1" />
1
2

​ 我们为 <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)
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

为了代码的可读性,我们提取了一个 shouldSetAsProps 函数。该函数会返回一个布尔值,代表属性是否应该作为 DOM Properties 被设置。

如果返回 true,则代表应该作为 DOM Properties 被设置,否则应该使用 setAttribute 函数来设置。

在 shouldSetAsProps 函数内,我们对 <input form="xxx" /> 进行特殊处理,即 <input/> 标签的 form 属性必须使用 setAttribute 函数来设置

实际上,不仅仅是 <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)
     }
   }
 })
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

​ 而在 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)
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

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

# class 的处理

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

# Vue 中设置 class

在 Vue.js 中为元素设置类名有以下几种方式:

  • 方式一:指定 class 为一个字符串值。<p class="foo bar"></p>
<p class="foo bar"></p>
1

这段模板对应的 vnode 是:

 const vnode = {
   type: 'p',
   props: {
     class: 'foo bar'
   }
 }
1
2
3
4
5
6
  • 方式二:指定 class 为一个对象值。<p :class="{ foo: true, bar: false }"></p>
<p :class="cls"></p>
1

假设对象 cls 的内容如下:

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

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

 const vnode = {
   type: 'p',
   props: {
     class: { foo: true, bar: false }
   }
 }
1
2
3
4
5
6
  • 方式三:class 是包含上述两种类型的数组。<p :class="['foo bar', { baz: true }]">
<p :class="arr"></p>
1

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

 const arr = [
   // 字符串
   'foo bar',
   // 对象
   {
     baz: true
   }
 ]
1
2
3
4
5
6
7
8

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

 const vnode = {
       type: 'p',
       props: {
         class: [
           'foo bar',
           { baz: true }
         ]
       }
 }
1
2
3
4
5
6
7
8
9

# class 值正常化

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

 const vnode = {
   type: 'p',
   props: {
     // 使用 normalizeClass 函数对值进行序列化
     class: normalizeClass([
       'foo bar',
       { baz: true }
     ])
   }
 }
1
2
3
4
5
6
7
8
9
10

​ 最后的结果等价于:

 const vnode = {
   type: 'p',
   props: {
     // 序列化后的结果
     class: 'foo bar baz'
   }
 }
1
2
3
4
5
6
7

normalizeClass 本质上就是一个数据结构转换的小算法。分字符串、对象、数组三种逻辑处理,对于数组就递归处理,最后还要去重。

function normalizeClass(value) {
  let res = ''
  if (typeof value === 'string') {
    res = value
  } else if (Array.isArray(value)) {
    for (let i = 0; i < value.length; i++) {
      const normalized = normalizeClass(value[i])
      if (normalized) {
        res += normalized + ' '
      }
    }
  } else if (Object.prototype.toString.call(value) === '[object Object]') {
    for (const name in value) {
      if (value[name]) {
        res += name + ' '
      }
    }
  }
  return res.trim()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# class 值设置

假设现在我们已经能够对 class 值进行正常化了。接下来,我们将讨论如何将正常化后的 class 值设置到元素上。

​ 其实,我们目前实现的渲染器已经能够完成 class 的渲染了。观察前文中函数的代码,由于 class 属性对应的 DOM Properties 是 el.className,所以表达式 'class' in el 的值将会是 false,因此,patchProps 函数会使用 setAttribute 函数来完成 class 的设置。

​ 但是我们知道,在浏览器中为一个元素设置 class 有三种方式:使用 ①setAttribute、②el.className 或 ③el.classList。那么哪一种方法的性能更好呢?下图对比了这三种方式为元素设置 1000 次 class 的性能:

性能

​ 可以看到,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] // 获取该 DOM Properties 的类型
       if (type === 'boolean' && nextValue === '') { // 如果是布尔类型,并且值是空字符串,则将值矫正为 true
         el[key] = true
       } else {
         el[key] = nextValue
       }
     } else {
       el.setAttribute(key, nextValue) // 如果要设置的属性没有对应的 DOM Properties,则使用 setAttribute 函数设置属性
     }
   }
 })
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

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

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

# 卸载操作

卸载操作发生在更新阶段,更新指的是,在初次挂载完成之后,后续渲染会触发更新:

 // 初次挂载
 renderer.render(vnode, document.querySelector('#app'))
 // 再次挂载新 vnode,将触发更新
 renderer.render(newVNode, document.querySelector('#app'))
1
2
3
4

​ 首次挂载完成后,后续渲染时如果传递了 null 作为新 vnode(调用 render 渲染空内容 null),则意味着什么都不渲染,这时我们需要卸载之前渲染的内容。

 // 初次挂载
 renderer.render(vnode, document.querySelector('#app'))
 // 新 vnode 为 null,意味着卸载之前渲染的内容
 renderer.render(null, document.querySelector('#app'))
1
2
3
4

​ 回顾前文实现的 render 函数,如下:

 function render(vnode, container) {
   if (vnode) {
     patch(container._vnode, vnode, container)
   } else {
     if (container._vnode) {
       // 卸载,清空容器
       container.innerHTML = ''
     }
   }
   container._vnode = vnode
 }
1
2
3
4
5
6
7
8
9
10
11

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

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

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

 function mountElement(vnode, container) {
   // 让 vnode.el 引用真实 DOM 元素
   const el = vnode.el = createElement(vnode.type)
   if (typeof vnode.children === 'string') {
     setElementText(el, vnode.children)
   } else if (Array.isArray(vnode.children)) {
     vnode.children.forEach(child => {
       patch(null, child, el)
     })
   }
   if (vnode.props) {
     for (const key in vnode.props) {
       patchProps(el, key, null, vnode.props[key])
     }
   }
   insert(el, container)
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

当我们调用 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
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

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

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

 function unmount(vnode) {
   const parent = vnode.el.parentNode
   if (parent) {
     parent.removeChild(vnode.el)
   }
 }
1
2
3
4
5
6

unmount 函数接收一个虚拟节点作为参数,并将该虚拟节点对应的真实 DOM 元素从父元素中移除。

​ 有了 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
 }
1
2
3
4
5
6
7
8
9
10
11

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

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

# 区分 vnode 的类型

​ 当后续调用 render 函数渲染空内容(即 null)时,会执行卸载操作。如果在后续渲染时,为 render 函数传递了新的 vnode,则不会进行卸载操作,而是会把新旧 vnode 都传递给 patch 函数进行打补丁操作。

​ 回顾前文实现的 patch 函数:

 function patch(n1, n2, container) {
   if (!n1) { // 如果 n1 不存在,意味着挂载,则调用 mountElement 函数完成挂载
     mountElement(n2, container)
   } else {
     // 更新
   }
 }
1
2
3
4
5
6
7

​ patch 函数的两个参数 n1 和 n2 分别代表旧 vnode 与新 vnode。如果旧 vnode 存在,则需要在新旧 vnode 之间打补丁。但在具体执行打补丁操作之前,我们需要保证新旧 vnode 所描述的内容相同

​ 新旧 vnode 类型要一致才会执行更新补丁操作,即 vnode.type 的值应该要相同,代表更新前后是同一个元素,这样才有打补丁的意义,否则就要先卸载旧 vnode 再挂载新 vnode

​ 举个例子,假设初次渲染的 vnode 是一个 p 元素:

 const vnode = {
   type: 'p'
 }
 renderer.render(vnode, document.querySelector('#app'))
1
2
3
4

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

 const vnode = {
   type: 'input'
 }
 renderer.render(vnode, document.querySelector('#app'))
1
2
3
4

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

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

​ 在这种情况下,正确的更新操作是,先将 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 {
     // 更新
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13

在真正执行更新操作之前,我们优先检查新旧 vnode 所描述的内容是否相同,如果不同,则直接调用 unmount 函数将旧 vnode 卸载

注意,卸载完成后,我们应该将参数 n1 的值重置为 null,这样才能保证后续挂载操作正确执行。

​ 即使新旧 vnode 描述的内容相同,我们仍然需要进一步确认它们的类型是否相同。我们知道,一个 vnode 可以用来描述普通标签,也可以用来描述组件,还可以用来描述 Fragment 等。对于不同类型的 vnode,我们需要提供不同的挂载或打补丁的处理方式

​ 所以,我们需要继续修改 patch 函数的代码以满足需求:

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

​ 如果 vnode.type 的值是字符串类型,则它描述的是普通标签元素,这时我们会调用 mountElement 或 patchElement 完成挂载和更新操作;如果 vnode.type 的值的类型是对象,则它描述的是组件,这时我们会调用与组件相关的挂载和更新方法

# 事件的处理

​ 事件可以视作一种特殊的属性,因此我们可以约定,在 vnode.props 对象中,凡是以字符串 on 开头的属性都视作事件。例如:

 const vnode = {
   type: 'p',
   props: {
     // 使用 onXxx 描述事件
     onClick: () => {
       alert('clicked')
     }
   },
   children: 'text'
 }
1
2
3
4
5
6
7
8
9
10

# 绑定

​ 只需要在 patchProps 中调用 addEventListener 函数来绑定事件,即可将事件添加到 DOM元素上:

 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 {
     // 省略部分代码
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 更新

​ 那么,更新事件要如何处理呢?按照一般的思路,我们需要先移除之前添加的事件处理函数,然后再将新的事件处理函数绑定到 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 {
     // 省略部分代码
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

​ 这样做代码能够按照预期工作,但其实还有一种性能更优的方式来完成事件更新。

​ 在绑定事件时,我们可以绑定一个伪造的事件处理函数 invoker,然后把真正的事件处理函数设置为 invoker.value 属性的值。这样当更新事件时,我们将不再需要调用 removeEventListener 函数来移除上一次绑定的事件,只需要更新 invoker.value 的值即可:

 patchProps(el, key, prevValue, nextValue) {
   if (/^on/.test(key)) {
     // 获取为该元素伪造的事件处理函数 invoker
     let invoker = el._vei
     const name = key.slice(2).toLowerCase()
     if (nextValue) {
       if (!invoker) {
         // 如果没有 invoker,则将一个伪造的 invoker 缓存到 el._vei 中
         // 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 {
     // 省略部分代码
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

伪造事件 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'))
1
2
3
4
5
6
7
8
9
10
11
12
13

当渲染器尝试渲染这上面代码中给出的 vnode 时,会先绑定 click 事件,然后再绑定 contextmenu 事件。

后绑定的 contextmenu 事件的处理函数将覆盖先绑定的 click 事件的处理函数。

​ 为了解决事件覆盖的问题,我们需要重新设计 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 // 将真正的事件处理函数赋值给 invoker.value
         el.addEventListener(name, invoker) // 绑定 invoker 作为事件处理函数
       } else {
         invoker.value = nextValue // 如果 invoker 存在,意味着更新,只需要更新 invoker.value 的值即可
       }
     } else if (invoker) {
       el.removeEventListener(name, invoker) // 新的事件绑定函数不存在,且之前绑定的 invoker 存在,则移除绑定
     }
   } else if (key === 'class') {
     // 省略部分代码
   } else if (shouldSetAsProps(el, key, nextValue)) {
     // 省略部分代码
   } else {
     // 省略部分代码
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

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

 el.addEventListener('click', fn1)
 el.addEventListener('click', fn2)
1
2

当点击元素时,事件处理函数 fn1 和 fn2 都会执行。

​ 因此,为了描述同一个事件的多个事件处理函数,我们需要调整 vnode.props 对象中事件的数据结构:

 const vnode = {
   type: 'p',
   props: {
     onClick: [
       // 第一个事件处理函数
       () => {
         alert('clicked 1')
       },
       // 第二个事件处理函数
       () => {
         alert('clicked 2')
       }
     ]
   },
   children: 'text'
 }
 renderer.render(vnode, document.querySelector('#app'))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

​ 我们使用一个数组来描述事件,数组中的每个元素都是一个独立的事件处理函数,并且这些事件处理函数都能够正确地绑定到对应元素上。

​ 为了实现此功能,我们需要修改 patchProps 函数中事件处理相关的代码,修改了 invoker 函数的实现:

 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 {
     // 省略部分代码
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

​ 当 invoker 函数执行时,在调用真正的事件处理函数之前,要先检查 invoker.value 的数据结构是否是数组,如果是数组则遍历它,并逐个调用定义在数组中的事件处理函数。

# 事件冒泡与更新时机问题

​ 为了更清晰地描述事件冒泡与更新时机相结合所导致的问题,我们需要构造一个小例子:

 const { effect, ref } = VueReactivity
 const bol = ref(false) // 响应式数据
 effect(() => {
   // 创建 vnode
   const vnode = {
     type: 'div',
     // props 对象的值由一个三元表达式决定
     props: bol.value ? {
       onClick: () => {
         alert('父元素 clicked')
       }
     } : {},
     children: [
       {
         type: 'p',
         props: {
           onClick: () => {
             bol.value = true
           }
         },
         children: 'text'
       }
     ]
   }
   // 渲染 vnode
   renderer.render(vnode, document.querySelector('#app'))
 })
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

​ 理论上,在首次渲染完成之后,由于 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 元素后,整个更新和事件触发的流程图如下。我们可以发现,之所以会出现上述奇怪的现象,是因为更新操作发生在事件冒泡之前,即为 div 元素绑定事件处理函数发生在事件冒泡之前。

更新操作发生在事件冒泡之前

​ 观察上图,可以发现触发事件的时间与绑定事件的时间之间是有联系的。

事件触发的时间要早于事件处理函数被绑定的时间

​ 事件触发的时间要早于事件处理函数被绑定的时间。这意味着当一个事件触发时,目标元素上还没有绑定相关的事件处理函数,我们可以根据这个特点来解决问题:屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行

​ 基于此,我们可以调整 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 {
     // 省略部分代码
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

首先,我们为伪造的事件处理函数添加了 invoker.attached 属性,用来存储事件处理函数被绑定的时间。

然后,在 invoker 执行的时候,通过事件对象的 e.timeStamp 获取事件发生的时间

最后,比较两者,如果事件处理函数被绑定的时间晚于事件发生的时间,则不执行该事件处理函数

# 更新子节点

# 规范化 vnode.children 的类型

​ 首先,回顾一下元素的子节点是如何被挂载的:

 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)
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

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

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

为什么要区分子节点的类型呢?其实这是一个规范性的问题,因为只有子节点的类型是规范化的,才有利于我们编写更新逻辑。

​ 在具体讨论如何更新子节点之前,我们有必要先规范化 vnode.children。

​ 那应该设定怎样的规范呢?为了搞清楚这个问题,我们需要先搞清楚在一个 HTML 页面中,元素的子节点都有哪些情况:

 <!-- 没有子节点 -->
 <div></div>
 <!-- 文本子节点 -->
 <div>Some Text</div>
 <!-- 多个子节点 -->
 <div>
   <p/>
   <p/>
 </div>
1
2
3
4
5
6
7
8
9

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

  • 没有子节点,此时 vnode.children 的值为 null。
  • 具有文本子节点,此时 vnode.children 的值为字符串,代表文本的内容。
  • 其他情况,无论是单个元素子节点,还是多个子节点(可能是文本和元素的混合),都可以用数组来表示。
 // 没有子节点
 vnode = {
   type: 'div',
   children: null
 }
 // 文本子节点
 vnode = {
   type: 'div',
   children: 'Some Text'
 }
 // 其他情况,子节点使用数组表示
 vnode = {
   type: 'div',
   children: [
     { type: 'p' },
     'Some Text'
   ]
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

​ 现在,我们已经规范化了 vnode.children 的类型。

​ 既然一个 vnode 的子节点可能有三种情况,那么当渲染器执行更新时,新旧子节点都分别是三种情况之一。我们可以总结出更新子节点时全部九种可能:

更新子节点时全部九种可能

元素类型相同时才有打补丁 patchElement 的必要,否则直接卸载旧元素,挂载新元素。

  1. 新节点是文本节点,旧节点不存在,将文本内容设置为元素内容
  2. 新节点是文本节点,旧节点是文本节点,将文本内容设置为元素内容
  3. 新节点是文本节点,旧节点是一组子节点,先循环拆卸旧节点,然后将文本内容设置为元素内容
  4. 新节点是一组子节点,旧节点不存在,将新节点逐个挂载
  5. 新节点是一组子节点,旧节点是文本节点,清空元素内容,将新节点逐个挂载
  6. 新节点是一组子节点,旧节点是一组子节点,使用 diff 算法更新
  7. 新节点不存在,旧节点不存在,什么也不做
  8. 新节点不存在,旧节点是文本节点,将元素内容清空
  9. 新节点不存在,旧节点是一组子节点,循环拆卸旧节点

​ 但落实到代码,我们会发现其实并不需要完全覆盖这九种可能。接下来我们就开始着手实现:

 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)
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

​ 如上代码所示,更新子节点是对一个元素进行打补丁的最后一步操作。我们将它封装到 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)
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12

首先,我们检测新子节点的类型是否是文本节点,如果是,则还要检查旧子节点的类型。

旧子节点的类型可能有三种情况,分别是:没有子节点、文本子节点或一组子节点。

如果没有旧子节点或者旧子节点的类型是文本子节点,那么只需要将新的文本内容设置给容器元素即可;如果旧子节点存在,并且不是文本子节点,则说明它的类型是一组子节点。这时我们需要循环遍历它们,并逐个调用 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))
     }
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

我们新增了对 n2.children 类型的判断:检测它是否是一组子节点,如果是,接着再检查旧子节点的类型。

同样,旧子节点也有三种可能:没有子节点、文本子节点和一组子节点。

对于没有旧子节点或者旧子节点是文本子节点的情况,我们只需要将容器元素清空,然后逐个将新的一组子节点挂载到容器中即可。如果旧子节点也是一组子节点,则涉及新旧两组子节点的比对,这里就涉及我们常说的 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))
     }
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

只有当新旧子节点都是一组子节点(子节点数组)时,才会涉及 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, '')
     }
     // 如果也没有旧子节点,那么什么都不需要做
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

如果代码走到了 else 分支,则说明新子节点不存在。

这时,对于旧子节点来说仍然有三种可能:没有子节点、文本子节点以及一组子节点。

如果旧子节点也不存在,则什么都不需要做;如果旧子节点是一组子节点,则逐个卸载即可;如果旧的子节点是文本子节点,则清空文本内容即可。

# 文本节点和注释节点

​ 用于描述普通标签的 vnode:用 vnode.type 来描述元素的名称,它是一个字符串类型的值。

 const vnode = {
   type: 'div'
 }
1
2
3

​ 如何用虚拟 DOM 描述更多类型的真实 DOM 呢?其中最常见的两种节点类型是文本节点和注释节点:

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

<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: '我是注释内容'
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

我们分别为文本节点和注释节点创建了 symbol 类型的值,并将其作为 vnode.type 属性的值。这样就能够用 vnode 来描述文本节点和注释节点了。

由于文本节点和注释节点只关心文本内容,所以我们用 vnode.children 来存储它们对应的文本内容。

​ 有了用于描述文本节点和注释节点的 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
       }
     }
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

我们增加了一个判断条件,即判断表达式 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) {
     // 省略部分代码
   }
 })
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

在调用 createRenderer 函数创建渲染器时,传递的选项参数中封装了 createText 函数和 setText 函数,分别用来创建文本节点和设置文本节点的内容。

​ 我们可以用这两个函数替换渲染器核心代码中所依赖的浏览器特有的 API:

 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)
       }
     }
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

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

# Fragment

渲染器 renderer 在渲染 Fragment 节点时,无论是新增还是删除,都只会渲染 Fragment 的 children 子节点,因为 Fragment 节点本身不会染任何真实 DOM。

​ Fragment(片断)是 Vue.js 3 中新增的一个 vnode 类型。为什么需要 Fragment?请思考这样的场景,假设我们要封装一组列表组件:

 <List>
   <Items />
 </List>
1
2
3

​ 整体由两个组件构成,即 <List> 组件和 <Items> 组件。其中 <List> 组件会渲染一个 <ul> 标签作为包裹层:

 <!-- List.vue -->
 <template>
   <ul>
     <slot />
   </ul>
 </template>
1
2
3
4
5
6

​ 而 <Items> 组件负责渲染一组 <li> 列表:

 <!-- Items.vue -->
 <template>
   <li>1</li>
   <li>2</li>
   <li>3</li>
 </template>
1
2
3
4
5
6

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

 <!-- Item.vue -->
 <template>
   <li>1</li>
 </template>
1
2
3
4

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

 <List>
   <Items v-for="item in list" />
 </List>
1
2
3

​ 类似的组合还有 <select> 标签与 <option> 标签。

​ 而 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' }
   ]
 }
1
2
3
4
5
6
7
8
9

​ 与文本节点和注释节点类似,片段也没有所谓的标签名称,因此我们也需要为片段创建唯一标识,即 Fragment。对于 Fragment 类型的 vnode 的来说,它的 children 存储的内容就是模板中所有根节点。有了 Fragment 后,我们就可以用它来描述 Items.vue 组件的模板了:

 <!-- Items.vue -->
 <template>
   <li>1</li>
   <li>2</li>
   <li>3</li>
 </template>
1
2
3
4
5
6

​ 这段模板对应的虚拟节点是:

 const vnode = {
   type: Fragment,
   children: [
     { type: 'li', children: '1' },
     { type: 'li', children: '2' },
     { type: 'li', children: '3' }
   ]
 }
1
2
3
4
5
6
7
8

类似地,对于如下模板:

 <List>
   <Items />
 </List>
1
2
3

可以用下面这个虚拟节点来描述它:

 const vnode = {
   type: 'ul',
   children: [
     {
       type: Fragment,
       children: [
         { type: 'li', children: '1' },
         { type: 'li', children: '2' },
         { type: 'li', children: '3' }
       ]
     }
   ]
 }
1
2
3
4
5
6
7
8
9
10
11
12
13

可以看到,vnode.children 数组包含一个类型为 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)
     }
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

在 patch 函数中增加了对 Fragment 类型虚拟节点的处理。

从本质上来说,渲染 Fragment 与渲染普通元素的区别在于,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)
   }
 }
1
2
3
4
5
6
7
8
9
10
11

当卸载 Fragment 类型的虚拟节点时,由于 Fragment 本身并不会渲染任何真实DOM,所以只需要遍历它的 children 数组,并将其中的节点逐个卸载即可。

# 总结

  • 对于子节点,只需要递归地调用 patch 函数完成挂载即可。而节点的属性比想象中的复杂,它涉及两个重要的概念:HTML Attributes 和 DOM Properties。为元素设置属性时,我们不能总是使用 setAttribute 函数,也不能总是通过元素的 DOM Properties来设置。至于如何正确地为元素设置属性,取决于被设置属性的特点。例如,表单元素的 el.form 属性是只读的,因此只能使用 setAttribute 函数来设置。
  • Vue.js 对 class 属性做了增强,它允许我们为 class 指定不同类型的值。但在把这些值设置给 DOM 元素之前,要对值进行正常化。style 也是。
  • unmount 函数是以一个 vnode 的维度来完成卸载的,它会根据 vnode.el 属性取得该虚拟节点对应的真实 DOM,然后调用原生 DOM API 完成DOM 元素的卸载。
  • 渲染器在执行更新时,需要优先检查新旧vnode 所描述的内容是否相同。只有当它们所描述的内容相同时,才有打补丁的必要。
  • 即使它们描述的内容相同,我们也需要进一步检查它们的类型,即检查 vnode.type 属性值的类型,据此判断它描述的具体内容是什么。如果类型是字符串,则它描述的是普通标签元素,这时我们会调用 mountElement 和patchElement 来完成挂载和打补丁;如果类型是对象,则它描述的是组件,这时需要调用 mountComponent 和 patchComponent 来完成挂载和打补丁。
  • 在更新事件的时候,为了提升性能,伪造了 invoker 函数,并把真正的事件处理函数存储在 invoker.value 属性中,当事件需要更新时,只更新 invoker.value 的值即可,这样可以避免一次 removeEventListener函数的调用。
  • 处理事件与更新时机的问题:利用事件处理函数被绑定到 DOM 元素的时间与事件触发时间之间的差异,需要屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行。
  • vnode.children 属性只能有如下三种类型:①字符串类型:代表元素具有文本子节点。②数组类型:代表元素具有一组子节点。③null:代表元素没有子节点。在更新时,新旧 vnode 的子节点都有可能是以上三种情况之一,所以在执行更新时一共要考虑九种可能。
  • 使用虚拟节点来描述文本节点和注释节点:利用 symbol 类型值的唯一性,为文本节点和注释节点分别创建唯一标识,并将其作为vnode.type 属性的值。
  • 渲染器渲染 Fragment 的方式类似于渲染普通标签,不同的是,Fragment 本身并不会渲染任何 DOM 元素。所以,只需要渲染一个 Fragment 的所有子节点即可。
上次更新: 2024/9/10 02:14:23