vue 的更新过程和 diff 算法
2021年10月24日 · 预计阅读时间: 29 分钟
前言
vue 中的 diff 算法是基于 vnode 实现的,vnode 简单来说就是将 dom 结构用 js 对象来表示,方便我们进行处理。
vnode 的部分属性如下:
export interface VNode {
type: VNodeTypes // VNode 类型
props: VNodeProps | null
key: string | number | null // 用于 diff
children: VNodeChildren // 子节点
// 性能优化
shapeFlag: number
patchFlag: number
}
patch
patch
函数在packages/runtime-core/src/renderer.ts
中,是 diff 过程的入口。
function patch(n1,n2){}
n1 和 n2 就分别代表旧的 vnode 和 新的 vnode。
显而易见,当n1 === n2
的时候,即没有更新的时候,就不需要进行后面的 diff 过程了,直接返回。
如果 n1 存在且 n1 和 n2 是不同类型的话,那么就直接卸载 n1,并将 n1 赋值为 null,之后的 diff 过程发现 n1 为 null 的的话会直接将新的 vnode 挂载。
例如某个节点从div
标签变成span
标签,最合理的方式是直接卸载旧节点,重新挂载新的节点。
if (n1 && !isSameVNodeType(n1, n2)) {
unmount(n1)
}
// 这里也可看出 key 的作用
function isSameVNodeType(n1, n2){
return n1.type === n2.type && n1.key === n2.key
}
再往下会根据 n2 的类型type
进行特定的处理。比如文本节点执行processText
的特定处理、注释节点执行processCommentNode
的特定处理。这样的前置处理实际上是一个优化,在编译阶段,vue
会将模版语法编译成渲染函数,这个时候会把 VNode 类型type
填上,如果这个参数命中了一些特殊类型节点,会直接执行相应的process
方法。
Text
|Comment
:n1 为 null 时插入文字,不为 null 时替换新的文字
const { type, ref, shapeFlag } = n2
switch (type) {
case Text:
processText(n1, n2, container, anchor)
break
case Comment:
processCommentNode(n1, n2, container, anchor)
break
// ...
default:
// DOM 元素
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1,n2)
// 组件
} else if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(n1,n2)
}
}
default
块儿里才是分析的重点,是处理普通DOM
元素和组件的流程。
processElement
const processElement = (n1, n2) => {
// 无旧节点,首次渲染
if (n1 == null) {
mountElement(n2)
} else {
patchElement(n1, n2)
}
}
processElement
逻辑是如果 n1 为 null,则是首次渲染,挂载 n2,否则才会去比较 n1 和 n2
前置属性
在具体看patchElement
之前,我们需要先了解两个前置属性:
- PatchFlags
在vnode
中有patchFlag
这样一个字段,用来表示当前节点发生改变的类型。PatchFlags
的所有枚举类型如下所示:
// shared/src/patchFlags.ts
export const enum PatchFlags {
TEXT = 1,
CLASS = 1 << 1,
STYLE = 1 << 2,
PROPS = 1 << 3,
FULL_PROPS = 1 << 4,
HYDRATE_EVENTS = 1 << 5,
STABLE_FRAGMENT = 1 << 6,
KEYED_FRAGMENT = 1 << 7,
UNKEYED_FRAGMENT = 1 << 8,
NEED_PATCH = 1 << 9,
DYNAMIC_SLOTS = 1 << 10,
DEV_ROOT_FRAGMENT = 1 << 11,
HOISTED = -1,
BAIL = -2
}
patchFlag
所有的枚举类型都由二进制来表示,这样做的好处是很容易对多种类型进行判断,比如当前变化包括TEXT
和CLASS
。
在判断时,只需要对想要判断的类型进行&
操作,如果大于 0,说明包含此类型。
- dynamicChildren
vue3
中对静态节点做了标记,在patch
阶段,不会比较静态节点 (Static
类型的 vnode),只会比较动态节点,即dynamicChildren
内的节点。
patchElement
了解完上面两个属性后我们回归主线,看一下patchElement
函数:
首先应该patchChildren
然后patchProps
,保持和 mountElement
一样的处理顺序并避免<select> value
的问题,关于这个问题的issue 地址。
const patchElement = (n1, n2) => {
// 缓存旧的 DOM 节点
const el = (n2.el = n1.el)
let { patchFlag, dynamicChildren } = n2
// 保存新旧 props
const oldProps = n1.props || {}
const newProps = n2.props || {}
// 更新子节点
if (dynamicChildren) {
patchBlockChildren(n1.dynamicChildren, dynamicChildren, el)
} else {
// 全量更新
patchChildren(n1, n2, el)
}
// 更新 props
if (patchFlag > 0) {
if (patchFlag & PatchFlags.FULL_PROPS) {
// 存在动态的 key ,需要全量更新
patchProps(el, n2, oldProps, newProps)
}
} else {
// 存在动态 class
if (patchFlag & PatchFlags.CLASS) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, 'class', null, newProps.class)
}
}
// 存在动态 style
if (patchFlag & PatchFlags.STYLE) {
hostPatchProp(el, 'style', oldProps.style, newProps.style)
}
// 针对除了 style、class 的 props
if (patchFlag & PatchFlags.PROPS) {
// 匹配到这个 flag 的话 n2.dynamicProps 肯定为非空
const propsToUpdate = n2.dynamicProps
for (let i = 0; i < propsToUpdate.length; i++) {
const key = propsToUpdate[i]
const prev = oldProps[key]
const next = newProps[key]
if (next !== prev) {
hostPatchProp(el, key, prev, next)
}
}
}
// 存在动态文本
if (patchFlag & PatchFlags.TEXT) {
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children)
}
} else if (dynamicChildren == null) {
// 全量更新
patchProps(el, n2, oldProps, newProps)
}
}
}
上面的判断过程可以下面的图来总结。
接下来我们分别来看看这些函数做了什么。
patchBlockChildren
对于动态子节点,我们遍历所有动态子节点进行patch
const patchBlockChildren = (oldChildren, newChildren) => {
for (let i = 0; i < newChildren.length; i++) {
const oldVNode = oldChildren[i]
const newVNode = newChildren[i]
patch(oldVNode, newVNode)
}
}
patchChildren
对于子节点来说,一共有三种可能性:text
, array
或者为空
patchChildren
做的就是对新旧节点可能出现的所有情况进行处理
const patchChildren = (n1, n2) => {
const c1 = n1 && n1.children
const prevShapeFlag = n1 ? n1.shapeFlag : 0
const c2 = n2.children
const { shapeFlag } = n2
// fragment...
// 新旧子节点的所有情况
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 新子节点是文本
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 旧子节点是数组,先卸载旧子节点
unmountChildren(c1)
}
// 然后替换文本
// 或者是 旧子节点也是文本,直接替换文本
if (c2 !== c1) {
hostSetElementText(c2)
}
} else {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 旧子节点是数组
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 新子节点也是数组,需要全量比较
patchKeyedChildren(c1, c2)
} else {
// 没有新子节点,直接卸载旧子节点
unmountChildren(c1)
}
} else {
// 现在还剩下的情况如下:
// 旧子节点为文本或空
// 新子节点为数组或空
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 旧子节点为文本,先将文本设为空
hostSetElementText(container, '')
}
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 如果新子节点为数组,那么再挂载
mountChildren(c2)
}
}
}
}
其中新旧节点都是数组的情况涉及到我们平常所说的diff
算法,会放到后面专门去解析。
patchProps
patchProps
做的事就是比较新旧 props 的变化。
const patchProps = (oldProps, newProps) => {
if (oldProps !== newProps) {
// 遍历 newProps
for (const key in newProps) {
const next = newProps[key]
const prev = oldProps[key]
if (next !== prev) {
hostPatchProp(key, prev, next)
}
}
if (oldProps !== EMPTY_OBJ) {
// 遍历 oldProps
for (const key in oldProps) {
// 如果 存在某个属性 不在 newProps 调用 hostPatchProp 移除该属性
if (!(key in newProps)) {
hostPatchProp(key, oldProps[key], null)
}
}
}
}
}
其中的hostPatchProp
会根据key
来执行相应方法更新属性。
// runtime-dom/src/patchProp.ts
export const patchProp = (key, prevValue, nextValue) => {
if (key === 'class') {
patchClass(nextValue, isSVG)
} else if (key === 'style') {
patchStyle(prevValue, nextValue)
}
// ...
}
patchClass
、patchStyle
这些方法最后都是调用原生的DOM API
去处理更新操作,如patchClass
:
这个方法比较有意思的一点是源码中注释写直接对className
赋值比使用setAttribute
方法要快,真的是很在意性能了,不用for of
代替普通for
循环也是
// runtime-dom/src/modules/class.ts
export function patchClass(el, value) {
// directly setting className should be faster than setAttribute in theory
// if this is an element during a transition, take the temporary transition
// classes into account.
if (value == null) {
el.removeAttribute('class')
} else {
el.className = value
}
}
processComponent
了解了DOM
元素的处理过程之后,让我们再回到patch
里,来看看组件的处理过程
// runtime-core/src/renderer.ts
const processComponent = (n1, n2, container, ...) => {
if (n1 === null) {
// 首次渲染
// ...
} else {
// 更新
updateComponent(n1, n2, ...)
}
}
updateComponent
我们直接来看updateComponent
的更新过程
const updateComponent = (n1, n2) => {
// 缓存旧组件实例
const instance = (n2.component = n1.component)
// 判断是否需要更新
if (shouldUpdateComponent(n1, n2)) {
// 把最新组件 vnode 赋值给 instance.next
instance.next = n2
// 如果该组件更新在队列中(可能是作为子组件更新),先移除掉
invalidateJob(instance.update)
// 然后再调用组件更新
instance.update()
} else {
// 不需要更新,只需复制属性
n2.component = n1.component
n2.el = n1.el
instance.vnode = n2
}
}
这里有一个优化,就是用shouldUpdateComponent
函数判断了一下是否需要更新,主要是比较组件vnode
的props
、children
等属性有无改变,这样的提前判断会避免不必要的渲染。
如果需要渲染,会把最新的组件vnode
赋值给instance.next
,这在下面调用组件首次渲染时注册的instance.update
副作用渲染函数时会使用到。
如果该组件的更新过程已经在调度任务队列中了,因为DOM
结构是一棵树,从上面的流程中可以知道,在更新一个节点时不光会更新节点本身,还会更新节点的子节点,所以,我们需要用invalidateJob
这个方法检查一下,如果在队列中,就用删除掉这个更新过程,因为下面也会主动执行一次更新,避免二次重复更新。
如果不需要更新的话,就只用复制一下属性。
更新流程小结
我们现在来结合实例梳理一下更新流程。
<template>
<div>
<button @click="add">add</button>
<foo :num="num" />
</div>
</template>
<script>
import { ref } from 'vue'
export default {
setup () {
const num = ref(1)
function add() {
num.value += 1
}
return {
num,
add
}
}
}
</script>
// foo 组件
<template>
<div>
<p>num is, {{ num }}</p>
</div>
</template>
<script>
export default {
props: {
num: Number
}
}
</script>
首先我们有一个根组件app
,app
模板的根DOM
元素是div
,div
里面有button
元素和foo
组件。app
与foo
之间通过props: num
通信,点击button
时num
会加一。
当点击add
后,app
组件内的num
更新,由于初次渲染时在组件实例上添加了响应式的update
方法。app
组件会触发自身的update
。
const componentUpdateFn = () => {
if (!instance.isMounted) {
// ...
} else {
// 组件更新有两种情况
// 1. 由组件自身状态的改变,next 为 null
// 2. 父级调用 processComponent,next 为 VNode
let { next, vnode } = instance
if (next) {
next.el = vnode.el
updateComponentPreRender(instance, next)
} else {
next = vnode
}
const nextTree = renderComponentRoot(instance)
const prevTree = instance.subTree
instance.subTree = nextTree
patch(prevTree, nextTree)
}
}
现在是app
组件自身的状态改变,所以next
为 null。接着渲染新的组件实例拿到nextTree
与prevTree
进行patch
。
进入 patch 过程后,由于app
组件结构如下:
<div>
<button @click="add">add</button>
<foo :num="num" />
</div>
当前处理的是div
,会先进入到处理DOM
元素的过程,即processElement
,先比较子节点,再比较 props,当前div
下的子节点有button
和foo
组件,先更新button
元素,再更新foo
组件。
在更新foo
组件时,会先将foo
组件instance.next
赋值为最新的foo
组件vnode
,之后再主动调用instance.update
进入上面的副作用渲染函数,这次的实例是foo
组件且next
存在值。之后就是同样的逻辑,进入foo
组件的patch
,后面就省略掉不细说了。
整个的流程图如下:(需要注意updateProps
和updateChildren
的顺序反了)
diff 算法
之前在介绍DOM
元素的更新过程时提到,当新旧子节点都是数组的时候,会涉及到我们常说的diff
算法来进行更新操作
假设所有元素都拥有一个独一无二的
key
值。
我们可以先想一下,新旧子节点都是数组的变化情况有哪些?
无论是哪种变化最后都是由更新、添加、删除、移动这四种操作的一种或者几种的组合。
源码中分了很多种情况,大致可以分为三类:在同一位置添加一个或多个节点、在同一位置删除一个或多个节点和处理未知序列。
我们可以发现这几种情况有一个共同点,那就是从头开始的一部分和从尾部倒序的一部分(所有淡黄色的)可能是不需要改变的,不论哪种情况整体都可以分为从头部正序不需要改动的部分、中间发生变动的部分、从尾部倒序不需要改动的部分。所以在最开始,可以先进行头部和尾部的预处理。
所以源码里在diff
算法的最开始,会先从头部正序扫描和从尾部倒序扫描,以便排除类型一样的干扰项,进一步的提高效率。
const patchKeyedChildren = (c1, c2) => {
let i = 0 // 从头部遍历的索引
const l2 = c2.length // 新节点长度
let e1 = c1.length - 1 // 旧数组尾部的索引
let e2 = l2 - 1 // 新数组尾部的索引
// 1. 从头部开始遍历
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = c2[i]
if (isSameVNodeType(n1, n2)) {
patch(n1, n2)
} else {
break
}
i++
}
// 2. 从尾部开始遍历
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = c2[e2]
if (isSameVNodeType(n1, n2)) {
patch(n1, n2)
} else {
break
}
e1--
e2--
}
// ...
}
这段代码就像上面说到的那样,先从头部正序扫描,再从尾部倒序扫描,终止的条件是当前索引不能越界或者遇到新旧数组序列中的节点,类型不一样或者key
值不一样,扫描到相同的节点会进行patch
更新,这里不用操心当前的节点到底是否需要更新,patch
函数内部会做相关的判断。
同一位置的添加、删除
这种情况相对比较简单,只涉及到添加或者删除,且位置并没有变化。
只需要扫描头部尾部,找出是在哪个位置进行的添加或删除,之后再进行相应的操作即可。
添加节点使用这个例子,从头部和从尾部扫描完毕之后,各个变量情况如图所示。
// 有新增的情况
// (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) {
while (i <= e2) {
patch(null, c2[i])
i++
}
}
}
当i > e1 && i <=2
时,说明新子节点在旧的子节点上有新增,所以需要将新子节点数组[i,e2]
范围类的子节点挂载,
而当i > e1 && i > e2
时,说明新子节点在旧的子节点上有删除,需要将旧子节点数组[i,e1]
范围内的子节点卸载。
// 4. 有删除的情况
// (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])
i++
}
}
未知数组序列
如果没有命中上面的两种情况,那么就需要处理未知数组序列了。看一下下面这个例子:
我们先来分析一下发生了哪些变化:
- 节点的移动:节点
c
和节点e
位置交换了 - 节点的添加:新增了节点
h
经过上面的正序和倒序的比较,我们找到了未知序列的范围,我们首先遍历新的未知序列建立一个键为节点的key
,值为节点在新数组的索引的map
,称为keyToNewIndexMap
。
// 5. 未知序列
// old: a b [c d e] f g
// new: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
else {
const s1 = i // 旧未知序列开始的索引
const s2 = i // 新未知序列开始的索引
// 5.1 为新未知序列建立 <key,index> 的 map
const keyToNewIndexMap = new Map()
// 遍历新未知序列
for (i = s2; i <= e2; i++) {
const nextChild = c2[i]
// 判断 key 有无重复
if (nextChild.key != null) {
if (keyToNewIndexMap.has(nextChild.key)) {
// 警告 key 重复 ...
}
keyToNewIndexMap.set(nextChild.key, i)
}
}
// ...
}
然后我们遍历一次旧未知序列,通过对比key
或者是节点的类型找到旧节点在新数组的索引,判断节点是被移除还是移动。
// 5.2 遍历旧未知序列,尝试 patch 节点并删除不再存在的节点
let j
let patched = 0 // 已 patch 的节点数量
const toBePatched = e2 - s2 + 1 // 待被 patch 的节点数量
let moved = false // 是否有节点需要移动
let maxNewIndexSoFar = 0 // 用于跟踪是否有节点的移动
// 这个数组的 index 代表新子节点数组元素在新未知序列的索引,
// 值代表旧数组元素在旧未知序列的索引(有 + 1 的偏移量,避免和默认值 0 冲突)
// oldIndex = 0 是一个特殊值,表示新节点没有对应的旧节点。
// 用于确定最长递增子序列
const newIndexToOldIndexMap = new Array(toBePatched).fill(0)
for (i = s1; i <= e1; i++) {
const prevChild = c1[i]
if (patched >= toBePatched) {
// 所有的新子节点已 patch,剩下的旧的子节点需要删除
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
let newIndex // 旧子节点在新子节点数组的索引
// 获取 newIndex
if (prevChild.key != null) {
// 旧子节点有 key,直接拿
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
// 旧子节点没有 key 就尝试在新未知序列找一个同样类型的子节点
for (j = s2; j <= e2; j++) {
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j])
) {
newIndex = j
break
}
}
}
if (newIndex === undefined) {
// 如果没找到 newIndex,那么把旧子节点卸载
unmount(prevChild)
} else {
newIndexToOldIndexMap[newIndex - s2] = i + 1
// maxNewIndexSoFar 初始值是 0
// 每次 maxNewIndexSoFar 赋值的是当前旧子节点在新数组中的索引
// 如果新数组的顺序和旧数组一样,那么就是递增的
// false 说明顺序发生改变
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
patch(prevChild, c2[newIndex])
patched++
}
}
在遍历的过程中,如果已 patch 的节点数量大于或等于待被 patch 的节点数量(patched >= toBePatched
),即所有的新子节点已 patch,那么剩下的旧的子节点就需要卸载。
经过获取旧子节点在新子节点数组的索引newIndex
的过程后,
如果没找到newIndex
,那么说明没有新节点和旧节点对应,旧节点被删除需要卸载;
如果有newIndex
,那么就需要和maxNewIndexSoFar
比较。maxNewIndexSoFar
初始值为 0,当newIndex >= maxNewIndexSoFar
时,就将newIndex
赋值给maxNewIndexSoFar
。如果如果新数组的顺序和旧数组一样,那么每次的newIndex
应该是递增的;如果不是,说明节点有移动的情况。
最后再patch
一下新旧节点。
最后就到了处理移动和新增节点的步骤
源码在这里用了最长递增子序列算法
得到increasingNewIndexSequence
,算法的实现可以查看 leetcode 300. 最长递增子序列 的题解
那么为什么要求最长递增子序列
呢?主要是为了减少移动操作。
递增子序列表明哪些在新数组中的节点是和旧数组一样的顺序,那么我们就只需要去移动那些不在递增子序列的节点就可以了
// 5.3 移动和新增
// 只有当节点移动时,才能生成最长递增子序列
// 求最长递增子序列
// eg. [10,9,2,5,3,7,101,18] -> [2,3,7,101]
// https://leetcode-cn.com/problems/longest-increasing-subsequence/
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: []
j = increasingNewIndexSequence.length - 1
// 倒序遍历新未知序列
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
const nextChild = c2[nextIndex]
if (newIndexToOldIndexMap[i] === 0) {
// 新节点,挂载
patch(null, nextChild)
} else if (moved) {
if (j < 0 || i !== increasingNewIndexSequence[j]) {
// 没有递增子序列或者当前索引不是递增子序列里的值,需要移动
move(nextChild)
} else {
// 当前索引是最长递增子序列里的值,j 指向下一个
j--
}
}
}
得到最长递增子序列后,我们倒序遍历新未知序列,因为无论是在patch
中还是下面移动节点的move
方法,其插入节点的操作都是使用insertBefore
向前插入。在每一次倒序遍历的时候,如果需要的话我们可以很轻松的选取上一次已经处理完毕的节点作为基准,把当前节点,插入到它的前面。
进入到每一轮的遍历,其实会出现三种情况:
- 使用
newIndexToOldIndexMap
用当前的新数组索引查找旧数组索引,发现是初始值0
,表示旧数组中没有这个节点,那么使用patch
方法挂载一个新的节点即可。 - 当前的索引不在最长递增子序列中(
j < 0
会越界,所以提前可以确定不存在),说明当前节点需要移动,那么调用move
方法即可。 - 当前的索引恰好是最长递增子序列的值,说明该节点不需要移动,维护
j
变量。
到这儿,完成了对于未知序列的更新就完成了,下面看一下当前这个例子的具体执行过程。
没有 key 值的情况
上面的流程一直在假设每一个节点都有一个独一无二的key
值,如果我们不写key
值会怎样呢?
因为一般数组渲染都会使用
v-for
,所以在这里这个没有 key 值的情况指所有的新旧数组节点都没有key
,而非有的节点存在key
,有的节点不存在key
。
如果没有写key
值,在patchChildren
函数内,会根据patchFlag
进入patchUnkeyedChildren
这个函数内。
const patchUnkeyedChildren = (c1, c2) => {
c1 = c1 || []
c2 = c2 || []
const oldLength = c1.length
const newLength = c2.length
// 公共最小长度
const commonLength = Math.min(oldLength, newLength)
for (let i = 0; i < commonLength; i++) {
const nextChild = c2[i]
patch(c1[i], nextChild)
}
if (oldLength > newLength) {
// 卸载旧节点
unmountChildren(c1, commonLength)
} else {
// 挂载新节点
mountChildren(c2, commonLength)
}
}
我们发现 vue 对于没有 key 的情况的处理非常的简单粗暴,直接先获取新旧数组的公共最小长度,遍历这个长度之前的节点,挨个 patch。
之后比较oldLength
和newLength
:
- 如果
oldLength > newLength
,说明有删除的节点,那么就从公共最小长度开始把后面的节点卸载 - 否则,说明有新增的节点,那么就从公共最小长度开始把后面的节点挂载
举个例子:
这种做法的缺点也很明显,没有利用任何旧节点,全部进行无脑的patch
更新。所以我们使用v-for
的时候最好是加上独一无二的 key 值