渲染器是用来执行渲染任务的。在浏览器平台上,用它来渲染其中的真实 DOM 元素。

渲染器与渲染是不同的。渲染器是更加宽泛的概念,它包含渲染。

挂载mountElement

  1. function mountElement(vnode, container) {
  2. // 创建 DOM 元素
  3. const el = document.createElement(vnode.type)
  4. // 处理子节点,如果子节点是字符串,代表元素具有文本节点
  5. if (typeof vnode.children === 'string') {
  6. // 因此只需要设置元素的 textContent 属性即可
  7. el.textContent = vnode.children
  8. }
  9. // 将元素添加到容器中
  10. container.appendChild(el)
  11. }

上面这u但代码存在的问题,我们的目标是设计一个不依赖于浏览器平台的通用渲染器,mountElement 函数内调用了大量依赖于浏览器的 API,例如 document.createElement 、 el.textContent 以及 appendChild 等。想要设计通用渲染器,第一步要做的就是将这些浏览器特有的 API 抽离

  1. // 用于创建元素
  2. function createElemnt(tag) {
  3. return document.createElement(tag)
  4. }
  5. // 用于设置元素的文本节点
  6. function setElementText(el, text) {
  7. el.textContent = text
  8. }
  9. // 用于在给定的 parent 下添加指定元素
  10. function insert(el, parent, anchor = null) {
  11. parent.insertBefore(el, anchor)
  12. }
  13. function mountElement(vnode, container) {
  14. // 调用 createElement 函数创建元素
  15. const el = createElement(vnode.type)
  16. if (typeof vnode.children === 'string') {
  17. // 调用 setElementText 设置元素的文本节点
  18. setElementText(el, vnode.children)
  19. }
  20. // 调用 insert 函数将元素插入到容器内
  21. insert(el, container)
  22. }

挂载与更新

当 vnode.children 的值是字符串类型时,会把它设置为元素的文本内容。一个元素除了具有文本子节点外,还可以包含其他元素子节点,并且子节点可以是很多个。为了完成子节点的渲染,我们需要修改 mountElement 函数:

  1. function mountElement(vnode, container) {
  2. const el = createElement(vnode.type)
  3. if (typeof vnode.children === 'string') {
  4. setElementText(el, vnode.children)
  5. } else if (Array.isArray(vnode.children)) {
  6. // 如果 children 是数组,则遍历每一个子节点,并调用 patch 函数挂载它们
  7. vnode.children.forEach(child => {
  8. patch(null, child, el)
  9. })
  10. }
  11. insert(el, container)
  12. }

需要注意两点:

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

为元素设置属性

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

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


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

  1. function mountElement(vnode, container) {
  2. const el = createElement(vnode.type)
  3. // 省略 children 的处理
  4. // 如果 vnode.props 存在才处理它
  5. if (vnode.props) {
  6. // 遍历 vnode.props
  7. for (const key in vnode.props) {
  8. // 调用 setAttribute 将属性设置到元素上
  9. el.setAttribute(key, vnode.props[key])
  10. }
  11. }
  12. insert(el, container)
  13. }

除了用 setAttribute 函数,还可以直接将属性设置在 DOM 对象上,即 el[key] = vnode.props[key] 。但是,实际上,为元素设置属性比想象中要复杂得多,需要搞清楚 HTML Attributes 和 DOM Properties 。

HTML Attributes 与 DOM Properties

例如:

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

HTML Attributes 指的就是定义在 HTML 标签上的属性,这里指的就是 id=”my-input” 、 type=”text” 和 value=”foo” 。

很多 HTML Attributes 在 DOM 对象上有与之同名的 DOM Properties 。
但 DOM Properties 与 HTML Attributes 的名字不总是一模一样的。
另外,并不是所有 HTML Attributes 都有与之对应的 DOM Properties。
类似地,也不是所有 DOM Properties 都有与之对应的 HTML Attributes。
我们把 HTML Attributes 与 DOM Properties 具有相同名称的属性看作直接映射。但并不是所有 HTML Attributes 与 DOM Properties 之间都是直接映射的关系

HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。一旦值改变,那么 DOM Properties 始终存储着当前值,而通过 getAttribute 函数得到的仍然是初始值。
这说明一个 HTML Attributes 可能关联多个 DOM Properties。

正确地设置元素属性

优先设置元素的 DOM Properties,但当值为空字符串时,要手动将值矫正为 true。

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

有一些 DOM Properties 是只读的,因此只能通过 setAttributes 函数来设置,这就需要修改现有的逻辑:

  1. function shouldSetAsProps(el, key, value) {
  2. // 特殊处理
  3. if (key === 'form' && el.tagName === 'INPUT') return false
  4. // 兜底
  5. return key in el
  6. }
  7. function mountElement(vnode, container) {
  8. const el = createElement(vnode.type)
  9. // 省略 children 的处理
  10. if (vnode.props) {
  11. for (const key in vnode.props) {
  12. const value = vnode.props[key]
  13. // 使用 shouldSetAsProps 函数判断是否应该作为 DOM Properties 设置
  14. if (shouldSetAsProps(el, key, value)) {
  15. const type = typeof el[key]
  16. if (type === 'boolean' && value === '') {
  17. el[key] = true
  18. } else {
  19. el[key] = value
  20. }
  21. } else {
  22. el.setAttribute(key, value)
  23. }
  24. }
  25. }
  26. inser(el, container)
  27. }

