Vue3 Diff算法解析

413 阅读4分钟

最近在看《vue.js 设计与实现》过程中,先对diff算法的过程做个总结。vue在渲染更新过程中,diff算法起到关键性作用,充分减少dom操作,从vue2到vue3,为了提升性能,尤大也在不断的优化这个过程。 diff算法也发生了些许变化,但目的都是优化vue应用更新的速度。接下来我们来了解一下:

patchFlags

vue代码在编译过程中,会添加patchFlag,减少非动态内容的对比更新。

<template>
    <p>text</p>
    <p>{{text}}</p>
    <p :title="title">text</p>
</template>

// 编译后
_createElementVNode("p", null, "text", -1);
_createElementVNode("p", null, _toDisplayString($setup.text), 1);
_createElementVNode("p", { title: $setup.title }, "text", 8);

所有patchFlag标识:

export const enum PatchFlags {
  // 1 表示具有动态文本内容的元素
  TEXT = 1,
  // 2 表示具有动态类绑定的元素
  CLASS = 1 << 1,
  // 4 表示具有动态样式的元素
  STYLE = 1 << 2,
  // 8 表示具有非类/样式动态props的元素
  PROPS = 1 << 3,
  // 16 表示带有带有动态key元素。
  FULL_PROPS = 1 << 4,
  // 32 表示带有事件监听器的元素
  HYDRATE_EVENTS = 1 << 5,
  // 64 表示子元素顺序不变的片段
  STABLE_FRAGMENT = 1 << 6,
  // 128 表示子元素中有key或部分有key的片段
  KEYED_FRAGMENT = 1 << 7,
  // 256 表示子元素中没有key的片段
  UNKEYED_FRAGMENT = 1 << 8,
  // 512 表示有非props需要patch的,比如'ref'、'directives'
  NEED_PATCH = 1 << 9,
  // 1024 表示具有动态插槽的组件
  DYNAMIC_SLOTS = 1 << 10,
  // 2048 表示仅因为用户在模板的根级别放置注释而创建的片段
  DEV_ROOT_FRAGMENT = 1 << 11,

  // 特殊标识
  // -1 表示静态节点不需要更新
  HOISTED = -1,
  // -2 表示差异算法应该退出
  BAIL = -2
}

Patch函数

patch的作用,就是对新旧节点添加相同的真实dom的引用,尽可能的复用真实dom节点,避免重复创建dom节点,对比新旧节点,通过判断patchFlag,进行不同类型的更新操作,打补丁。

// 判断节点的类型及key值是否相同
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  ...
  return n1.type === n2.type && n1.key === n2.key
}
const patch: PatchFn = (
    n1, // 旧节点
    n2, // 新节点
    container, // 所在容器
    anchor = null, // 锚点元素
    ...
  ) => {
	...
	// 节点类型不同直接卸载
	if (n1 && !isSameVNodeType(n1, n2)) {
	  anchor = getNextHostNode(n1)
	  unmount(n1, parentComponent, parentSuspense, true)
	  n1 = null
	}
	...
	// 根据节点类型,进行不同的挂载及更新 
	const { type, ref, shapeFlag } = n2
	    switch (type) {
	      case Text:
	        processText(n1, n2, container, anchor)
	        break
		  ... 
}

在patch过程中,当n1为null,会在容器中挂载n2节点。

其中anchor是一个锚点元素,当我们对新旧节点做增删或移动等操作时,作为参照节点。

  • 通过insertBefore完成移动,dom元素挂载到锚点元素之前;
  • 当anchor为null,会在容器末尾插入元素;
// insert函数
insert(el, parent, anchor){
    container.insertBefore(el, anchor || null)
}

快速DIFF(源码位置

同层比较vNode中的children,将通过patchFlag区分为有key和无key的情况,两种情况的对比会存在不同。

const patchChildren: PatchChildrenFn = (
    n1,
    n2,
    container,
    anchor,
    ...
  ) => {
    const c1 = n1 && n1.children
    const prevShapeFlag = n1 ? n1.shapeFlag : 0
    const c2 = n2.children

    const { patchFlag, shapeFlag } = n2
    // fast path
    if (patchFlag > 0) {
	  // 有key或部分有key,位与运算大于0
      if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
        patchKeyedChildren(
          c1 as VNode[],
          c2 as VNodeArrayChildren,
          container,
          anchor,
          ...
        )
        return
      } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
        // 没有key
        patchUnkeyedChildren(
          c1 as VNode[],
          c2 as VNodeArrayChildren,
          container,
          anchor,
          ...
        )
        return
      }
    }
	...
}

