《Vue.js 设计与实现》读书笔记 - 第9章、简单 Diff 算法

第9章、简单 Diff 算法

9.1 减少 DOM 操作的性能开销

在之前的章节,如果新旧子节点的类型都是数组,我们会先卸载所有旧节点,再挂载所有新的子节点。但是如果存在相同类型的节点,我们完全可以复用节点,只修改类型即可。

所以这一节采取就朴素的复用思路,按顺序依次 patch 节点,如果旧子节点多就卸载,新子节点多就挂载。

for (let i = 0; i < minLen; i++) {
  patch(oldChildren[i], newChildren[i], container)
}

9.2 DOM 复用与 key 的作用

因为直接按顺序复用并不总是合理,比如我把第一个节点移到最后,则所有的节点顺序都乱了。在 Vue 中数组节点都需要设置 key,所以我们可以通过 key 来找到复用的节点。

通过两层循环我们可以找到一个新节点对应的旧节点,然后把两个节点进行 patch。

function patchChildren() {
  // 只写 diff 相关逻辑
  const oldChildren = n1.children
  const newChildren = n2.children

  for (let i = 0; i < newChildren.length; i++) {
    const newVNode = newChildren[i]

    for (let j = 0; j < oldChildren.length; j++) {
      const oldVNode = oldChildren[j]
      if (newVNode.key === oldVNode.key) {
        patch(oldVNode, newVNode, container)
        break
      }
    }
  }
}

但是 patch 只是原地修改节点的属性,之后还需要移动节点。

9.3 找到需要移动的元素

判断移动的方式也很简单,遍历新子元素数组,找到对应的旧节点的下标并记录。如果后续的节点对应旧节点下标大于当前下标,则不需要移动,同时保留最大下标,否则表示需要移动。

function patchChildren() {
  // 只写 diff 相关逻辑
  const oldChildren = n1.children
  const newChildren = n2.children

  let lastIndex = 0
  for (let i = 0; i < newChildren.length; i++) {
    const newVNode = newChildren[i]

    for (let j = 0; j < oldChildren.length; j++) {
      const oldVNode = oldChildren[j]
      if (newVNode.key === oldVNode.key) {
        patch(oldVNode, newVNode, container)
        if (j < lastIndex) {
          // 需要移动
        } else {
          // 不需要移动 更新最大下标
          lastIndex = j
        }
        break
      }
    }
  }
}

9.4 如何移动元素

直接判断需要移动就移动到上一个(新)子元素后就可以了。

function patchChildren() {
  // ... 上面都是之前的逻辑
        if (j < lastIndex) {
          // 需要移动
          const prevVNode = newChildren[i - 1]
          const ancher = prevVNode.el.nextSibling
          // 把 newVNode.el 插入到前一个节点(prevVNode.el.nextSibling)的下一个节点之前
          insert(newVNode.el, container, ancher)
        } else {
          // 不需要移动
          lastIndex = j
        }
        break
      }
    }
  }
}

9.5 添加新元素

感觉这种情况也非常好理解,无非是旧节点中没有对应的,那就在新节点对应的前一个节点后面加入就好了。

function patchChildren() {
  // 只写 diff 相关逻辑
  const oldChildren = n1.children
  const newChildren = n2.children

  let lastIndex = 0
  for (let i = 0; i < newChildren.length; i++) {
    const newVNode = newChildren[i]
    let find = false
    for (let j = 0; j < oldChildren.length; j++) {
      const oldVNode = oldChildren[j]
      if (newVNode.key === oldVNode.key) {
        find = true
        patch(oldVNode, newVNode, container)
        if (j < lastIndex) {
          // 需要移动
          const prevVNode = newChildren[i - 1]
          const ancher = prevVNode.el.nextSibling
          // 把 newVNode.el 插入到前一个节点(prevVNode.el.nextSibling)的下一个节点之前
          insert(newVNode.el, container, ancher)
        } else {
          // 不需要移动
          lastIndex = j
        }
        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)
    }
  }
}

新节点挂载需要使用 patch 来处理。不过之前的 patch 不能指定锚点,需要调整逻辑。

// n1 旧node n2 新node container 容器
function patch(n1, n2, container, anchor) {
  // ...
  if (typeof type === 'string') {
    if (!n1) {
      // 挂载 传入anchor
      mountElement(n2, container, anchor)
    } else {
      // 打补丁
      patchElement(n1, n2)
    }
  }
  // ...
}

function mountElement(vnode, container, anchor) {
  // ...
  insert(el, container, anchor)
}

9.6 移除不存在的元素

移除就是在旧节点找和新节点没有对应 key 的节点,然后卸载。

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

最后附本章完整代码:

点击查看代码
const { effect, ref } = VueReactivity

