在上一篇文章# Vue3源码之初始化渲染流程解读二主要分析了初始化渲染流程;在我们的App挂载到根节点之后,如果响应式数据值发生变化,那么这个时候将会执行Vue的更新渲染流程。关于vue的更新渲染过程,在面试中也是经常被问到,如:
- 数据变化是如何导致页面发生更新渲染的?
- data中的数据发生变化会一定会导致更新渲染吗?
- 在更新渲染的过程中patch是对比新旧完整的vdom树吗?
- 在patch的过程中什么时候会进行耗时的diff?
- 可以简单描述下diff的流程吗?
- vue3对比vue2在diff方面有哪些优化?
本篇文章,就带大家通过学习源码的方式,了解Vue3的更新渲染流程。
1. 响应式数据变化是如何导致更新渲染的?
先看一个我们非常熟悉的挂载示例
// App.vue
<template>
<div>{{msg}}</div>
</template>
<script>
import { defineComponent, ref} from 'vue';
export default defineComponent({
setup() {
const msg = ref('hello vue3');
return {
msg
}
}
});
</script>
// index.js
import { createApp } from 'vue';
import App from '@/App.vue';
const app = createApp(App);
app.mount('#app');
在我们调用createApp方法传入根组件App,我们在初始化渲染流程中知道,由于App是组件类型,在Patch方法内部通过shapeFlags与运算判断则会进入processComponent处理组件方法内部。
const patch: PatchFn = (
n1, // old vnode
n2, // new vnode
container, // 目标容器
anchor = null,
parentComponent = null,
parentSuspense = null,
isSVG = false,
slotScopeIds = null,
optimized = false
) => {
// 旧节点与新节点类型不一致,卸载旧节点
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}
// 获取新vnode信息
const { type, ref, shapeFlag } = n2
// 根据 Vnode 类型判断
switch (type) {
// 文本类型
case Text:
processText(n1, n2, container, anchor)
break
// 注释
case Comment:
processCommentNode(n1, n2, container, anchor)
break
// 静态节点类型
case Static:
if (n1 == null) {
mountStaticNode(n2, container, anchor, isSVG)
} else if (__DEV__) {
patchStaticNode(n1, n2, container, isSVG)
}
break
// Fragment 类型
case Fragment:
processFragment(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
break
// 元素类型、组件类型、teleport、supense
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
// 按位与运算,判断是一个元素类型
processElement(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// 按位与运算,判断是一个组件类型
processComponent(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (__DEV__) {
warn('Invalid VNode type:', type, `(${typeof type})`)
}
}
}
patch方法首先根据type检测是否为文本、注释、静态节点、Fragment等,最后在default内部处理根据shapeFlags判断真实元素和组件类型,由于App是组件类型,所以会进入processComponent方法
const processComponent = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
n2.slotScopeIds = slotScopeIds
if (n1 == null) {
// ....
// 挂载
mountComponent(
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
)
} else {
// 更新
updateComponent(n1, n2, optimized)
}
}
processComponent方法内不根据传入的old vnode是否存在执行挂载组件或者更新组件,在# Vue3源码之初始化渲染流程解读二中,我们知道在第一次执行mountComponent方法内部会依据创建的组件instance执行setupRenderEffect方法,看下该方法的内部实现,我们就找到答案了。
// 只关心核心代码部分
const setupRenderEffect: SetupRenderEffectFn = (
instance,
initialVNode,
container,
anchor,
parentSuspense,
isSVG,
optimized
) => {
// 创建响应式依赖函数并保存在instance.update
instance.update = effect(function componentEffect() {
// 挂载
if (!instance.isMounted) {
// 组件生命周期钩子函数
instance.emit('hook:beforeMount')
// 渲染组件vnode并保存instance.subTree
const subTree = (instance.subTree = renderComponentRoot(instance))
// 挂载
patch(
null,
subTree,
container,
anchor,
instance,
parentSuspense,
isSVG
)
// 保存已挂载dom节点信息
initialVNode.el = subTree.el
// 组件生命周期钩子函数
instance.emit('hook:mounted')
} else {
// 更新update
let { next, bu, u, parent, vnode } = instance
// 缓存一份新vnode
let originNext = next
// 组件自身发起的更新 next 为 null, 父组件发起的更新 next 为 下一个状态的组件VNode
if (next) {
next.el = vnode.el
updateComponentPreRender(instance, next, optimized)
} else {
next = vnode
}
// 组件生命周期钩子函数
instance.emit('hook:beforeUpdate')
// 渲染新vnode树
const nextTree = renderComponentRoot(instance)
// 获取上一份旧vnode树
const prevTree = instance.subTree
// 再缓存一份新vnode树
instance.subTree = nextTree
// 新旧vnode树进行patch
patch(
prevTree,
nextTree,
// parent may have changed if it's in a teleport
hostParentNode(prevTree.el!)!,
// anchor may have changed if it's in a fragment
getNextHostNode(prevTree),
instance,
parentSuspense,
isSVG
)
// 更新dom
next.el = nextTree.el
// 组件生命周期钩子函数
instance.emit('hook:updated')
}
})
}
通过上面的setupRenderEffect函数内部流程我们看到,通过effect创建了一个响应式依赖函数并保存在组件实例instance.update上面,在# Vue3源码之依赖收集和触发更新文章中,我们知道,effect就是一个将传入参数fn函数包装为副作用函数的方法。举个例子
const num = ref(0)
effect(() => {
console.log('执行了')
console.log(num.value)
})
setTimeout(() => {
num.value += 1;
}, 100)
由于在effect函数内部访问了响应式数据num,所以在num值改变的时候,effect包括的函数会执行,这就是响应式依赖副作用函数。
回答第一个问题:响应式数据变化是如何导致更新渲染的? 在我们的首次挂载阶段,组件类型的vnode会在setupRenderEffect方法内部通多effect()创建一个响应式依赖副作用函数,并保存在组件实例instance.update上面,当组件模板内部依赖的响应式数据变化的时候,instance.update会再次执行,由于在挂载阶段instance.isMounted已经被设为true,所以在instance.update方法内部会执行更新渲染流程。
接着回答第二个问题:data中的数据发生变化会一定会导致更新渲染吗? vue2中响应式数据全部在data里面声明,而在vue3中,我们可以使用Composition Api在setup内部创建响应式数据,其最后依然会被合并到组件instance.data上;我们在模板中使用过的响应式数据,才会被依赖收集,假如声明的响应式数据,最终没有被render访问使用,那么自然不会被依赖收集,即未被使用的响应式数据发生变化,不会导致instance.update方法执行,只有声明的响应式数据被render访问使用,这样在发生数据变化时候,才会使得instance.update被重新执行,导致页面更新渲染。
2. patch更新流程
响应式数据发生变化,组件实例instance.update副作用函数会被调用,内部根据instance.isMounted属性判断是否已挂载完成;在判断已挂载的前提下,则会执行更新相关的处理逻辑;
在处理更新逻辑里面我们看到,主要通过获取到新旧vnode dom树,调用patch函数进行更新渲染相关的操作。
2. 1 首先判断新旧节点type类型是否一致
// 工具方法--判断新旧节点是否同一类型
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
// type 节点类型相同,key值相等
return n1.type === n2.type && n1.key === n2.key
}
// patch
if (n1 && !isSameVNodeType(n1, n2)) {
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}
- n1代表旧vnode
- 如果n1存在,即为更新过程,因为初始化流程n1为null
- n1存在并且n1和n2节点类型不一致,即卸载旧节点
2. 2 节点类型相同情况下--根据不同节点类型执行不同处理
文本类型
const processText: ProcessTextOrCommentFn = (n1, n2, container, anchor) => {
if (n1 == null) {
// 挂载
// 不存在就text,则将新text vnode插入
hostInsert(
(n2.el = hostCreateText(n2.children as string)),
container,
anchor
)
} else {
// 更新
// 获取n1旧dom节点并更新到n2
const el = (n2.el = n1.el!)
// 内容文本不相等--以新文本内容更新dom
if (n2.children !== n1.children) {
hostSetText(el, n2.children as string)
}
}
}
注释
const processCommentNode: ProcessTextOrCommentFn = (
n1,
n2,
container,
anchor
) => {
if (n1 == null) {
// 挂载阶段
hostInsert(
(n2.el = hostCreateComment((n2.children as string) || '')),
container,
anchor
)
} else {
// 更新
// 获取旧dom节点并保存到新vnode.el
n2.el = n1.el
}
}
静态节点
const patchStaticNode = (
n1: VNode,
n2: VNode,
container: RendererElement,
isSVG: boolean
) => {
// 只有在开发阶段会出现静态节点内容发生变化
if (n2.children !== n1.children) {
// 静态节点子元素不相等
const anchor = hostNextSibling(n1.anchor!)
// remove existing
removeStaticNode(n1)
// insert new
;[n2.el, n2.anchor] = hostInsertStaticContent!(
n2.children as string,
container,
anchor,
isSVG
)
} else {
// 相等-不做处理
// 缓存dom元素到新vnode.el
n2.el = n1.el
n2.anchor = n1.anchor
}
}
html element元素
const processElement = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
if (n1 == null) {
// 挂载阶段
} else {
// 更新阶段
patchElement(
n1,
n2,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
}
const patchElement = (
n1: VNode,
n2: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
// 获取dom元素并更新到n2
const el = (n2.el = n1.el!)
let { patchFlag, dynamicChildren, dirs } = n2
patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS
// 将新旧节点的 props 声明提取出来,因为之后需要对 props 进行 patch 比较。
const oldProps = n1.props || EMPTY_OBJ
const newProps = n2.props || EMPTY_OBJ
let vnodeHook: VNodeHook | undefined | null
// 触发钩子onVnodeBeforeUpdate
if ((vnodeHook = newProps.onVnodeBeforeUpdate)) {
invokeVNodeHook(vnodeHook, parentComponent, n2, n1)
}
// 触发指令钩子
if (dirs) {
invokeDirectiveHook(n2, n1, parentComponent, 'beforeUpdate')
}
// 如果此时元素被标记过 patchFlag,则会通过 patchFlag 进行按需比较
if (patchFlag > 0) {
if (patchFlag & PatchFlags.FULL_PROPS) {
// 如果元素的 props 中含有动态的 key,则需要全量比较
patchProps(
el,
n2,
oldProps,
newProps,
parentComponent,
parentSuspense,
isSVG
)
} else {
// 当 patchFlag 为 CLASS 时
if (patchFlag & PatchFlags.CLASS) {
// 当新旧节点的 class 不一致时,此时会对 class 进行 patch
if (oldProps.class !== newProps.class) {
hostPatchProp(el, 'class', null, newProps.class, isSVG)
}
}
// 当 patchFlag 为 STYLE 时,会对 style 进行更新
if (patchFlag & PatchFlags.STYLE) {
// 这时每次 patch 都会进行的,这个 Flag 会在有动态 style 绑定时被加入
hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG)
}
// 动态文本text
if (patchFlag & PatchFlags.TEXT) {
// 新旧节点文本发生变化
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string)
}
}
} else if (!optimized && dynamicChildren == null) {
// unoptimized, full diff
// 全量比较
patchProps(
el,
n2,
oldProps,
newProps,
parentComponent,
parentSuspense,
isSVG
)
}
// 是否存在动态子节点
if (dynamicChildren) {
// 调用 patchBlockChildren 仅仅更新动态的子节点
patchBlockChildren(
n1.dynamicChildren!,
dynamicChildren,
el,
parentComponent,
parentSuspense,
areChildrenSVG,
slotScopeIds
)
} else if (!optimized) {
// 对子节点进行全量更新。
patchChildren(
n1,
n2,
el,
null,
parentComponent,
parentSuspense,
areChildrenSVG,
slotScopeIds,
false
)
}
}
在处理元素节点类型时我们看到,主要更具模板编译创建render函数的过程中增加优化标识PatchFlags进行靶向更新处理,主要分为PatchFlags大于0和小于0两种情况:
大于0情况:
- props 中含有动态的 key,则需要全量比较patchProps
- 动态class
- 动态style
- 动态文本
小于0情况: - 不包含动态子节点情况下,仅执行全量patchProps比较
再处理子节点:
- 存在动态子节点,则patchBlockChildren仅更新动态子节点
- 不存在动态子节点,则patchChildren执行完整的子节点patch
组件类型
const processComponent = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
if (n1 == null) {
// 挂载
mountComponent(
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
)
} else {
// 更新
updateComponent(n1, n2, optimized)
}
}
我们可以看到,如果子元素为组件类型,则会递归的调用patch进行组件的挂载或者更新渲染。
3. patch的过程中什么情况下会进行耗时的diff计算
我们可以大概思考一下,假如一个dom元素的子元素是文本内容或者注释类型,那这个时候只需要判断子元素更新前后的文本内容值是否相等,来执行是否更新dom操作,但假如一个dom元素的子元素为多个元素类型,即:
<div>
<p>文本1</p>
<p>文本2</p>
<p>文本3</p>
</div>
在这个情况下,一个元素类型的子元素为多个元素的情况下,就需要遍历比对更新前后的新旧vnode,检查哪些子元素属于新添加,哪些属于修改,哪些属于移动、哪些属于删除等,此时即为我们常说的diff算法。
在开始了解diff之前,我们需要首先了解两个点:
dynamicChildren:在模板编译阶段添加的优化标识,表明在diff阶段,只比对动态变化的子节点
<div>
<p>文本1</p>
<p>文本2</p>
<p>{{msg}}</p>
</div>
在代码的示例部分,前两个p标签属于静态节点,会被忽略跳过,只需要检查第三个动态节点
- patch方法最后一个参数:
optimized,即在patch被调用过程中,是否包含有优化标识。
在上面的patchElement方法内部,我们可以看到以下核心:
let { patchFlag, dynamicChildren, dirs } = n2
// 是否存在动态子节点
if (dynamicChildren) {
// 仅仅更新动态的子节点
patchBlockChildren()
} else if(!optimized) {
// full children diff
patchChildren()
}
接着看下patchBlockChildren
// The fast path for blocks.
const patchBlockChildren: PatchBlockChildrenFn = (
oldChildren,
newChildren,
fallbackContainer,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds
) => {
for (let i = 0; i < newChildren.length; i++) {
const oldVNode = oldChildren[i]
const newVNode = newChildren[i]
patch(
oldVNode,
newVNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
true // 注意,optimized设置为true
)
}
}
在处理动态子节点的情况下比较简单,就是以新vnode为基础,循环遍历新旧vnode进行patch,即向下递归进入patch处理动态节点class、style、props、text、component、children、component等。
再看下patchChildren
const patchChildren: PatchChildrenFn = (
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized = false
) => {
// 旧vnode.children
const c1 = n1 && n1.children
// 旧vnode类型
const prevShapeFlag = n1 ? n1.shapeFlag : 0
// 新vnode.children
const c2 = n2.children
// 获取新vnode 标识
const { patchFlag, shapeFlag } = n2
// children has 3 possibilities: text, array or no children.
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 首先处理新children为text文本情况
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 新vnode.children为text,旧children为数组即多元素类型,卸载旧children
unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
}
// 新旧children同为文本且不相等,更新文本内容
if (c2 !== c1) {
hostSetElementText(container, c2 as string)
}
} else {
// 新children为多元素
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 新旧vnode children都为多元素情况
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// two arrays full diff
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
// 旧children为文本或者注释,卸载旧children
unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
}
} else {
// 旧children为text或者null,新vnode children为文本, 更新/添加文本内容
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(container, '')
}
// 旧children为text或者null,新vnode children为多元素,直接挂载新children
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
}
}
}
总结下patchChildren的处理流程:
首先获取旧vnode属性prevShapeFlag, 获取新vnode属性ShapeFlag和PatchFlag,依据新旧节点信息,来比对新旧children,新旧children都可能为:文本text节点、多元素Array节点、无子元素三种情况。
首先:新children为文本类型的子节点- 旧children是数组型,直接卸载
- 旧children为文本且新旧文本内容不相等,以新文本内容替换旧文本内容
其次:新children为Array多元素节点- 旧children也同为Array多元素节点,则进行patchKeyedChildren--full diff
- 旧children为text或者为空,卸载旧children,挂载新children
最后:新children为空- 旧children为Array多元素或者文本,直接卸载
回答第四个问题:在patch的过程中什么时候会进行耗时的diff? 通过上面的分析源码我们看到,在更新前后新旧元素节点的子元素都同为Array包含有多元素的情况下,才会进行diff新旧节点的比对计算。
4. diff 流程
在新旧子节点都是数组的情况下,即会进行较为效率低下和耗时的diff计算更新,在刚开始接触vnode时候,我们可能会有疑问,为什么要进行新旧vnode diff呢?为什么不直接删除旧dom再添加新子节点dom元素呢?
如果我们直接进行对真实的dom操作,旧子节点dom元素移除,新dom子节点元素添加,这将会带来大量的dom操作,并最终导致浏览器的重排和重绘,带来更大的页面更新渲染操作。举个例子,假如我们通过使用v-for在页面创建了一个长列表,而这时候在数据最后添加了一条数据,这时候如果通过diff计算,我们只需要最终向页面添加一个dom元素的操作;而如果使用暴力点的操作,将原有的列表dom全部删除,再以新数据重新向页面添加dom,这将会使得没有发生变化的dom出现没必要的删除和添加操作。
所以,在更新阶段的vnode进行diff计算,其主要目标是尽可能实现对真实dom的引用保留,减少不必要的真实dom操作开销。不过这也是以牺牲部分计算性能为代价的。
首先,我们要需要知道,在更新前后,一个子节点可能发生的变化有4种情况:
- 新增
- 删除
- 移动
- 修改
其次,diff计算做出了一个假定,假定每个元素都具有唯一的key
vue3相比vue2在diff的计算算法做出了优化,使得diff计算更加高效。
4. 1 从前开始扫描
// patchKeyedChildren
let i = 0 // 循环起始
const l2 = c2.length // 新children 长度
let e1 = c1.length - 1 // 旧children循环结束标志
let e2 = l2 - 1 // 新children循环结束标志
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
if (isSameVNodeType(n1, n2)) {
// 新旧vnode为相同节点,patch
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
break
}
// 标记从前比对相同位置
i++
}
第一:从新旧vnode头部开始扫描,直到遇到新旧节点类型不相同,跳出循环。新旧节点如果为同一节点,则进行patch更新。
4. 2 从尾部扫描
// patchKeyedChildren
let i = 0 // 循环起始
const l2 = c2.length // 新children 长度
let e1 = c1.length - 1 // 旧children循环结束标志
let e2 = l2 - 1 // 新children循环结束标志
while (i <= e1 && i <= e2) {
// 从尾部开始取值
const n1 = c1[e1]
const n2 = (c2[e2] = optimized
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]))
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
break
}
// 修改结束循环状态
e1--
e2--
}
第二:从新旧vnode尾部开始扫描,知道遇到新旧节点类型不相同,跳出循环;新旧节点如果为同一节点,则进行patch更新。
4. 3 处理中部插入新节点
let i = 0 // 循环起始
const l2 = c2.length // 新children 长度
let e1 = c1.length - 1 // 旧children循环结束标志
let e2 = l2 - 1 // 新children循环结束标志
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,
(c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i])),
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
i++
}
}
}
第三:在前面的从前和从尾部扫描结束之后,i > e1,即旧节点遍历完毕,i <= e2,即中间剩余未遍历的新节点,如下示例:
- 旧:(a,b) (c,d)
- 新:(a,b) e,f,(c,d) 在这种情况下,只需将e,f新节点插入挂载。
4. 4 旧中间节点删除
let i = 0 // 循环起始
const l2 = c2.length // 新children 长度
let e1 = c1.length - 1 // 旧children循环结束标志
let e2 = l2 - 1 // 新children循环结束标志
if (i > e1) {
// 接上面
} else if (i > e2) {l
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
第四:如果i > e2并且i <= e1条件下,即经过从前和从尾部扫描,新节点遍历结束,旧节点中间有剩余,即需要将旧中部节点删除卸载。如下示例:
- 旧: (a,b) c, d, (e, f)
- 新:(a,b) (e, f) 在这种情况下,需要将旧节点c,d删除。
4. 5 未知序列
上面的4.3新节点中间插入和4.4旧节点中间节点删除情况都是在理想的情况下,即新旧节点没有发生移动的情况下,在经过了4.1头部扫描和4.2尾部扫描,在新旧节点中,如果中间节点发生移动、新增、删除、修改等未知的操作情况下,就要进入到diff的核心处理部分--未知序列的处理
// 旧未知序列起始索引
const s1 = i
// 新未知序列起始索引
const s2 = i
// 创建新节点未知序列key---索引map {key5: 3, key6: 4}
const keyToNewIndexMap: Map<string | number, 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) {
// key--index map
keyToNewIndexMap.set(nextChild.key, i)
}
}
// 指针,记录剩下新节点索引
let j
// 新节点未知序列--已经patch过的节点数
let patched = 0
// 新节点未知序列--未patch的节点数
const toBePatched = e2 - s2 + 1
// 是否有节点需要移动
let moved = false
// 记录旧节点在新节点未知索引
let maxNewIndexSoFar = 0
// 声明一个数组,记录旧数组节点索引存放在新节点未知序列中,初始化未新节点未知序列长度[0,0,0,0]
const newIndexToOldIndexMap = new Array(toBePatched)
// 初始化[0,0,0,0]
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
// 遍历旧数组未知序列
for (i = s1; i <= e1; i++) {
// 当前旧节点
const prevChild = c1[i]
// 已经patch过的新节点数大于等于新节点未知序列长度,说明新节点未知序列部分处理完毕
if (patched >= toBePatched) {
// 多余旧节点循环移除
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
// 旧节点在新节点序列的索引
let newIndex
// 假定旧节点key存在
if (prevChild.key != null) {
// 依据旧节点key获取新节点key值对对应的索引
newIndex = keyToNewIndexMap.get(prevChild.key)
}
// 当前节点在新节点未知序列不存在
if (newIndex === undefined) {
// 移除节点
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
// 把老节点索引,记录在存放新节点数组中
newIndexToOldIndexMap[newIndex - s2] = i + 1
// maxNewIndexSoFar初始为0
// maxNewIndexSoFar赋值为当前旧节点在新节点未知序列的索引
if (newIndex >= maxNewIndexSoFar) {
// 说明旧节点未知序列和新节点未知序列的顺序是一样的递增
maxNewIndexSoFar = newIndex
} else {
// 顺序发生变化,需要移动
moved = true
}
// patch新旧节点中相同节点
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
// 未知序列处理过标识自增
patched++
}
}
上面是对未知序列处理的diff核心,总结:
- 以新旧节点未处理序列分别组成新待处理序列
- 遍历新节点未知序列,并创建新未知序列的节点key:index的map映射结构keyToNewIndexMap
- 遍历旧节点未知序列,通过节点key获取旧节点在新节点未知序列的索引
- 新节点不存在旧节点key对应的索引,即旧节点需要被删除
- 新节点未知序列中存在旧节点key对应的索引,newIndexToOldIndexMap旧节点索引保存在新未知序列中
- maxNewIndexSoFar初始化为0,循环过程中newIndex大于maxNewIndexSoFar表明旧节点在新未知序列的顺序保持递增,更新maxNewIndexSoFar=newIndex,如果newIndex小于maxNewIndexSoFar说明旧节点在新未知序列中有移动
在经过了对老节点的遍历,知道了哪些旧节点在新节点未知序列中发生了移动,剩下的就要处理未知序列的移动和新节点的挂载
// 如故有移动,获取旧节点在新未知序列的最长稳定递增子序列
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
j = increasingNewIndexSequence.length - 1
// 倒序新数组的未知序列,因为插入节点时使用 insertBefore 即向前插,倒序遍历可以使用上一个更新的节点作为锚点
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
// 0,代表新节点为插入节点
if (newIndexToOldIndexMap[i] === 0) {
// 新节点挂载
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (moved) {
// 当前索引不是最长递增子序列里的值,需要移动
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER)
} else {
j--
}
}
}
通过getSequence(newIndexToOldIndexMap)获得旧节点在新未知序列中的最长稳定递增子序列,此子序列是不需要移动的,并以此递增子序列为基准,倒序遍历新节点未知序列,处理新节点的挂载和旧节点复用移动。
5. 不带有key的children为多个元素的处理
上面在进行diff计算过程,是在判断children都存在key的情况下,那么,如果children没有key的情况呢?
const patchUnkeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
c1 = c1 || EMPTY_ARR
c2 = c2 || EMPTY_ARR
// 旧节点children长度
const oldLength = c1.length
// 新节点children长度
const newLength = c2.length
// 取最小长度为基准
const commonLength = Math.min(oldLength, newLength)
let i
// 循环patch新旧vnode
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,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
if (oldLength > newLength) {
// 删除旧节点多余节点
unmountChildren(
c1,
parentComponent,
parentSuspense,
true,
false,
commonLength
)
} else {
// 挂载新节点
mountChildren(
c2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized,
commonLength
)
}
}
对于不带有key的children 比较过程很简单,不会涉及diff:
- 获取新旧节点children长度
- 取新旧节点最小长度为基准
- 循环新旧节点进行patch
- 旧节点过长部分,需要删除
- 新节点过长部分,挂载
6. vue3对比vue2在diff方面有哪些优化?
- 首先vue3在编译阶段做了静态节点提升,静态节点不会参与更新前后的patch
- vue3在编译阶段添加了优化标识
PatchFlags和dynmiacChildren,可以根据这些标识在patch阶段对节点做出更精准的对比更新靶向更新。 - vue2在更新渲染的patch过程中,会进行所有节点的patch包含静态节点和动态节点;其diff计算算法主要运用了新旧节点的
双端比较预判,主要针对新旧节点的前前、后后、前后、后前等4中情况的预判,在预判处理完还有剩余的情况:- 缓存旧节点未处理key值
- 检查新节点key值是否存在旧节点,不存在即为新节点,直接挂载;存在则进行新旧节点的patchNode
- vue3在更新渲染的patch过程中,只需要patch动态节点,节点本身根据
PatchFlags进行精准的靶向更新;对节点children的diff算法,首先从新旧节点的头部和尾部扫描,找出相同节点;剩余的未知序列处理方面主要应用了最长递增子序列算法。