注意:不会将所有特殊情况一一列举,因为掌握处理问题的思路更加重要。不要惧怕写出不完美的代码,只要在后续迭代过程中“见招拆招”,代码就会变得越来越完善,框架也会变得越来越健壮。

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

  1. const renderer = createRenderer({
  2. createElement(tag) {
  3. return document.createElement(tag)
  4. },
  5. setElementText(el, text) {
  6. el.textContent = text
  7. },
  8. insert(el, parent, anchor = null) {
  9. 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 函数,并为其传递相关参数即可:

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

class的处理

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

  1. 指定 class 为一个字符串值

    1. <p class="foo bar"></p>

    这段模板对应的 vnode 是:

    1. const vnode = {
    2. type: 'p',
    3. props: {
    4. class: 'foo bar'
    5. }
    6. }
  2. 指定 class 为一个对象值。

    1. <p :class="cls"></p>

    假设对象 cls 的内容如下:

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

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

    1. const vnode = {
    2. type: 'p',
    3. props: {
    4. class: { foo: true, bar: false }
    5. }
    6. }
  3. class 是包含上述两种类型的数组

    1. <p :class="arr"></p>

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

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

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

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

因为 class 的值可以是多种类型,所以需要封装 normalizeClass 函数,将不同类型的 class 值正常化为字符串。

由于 class 属性对应的 DOM Properties 是 el.className ,所以表达式 ‘class’ in el 的值将会是 false ,因此, patchProps 函数会使用 setAttribute 函数来完成 class 的设置。但是在浏览器中为一个元素设置 class 有三种方式,setAttribute 、el.className 或 el.classList 。el.className 的性能最优,因此:

  1. const renderer = createRenderer({
  2. // 省略其他选项
  3. patchProps(el, key, prevValue, nextValue) {
  4. // 对 class 进行特殊处理
  5. if (key === 'class') {
  6. el.className = nextValue || ''
  7. } else if (shouldSetProps(el, key, nextValue)) {
  8. const type = typeof el[key]
  9. if (type === 'boolean' && nextValue === '') {
  10. el[key] = true
  11. } else {
  12. el[key] = nextValue
  13. }
  14. } else {
  15. el.setAttribute(key, nextValue)
  16. }
  17. }
  18. })

除了 class 属性以外, Vue.js 对 style 属性也做了增强,所以也需要对 style 做类似的处理。

卸载操作

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

在前面,卸载之前渲染的内容,是通过 container.innerHTML = ‘’ 的方式,但是不太严谨,原因是:

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

正确的卸载方式是,根据 vnode 对象获取与其相关联的真实 DOM 元素,然后使用原生的 DOM 操作方法将该 DOM 元素移除。为此,我们需要在 vnode 与真实 DOM 元素之间建立联系,修改 mountElement 函数。

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

当卸载操作发生时,只需要根据虚拟节点对象 vnode.el 取得真实 DOM 元素,再将其从父元素中移除即可:

  1. function render(vnode, container) {
  2. if (vnode) {
  3. patch(container._vnode, vnode, container)
  4. } else {
  5. if (container._vnode) {
  6. // 根据 vnode 获取要卸载的真实 DOM 元素
  7. const el = container._vnode.el
  8. // 获取 el 的父元素
  9. 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 函数中。

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

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

  1. function render(vnode, container) {
  2. if (vnode) {
  3. patch(container._vnode, vnode, container)
  4. } else {
  5. if (container._vnode) {
  6. // 调用 unmount 函数卸载 vnode
  7. unmount(container._vnode)
  8. }
  9. }
  10. container._vnode = vnode
  11. }

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

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

    区分 vnode 的类型

    假设初次渲染的 vnode 是一个 p 元素:
    1. const vnode = {
    2. type: 'p',
    3. }
    4. renderer.render(vnode, document.querySelector('#app'))
    后续又渲染了一个 input 元素:
    1. const vnode = {
    2. type: 'input'
    3. }
    4. renderer.render(vnode, document.querySelector('#app'))
    这种情况下,更新的正确操作是,先将 p 元素卸载,再将 input 元素挂载到容器中。因此我们需要调整 patch 函数的代码:
    1. function patch(n1, n2, container) {
    2. // 如果 n1 存在,则对比 n1 和 n2 的类型
    3. if (n1 && n1.type !== n2.type) {
    4. // 如果新旧 vnode 的类型不同,则直接将旧 vnode 卸载
    5. unmount(n1)
    6. n1 = null
    7. }
    8. if (!n1) {
    9. mountElement(n2, container)
    10. } else {
    11. // 更新
    12. }
    13. }

    事件的处理

    如何在虚拟节点中描述事件,如何把事件添加到 DOM 元素上,以及如何更新事件。

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

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

如何将事件添加到 DOM 元素上?
在 patchProps 中调用 addEventListener 函数来绑定事件。

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

如何更新事件?
按照一般的思路,需要先移除之前添加的事件处理函数,然后再将新的事件处理函数绑定到 DOM 元素上。

  1. patchProps(el, key, prevValue, nextValue) {
  2. if (/^on/.test(key)) {
  3. const name = key.slice(2).toLowerCase()
  4. // 移除上一次绑定的事件处理函数
  5. prevValue && el.removeEventListenre(name, prevValue)
  6. // 绑定新的事件处理函数
  7. el.addEventListener(name, nextValue)
  8. } else if (key === 'class') {
  9. // 省略部分代码
  10. } else if (shouldSetAsProps(el, key, nextValue)) {
  11. // 省略部分代码
  12. } else {
  13. // 省略部分代码
  14. }
  15. }

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

  1. patchProps(el, key, prevValue, nextValue) {
  2. if (/^on/.test(key)) {
  3. // 获取为该元素伪造的事件处理函数 invoker
  4. let invoker = el._vei
  5. const name = key.slice(2).toLowerCase()
  6. if (nextValue) {
  7. if (!invoker) {
  8. // 如果没有 invoker ,则将一个伪造的 invoker 缓存到 el._vei 中
  9. // 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 函数的调用,从而提升了性能。实际上,伪造的事件处理函数的作用不止于此,它还能解决事件冒泡与事件更新之间相互影响的问题。

但是还有问题,在同一时刻只能缓存一个事件处理函数。这意味着,如果一个元素同时绑定了多种事件,将会出现事件覆盖的现象。例如同时给元素绑定 click 和 contextmenu 事件:

  1. const vnode = {
  2. type: 'p',
  3. props: {
  4. onClick: () => {
  5. alert('clicked')
  6. },
  7. onContextmenu: () => {
  8. alert('contextmenu')
  9. }
  10. },
  11. children: 'text'
  12. }
  13. renderer.render(vnode, document.querySelector('#app'))

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

  1. patchProps(el, key, prevValue, nextValue) {
  2. if (/^on/.test(key)) {
  3. // 定义 el._vei 为一个对象,存在事件名称到事件处理函数的映射
  4. const invokers = el._vei || (el._vei = {})
  5. // 根据事件名称获取 invoker
  6. let invoker = invokers[key]
  7. const name = key.slice(2).toLowerCase()
  8. if (nextValue) {
  9. 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 函数为元素绑定同一类型的事件时,多个事件处理函数可以共存,例如:

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

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

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

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

  1. patchProps(el, key, prevValue, nextValue) {
  2. if (/^on/.test(key)) {
  3. const invokers = el._vei || (el._vei = {})
  4. let invoker = invokers[key]
  5. const name = key.slice(2).toLowerCase()
  6. if (nextValue) {
  7. if (!invoker) {
  8. invoker = el._vei[key] = (e) => {
  9. // 如果 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 的数据结构是否是数组,如果是数组则遍历它,并逐个调用定义在数组中的事件处理函数。

事件冒泡与更新时机问题

来看一个例子:

  1. const { effect, ref } = VueReactivity
  2. const bol = ref(false)
  3. effect(() => {
  4. // 创建 vnode
  5. const vnode = {
  6. type: 'div',
  7. props: bol.value ? {
  8. onClick: () => {
  9. alert('父元素 clicked')
  10. }
  11. } : {},
  12. children: [
  13. {
  14. type: 'p',
  15. props: {
  16. onClick: () => {
  17. bol.value = true
  18. }
  19. },
  20. children: 'text'
  21. }
  22. ]
  23. }
  24. // 渲染 vnode
  25. renderer.render(vnode, document.querySelector('#app'))
  26. })

这个例子比较复杂。在上面这段代码中,我们创建一个响应式数据 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 事件的处理函数,因此就发生了上述奇怪的现象。

之所以会出现上述奇怪的现象,是因为更新操作发生在事件冒泡之前,即为 div 元素绑定事件处理函数发生在事件冒泡之前

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

  1. patchProps(el, key, prevValue, nextValue) {
  2. if (/^on/.test(key)) {
  3. const invokers = el._vei || (el._vei = {})
  4. let invoker = invokers[key]
  5. const name = key.slice(2).toLowerCase()
  6. if (nextValue) {
  7. if (!invoker) {
  8. invoker = el._vei[key] = (e) => {
  9. // 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 获取事件发生的时间。最后,比较两者,如果事件处理函数被绑定的时间晚于事件发生的时间,则不执行该事件处理函数。

更新子节点

回顾一下元素的子节点是如何被挂载的,如下面 mountElement 函数的代码所示:

  1. function mountElement(vnode, container) {
  2. const el = vnode.el = createElement(vnode.type)
  3. // 挂载子节点,首先判断 children 的类型
  4. // 如果是字符串类型,说明是文本子节点
  5. if (typeof vnode.children === 'string') {
  6. setElementText(el, vnode.children)
  7. } else if (Array.isArray(vnode.children)) {
  8. // 如果是数组,说明是多个子节点
  9. vnode.children.forEach(child => {
  10. patch(null, child, el)
  11. })
  12. }
  13. if (vnode.props) {
  14. for (const key in vnode.props) {
  15. patchProps(el, key, null, vnode.props[key])
  16. }
  17. }
  18. insert(el, container)
  19. }

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

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

如下面的代码所示:

  1. // 没有子节点
  2. vnode = {
  3. type: 'div',
  4. children: null
  5. }
  6. // 文本子节点
  7. vnode = {
  8. type: 'div',
  9. children: 'some text'
  10. }
  11. // 其他情况,子节点使用数组表示
  12. vnode = {
  13. type: 'div',
  14. children: [
  15. { type: 'p' },
  16. 'some text'
  17. ]
  18. }

现在,我们已经规范化了 vnode.children 的类型。既然一个 vnode 的子节点可能有三种情况,那么当渲染器执行更新时,新旧子节点都分别时三种情况之一。

更新元素

  1. function patchElement(n1, n2) {
  2. const el = n2.el = n1.el
  3. const oldProps = n1.props
  4. const newProps = n2.props
  5. // 第一步: 更新 props
  6. for (const key in newProps) {
  7. if (newProps[key] !== oldProps[key]) {
  8. patchProps(el, key, oldProps[key], newProps[key])
  9. }
  10. }
  11. for (const key in oldProps) {
  12. if (!(key in newProps)) {
  13. patchProps(el, key, oldProps[key], null)
  14. }
  15. }
  16. // 第二步: 更新 children
  17. patchChildren(n1, n2, el)
  18. }

patchChildren 函数的实现如下:

  1. function patchChildren(n1, n2, container) {
  2. // 判断子节点的类型是否是文本节点
  3. if (typeof n2.children === 'string') {
  4. // 旧子节点的类型有三种可能: 没有子节点、文本子节点以及一组子节点
  5. // 只有当旧子节点为一组子节点时,才需要逐个卸载,其他情况下什么都不需要做
  6. if (Array.isArray(n1.children)) {
  7. n1.children.forEach((c) => unmount(c))
  8. }
  9. // 最后将新的文本节点内容设置给容器元素
  10. setElementText(container, n2.children)
  11. } else if (Array.isArray(n2.children)) {
  12. // 说明新子节点是一组子节点
  13. // 判断旧子节点是否也是一组子节点
  14. if (Array.isArray(n1.children)) {
  15. // 代码运行到这里,则说明新旧子节点都是一组子节点,这里涉及核心的 Diff 算法
  16. } else {
  17. // 此时:
  18. // 旧子节点要么是文本子节点,要么不存在
  19. // 但无论是哪种情况,我们都只需要将容器清空,然后将新的一组子节点逐个挂载
  20. setElementText(container, '')
  21. n2.children.forEach(c => patch(null, c, container))
  22. }
  23. } else {
  24. // 代码运行到这里,说明新子节点不存在
  25. // 旧子节点是一组子节点,只需逐个卸载即可
  26. if (Array.isArray(n1.children)) {
  27. n1.children.forEach(c => unmount(c))
  28. } else if (typeof n1.children === 'string') {
  29. // 旧子节点是文本子节点,清空内容即可
  30. setElementText(container, '')
  31. }
  32. // 如果也没有旧子节点,那么什么都不需要做
  33. }
  34. }

文本节点和注释节点

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

  1. // 文本节点的 type 标识
  2. const Text = Symbol()
  3. const newVnode = {
  4. // 描述文本节点
  5. type: Text,
  6. children: '我是文本内容'
  7. }
  8. // 注释节点的 type 标识
  9. const Comment = Symbol()
  10. const newVnode = {
  11. // 描述注释节点
  12. type: Comment,
  13. children: '我是注释内容'
  14. }

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

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

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

  1. const renderer = createRenderer({
  2. createElement(tag) {
  3. // 省略部分代码
  4. },
  5. setElementText(el, text) {
  6. // 省略部分代码
  7. },
  8. insert(el, parent, anchor = null) {
  9. // 省略部分代码
  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. })

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

Fragment

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

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

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

    标签作为包裹层:

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

    组件负责渲染一组

  • 列表:

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

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

  • 标签:

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

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

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

    类似的组合还有