【Vue】手把手带你深入了解Vue3.0的渲染器

前置概念

  • vdom:多个虚拟元素节点组合成的树状结构
  • vnode:某一个虚拟元素节点
  • 挂载:将虚拟DOM节点渲染成真实DOM节点的过程

一、渲染器的设计

首先我们要区分vue当中两个概念,一个是渲染器(renderer),一个是渲染(render)。

前者renderer,渲染器的作用是将我们的vdom转化成真实的DOM元素来呈现。
后者render,渲染函数的作用是将我们的vnode添加并且挂载到真实DOM容器下。

1.1 渲染器内部属性和方法

1、options:抽离依赖环境方法,使其变成自定义渲染器
2、render:将vnode节点渲染到真实的DOM元素当中
3、patch:挂载、更新vnode节点
4、mountElement:挂载vnode节点为真实的DOM元素
5、patchProps:挂载、更新props属性
6、patchElement:更新vnode节点
7、patchChildren:更新子节点

1.2 核心代码
/*
renderer{
  options
  render
  patch:挂载和更新vnode节点
  mountElement: 渲染为真实dom元素
}

vnode{
  type
  props
  children:啥也没有 | 文本节点 | 子节点  
  el:真实DOM
}
*/
const options = {
  createElement(tag){
    return document.createElement(tag)
  },
  setElementText(el,text){
    el.textContent = text
  },
  insert(el,parent,anchor){
    parent.insertBefore(el,anchor)
  },
  createText(text){
    return document.createTextNode(text)
  },
  setText(el,text){
    el.nodeValue = text
  },
  patchProps(el,key,preValue,nextValue){
    function shouldSetAsProps(el,key,value){
      // 处理特殊情况
      if(key === 'form' && el.tagName === 'INPUT') return false
      return key in el
    }

    // 当前属性是否存在于DOM properties当中,如果存在那么我们可以进行操作,如果不存在,而只是在HTMl Attribute当中存在就用setAttribute
    if(/^on/.test(key)){
      // 事件名称
      const name = key.slice(2).toLowerCase()
      // 利用伪事件函数处理,减少一次removeEventListener
      const invokers = el._vei || (el._vei = {})
      let invoker = invokers[key] 
      if(nextValue){
        // 不存在的情况下,就需要初始化一个
        if(!invoker){
          invoker = el._vei[key] = (e)=>{
            // 正确处理事件执行顺序,当执行实际早于绑定时机就不进行执行
            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()
          // 真实dom'监听事件
          el.addEventListener(name,invoker)
        }
        // 存在的情况下,就需要更新它的值即可
        else{
          invoker.value = nextValue
        }
      }
    }
    else if(key === 'class'){
      el.className = nextValue || ''
    }
    else if(shouldSetAsProps(el,key,nextValue)){
      let type = typeof el[key]
      // 获取到DOM property属性对应的类型,去对它的值进行矫正
      if(type === 'boolean' && nextValue === ''){
        el[key] = true
      }else{
        el[key] = nextValue
      }
    }else{
      el.setAttribute(key,nextValue)
    }
  },
  unmount(vnode){
    if(vnode.type === Fragment){
      vnode.children.forEach(c => unmount(c))
      return 
    }

    let parent = vnode.el.parentNode
    if(parent){
      parent.removeChild(vnode.el)
    }
  }
}

function createRenderer(options){
  const { createElement,setElementText,insert } = options

  function mountElement(vnode,container){
    // 创建真实的dom元素
    let 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){
        let value = vnode.props[key]
        patchProps(el,key,null,value)
      }
    }
    // 添加到容器当中
    insert(el,container)
  }

  function patchChildren(n1,n2,container){
    // 子节点更新有九种情况:
    // 1、新子节点为文本节点:1、旧子节点不存在 2、旧子节点为文本节点 3、旧子节点为一组子节点(diff算法)
    // 2、新子节点为一组子节点:1、旧子节点不存在 2、旧子节点为文本节点 3、旧子节点为一组子节点(diff算法)
    // 3、新子节点不存在:1、旧子节点不存在 2、旧子节点为文本节点 3、旧子节点为一组子节点(diff算法)
    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)){
      // Diff算法
      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,'')
      }
    }
  }

  function patchElement(n1,n2){
    // 同步真实的DOM
    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)
      }
    }
    // 更新子节点
    patchChildren(n1,n2,el)
  }

  

  function patch(oldVnode,newVnode,container){
    // patch分为三种情况:1、旧节点不存在 2、旧节点存在但类型不同 3、旧节点存在类型相同,但是旧节点存在,但类型不同,直接卸载掉旧节点的内容,挂载新的,其实就是和情况一相同,所以走到下方只用考虑两种情况,旧存在和不存在
    if(oldVnode && oldVnode.type !== newVnode.type){
      unmount(oldVnode)
      oldVnode = null
    }

    const { type } = newVnode
    // vnode元素是一个dom元素
    if(typeof type === 'string'){
      // 旧节点不存在就是挂载
      if(!oldVnode){
        mountElement(newVnode,container)
      }
      // 旧节点存在就是打补丁更新
      else{
        patchElement(oldVnode,newVnode)
      }
    }
    // vnode元素是一个文本节点
    else if(type === Text){
      const el = n2.el = n1.el
      // 挂载
      if(!n1){
        const el = n2.el = createTextNode(n2.children)
        insert(el,container)
      }else{
        const el = n2.el = n1.el
        if(n2.children !== n1.children){
          setText(el,n2.children)
        }
      }
    }
    // vnode元素是一个Fragment(文档碎片)
    else if(type === Fragment){
      if(!n1){
        n2.children.forEach((c)=>patch(null,c,container))
      }else{
        patchChildren(n1,n2,container)
      }
    }
  }
  function render(vnode,container){
    // 当前有新的vnode节点
    if(vnode){
      patch(container._vnode,vnode,container)
    }
    // 当前没有新的vnode节点,就将旧的内容进行清除
    else{
      unmount(container._vnode)
    }
    // 将vnode存储在container下,作为oldVnode去比较
    container._vnode = vnode
  }

  return {
    render
  }
}
1.3 整体执行流程图

