渲染器之挂载与更新

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

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),如下图所示:
在这里插入图片描述
这些属性就是所谓的 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" />

在这段代码中,我们为 <input/> 标签设置了 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 函数内,我们对 <input form="xxx" /> 进行特殊处理,即 <input/> 标签的 form 属性必须使用 setAttribute 函数来设置。实际上,不仅仅是 <input/> 标签,所有表单元素都具有 form 属性,它们都应该作为 HTML Attributes 被设置。

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

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

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 的性能:
在这里插入图片描述
可以看到,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 })

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/154369.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

IonQ、Rigetti、D-Wave公布2023年第三季度财报!

近期&#xff0c;量子计算公司Rigetti、IonQ和D-Wave均公布了各自在2023年第三季度的盈利收益。 这三家公司在近期均实现了收入增长&#xff0c;并助力客户实现相应的业务增长。然而&#xff0c;在追求实现量子霸权和超越经典硅基计算机系统的同时&#xff0c;这些公司仍面临着…

ECharts零基础使用思路 图表案例网站推荐

1、用npm安装echarts npm i echarts -S 2、引入 &#xff08;1&#xff09;可以在mian.js里全局引入 import echarts from ‘echarts’ Vue.prototype.$echarts echarts 将echarts挂载在Vue原型上 用时直接this.$echarts即可 &#xff08;2&#xff09;也可以在组件中按需引入…

安卓毕业设计:基于安卓android微信小程序的在线医生答疑系统

项目介绍 在线医生答疑开发使系统能够更加方便快捷&#xff0c;同时也促使在线医生答疑变的更加系统化、有序化。系统界面较友好&#xff0c;易于操作。具体在系统设计上&#xff0c;客户端使用微信开发者&#xff0c;后台也使用java技术在动态页面上进行了设计&#xff0c;My…

AR眼镜方案—单目光波导AR智能眼镜

光波导技术是一项具有前沿意义的技术&#xff0c;它能够将光线反射180度&#xff0c;使得眼镜框架内置的MicroLED屏幕的图像通过多次反射与扩散后准确地传递到人眼中。采用MicroLED显示技术的AR智能眼镜不仅体积显著缩小&#xff0c;屏幕只有0.68英寸大小&#xff0c;并且还能够…

KT142C语音芯片音乐前要空白音才行,声音会被截掉,实际语音是你好,播放变成好

KT142C语音芯片播放音乐前必须有一段空白音才行&#xff0c;不然声音会被截掉一部分&#xff0c;播放 温度1超高&#xff0c;如果前面没有空白音&#xff0c;就会变成 度1超高 出现这个问题&#xff0c;核心的原理在于功放芯片是受控了 这个问题只存在于&#xff0c;配置为DAC…

软考必须得从初级开始考吗?

软考是指软件技术专业资格考试&#xff0c;是由中国计算机技术职业资格认证中心&#xff08;NCTC&#xff09;主办的一项国家级考试。软考考试内容涵盖了软件工程、数据库、网络与信息安全、嵌入式系统等多个方面的知识&#xff0c;是评价软件技术人员专业水平的重要标准。 对于…

手机 IOS 软件 IPA 签名下载安装详情图文教程

由于某些应用由于某些原因无法上架到 appStore 或者经过修改过的软件游戏等无法通过 appStore 安装&#xff0c;我们就可以使用签名的方式对相应软件的IPA文件进行签名然后安装到你的手机上 这里我们使用爱思助手进行签名安装&#xff0c;爱思助手支持两种方式&#xff0c;一种…

visionOS空间计算实战开发教程Day 1:环境安装和编写第一个程序

安装 截至目前visionOS还未在Xcode稳定版中开放&#xff0c;所以需要下载​​Xcode Beta版​​。比如我们可以下载Xcode 15.1 beta 2&#xff0c;注意Xcode 15要求系统的版本是macOS Ventura 13.5或更新&#xff0c;也就是说2017年的MacBook Pro基本可以勉强一战&#xff0c;基…

从复杂大模型加载到3D PDF发布: EVGET HOOPS Framework如何助力高性能3D桌面应用开发?

在当今数字化时代&#xff0c;3D图形和CAD&#xff08;计算机辅助设计&#xff09;技术在各行各业中扮演着至关重要的角色。 HOOPS SDK是由Tech Soft 3D公司开发的一套强大的3D图形和CAD&#xff08;计算机辅助设计&#xff09;开发工具包。HOOPS致力于提供先进的3D复杂数据处…

