Vue渲染器如何对节点进行挂载和更新

一、子节点和元素的属性

先前我们讨论了一个简单的渲染器是如何实现的 一文详解Vue中渲染器的简单实现_vue.js_IT俱乐部 (jb51.net) 但是实际上还有一些问题需要完善:

  • 子节点不一定只是一个文本节点,实际上其可能会是多个不同的节点。
  • 我们并没有对被挂载的元素的属性进行处理。

1.如何处理子节点为多个节点的情况:

处理vnode的数据结构以正确描述多个节点的情况

我们可以将vnodechildren定义为一个数组,数组的每一项也是一个vnode,这样就可以去正确的描述其结构。

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

如上代码所示其描述的子节点为一个

hello

,在数组中也可继续去添加别的不同类型的vnode,这样便形成了一种树形结构虚拟DOM树,可以更好的去描述真实DOM的情况。

调整mountElement去正确的挂载修改后的vnode

1
2
3
4
5
6
7
8
9
10
11
12
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)
    })
  }
   
  insert(el, container)
}

我们给vnode.children的类型做了一个判断,当其为数组类型时表示子节点有多个,通过遍历调用patch来进行挂载

2.如何处理被挂载元素的属性:

####如何修改vnode去描述元素属性:
vnode添加props字段,其类型是一个对象,对象的键是属性名,值是属性值,

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

调整mountElement去正确的挂载修改后的vnode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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) {
      el.setAttribute(key, vnode.props[key])
    }
  }
 
  insert(el, container)
}

增加一个对props的判断,对其进行遍历,获取到props对象的键和值,并使用setAttribute函数将属性应用到el上。 除此之外还可以直接在DOM对象上直接进行元素属性的设置:

1
2
3
4
5
6
if (vnode.props) {
    for (const key in vnode.props) {
        // 直接设置
        el[key] = vnode.props[key]
    }
}

以上两种设置方法都有一定的局限性,所以我们需要在不同情况下灵活进行使用,接下来我们将讨论其区别,从而明确其使用时机。

二、HTML Attributes 与 DOM Properties 的区别

假设有一个如下元素:

1
 

对于此元素而言:

  • HTML Attributes是:id="my-input"type="text"value="foo"
  • DOM Properties是:浏览器解析元素的HTML后生成的一个DOM对象,假设以上元素对应的DOM对象为el,则对应DOM Properties分别是el.idel.type,el.value.

区别

二者名称不一定相同,比如

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

对于上面的元素:class="foo" 对应的 DOM Properties 是 el.className
二者也不是一一对应的, 有些HTML Attributes没有对应的DOM Properties反之亦然 关键的一点在于:
HTML Attributes 的作用是设置与之对应的 DOM Pr operties 的初始值 对于input标签的value属性而言,如果没有修改input值得情况下,el.value读取得到值是foo,但是当文本框被输入之后,此时再使用el.value去获取值时得到得值就是新输入得值,但是使用el.getAttribute('value')得到得值仍是foo,即HTML Attributes存储的是元素的初始值

三、完善元素属性的设置

当元素在正常的HTML文件中时,浏览器会自动分析 HTML Attributes 并设置对应的 DOM Properties,但是在Vue中,模板有时并不会被解析并设置对应关系。

1.对于属性值为布尔类型节点的处理

有如下元素:

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

在HTML中其会被解析的结果是button有一个disabled的HTML Attributes,对应的DOM Properties(el.disabled)的值设为true,按钮为禁止状态。 在Vue中该HTML对应如下vnode节点:

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

在渲染器中调用setAttribute设置disabled HTML Attributes时会起作用,按钮会被禁用

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

但在vue的模板中会存在属性是变量的情况,如下

1
<button>Button</button>

此时渲染器渲染时使用的vnode是

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

此时调用setAttribute设置disabled

1
el.setAttribute('disabled', false)

由于通过setAttribute设置的属性会字符串化即变成如下情况

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

由于el.disable为布尔类型的值,当设置为'false'时,其实就是true,即禁用按钮,这显然不符合期望。 我们可以通过DOM Properties设置即el.disabled = false。 通过DOM Properties设置可以解决当前的问题,但是如果属性值对于一开始的情况

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

又会存在问题,对于vnode

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

使用DOM Properties设置

1
el.disabled = ''

由于el.disable为布尔类型的值,当设置为''时,其实就是false,即不禁用按钮,这也不符合期望。