二、Diff算法

2.1 简单Diff算法

Diff算法的引出:
对于oldVnodenewVnode不同的children进行更新操作,最容易想到的就是将旧的子节点全部卸载,然后重新挂载最新的子节点。但是对于这个操作会有很大的性能开销,因为直接操作Dom了。

优化和改进:
对于更新子节点情况,有时候可能只是单纯顺序不一样,因此其实可以通过去判断vnodetype去判断,但是这里也有一个缺陷,就是我们的children值可能是不同的,因此引入key去确定是否可以复用当前的这个DOM节点,然后再去判断移动位置、卸载和挂载节点。

执行流程:

  • 拿新的一组子节点中的节点去旧的一组子节点中去查找。如果找到,更新节点,记录下最大索引值,同时标记当前节点已存在,如果索引呈现递增的形式就是不需要移动,否则就要移动
  • 当前节点不存在的时候,需要找到合适的位置进行挂载节点,如果是中间的节点,那么找之前的节点的兄弟节点,这个兄弟节点的之前位置就是正确的,若是首个节点,那么挂载在当前容器的第一个节点之前
  • 当新的一组子节点遍历完成,遍历旧的一组子节点,检查是否容器有多余的旧节点需要卸载

核心代码:

function patchKeyedChildren(n1,n2,container) {
  const oldChildren = n1.children
  const newChildren = n2.children

  // 最大索引值
  let lastIndex = 0
  for(let i=0;i<newChildren.length;i++) {
    let newVnode = newChildren[i]
    for(let j=0;j<oldChildren.length;j++) {
      // 标记当前节点是否可以找到
      let find = false
      let oldVnode = oldChildren[j]
      // 如果新节点在旧节点中可以找到就进行更新
      if(oldVnode.key === newVnode.key) {
        find = true
        // 更新当前节点
        patch(oldVnode,newVnode,container)
        // 看当前节点是否移动
        if(j < lastIndex){
          let preVnode = newChildren[i-1]
          if(preVnode) {
            const anchor = preVnode.el.nextSibling
            insert(newVnode.el,container.anchor)
          }
        }else{
          lastIndex = j
        }
        break
      }
      // 当前节点无法找到就进行新增
      if(!find){
        const preVnode = newChildren[i-1]
        let anchor = preVnode ? preVnode.el.nextSibling : container.firstChild
        pacth(null,newVnode,container,anchor)
      }
    }
  }

  // 遍历旧的节点删除不要的节点
  for(let i=0;i<oldChildren.length;i++){
    const oldVnode = oldChildren[i]
    const has = newChildren.find(vnode => vnode.key === oldVnode.key)
    if(!has){
      unmount(oldVnode)
    }
  }
}
2.2 双端Diff算法

优势:
执行的DOM移动操作次数更少

