props 更新和子节点 diff

2025-10-22 20:53:43

上一节实现了元素的首次挂载和卸载。来完善一下 patchElement 方法

patchElement

patchElement 的更新流程可以分成三步:

  1. 复用旧 vnode 对应的 DOM
  2. 对比并更新 props
  3. 对比并更新 children
function patchElement(n1, n2) {
  const el = n2.el = n1.el

  patchProps(el, n1.props, n2.props)
  patchChildren(n1, n2)
}

patchProps

更新 props 时,需要处理两种情况:

  • props 中存在、新 props 中不存在:删除旧属性
  • props 中存在:设置新属性,或者更新已有属性
function patchProps(el, oldProps = {}, newProps = {}) {
  for (const key in oldProps) {
    if (!(key in newProps)) {
      hostPatchProp(el, key, oldProps[key], null)
    }
  }

  for (const key in newProps) {
    const prevValue = oldProps[key]
    const nextValue = newProps[key]

    if (prevValue !== nextValue) {
      hostPatchProp(el, key, prevValue, nextValue)
    }
  }
}

先删除已经失效的属性,再只更新真正发生变化的属性。

patchChildren

patchChildren 负责更新子元素。子元素情况比较多,分为

旧 children新 children处理方式
文本或空文本更新文本
数组文本卸载旧数组,设置新文本
文本或空数组挂载新数组
数组数组进入数组 diff
文本或数组清空或卸载旧节点
function patchChildren(n1, n2) {
  const el = n2.el

  const prevShapeFlag = n1.shapeFlag
  const shapeFlag = n2.shapeFlag

  const c1 = n1.children
  const c2 = n2.children

  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { // 新节点是文本
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 老节点是数组: 将老节点的 children 卸载
      unmountChildren(c1)
    }
    if (c1 !== c2) {
      hostSetElementText(el, c2)
    }
  }
  else {
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 老节点是数组
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 新节点也是数组
        patchKeyedChildren(c1, c2, el)
      }
      else {
        // 新节点不是数组,卸载老节点的数组
        unmountChildren(c1)
      }
    }
    else {
      // 老节点是 null
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        mountChildren(el, c2)
      }
    }
  }
}

通过 shapeFlag 判断新旧 children 的类型。

如果新旧 children 都是数组,就不能简单地全部删除后重新挂载,而是需要进入 patchKeyedChildren

Vue 的实现会先从头部和尾部开始比较,尽量快速处理已经对齐的节点。

头部对比

const c1 = [a, b]
const c2 = [a, b, c]

头部对比从 i = 0 开始。

只要新旧节点类型相同,就可以直接调用 patch 更新,并把指针向后移动。

如果遇到不同类型的节点,就暂时停止头部比较。

function patchKeyedChildren(c1, c2, container) {
  let i = 0
  let e1 = c1.length - 1
  let e2 = c2.length - 1

  while (i <= e1 && i <= e2) {
    const n1 = c1[i]
    const n2 = c2[i]

    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container)
    }
    else {
      break
    }

    i++
  }
}

尾部对比

const c1 = [a, b]
const c2 = [c, a, b]

头部并没有完全对齐,但尾部的 ab 是相同的。 尾部比较从最后一个节点开始,比较成功后同时向前移动 e1e2

while (i <= e1 && i <= e2) {
  const n1 = c1[e1]
  const n2 = c2[e2]

  if (isSameVNodeType(n1, n2)) {
    patch(n1, n2, container)
  }
  else {
    break
  }

  e1--
  e2--
}

处理新增和删除

头部、尾部比较完成后,剩余区间就是还没有处理的部分。

如果 i > e1,说明新的子节点多,老的子节点少。需要挂载新节点。

if (i > e1) {
  const nextPos = e2 + 1
  const anchor = nextPos < c2.length ? c2[nextPos].el : null

  while (i <= e2) {
    patch(null, c2[i], container, anchor)
    i++
  }
}

anchor 表示新节点应该插入到哪个节点之前。

如果插入位置已经是末尾,anchor 就是 null,最终效果就是追加到容器末尾。

如果 i > e2,说明老的子节点多,新的子节点少。需要卸载旧节点。

else if (i > e2) {
    while (i <= e1) {
        unmount(c1[i])
        i++
    }
}

乱序 diff

头部和尾部对比只能处理已经对齐的节点。

const c1 = [a, b, c, d, e]
const c2 = [a, c, d, b, e]

经过头部和尾部比较后,ae 已经处理完成,剩余部分是:

c1 = [b, c, d]
c2 = [c, d, b]

这些节点仍然可以通过 key 找到对应关系,所以不需要全部重新创建。

先建立新节点的 key 到下标的映射:

const s1 = i
const s2 = i
const keyToNewIndexMap = new Map()

for (let j = s2; j <= e2; j++) {
  const n2 = c2[j]
  keyToNewIndexMap.set(n2.key, j)
}

然后遍历旧节点。

如果旧节点的 key 在新节点中还能找到,就调用 patch 更新。

如果找不到,说明这个旧节点已经被删除。

for (let j = s1; j <= e1; j++) {
  const n1 = c1[j]
  const newIndex = keyToNewIndexMap.get(n1.key)

  if (newIndex != null) {
    patch(n1, c2[newIndex], container)
  }
  else {
    unmount(n1)
  }
}

到这里,虽然节点内容已经更新了,但 DOM 顺序还不正确。

最后倒序遍历新节点,把节点插入到正确位置:

for (let j = e2; j >= s2; j--) {
  const n2 = c2[j]
  const anchor = c2[j + 1]?.el || null

  if (n2.el) {
    hostInsert(n2.el, container, anchor)
  }
  else {
    patch(null, n2, container, anchor)
  }
}

倒序插入时,后一个节点已经确定,因此可以把它作为当前节点的锚点。

如果节点已经存在,hostInsert 会把它移动到正确位置。

如果节点还没有 DOM,就调用 patch 完成挂载。

最强递增子序列

每一个对 DOM 都进行移动操作的话,还是比较耗费性能的。要尽可能的移动比较少的元素