很显然我们在对元素属性进行设置时需要对特殊的情况进行处理,而不是单一的使用setAttribute设置HTML Attributes或者设置DOM Properties,从而正确设置属性: 具体的解决方法是: 优先设置元素的 DOM Properties,但当值为空字符串时,要手动将值矫正为 true 因此对mountElement函数做优化

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
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) {
     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)
}

在设置vnode的props时,首先确认是否存在DOM Properties,存在则优先使用,而当遇到属性值为空字符串时,将值变为true,若DOM Properties不存在使用setAttribute设置。

2.只读DOM Properties处理

有一些元素的DOM 是只读的,比如

1
 

inputel.form属性是只读的,此时我们只能使用setAttribute去设置它,需要对mountElement再次完善,增加一个shouldSetAsProps函数用于判断属性是否可以使用DOM Properties来设置否则使用setAttribute

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
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)
  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) {
      const value = vnode.props[key]
      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, vnode.props[key])
      }
    }
  }
 
  insert(el, container)
}

实际上类似form属性的情况很多,在类似的情况下也需要使用和处理form属性相似的逻辑进行优化

3.将渲染器处理为与平台无关

同样的为了不把渲染器限定在浏览器平台,需要将设置属性的逻辑也作为配置项处理

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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(el, key, preValue, 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)
    }
  }
})
 
...
 
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(el, key, null, vnode.props[key])
      }
    }
 
    insert(el, container)
  }
 
  function patch(n1, n2, container) {
    if (!n1) {
      mountElement(n2, container)
    } else {
      //
    }
  }

我们将patchProps函数作为配置项传入,并在mountElement中处理vnode.props时使用,这样就可以将逻辑抽离出去。

四、处理class

Vue中对class做了处理,有多种方式可以设置class

1.字符串

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

2.对象

1
<p></p>

3.数组:可以组合以上两种类型

1
<p></p>

class为字符串时,直接使用el.className进行设置即可,但是其余两种情况需要处理,在Vue中其使用normalizeClass去处理,主要的逻辑就是遍历数组和对象,然后使用+=逐步将数组中的class项和对象中值为true的项的键累加,变为字符串并返回。

1
2
3
4
5
6
function normalizeClass(value) {
  let res = ''
  if (isString(value)) {
    res = value
  } else if (isArray(value)) {
    for (let i = 0; i

五、节点的卸载:

在之前实现的渲染器中,卸载是直接使用innerHTML将容器的内容清空,这可以达到效果,但是却不太完善,因为在实际情况下:

  • 如果容器的内容由组件渲染的,则当其被卸载时需要触发组件的beforeUnmount等钩子函数。
  • 如果元素存在自定义指令,自定义指令中同时存在卸载时需要触发的钩子函数。
  • 直接使用innerHTML将容器的内容清空,元素上的事件不会被清空
    为了解决以上问题,我们使用如下方式去卸载节点

根据 vnode 对象获取与其相关联的真实 DOM 元素,然后使用原生 DOM 操作方法将该DOM 元素移除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function mountElement(vnode, container) {
  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)
}

调整mountElement,在创建真实DOM元素的时候,将创建的元素赋值给vnode.el,这样就能通过vnode.el取得并操作真实DOM。当需要卸载时首先使用vnode.el.parentNode拿到vnode对应的真实DOM,然后再使用removeChild移除(vnode.el):

1
2
3
4
5
6
7
8
9
10
11
12
13
function render(vnode, container) {
  if (vnode) {
    patch(container._vnode, vnode, container)
  } else {
    if (container._vnode) {
      const parent = vnode.el.parentNode
      if (parent) {
        parent.removeChild(vnode.el)
      }
    }
  }
  container._vnode = vnode
}

为方便复用以及后续对组件的生命周期钩子和自定义指令钩子的调用,我们将卸载的逻辑封装在unmount函数中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
   function unmount(vnode) {
      const parent = vnode.el.parentNode
      if (parent) {
        parent.removeChild(vnode.el)
      }
    }
 
  function render(vnode, container) {
  if (vnode) {
    patch(container._vnode, vnode, container)
  } else {
    if (container._vnode) {
      unmount(container._vnode)
    }
     
  }
  container._vnode = vnode
}

六、对于patch函数的优化

1.新旧节点不一样时是否一定要使用patch打补丁呢?

在之前实现的渲染器中,我们使用patch对于节点处理逻辑如下:

1
2
3
4
5
6
7
function patch(n1, n2, container) {
    if (!n1) {
        mountElement(n2, container)
    } else {
        // 更新
    }
}

如果新旧节点均存在则意味着需要打补丁去更新其中的内容。但是考虑一种情况,当新旧节点的类型不同时,打补丁是没有意义的,因为类型的变化会导致节点属性的不同,比如vnode的类型(type)从'p'变为'input',在这种情况下我们应该做的是卸载旧的vnode,然后挂载新的vnode。

1
2
3
4
5
6
7
8
9
10
11
12
function patch(n1, n2, container) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1)
    n1 = null
  }
 
  if (!n1) {
    mountElement(n2, container)
  } else {
    patchElement(n1, n2)
  }
}