执行过程:

  • 新前节点与旧前节点的key进行比较
  • 新后节点与旧后节点的key进行比较
  • 新后节点与旧前节点的key进行比较
  • 新前节点与旧后节点的key进行比较
  • 在旧节点当中查找当前新前节点是否存在,如果存在就调用patch方法更新,将旧节点当中真实的DOM节点移动到旧节点当中第一个节点的;若不存在,就调用patch方法挂载,并挂载到头部
    【Vue】手把手带你深入了解Vue3.0的渲染器_第1张图片

添加、删除真实DOM的极端case
1、添加情况下的极端case
【Vue】手把手带你深入了解Vue3.0的渲染器_第2张图片
2、删除情况下的极端case
【Vue】手把手带你深入了解Vue3.0的渲染器_第3张图片

按照上面双端Diff算法的执行过程,那么这两种情况下,均会跳出执行的循环,因此我们需要在循环外部再做一些操作

核心代码:

function patchKeyedChildren(n1,n2,container){
  let oldChildren = n1.children
  let newChildren = n2.children
  // 四个索引值
  let oldStartIdx = 0
  let oldEndIdx = oldChildren.length - 1
  let newStartIdx = 0
  let newEndIdx =newChildren.length - 1
  // 四个节点
  let oldStartVnode = oldChildren[oldStartIdx]
  let oldEndVnode = oldChildren[oldEndIdx]
  let newStartVnode = newChildren[newStartIdx]
  let newEndVnode = newChildren[newEndIdx]
  // diff执行
  while(newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx){
    // 排除掉结点为undefined的情况,处理过了跳过就行
    if(!oldStartVnode) {
      oldStartVnode = oldChildren[++oldStartIdx]
    }else if(!oldEndVnode) {
      oldEndVnode = oldChildren[--oldEndIdx]
    }
    // 双端Diff的核心处理四步
    else if(newStartVnode.key === oldStartVnode.key){
      patch(oldStartVnode,newStartVnode,container)
      // 更新索引值和节点
      newStartVnode = newChildren[++newStartIdx]
      oldStartVnode = oldChildren[++oldStartIdx]
    }else if(newEndVnode.key === oldEndVnode.key){
      patch(oldEndVnode,newEndVnode,container)
      // 更新索引值和节点
      newEndVnode = newChildren[--newEndIdx]
      oldEndVnode = oldChildren[--oldEndIdx]
    }else if(newEndVnode.key === oldStartVnode.key){
      patch(oldStartVnode,newEndVnode,container)
      // 移动真实的DOM
      insert(oldStartVnode.el,container,oldEndVnode.el.nextSibling)
      // 更新索引值和节点
      newEndVnode = newChildren[--newEndIdx]
      oldStartVnode = oldChildren[++oldStartIdx]
    }else if(newStartVnode.key === oldEndVnode.key){
      patch(oldEndVnode,newStartVnode,container)
      // 移动真实的DOM
      insert(oldEndVnode.el,container,oldStartVnode.el)
      // 更新索引值和节点
      newStartVnode = newChildren[++newStartIdx]
      oldEndVnode = oldChildren[--oldEndIdx]
    }else{
      // 在旧子节点当中查找,新前节点是否存在
      const idxInOld = oldChildren.findIndex(node => node.key === newStartVnode.key)
      // 查找到,要将真实dom移动到头部
      if(idxInOld > 0){
        // 要移动的旧节点
        const vnodeToMove = oldChildren[idxInOld]
        // 更新节点
        patch(vnodeToMove,newStartVnode,container)
        // 将旧节点移动到头部
        insert(vnodeToMove.el,container,oldStartVnode.el)
        // 由于旧节点的双端指针没有改变,可能会扫描到,因此要将此处操作过的旧节点置为undefined
        oldChildren[idxInOld] = undefined
      }
      // 查找不到,直接在容器头部挂载
      else{
        // 挂载新前节点
        patch(null,newStartVnode,container,oldStartVnode.el)
      }
      // 更新索引
      newStartVnode = newChildren[++newStartIdx]
    }
  }

  // 处理添加、删除情况和极端case
  if(oldEndIdx > oldStartIdx && newStartIdx <= newEndIdx){
    // 如果旧节点没有了,新节点还有,就要挂载他们
    for(let i = newStartIdx;i <= newEndIdx;i++){
      patch(null,newChildren[i],container,oldStartVnode.el)
    }
  }else if(oldEndIdx >= oldStartIdx && newStartIdx > newEndIdx){
    // 如果新节点没有了,旧节点有,就要删除旧节点
    for(let i = oldStartIdx;i <= oldEndIdx;i++){
      unmount(oldChildren[i])
    }
  }
}
2.3 快速Diff算法