patchUnkeyedChildren(无key)

那么针对没有key的节点,patchUnkeyedChildren的作用是什么呢? image.png

  • 比较新旧children的length,取最小值,进行循环,调用patch函数
  • 如果oldLength > newLength,卸载剩余的旧节点
  • 如果newLength > oldLength,挂载剩余的旧节点
const patchUnkeyedChildren = (
    c1: VNode[],
    c2: VNodeArrayChildren,
    container: RendererElement,
    anchor: RendererNode | null,
    ...
  ) => {
    c1 = c1 || EMPTY_ARR
    c2 = c2 || EMPTY_ARR
    const oldLength = c1.length
    const newLength = c2.length
    // 获取最小值
    const commonLength = Math.min(oldLength, newLength)
    let i
    for (i = 0; i < commonLength; i++) {
      const nextChild = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      patch(
        c1[i],
        nextChild,
        container,
        null,
        ...
      )
    }
    if (oldLength > newLength) {
        // 卸载节点
        unmountChildren
    } else {
	// 挂载节点
        mountChildren
    }
  }

patchKeyedChildren(有key或部分有key)

这种情况的比较会复杂一些,先举个简单的例子,和纯文本对比思路类似:

TEXT1: (i like you) 
TEXT2: (i like you)too
------
TEXT3: (i use) vue (for app developement)
TEXT4: (i use) react (for app developement)

两段文本对比中,括号中的内容是不需要作处理的,经过预处理以后,真正需要比较的是不同的部分。比如12的区别是新增了too34的区别是vuereact。 vue中也是这样的提前做了处理,对不同的部分进行新增、删除、比较操作。

const patchKeyedChildren = (
    c1: VNode[], // 旧的节点
    c2: VNodeArrayChildren, // 新的节点
    container: RendererElement,
    parentAnchor: RendererNode | null,
    ...
  ) => {
    let i = 0  // 开始 index
    const l2 = c2.length
    let e1 = c1.length - 1 // 旧:最后一个节点index
    let e2 = l2 - 1 // 新:最后一个节点index
    ...
}
预处理

从开头、结尾对新旧节点中可以复用的节点进行patch操作,打补丁

image.png

// 从开头比较
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
  ...
  if (isSameVNodeType(n1, n2)) {
	patch(n1, n2, container, null, ...)
  } else {
	break
  }
  i++
}

// 从结尾比较
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
  ...
  if (isSameVNodeType(n1, n2)) {
	patch(n1, n2, container, null, ...)
  } else {
	break
  }
  e1--
  e2--
}
新增和删除

当新旧节点进过步骤1处理后,一种情况是只有新增和删除节点,进行处理

image.png

// 新增节点
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
if (i > e1) {
  if (i <= e2) {
	const nextPos = e2 + 1
	const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
	while (i <= e2) {
	  patch(null, n2, container, anchor, ...)
	  i++
	}
  }
}

// 删除节点
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
else if (i > e2) {
  while (i <= e1) {
	unmount(c1[i], parentComponent, parentSuspense, true)
	i++
  }
}
移动节点

image.png

当新旧节点进过步骤1处理后,另一种情况是还有剩余的节点,需要按照新节点的顺序对元素进行移动等处理 过程如下:

// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
const s1 = i // 旧:开始 index
const s2 = i // 新:开始 index
  1. 遍历剩余的新节点,生成一个 key:index 的索引表keyToNewIndexMap;
const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
  for (i = s2; i <= e2; i++) {
	const nextChild = (c2[i] = optimized
	  ? cloneIfMounted(c2[i] as VNode)
	  : normalizeVNode(c2[i]))
	if (nextChild.key != null) {
		...
	    keyToNewIndexMap.set(nextChild.key, i)
	}
}
  1. 遍历剩余的旧节点,获取新节点对应在旧节点容器中的 index,生成一个数组newIndexToOldIndexMap,同时进行判断
  • 如果旧节点数大于新节点数,说明剩余的节点都是多余的,patched >= toBePatched,直接卸载
  • 如果在keyToNewIndexMap中未找到或没有相同类型的节点,直接卸载
  • 如果旧节点在keyToNewIndexMap中对应的新索引值处于递增,则不需要移动,否则添加标记 moved = true
// 已patched节点的个数
let patched = 0 
// 需要patched的节点个数
const toBePatched = e2 - s2 + 1 
// 节点是否需要移动
let moved = false 
// 最大索引值,会改变
let maxNewIndexSoFar = 0
const newIndexToOldIndexMap = new Array(toBePatched)
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
for (i = s1; i <= e1; i++) {
    const prevChild = c1[i]
    // 多余节点
    if (patched >= toBePatched) {
        // 卸载节点
        unmount(prevChild, parentComponent, parentSuspense, true)
        continue
    }
    let newIndex
    if (prevChild.key != null) {
        newIndex = keyToNewIndexMap.get(prevChild.key)
    } else {
      // 没有key时,通过isSameVNodeType比较
      for (j = s2; j <= e2; j++) {
        if (
          newIndexToOldIndexMap[j - s2] === 0 &&
          isSameVNodeType(prevChild, c2[j] as VNode)
        ) {
          newIndex = j
          break
        }
      }
    }
    // 未找到相同类型节点
    if (newIndex === undefined) {
        // 卸载节点
        unmount(prevChild, parentComponent, parentSuspense, true)
    } else {
        // 判断是否需要移动
        newIndexToOldIndexMap[newIndex - s2] = i + 1
        if (newIndex >= maxNewIndexSoFar) {
                maxNewIndexSoFar = newIndex
        } else {
                moved = true
        }
        patch(
                prevChild,
                c2[newIndex] as VNode,
                container,
                null,
                ...
        )
        patched++
    }
}
  1. 根据新节点的顺序及最大递增子序列移动相应dom元素,如[0, 8, 4, 12]的最大递增子序列为[0, 8, 12]或[0, 4, 12]
  • newIndexToOldIndexMap中默认值为0,说明没有对应的节点, 挂载新节点
  • 如果 moved = true ,通过insert函数完成移动节点
// 获取最大递增子序列
const increasingNewIndexSequence = moved
    ? getSequence(newIndexToOldIndexMap)
    : EMPTY_ARR
j = increasingNewIndexSequence.length - 1
for (i = toBePatched - 1; i >= 0; i--) {
    const nextIndex = s2 + i
    const nextChild = c2[nextIndex] as VNode
    // 锚点元素为下一个新节点
    const anchor = nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
    // newIndexToOldIndexMap中默认值为0,说明没有对应的节点
    if (newIndexToOldIndexMap[i] === 0) {
        // 挂载节点
        patch(
                null,
                nextChild,
                container,
                anchor,
                ...
        )
    } else if (moved) {
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
            // 通过insert函数完成移动节点
            move(nextChild, container, anchor, MoveType.REORDER)
        } else {
            j--
        }
    }
}

关于key

通过分析diff算法,可以知道,当我们在项目中做列表循环的时候,有时候会将index或者index + 变量作为key赋值,每次列表变化,都会重新赋值,并没有起到快速diff的作用。