通过如上处理在patch中我们先去若旧节点存在并且新旧节点类型不同则调用unmount卸载旧节点,并将其值置为null,以便后续去判断是要执行挂载还是打补丁操作。若新旧节点类型相同则则使用patch去通过打补丁的方式更新。

2.vnode如果描述的是一个组件的话如何去处理挂载和打补丁呢?

在节点是一个组件的情况下,vnode的type会是一个对象,我们通过判断vnode的type是否为对象从而执行特定的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 (typeof type === 'object') {
    // 组件
  }
}

七、如何给节点挂载事件:

1.在vnode节点中如何描述事件:

在vnode的props对象中,凡是以on开头的属性都被定义为事件:

1
2
3
4
5
6
7
8
9
const vnode = {
  type: 'p',
  props: {
    onClick: () => {
        alert('clicked 1')
      }
  },
  children: 'text'
}

如上所示我们给一个类型为’p’的vnode描述了一个onCLick事件

2.如何将描述有事件的vnode节点挂载

我们先前使用patchProps去挂载vnode的props,为了能够支持事件的挂载需要对其进行一定的修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
patchProps(el, key, preValue, nextValue) {
  if (/^on/.test(key)) {
    const name = key.slice(2).toLowerCase()
    // 移除上一次绑定的事件处理函数prevValue
    prevValue && el.removeEventListener(name, prevValue)
    // 绑定新的事件处理函数
    el.addEventListener(name, nextValue)
  } else if (key === 'class') {
    el.className = nextValue || ''
  } else if (shouldSetAsProps(el, key, nextValue)) {
    const type = typeof el[key]
    if (type === 'boolean' && nextValue === '') {
      el[key] = true
    } else {
      el[key] = nextValue
    }
  } else {
    el.setAttribute(key, nextValue)
  }
}

如上使用正则去匹配on开头的key,首先判断是否已经挂载了一个同名的事件处理函数,有的话就先移除,然后再使用addEventListener挂载新的事件处理函数。

3.事件处理函数频繁更新时如何优化性能?

优化思路

我们可以将事件处理函数固定并命名为invoker,并将实际的事件处理函数赋值给invoker.value。这样在挂载的时候我们挂载的是invoker,并invoker内部执行真正的事件处理函数invoker.value,这样当需要更新事件处理函数时我们直接替换invoker.value的值即可,而不用使用removeEventListener去移除。为了能够在事件处理函数更新时判断有没有设置invoker我们将invoker缓存在el._vei上.

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
patchProps(el, key, prevValue, nextValue) {
  if (/^on/.test(key)) {
    let invoker = el._vei
    const name = key.slice(2).toLowerCase()
    if (nextValue) {
      if (!invoker) {
        invoker = el._vei = (e) => {
            invoker.value(e)
        }
        invoker.value = nextValue
        el.addEventListener(name, invoker)
      } else {
        invoker.value = nextValue
      }
    } else if (invoker) {
      el.removeEventListener(name, invoker)
    }
  } else if (key === 'class') {
    el.className = nextValue || ''
  } else if (shouldSetAsProps(el, key, nextValue)) {
    const type = typeof el[key]
    if (type === 'boolean' && nextValue === '') {
      el[key] = true
    } else {
      el[key] = nextValue
    }
  } else {
    el.setAttribute(key, nextValue)
  }
}

一个vnode上同时存在多个事件应该如何处理 在之前的实现中我们直接将el._vei赋值给invoker,这样无法去处理vnode上的多个事件,如果像下面这样定义了多个事件,会导致后面的事件覆盖之前的事件