数据结构与算法之美学习笔记:23 | 二叉树基础(上):什么样的二叉树适合用数组来存储?

目录 前言树&#xff08;Tree&#xff09;二叉树&#xff08;Binary Tree&#xff09;二叉树的遍历解答开篇 & 内容小结 前言 本节课程思维导图&#xff1a; 前面我们讲的都是线性表结构&#xff0c;栈、队列、链表等等。今天我们讲一种非线性表结构&#xff0c;树。问题&…

WebGoat通关攻略之 SQL Injection (intro)

SQL Injection (intro) 1. What is SQL? 本题练习SQL查询语句&#xff0c;就是写一句SQL获取叫Bob Franco所在的department SELECT department FROM employees WHERE first_name Bob AND last_name Franco成功通关&#xff01; 2. Data Manipulation Language (DML) 本题…

安卓毕业设计:基于安卓android微信小程序的超市购物系统

运行环境 开发语言&#xff1a;Java 框架&#xff1a;ssm JDK版本&#xff1a;JDK1.8 服务器&#xff1a;tomcat7 数据库&#xff1a;mysql 5.7&#xff08;一定要5.7版本&#xff09; 数据库工具&#xff1a;Navicat11 开发软件&#xff1a;eclipse/myeclipse/idea Maven包&a…

喜报 | 思码逸 DevInsight 通过DaoCloud兼容性互认证

近日&#xff0c;北京思码逸科技有限公司&#xff08;简称&#xff1a;思码逸&#xff09;的 Devlnsight 一站式研发效能度量平台与上海道客网络科技有限公司&#xff08;简称&#xff1a;DaoCloud&#xff09;的 DaoCloud Enterprise 云原生操作系统 V5.0&#xff0c;经双方联…

数据结构:lambda表达式

基本概念 语法 // 1. 不需要参数,返回值为 2 () -> 2 // 2. 接收一个参数(数字类型),返回其2倍的值 x -> 2 * x // 3. 接受2个参数(数字),并返回他们的和 (x, y) -> x y // 4. 接收2个int型整数,返回他们的乘积 (int x, int y) -> x * y // 5. 接受一个 string 对…

配置中心

服务配置中心介绍 首先我们来看一下,微服务架构下关于配置文件的一些问题&#xff1a; 1. 配置文件相对分散。 在一个微服务架构下&#xff0c;配置文件会随着微服务的增多变的越来越多&#xff0c;而且分散 在各个微服务中&#xff0c;不好统一配置和管理。 2. 配置文件无…

ACREL DC energy meter Application in Indonesia

安科瑞 华楠 Abstract: This article introduces the application of Acrel DC meters in base station in Indonesia.The device is measuring current,voltage and energy together with hall current sensor. 1.Project Overview This company is located in Indonesia a…

substring-after用法

substring-after&#xff1a;函数返回一个字符串&#xff0c;该字符串是给定子字符串后给定字符串的其余部分。 #句法 substring-after( haystack ,needle) haystack&#xff1a;要评估的字符串。该字符串的一部分将被返回。 needle&#xff1a;要搜索的子字符串。needle在h…

SqlServer_idea连接问题

问题描述&#xff1a; sqlServer安装之后可以使用navicat进行连接idea使用账户密码进行登录连接失败 问题解决&#xff1a; 先使用sqlServer管理工具进行登录 使用window认证连接修改账户密码 启用该登录名 这时idea还是无法连接&#xff0c;还需要如下配置 打开sqlserve…

140.【鸿蒙OS开发-01】

鸿蒙开发 (一)、初识鸿蒙1.初识鸿蒙(1).移动通讯技术的发展(2).完整的鸿蒙开发 (二)、鸿蒙系统介绍1.鸿蒙系统的官方定义(1).鸿蒙操作系统概述(2).鸿蒙的生态 2.鸿蒙系统的特点3.鸿蒙和安卓的对比4.鸿蒙开发的发展前景 (三)、鸿蒙开发准备工作1.鸿蒙OS的完整开发流程2.注册并实…

哈希表HashTable

散列表&#xff08;Hash table&#xff0c;也叫哈希表&#xff09;&#xff0c;是根据键&#xff08;Key&#xff09;而直接访问在内存存储位置的数据结构。 哈希表中关键码就是数组的索引下标&#xff0c;然后通过下标直接访问数组中的元素&#xff0c;复杂度O(1) 哈希表本质…