07-简单Diff算法

53 阅读5分钟

当新旧vnode的子节点都是一组子节点,最小性能开销完成更新操作,需要比较两组子节点,用于比较的算法旧叫Diff算法。

1、减少DOM操作的性能开销

核心的Diff算法只关心新旧虚拟节点都存在一组子节点的情况,之前的渲染器采用的全部卸载,再全部挂载,没有复用如何DOM元素,会产生极大的性能开销

调整方法:

1、在子节点只有文本子节点不同时,只更新文本,可以提高一倍的性能.

2、比较新旧两组子节点的长度,如果新的长,则说明有新的子节点需要挂载,如果旧的长。则说明有旧的需要被卸载。

新旧子节点三种状况图

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)) {
      const oldChildren = n1.children
      const newChildren = n2.children
      const oldLen = oldChildren.length
      const newLen = newChildren.length
      const commonLength = Math.min(oldLen, newLen)
      for (let i = 0; i < commonLength; i++) {
        patch(oldChildren[i], newChildren[i])
      }
      // 如果 nextLen > prevLen,将多出来的元素添加
      if (newLen > oldLen) {
        for (let i = commonLength; i < newLen; i++) {
          patch(null, newChildren[i], container)
        }
      } else if (oldLen > newLen) {
        // 如果 prevLen > nextLen,将多出来的元素移除
        for (let i = commonLength; i < oldLen; i++) {
          unmount(oldChildren[i])
        }
      }
    } else {
      if (Array.isArray(n1.children)) {
        n1.children.forEach(c => unmount(c))
      } else if (typeof n1.children === 'string') {
        setElementText(container, '')
      }
    }
  }

2、DOM复用与key的作用

如果新旧子节点并不完全相同,那么上面减少DOM的操作并不会减少性能的消耗,举个栗子:

// oldChildren
 [
   { type: 'p' },
   { type: 'div' },
   { type: 'span' }
 ]

 // newChildren
 [
   { type: 'span' },
   { type: 'p' },
   { type: 'div' }
 ]

这种情况下,之前的优化操作就不好使了,需要移动DOM来进行优化。

注意:移动DOM解决性能问题必须要有可复用的的节点

进一步的优化:在vnode中加上标识key字段,通过type和key双重确定vnode相同,DOM可以进行复用

无key、有key图

无key无法确定映射关系,有key可以确定映射关系。

具体比较key是否系统,代码如下:

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)) {
      const oldChildren = n1.children
      const newChildren = n2.children

      // 遍历新的 children
      for (let i = 0; i < newChildren.length; i++) {
        const newVNode = newChildren[i]
        let j = 0
        // 遍历旧的 children
        for (j; j < oldChildren.length; j++) {
          const oldVNode = oldChildren[j]
          // 如果找到了具有相同 key 值的两个节点,则调用 `patch` 函数更新之
          if (newVNode.key === oldVNode.key) {
            patch(oldVNode, newVNode, container)
            break // 这里需要 break
          }
        }
      }
      
    } else {
      if (Array.isArray(n1.children)) {
        n1.children.forEach(c => unmount(c))
      } else if (typeof n1.children === 'string') {
        setElementText(container, '')
      }
    }
  }

两层for循环,一层获取新节点,另一层获取旧节点,比较key相同,调用patch打补丁。

3、找到需要移动的元素

刚刚学习了通过key确定哪些DOM可以复用,接下来学习如何查找需要移动的元素,思路如下:添加lastIndex变量,在旧的children中找到了可复用的DOM节点,使用节点中的旧children中的索引j与lastIndex进行比较,如果j小于lastindex,则说明当前oldvnode对应真实DOM需要移动,否则不需要移动,此时把j的值付给变量lastIIndex,lastIIndex始终存储着当前遇到的最大索引值,代码如下:

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)) {
      const oldChildren = n1.children
      const newChildren = n2.children

      // 遍历新的 children
      for (let i = 0; i < newChildren.length; i++) {
        const newVNode = newChildren[i]
        let j = 0
        // 遍历旧的 children
        for (j; j < oldChildren.length; j++) {
          const oldVNode = oldChildren[j]
          // 如果找到了具有相同 key 值的两个节点,则调用 `patch` 函数更新之
          if (newVNode.key === oldVNode.key) {
            patch(oldVNode, newVNode, container)
            if (j < lastIndex) {
              // 需要移动
            } else {
              // 更新 lastIndex
              lastIndex = j
            }
            break // 这里需要 break
          }
        }
      }
      
    } else {
      if (Array.isArray(n1.children)) {
        n1.children.forEach(c => unmount(c))
      } else if (typeof n1.children === 'string') {
        setElementText(container, '')
      }
    }
  }

4、如何移动元素

确定了要移动哪些元素,接下来就是如何对元素进行移动,思路大致如下,先让新旧节点都对真实DOM进行引用,取新的一组子节点,找到可以复用的key比较lastIndex大小,小则不需要移动真实DOM,代码如下:

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)) {
      const oldChildren = n1.children
      const newChildren = n2.children

      let lastIndex = 0
      // 遍历新的 children
      for (let i = 0; i < newChildren.length; i++) {
        const newVNode = newChildren[i]
        let j = 0
        // 遍历旧的 children
        for (j; j < oldChildren.length; j++) {
          const oldVNode = oldChildren[j]
          // 如果找到了具有相同 key 值的两个节点,则调用 `patch` 函数更新之
          if (newVNode.key === oldVNode.key) {
            patch(oldVNode, newVNode, container)
            if (j < lastIndex) {
              // 需要移动
              const prevVNode = newChildren[i - 1]
              if (prevVNode) {
                const anchor = prevVNode.el.nextSibling
                insert(newVNode.el, container, anchor)
              }
            } else {
              // 更新 lastIndex
              lastIndex = j
            }
            break // 这里需要 break
          }
        }
      }
      
    } else {
      if (Array.isArray(n1.children)) {
        n1.children.forEach(c => unmount(c))
      } else if (typeof n1.children === 'string') {
        setElementText(container, '')
      }
    }
  }