1
2
3
4
5
6
7
8
9
10
11
12
const newVnode = {
  type: 'p',
  props: {
    onClick: () => {
        alert('click')
    },
    onContextmenu: () => {
      alert('contextmenu')
    }
  },
  children: 'text'
}

解决方式是:将patchProps中的 el._vei定义为一个对象,将事件名称作为其键,值则是该事件对应的事件处理函数

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
patchProps(el, key, prevValue, nextValue) {
  if (/^on/.test(key)) {
    const invokers = el._vei || (el._vei = {})
    //根据事件名称获取 invoker
    let invoker = invokers[key]
    const name = key.slice(2).toLowerCase()
    if (nextValue) {
      if (!invoker) {
      // 将事件处理函数缓存到 el._vei[key] 下,避免覆盖
        invoker = el._vei[key] = (e) => {
            invoker.value(e)
        }
        invoker.value = nextValue
        el.addEventListener(name, invoker)
      } else {
        invoker.value = nextValue
      }
    } else if (invoker) {
      el.removeEventListener(name, invoker)
    }
  } else if (key === 'class') {
    el.className = nextValue || ''
  } else if (shouldSetAsProps(el, key, nextValue)) {
    const type = typeof el[key]
    if (type === 'boolean' && nextValue === '') {
      el[key] = true
    } else {
      el[key] = nextValue
    }
  } else {
    el.setAttribute(key, nextValue)
  }
}

一个事件需要多个事件处理函数执行应该如何处理 当同一个事件存在多个事件处理函数,比如同时存在两个click的事件处理函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const vnode = {
  type: 'p',
  props: {
    onClick: [
      () => {
        alert('clicked 1')
      },
      () => {
        alert('clicked 2')
      }
    ]
  },
  children: 'text'
}

此时我们需要对 el._vei[key]增加一层判断,时数组的情况下,需要遍历去调用其中的事件处理函数

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
36
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) => {
        //如果是数组,遍历调用事件处理函数
          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') {
    el.className = nextValue || ''
  } else if (shouldSetAsProps(el, key, nextValue)) {
    const type = typeof el[key]
    if (type === 'boolean' && nextValue === '') {
      el[key] = true
    } else {
      el[key] = nextValue
    }
  } else {
    el.setAttribute(key, nextValue)
  }
}

八、事件冒泡处理

当vnode的父子节点的事件之间有关联时,会因为事件冒泡出现一定问题,如下情况

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
const { effect, ref } = VueReactivity
 
const bol = ref(false)
 
effect(() => {
  const vnode = {
    type: 'div',
    props: bol.value ? {
      onClick: () => {
        alert('父元素 clicked')
      }
    } : {},
    children: [
      {
        type: 'p',
        props: {
          onClick: () => {
            bol.value = true
          }
        },
        children: 'text'
      }
    ]
  }
  renderer.render(vnode, document.querySelector('#app'))
})

看一下以上代码:

  • 定义了一个响应式数据bol,初始值为false
  • 在副作用函数effect中使用了bol,并且调用了渲染器将vnode渲染到了id为app的节点上
  • vnode中父节点的事件onClick的存在与否取决于bol的值,若为true则父元素的onClick事件才会挂载。 首次渲染时由于bolfalse,所以vnode中的父节点并不会被绑定一个onClick事件

当点击了渲染处理的p元素,即vnode的子节点时,会出现父元素的click事件也会被选择的情况,其过程如下:

  • 点击了p元素,bol被修改,副作用函数重新执行
  • 父元素div的props中onClick事件挂载
  • 对p的点击事件冒泡到了父元素div上,导致触发了其上的onClick事件

其流程如下:

为了解决这个问题: 对patchProps进行处理:屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行

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
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) => {
           if (e.timeStamp  fn(e))
           } else {
             invoker.value(e)
           }
         }
         invoker.value = nextValue
         invoker.attached = performance.now()
         el.addEventListener(name, invoker)
       } else {
         invoker.value = nextValue
       }
     } else if (invoker) {
       el.removeEventListener(name, invoker)
     }
   } else if (key === 'class') {
     el.className = nextValue || ''
   } else if (shouldSetAsProps(el, key, nextValue)) {
     const type = typeof el[key]
     if (type === 'boolean' && nextValue === '') {
       el[key] = true
     } else {
       el[key] = nextValue
     }
   } else {
     el.setAttribute(key, nextValue)
   }
 }