const Text = Symbol()
const Comment = Symbol()
const Fragment = Symbol()

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

  function mountElement(vnode, container, anchor) {
    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, anchor)
  }
  // n1 旧node n2 新node
  function patchElement(n1, n2) {
    const el = (n2.el = n1.el)
    const oldProps = n1.props
    const newProps = n2.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)
      }
    }
    // 更新children
    patchChildren(n1, n2, el)
  }

  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)) {
        // 如果旧子节点也是一组节点
        const oldChildren = n1.children
        const newChildren = n2.children

        let lastIndex = 0
        for (let i = 0; i < newChildren.length; i++) {
          const newVNode = newChildren[i]
          let find = false
          for (let j = 0; j < oldChildren.length; j++) {
            const oldVNode = oldChildren[j]
            if (newVNode.key === oldVNode.key) {
              find = true
              patch(oldVNode, newVNode, container)
              if (j < lastIndex) {
                // 需要移动
                const prevVNode = newChildren[i - 1]
                const ancher = prevVNode.el.nextSibling
                // 把 newVNode.el 插入到前一个节点(prevVNode.el.nextSibling)的下一个节点之前
                insert(newVNode.el, container, ancher)
              } else {
                // 不需要移动
                lastIndex = j
              }
              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]
          const has = newChildren.find((vnode) => vnode.key === oldVNode.key)
          if (!has) {
            unmount(oldVNode)
          }
        }
      } 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, '')
      }
    }
  }

  // n1 旧node n2 新node 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) {
        // 挂载 传入anchor
        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, children)
      }
    } else if (typeof type === 'object') {
      // 组件
    } else if (type == 'xxx') {
      // 处理其他类型的vnode
    }
  }
  // 传入一个 vnode 卸载与其相关联的 DOM 节点
  function unmount(vnode) {
    if (vnode.type === Fragment) {
      vnode.children.forEach((c) => unmount(c))
      return
    }
    const parent = vnode.el.parentNode
    if (parent) {
      parent.removeChild(vnode.el)
    }
  }

  function render(vnode, container) {
    console.log(vnode, container)
    if (vnode) {
      // 如果有新 vnode 就和旧 vnode 一起用 patch 处理
      patch(container._vnode, vnode, container)
    } else {
      if (container._vnode) {
        // 没有新 vnode 但是有旧 vnode 卸载
        unmount(container._vnode)
      }
    }
    // 把旧 vnode 缓存到 container
    container._vnode = vnode
  }

  function hydrate(vnode, container) {
    // 服务端渲染
  }

  return {
    render,
    hydrate,
  }
}

function shouldSetAsProps(el, key, value) {
  // 特殊处理
  if (key === 'form' && el.tagName === 'INPUT') return false

  return key in el
}

const renderer = createRenderer({
  createElement(tag) {
    return document.createElement(tag)
  },
  setElementText(el, text) {
    el.textContent = text
  },
  insert(el, parent, anchor = null) {
    parent.insertBefore(el, anchor)
  },
  createText(text) {
    return document.createTextNode(text)
  },
  setText(el, text) {
    el.nodeValue = text
  },
  patchProps(el, key, prevValue, nextValue) {
    if (/^on/.test(key)) {
      // evl: vue event invoker
      const invokers = el._vel || (el._vel = {})
      let invoker = invokers[key]
      const name = key.slice(2).toLowerCase()
      if (nextValue) {
        if (!invoker) {
          invoker = el._vel[key] = (e) => {
            // e.timeStamp 是事件发生的时间
            if (e.timeStamp < invoker.attached) return
            if (Array.isArray(invoker.value)) {
              invoker.value.forEach((fn) => fn(e))
            } else {
              invoker.value(e)
            }
          }
          // 存储事件被绑定的时间
          invoker.attached = performance.now()
          invoker.value = nextValue
          el.addEventListener(name, invoker)
        } else {
          invoker.value = nextValue
        }
      } else if (invoker) {
        el.removeEventListener(name, invoker)
      }
    }

    if (key === 'class') {
      el.className = nextValue || ''
    } else if (shouldSetAsProps(el, key, nextValue)) {
      // 判断 key 是否存在对应的 DOM Properties
      const type = typeof el[key] // 获取该属性的类型
      if (type === 'boolean' && nextValue === '') {
        el[key] = true
      } else {
        el[key] = nextValue
      }
    } else {
      // 没有对应的 DOM Properties
      el.setAttribute(key, nextValue)
    }
  },
})

const vnode1 = {
  type: 'div',
  children: [
    {
      type: 'p',
      children: '1',
      key: 1,
    },
    {
      type: 'div',
      children: '2',
      key: 2,
    },
    {
      type: 'h1',
      children: '3',
      key: 3,
    },
  ],
}
const vnode2 = {
  type: 'div',
  children: [
    {
      type: 'h1',
      children: '3',
      // key: 3,
    },
    {
      type: 'p',
      children: '1',
      // key: 1,
    },
    {
      type: 'div',
      children: '4',
    },
  ],
}

renderer.render(vnode1, document.querySelector('#app'))
renderer.render(vnode2, document.querySelector('#app'))

posted @ 2023-02-03 17:45  我不吃饼干呀  阅读(35)  评论(0编辑  收藏  举报