06-挂载与更新

151 阅读8分钟

本章节学习渲染器的核心功能:挂载与更新

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

一个元素除了具有文本子节点,还可以包含其他元素子节点,且子节点可以有多个。举个栗子,vnode的子节点写成个数组

const vnode = {
  type: 'div',
  children: [
    {
      type: 'p',
      children: 'hello'
    }
  ]
}

上面代码表示虚拟节点div下面有个子节点p,很直观的形成了虚拟DOM树。

完成树型结构的子节点渲染,需要调整之前的mountElement方法,添加判断分支,判断子节点children是否是数组,如果是数组,则遍历并调用patch挂载子节点。

注意:

1.传递给patch函数的第一个参数是null,挂载阶段不需要旧的vnode

2.传递给patch函数的第三个个参数是挂载点,确保子节点的挂载位置正确

代码如下:

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中添加props字段,props是对象,它的key来表示元素名称,它的值表示属性的值,遍历props对象,把属性渲染到元素上。修改之前代码如下:

const vnode = {
  type: 'div',
  props: {
    name: 'fred'
  },
  children: [
    {
      type: 'p',
      children: 'hello'
    }
  ]
}
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)
  }

2、HTML Attributes与DOM Properties

HTML Attributes与DOM Properties的差异和关联

HTML Attributes就是定义在HTML标签上的属性,html代码在浏览器解析之后会生成对应的js对象,举个栗子:

<input id="example-input" type="text" value="666" />

<script>

  const el = document.querySelector('#example-input')
  console.log(el.value)
</script>

html标签的属性可以通过js对象进行访问,但名字不是完全一一对应,而且js对象也并不是能访问所有属性,例如aria-valuenow标签属性,反过来也一样,js对象的textContent,html标签中也没有。

修改文本框的值输出el.value会是修改后的值,但是调用getAttribute方法拿到的仍然是旧值。

通过这个效果可以知道HTML Attributes 是与DOM Properties(也就是js对象属性)的初始值对应,标签的改变并没有影响到,DOM Properties,DOM Properties里面的存放的值始终是初始值,通过getAttribute获取仍然是初始值。

上面的例子还可以说明一个HTML Attribute可能关联多个DOM Properties。

3、正确的设置元素属性

上面提到了HTML Attributes 与 DOM Properties的初始值对应,那么之前的渲染器对于布尔值类型的处理会产生问题,会影响例如按钮禁用时出现的问题,需要优先设置DOM Properties,但当字符串为空值时,需要手动矫正,避免出现一直为false或者true的问题。代码如下:

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

4、class的处理

Vue.js对class的处理进行了增强,有以下三种方式设置:

1.指定class为一个字符串

<div class="demo">

</div>

 const vnode = {
    type: 'div',
    props: {
      class: 'demo'
    }
  }

2.指定class为一个对象

<div :class="cls">

 </div>

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

const vnode = {
    type: 'div',
    props: {
      class: 'demo1'
    }
}

3.class包含以上两种类型数组

<div :class="arr">

</div>

const arr = [
    'foo',
    {
      bar: true
    }
  ]

  const vnode = {
    type: 'div',
    props: {
      class: ['foo',
        {
          bar: true
        }
      ]
    }
  }

性能更好的class设置方式

在浏览器中为一个元素设置class有三种方法,使用setAttribute、el.className、el.classList

,其中1000次的性能对比如下图所示

其中性能最好的是className方式,所以需要调整patchProps函数,对class进行特殊处理。

代码如下:

patchProps(el, key, preValue, nextValue) {
    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)
    }
  }

5、卸载操作

卸载操作发生在更新阶段,更新:指的是首次渲染之后再次进行渲染,后续渲染会触发更新

当前的渲染器的卸载方法存在缺陷:

1、容器内容可能是多个组件渲染,卸载时,应该正确执行生命周期函数

2、即使内容不是由组件渲染的,元素存在自定义指令,应该在卸载操作发生时正确执行对应的钩子函数

3、使用innerHTML清空容器元素内容,不会移除绑定在DOM元素上的事件处理函数

所以,不能简单地使用innerHTML来完成卸载操作。

正确的卸载方式:根据vnode对象获取与其相关联的真实DOM元素,然后使用原生DOM操作方法将该DOM元素移除。代码如下

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)
  }
  //根据虚拟节点对象vnode.el取得真实DOM元素,再从父元素中移除
  function unmount(vnode) {
    const parent = vnode.el.parentNode
    if (parent) {
      parent.removeChild(vnode.el)
    }
  }
  

6、区分vnode的类型

之前所说的打补丁、卸载都没有提过一个重要的问题,那就是区分当前要操作哪个元素,如果挂载了两个元素,那么需要区分是对哪个更新,哪个卸载。所以,还需要调整patch函数

function patch(n1, n2, container) {
    // 如果n1存在对比,n1 n2类型,如果新旧类型不同,直接卸载旧vnode
    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') {
      // 组件
    }
  }

7、事件的处理

虚拟节点中描述事件

虚拟节点中所有on描述的属性都视为事件,如onClick

添加事件到DOM元素上

在patchProps上调用addEventListener函数绑定事件

更新事件

先移除之前的事件,再添加,总体代码如下

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

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

当前的事件处理和事件触发存在着时间差的问题,具体问题如下:事件触发的时间早于事件处理函数绑定的时间,在虚拟节点复杂的情况下,可能会有标签响应事件失效

解决方案:屏蔽所有绑定时间晚于事件触发处理函数的执行,调整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) => {
            console.log(e.timeStamp)
            console.log(invoker.attached)
            //触发时间如果早于绑定时间,则不执行处理函数
            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 = 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)
    }
  }

9、更新子节点

vnode子节点规范:没有子节点、文本子节点、一组子节点。

更新子节点的操作:首先,检测新子节点的类型是否是文本节点,如果是,则要检查旧子节点的类型,旧子节点也是三种情况(看上面)。如果没有旧子节点或者子节点是文本子节点,那么只需要把新的文本内容设置给容器元素即可,如果就子节点是一组子节点需要遍历逐个调用unmount方法卸载。代码如下:

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, '')
      }
    }
  }

10、Fragment

Fragment(片段)是Vue.js 3中新增的vnode类型。

应用场景:可以解决Vue.js 2只能有单一根节点缺陷

vnode中描述方法:创建唯一标识Fragment,children中设置为数组

注意:Fragment本身并不渲染任何内容

代码如下:

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) {
        const el = n2.el = createText(n2.children)
        insert(el, container)
      } else {
        const el = n2.el = n1.el
        if (n2.children !== n1.children) {
          setText(el, n2.children)
        }
      }
    } else if (type === Fragment) {
      if (!n1) {
        n2.children.forEach(c => patch(null, c, container))
      } else {
        patchChildren(n1, n2, container)
      }
    }
  }

总结

1、学习了如何挂载子节点,递归调用patch,两个重要概念HTML Attributes 与 DOM Properties。

2、特殊属性的处理,class的处理,三种设置class的方式及其性能

3、卸载操作,卸载操作的三个注意事项,不能够通过简单的innerHTML清空容器

4、vnode类型区分,判断新旧vnode描述的内容是否相同,才去打补丁,如何判断是普通标签还是对象

5、事件的处理,vnode.props对象中以on开头的属性当做事件处理

6、处理事件与更新时机,利用触发和绑定的时间差,屏蔽所有绑定时间晚于触发的事件处理函数的执行

7、子节点的更新,子节点只能是三种类型:字符串类型、数组类型、null

8、Fragment及其用途,Fragment本身不会渲染任何DOM元素