修改后的代码如上: 我们在invoker上添加一个属性attached用于记录事件处理函数被挂载的时间,在事件处理函数invoke.value被执行前进行判断,如果事件处理函数被绑定的时间invoke.attached晚于事件触发的事件e.timeStamp时,则取消副作用函数的执行。

九、子节点的更新

在处理完了节点的事件挂载之后,我们需要处理子节点的更新 在文章开始我们讨论了子节点vnode.children的类型主要有以下三种:nullstring(文本节点)、Array(一个或者多个节点) 通过分析可知: 在子节点的更新过程中,新旧节点都有三种类型,这样总共会有九种情况,但是并不是每一种情况都要特殊处理,只需要考虑如下情况:

1.当新节点的类型是一个文本节点的情况下

  • 旧子节点为null或者文本节点时,直接将新节点的文本内容更新上去即可;
  • 旧子节点是一组节点时,需要遍历这一组节点并使用unmount函数卸载;

2.当新节点的类型是一组节点的情况下

  • 旧子节点为null或者文本节点时,直接将旧节点内容清空并逐一挂载新节点即可;
  • 旧子节点是一组节点时,需要遍历旧节点并使用unmount函数逐一卸载,并逐一挂载新的节点;(在实际处理过程中性能不佳,所以Vue使用了diff算法去处理这种情况下的更新

3.当新子节点不存在:

  • 旧子节点也不存在,则无需处理;
  • 旧子节点是一组子节点,则需要逐个卸载;
  • 旧子节点是文本子节点,则清空文本内容;

根据以上三种情况,我们将patchChildren函数进行更新

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
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))
    }
    //新子节点不存在
  } else {
    if (Array.isArray(n1.children)) {
      n1.children.forEach(c => unmount(c))
    } else if (typeof n1.children === 'string') {
      setElementText(container, '')
    }
  }
}

十、如何描述没有标签的节点:文本和注释节点

在先前的实现中vnode的节点类型type是一个字符串,根据其类型我们可以判断标签名称,但是没有标签名称的节点需要如何处理呢,比如下面的节点?

1
2
3
4
<div>
     
    我是文本节点
</div>

1.如何使用vnode描述

为了表示没有标签名称的节点,我们需要使用Symbol数据类型去作为vnode.type的值,这样就可以确保其唯一性

这样我们用于描述文本节点的vnode如下:

1
2
3
4
5
const Text = Symbol()
const newVnode = {
  type: Text,
  children: '文本节点内容'
}

用于描述注释节点的vnode如下:

1
2
3
4
5
const Comment = Symbol()
const newVnode = {
  type: Comment,
  children: '注释节点内容'
}

2.如何渲染

假设我们需要调整patch函数去适应如上vnode文本节点的渲染:

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
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) {
      // 使用 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
      }
    }
  }

增加了一个对type类型的判断,如果类型是Text证明是文本节点,则判断旧节点上是否存在,如果旧节点存在只需要更新文本内容即可,否则需要先创建文本节点,再将其插入到容器中。

3.优化渲染器的通用性

以上实现的代码中仍旧依赖了浏览器的API:createTextNodeel.nodeValue,为了保证渲染器的通用性,需要将这部分功能提取成为独立的函数,并且作为用于创建渲染器的函数createRenderer的参数传入:

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
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) {
    // 使用 createTextNode 创建文本节点
    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)
      }
    }
  }

我们将依赖到浏览器API的createTextNodeel.nodeValue分别放到了createTextsetText两个函数内,并在创建渲染器的函数createRenderer中作为参数传入并使用:

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
function createRenderer(options) {
 
  const {
    createElement,
    insert,
    setElementText,
    patchProps,
    createText,
    setText
  } = options
   
  省略内容
}
 
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) {
    ...
  }
})

这样对于文本节点的操作,不再仅依赖于浏览器的API我们可以通过改变createRendereroptions参数对象里面的createTextsetText方法灵活选择。

以上就是Vue渲染器如何对节点进行挂载和更新的详细内容,更多关于Vue节点挂载和更新的资料请关注IT俱乐部其它相关文章!

本文收集自网络,不代表IT俱乐部立场,转载请注明出处。https://www.2it.club/navsub/js/11050.html
上一篇
下一篇
联系我们

联系我们

在线咨询: QQ交谈

邮箱: 1120393934@qq.com

工作时间:周一至周五,9:00-17:30,节假日休息

关注微信
微信扫一扫关注我们

微信扫一扫关注我们

返回顶部