5、添加新元素

新的子节点中多出key,旧子节点不存在,应该进行挂载操作。

定义名为find变量,表示渲染器能否在旧的一组子节点找到可复用的节点。find初始值为false,找到可复用的节点,则find值设置为true。如果结束内层循环后find仍然为false,说明当前的newVNode是个全新的节点,需要挂载。获取锚点元素,使用虚拟节点的前一个节点作为锚点元素,将锚点元素作为patch函数的第四个参数,调用patch完成挂载,修改代码如下:

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)) {
      const oldChildren = n1.children
      const newChildren = n2.children

      let lastIndex = 0
      // 遍历新的 children
      for (let i = 0; i < newChildren.length; i++) {
        const newVNode = newChildren[i]
        let j = 0
        let find = false
        // 遍历旧的 children
        for (j; j < oldChildren.length; j++) {
          const oldVNode = oldChildren[j]
          // 如果找到了具有相同 key 值的两个节点,则调用 `patch` 函数更新之
          if (newVNode.key === oldVNode.key) {
            find = true
            patch(oldVNode, newVNode, container)
            if (j < lastIndex) {
              // 需要移动
              const prevVNode = newChildren[i - 1]
              if (prevVNode) {
                const anchor = prevVNode.el.nextSibling
                insert(newVNode.el, container, anchor)
              }
            } else {
              // 更新 lastIndex
              lastIndex = j
            }
            break // 这里需要 break
          }
        }
        if (!find) {
          const prevVNode = newChildren[i - 1]
          let anchor = null
          if (prevVNode) {
            anchor = prevVNode.el.nextSibling
          } else {
            anchor = container.firstChild
          }
          patch(null, newVNode, container, anchor)
        }
      }
      
    } else {
      if (Array.isArray(n1.children)) {
        n1.children.forEach(c => unmount(c))
      } else if (typeof n1.children === 'string') {
        setElementText(container, '')
      }
    }
  }

  function patch(n1, n2, container, anchor) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1)
    n1 = null
  }

  const { type } = n2

  if (typeof type === 'string') {
    if (!n1) {
      mountElement(n2, container, anchor)
    } 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)
    }
  }
}

  function render(vnode, container) {
    if (vnode) {
      // 新 vnode 存在,将其与旧 vnode 一起传递给 patch 函数进行打补丁
      patch(container._vnode, vnode, container)
    } else {
      if (container._vnode) {
        // 旧 vnode 存在,且新 vnode 不存在,说明是卸载(unmount)操作
        unmount(container._vnode)
      }
    }
    // 把 vnode 存储到 container._vnode 下,即后续渲染中的旧 vnode
    container._vnode = vnode
  }
  
  return {
    render
  }
}

6、删除元素

当基本的更新结束时,遍历旧的子节点,然后去新的子节点中寻找相同key值的节点,如果找不到,说明需要删除该节点。代码如下

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)) {
      const oldChildren = n1.children
      const newChildren = n2.children

      let lastIndex = 0
      // 遍历新的 children
      for (let i = 0; i < newChildren.length; i++) {
        const newVNode = newChildren[i]
        let j = 0
        let find = false
        // 遍历旧的 children
        for (j; j < oldChildren.length; j++) {
          const oldVNode = oldChildren[j]
          // 如果找到了具有相同 key 值的两个节点,则调用 `patch` 函数更新之
          if (newVNode.key === oldVNode.key) {
            find = true
            patch(oldVNode, newVNode, container)
            if (j < lastIndex) {
              // 需要移动
              const prevVNode = newChildren[i - 1]
              if (prevVNode) {
                const anchor = prevVNode.el.nextSibling
                insert(newVNode.el, container, anchor)
              }
            } else {
              // 更新 lastIndex
              lastIndex = j
            }
            break // 这里需要 break
          }
        }
        if (!find) {
          const prevVNode = newChildren[i - 1]
          let anchor = null
          if (prevVNode) {
            anchor = prevVNode.el.nextSibling
          } else {
            anchor = container.firstChild
          }
          patch(null, newVNode, container, anchor)
        }
      }

      // 遍历旧的节点
      for (let i = 0; i < oldChildren.length; i++) {
        const oldVNode = oldChildren[i]
        // 拿着旧 VNode 去新 children 中寻找相同的节点
        const has = newChildren.find(
          vnode => vnode.key === oldVNode.key
        )
        if (!has) {
          // 如果没有找到相同的节点,则移除
          unmount(oldVNode)
        }
      }
      
    } else {
      if (Array.isArray(n1.children)) {
        n1.children.forEach(c => unmount(c))
      } else if (typeof n1.children === 'string') {
        setElementText(container, '')
      }
    }
  }

总结

1、学习了简单的Diff算法,Diff的算法核心意义就是为了减少DOM操作,减少性能开销

2、学习了DOM的复用,加入key的必要性,用来确定是否能复用

3、查找需要移动的元素,如何移动元素、添加元素、删除元素