执行流程:

  • 处理两组子节点的前置节点和后置节点
  • 创建source数组即索引数组来获得最长递增子序列来判断剩余的节点哪些需要进行移动(优化:在进行对source数组进行填充的时候通过索引表降低复杂度来填充

核心代码:

function patchKeyedChildren(n1,n2,container) {
  const oldChildren = n1.children
  const newChildren = n2.children

  // 处理相同的前置节点
  let j = 0
  let oldVnode = oldChildren[j]
  let newVnode = newChildren[j]
  while(oldVnode.key === newVnode.key) {
    patch(oldVnode,newVnode,container)
    j++
    oldVnode = oldChildren[j]
    newVnode = newChildren[j]
  }
  // 处理相同的后置节点
  let newEnd = newChildren.length - 1
  let oldEnd = oldChildren.length - 1
  newVnode = newChildren[newEnd]
  oldVnode = oldChildren[oldEnd]
  while(newEndVnode.key === oldEndVnode.key){
    patch(oldVnode,newVnode,container)
    newVnode = newChildren[--newEnd]
    oldVnode = oldChildren[--oldEnd]
  }
  // 预处理完毕,有可能会有一边节点处理完毕,如果新子节点处理完毕就是进行删除,旧子节点处理完毕就进行新增
  // 新增
  if(j > oldEnd && j <= newEnd){
    const anchorIndex = newEnd + 1
    const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
    while(j <= newEnd){
      patch(null,newChildren[j++],container,anchor)
    }
  }
  // 删除节点
  else if(j <= oldEnd && j > newEnd) {
    while(j <= oldEnd) {
      unmount(oldChildren[j++])
    }
  }
  // 移动原话节点,处理非理想情况
  else {
    // 创建source数组
    const count = newEnd - j + 1
    const source = new Array(count).fill(-1)
    // 创建起始索引
    let newStart = j
    let oldStart = j
    let moved = false
    let pos = 0
    let patched = 0
    // 建立索引表
    let keyIndex = {}
    for(let i = newStart;i <= newEnd;i++){
      keyIndex[newChildren[i].key] = i
    }
    // 遍历剩余旧的子节点数组
    for(let i = oldStart;i <= oldEnd;i++){
      oldVnode = oldChildren[i]
      if(patched <= count){
        // 获取当前旧节点在新节点数组的索引位置
        const k = keyIndex[oldVnode.key]
        if(typeof k !== 'undefined'){
          patch(oldVnode,newChildren[k],container)
          patched ++
          source[k - newStart] = i
          // 确定是否移动原始节点,和简单Diff算法一样,比较当前最大索引,比它小就要移动,比它大就不用移动
          if(k < pos){
            moved = true
          }else{
            pos = k
          }
        }else{
          unmount(oldVnode)
        }
      }else{
        unmount(oldVnode)
      }
    }

    // 可能是整个剩余部分需要移动,可能某一个节点需要进行移动,具体移动看最长递增子序列
    if(moved) {
      // 计算最长递增子序列,得到的是source的符合要求的元素索引,会和去除前置节点的索引一致
      const seq = lis(sources)
      // 利用source数组获取最长递增子序列来看哪些节点需要进行移动,原理:由于索引如果呈递增的情况,那么说明节点顺序是正确不需要移动,且尽可能保证更多的节点不去移动
      let s = seq.length - 1
      let i = count - 1
      for(i;i >= 0;i--){
        // source[i] === -1说明当前节点不存在于旧节点数组当中
        if(source[i] === -1) {
          const pos = i + newStart
          const newVnode = newChildren[pos]
          const nextPos = pos + 1
          const anchor = nextPos < newChildren.length ? newChildren[pos].el : null
          patch(null,newVnode,container,anchor)
        }
        // 如果索引值不匹配,那么说明该节点需要移动
        else if(i !== seq[s]){
          const pos = i + newStart
          const newVnode = newChildren[pos]
          const nextPos = pos + 1
          const anchor = nextPos < newChildren.length ? newChildren[pos].el : null
          insert(newVnode.el,container,anchor)
        }
        // 相等的情况下,不进行移动
        else{
          s--
        }
      }
    }
  }
}
2.4 总结

Diff算法根本的核心:

  • 判断是否有节点需要移动,以及如何移动
  • 找到哪些需要被添加或者移除的节点

三个Diff算法的速度性能:
简单Diff < 双端Diff < 快速Diff

主要的根本原因是这三个Diff算法层层减少了移动DOM的操作,从而大大提高了性能

你可能感兴趣的:(Vue框架,vue.js,javascript,前端)