vue3.0源码解析,patch&diff过程

什么是patch

在浏览器中,每次操作dom,都会引起一次重绘重排过程,如果短时间进行多次操作,对性能损耗很大,容易引起卡顿。
在vue中,使用虚拟dom(Virtual dom),来对真实dom的一种抽象化处理的树结构,模拟真实dom,提升性能。
而在更新dom节点时,通过对虚拟dom的对比diff(如果是更新操作)来进行对虚拟dom结构的增删改的一系列操作流程,就是patch过程。

前置了解

在解读源码之前,需要知道一下vnode中的几个属性以及“最长递增子序列”算法,以便对patch流程阅读更加清晰。

最长递增子序列算法
在对比数组类型的子节点时,当新节点不是旧节点的简单push新增或pop删除等操作,有移动删除新增等操作,会使用此算法算出最小操作节点组合,用最少的执行步骤完成对比。
详情请到LeetCode查看,里面有大量解题思路,最常见的解法是动态规划
而vue里面用了维基百科里的贪心+二分查找解法去做,相比动态规划,时间复杂度能更小。
动态规划:O(n^2);
贪心+二分查找:O(nlogn);
leetcode 300. 最长递增子序列

PatchFlags.FRAGMENT:
什么情况下会为此值,STABLE_FRAGMENT、KEYED_FRAGMENT、UNKEYED_FRAGMENT必定有一个fragment包裹。
1、一个组件有多个根节点,会为其创建一个包裹:STABLE_FRAGMENT
2、v-if,有多个子节点: STABLE_FRAGMENT
3、v-for语句: KEYED_FRAGMENT、UNKEYED_FRAGMENT

// 源码v-for指令创建节点时fragmentFlag变量就是判断是否使用哪个。
const fragmentFlag = isStableFragment ? PatchFlags.STABLE_FRAGMENT : keyProp ? PatchFlags.KEYED_FRAGMENT : PatchFlags.UNKEYED_FRAGMENT;

// 其余其他属性忽略,有兴趣请自行查看
interface VNode {
  // 节点的key属性,被当作节点的标志,用以优化
  key: string | number | symbol | null,
  // 一个枚举值,是一个标识,描述该组件的类型,值是位运算左移的结果
  shapeFlag: number,
  // 一个枚举值,也是一个标识,描述组件的特性,帮助实现vue3中patch对比的一个特性:靶向更新
  // 值大于0,即代表所对应的element在patch阶段,可以进行优化diff
  // 值小于0,即代表所对应的element在patch阶段,不需要进行diff
  // 重点:patchFlag可以代表多个状态组合
  patchFlag: number,
  // 存储子组件的变量
  // 纯文本 数组 slot对象
  children: string | VNodeArrayChildren | RawSlots | null
}

export const enum PatchFlags {
  // 动态的文本节点
  TEXT = 1,

  // 动态的class节点
  CLASS = 1 << 1,

  // 动态的style节点
  STYLE = 1 << 2,

  // 动态属性节点
  PROPS = 1 << 3,

  // 有动态key的节点,每当key改变,需要进行完整的diff
  FULL_PROPS = 1 << 4,

  // 绑定了监听事件
  HYDRATE_EVENTS = 1 << 5,

  // 不会变换子节点顺序的fragment
  STABLE_FRAGMENT = 1 << 6,
  
  // 有带key的fragment
  KEYED_FRAGMENT = 1 << 7,

  // 没有带key的fragment
  UNKEYED_FRAGMENT = 1 << 8,

  // 一个子节点只会进行非props比较
  NEED_PATCH = 1 << 9,

  // 动态slot 比如带v-for的slot插槽
  DYNAMIC_SLOTS = 1 << 10,

  // 以下类型不会被diff
  // 开发阶段注释文本,不需要diff
  DEV_ROOT_FRAGMENT = 1 << 11,

  // 静态节点,不需要diff
  HOISTED = -1,
  
  // 用来表示一个节点不需要优化模式optimized,patch时进行全比对
  BAIL = -2
}

export const enum ShapeFlags {
  // 普通html节点
  ELEMENT = 1, 

  // 函数组件
  FUNCTIONAL_COMPONENT = 1 << 1,

  // 普通有状态组件
  STATEFUL_COMPONENT = 1 << 2,

  // 子组件是纯文本
  TEXT_CHILDREN = 1 << 3,

  // 子组件是数组列表
  ARRAY_CHILDREN = 1 << 4,

  // 子组件有slot插槽
  SLOTS_CHILDREN = 1 << 5,

  // vue3 Teleport组件,具体请查看官方文档
  TELEPORT = 1 << 6,

  // vue3 Supspense组件,具体请查看官方文档
  SUSPENSE = 1 << 7,

  // 准备被KeepAlive的组件
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,

  // 已经被KeepAlive的组件
  COMPONENT_KEPT_ALIVE = 1 << 9,

  // 函数组件或普通有状态组件
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}


vnode中的dynamicChildren属性

在vnode生成过程中,如果patchFlag>0(含有动态属性、动态class、动态style、动态文案等),则会将含有动态属性的children节点映射到此属性上,进行标记,那么在diff时,就不需要全量对children遍历对比子节点,只需对dynamicChildren进行对比操作,可以减少遍历次数,加快过程。


patch函数

本文会讲出patch流程,但由于patch整体流程太过长,对于不同元素渲染会有特别处理(Fragment、自定义组件、TeleportImpl),特别处理部分不会涉及到,只总结出主要逻辑。
如有兴趣,可下载vue3源码打开packages > runtime-core > src > renderer.ts 阅读。

const patch: PatchFn = (
	// 旧节点
	n1,
	// 新节点
	n2,
	// 节点容器,父节点
	container,
	// 要以哪个节点为标准插入进这个节点的前一个位置,为空则插入到最后
	anchor = null,
	parentComponent = null,
	parentSuspense = null,
	isSVG = false,
	slotScopeIds = null,
	
	// optimized 是否优化vNode
	// __DEV__ 开发环境
	// isHmrUpdating开发热更新模式
	optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
) => {

	// 等于就是没变化不用对比,跳过此处节点
	if (n1 === n2) {
		return
	}

	// 如果tag和key值都不一样,则删除此就节点
	// patching & not same type, unmount old tree
	if (n1 && !isSameVNodeType(n1, n2)) {
		anchor = getNextHostNode(n1)
		unmount(/* 忽略参数 */)
		n1 = null
	}

	// 是否启用统一vnode处理
	if (n2.patchFlag === PatchFlags.BAIL) {
		optimized = false
		n2.dynamicChildren = null
	}


	const { type, ref, shapeFlag } = n2
	switch (type) {
		// 文本类型
		case Text:
			processText(/* 忽略参数 */)
			break

		// 注释类型
		case Comment:
			processCommentNode(/* 忽略参数 */)
			break

		// 静态节点,不会变化
		case Static:
			//   没有直接渲染,有则不用渲染,因为不会变化
			if (n1 == null) {
				mountStaticNode(/* 忽略参数 */)
			} else if (__DEV__) {
				patchStaticNode(/* 忽略参数 */)
			}
			break

		// Fragment 类型
		case Fragment:
			processFragment(/* 忽略参数 */)
			break

		default:
			if (shapeFlag & ShapeFlags.ELEMENT) {
				// 普通标签处理
				processElement(/* 忽略参数 */)
			} else if (shapeFlag & ShapeFlags.COMPONENT) {
				// 自定义组件的处理
				processComponent(/* 忽略参数 */)
			} else if (shapeFlag & ShapeFlags.TELEPORT) {
				// Teleport组件(vue原生组件)的处理
				; (type as typeof TeleportImpl).process(/* 忽略参数 */)
			} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
				// Suspense组件(vue原生组件)处理
				; (type as typeof SuspenseImpl).process(/* 忽略参数 */)
			} else if (__DEV__) {
				warn('Invalid VNode type:', type, `(${typeof type})`)
			}
	}

	// 设置dom的ref
	if (ref != null && parentComponent) {
		setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
	}
}

// 对比新旧的tag值以及key值
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  return n1.type === n2.type && n1.key === n2.key
}

流程:

  1. 第一个if判断没什么可说,如果新旧节点相同,则跳过此节点,对下下一个节点。
  2. 第二个if,直接对比新旧的tag值以及key值,如果都不一样,则删除旧节点。
  3. 第三个if,判断是否为BAIL特性,是则不开启优化模式。
  4. 来到switch分支,首先通过节点的type属性对文本类型、注释类型、静态节点和Fragment进行特殊处理,在这里Fragment因为是一个虚拟节点vue3特性,所以实际渲染会将Fragment节点的子节点patch的container为当前的container。
  5. 如果不属于上述类型,则在switch default,通过节点的shapeFlag(描述该组件的类型)进行不同处理。
  6. 先看processElement(源码在下文放出,可滚动至下方查看),processElement函数只判断是否有旧节点,如果旧节点为空则渲染,有旧节点则开始对比。

unmount函数 删除节点

unmount函数主要功能是卸载相关节点并执行删除操作,如注册了事件,则进行派发。
代码里unmountChildren最终也只是将子节点使用remove方法删除,比如遇到虚拟节点(fragments),unmountChildren将传入此fragments的child进行remove;

const unmount: UnmountFn = (
	vnode,
	parentComponent,
	parentSuspense,
	doRemove = false,
	optimized = false
) => {
	const {
		type,
		props,
		ref,
		children,
		dynamicChildren,
		shapeFlag,
		patchFlag,
		dirs
	} = vnode;

	// 删除解绑 ref
	if (ref != null) {
		setRef(ref, null, parentSuspense, vnode, true)
	}

	// 如果是准备keepAlive的组件,直接使之无效化
	if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
		; (parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
		return
	}

	// 是否为普通节点并绑定了指令
	const shouldInvokeDirs = shapeFlag & ShapeFlags.ELEMENT && dirs

	// 是否能调用钩子事件,非异步组件可调用
	const shouldInvokeVnodeHook = !isAsyncWrapper(vnode)

	let vnodeHook: VNodeHook | undefined | null;

	// 如果有onVnodeBeforeUnmount事件,则执行调用
	if (shouldInvokeVnodeHook && (vnodeHook = props && props.onVnodeBeforeUnmount)) {
		invokeVNodeHook(vnodeHook, parentComponent, vnode)
	}

	if (shapeFlag & ShapeFlags.COMPONENT) {
		// 对于自定义组件(函数组件与自定义组件都算)
		unmountComponent(vnode.component!, parentSuspense, doRemove)
	} else {
		// suspense组件处理
		if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
			vnode.suspense!.unmount(parentSuspense, doRemove)
			return
		}

		// 如果为普通节点并绑定了指令,则执行beforeUnmount回调
		if (shouldInvokeDirs) {
			invokeDirectiveHook(vnode, null, parentComponent, 'beforeUnmount')
		}

		// Teleport组件(vue原生组件)的处理
		if (shapeFlag & ShapeFlags.TELEPORT) {
			; (vnode.type as typeof TeleportImpl).remove(/* 忽略参数 */)
		} else if (
			dynamicChildren && (type !== Fragment || (patchFlag > 0 && patchFlag & PatchFlags.STABLE_FRAGMENT))
		) {
			// 如果有dynamic优化,那么只需卸载dynamicChildren里面的节点,其余静态节点可直接删除
			// fast path for block nodes: only need to unmount dynamic children.
			unmountChildren(/* 忽略参数 */)
		} else if (
			(type === Fragment && patchFlag & (PatchFlags.KEYED_FRAGMENT | PatchFlags.UNKEYED_FRAGMENT)) 
			||
			(!optimized && shapeFlag & ShapeFlags.ARRAY_CHILDREN)
		) {
			// 如果为Fragment,由于Fragment是一个虚拟节点(不会渲染到实际dom中),所以对Fragment子节点进行卸载删除
			unmountChildren(/* 忽略参数 */)
		}

		// 执行删除操作
		if (doRemove) {
			remove(vnode)
		}
	}

	// 如果注册了onVnodeUnmounted, 则派发onVnodeUnmounted和unmounted事件
	if ((shouldInvokeVnodeHook && (vnodeHook = props && props.onVnodeUnmounted)) || shouldInvokeDirs) {
		queuePostRenderEffect(() => {
			vnodeHook && 
			invokeVNodeHook(vnodeHook, parentComponent, vnode)
			shouldInvokeDirs &&
			invokeDirectiveHook(vnode, null, parentComponent, 'unmounted')
		}, parentSuspense)
	}
}

processElement函数 普通标签对比创建

这里会主要关注新旧对比的代码。
注意:patchFlag的属性同时可存在多个

流程:

  1. 如果就节点没有,则创建渲染。如果旧节点有,则进行对比;
  2. 如果有绑定beforeUpdate,则进行触发,并且为了防止自己被触发,会将allowRecurse设为false再触发事件;
  3. 如果已经进行patchFlag识别,则进行靶向更新;
  4. 如果是FULL_PROPS动态key类型或optimized=false的元素,没有进行优化,则进行全量对比;
  5. 靶向更新:如果有非FULL_PROPS动态key类型,则逐步判断是否为动态class、动态style、动态props(props会在初始化时就将props的静态常量与动态变量分离开,动态变量会额外映射到dynamicProps中,这时候只需要遍历dynamicProps即可)。
  6. 靶向更新:最后再对比TEXT,更新则直接覆盖
  7. 如果有绑定钩子函数,那么将通过queuePostRenderEffect的方式,在组件渲染完毕后,执行节点updated生命周期事件

如果需要全量对比dom,那么过程又是如何,请继续往下看

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
) => {
    isSVG = isSVG || (n2.type as string) === 'svg'
    if (n1 == null) {
        // 创建渲染
        mountElement(/* 忽略参数 */)
    } else {
        // 进行对比
        patchElement(/* 忽略参数 */)
    }
}

// 对比元素
const patchElement = (
    n1: VNode,
    n2: VNode,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
) => {
    const el = (n2.el = n1.el!)
    let { patchFlag, dynamicChildren, dirs } = n2

    const oldProps = n1.props || EMPTY_OBJ
    const newProps = n2.props || EMPTY_OBJ

    let vnodeHook: VNodeHook | undefined | null

    // toggleRecurse作用是不允许递归beforeUpdate hooks
    parentComponent && toggleRecurse(parentComponent, false)
    if ((vnodeHook = newProps.onVnodeBeforeUpdate)) {
        invokeVNodeHook(vnodeHook, parentComponent, n2, n1)
    }

    // 触发beforeUpdate事件
    if (dirs) {
        invokeDirectiveHook(n2, n1, parentComponent, 'beforeUpdate')
    }
    // 解锁
    parentComponent && toggleRecurse(parentComponent, true)

    if (dynamicChildren) {
        // 动态属性节点的处理
        // 使用dynamicChildren来直接处理
        patchBlockChildren(/* 忽略参数 */)
    } else if (!optimized) {
        //   没有经过optimized优化的,全量diff
        patchChildren(/* 忽略参数 */)
    }

    if (patchFlag > 0) {
        // 针对优化过的vnode,进行靶向更新

        if (patchFlag & PatchFlags.FULL_PROPS) {
            // 如果是动态key节点,需要全量对比
            patchProps(/* 忽略参数 */)
        } else {
            if (patchFlag & PatchFlags.CLASS) {
                // 动态calss节点,那么需要进行class比较
                if (oldProps.class !== newProps.class) {
                    hostPatchProp(/* 忽略参数 */)
                }
            }

            // style
            // 动态style节点,那么需要进行class比较
            if (patchFlag & PatchFlags.STYLE) {
                hostPatchProp(/* 忽略参数 */)
            }

            // 
            // 动态属性节点
            if (patchFlag & PatchFlags.PROPS) {
                //   变量后面加!是typescript的非空断言
                // dynamicProps的含义跟dynamicChild一样,是编辑动态传入的prop
                //   只需对比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 || key === 'value') {
                        hostPatchProp(/* 忽略参数 */)
                    }
                }
            }
        }

        //   当有动态text文本类型,则进行对比,文本类型相对简单,直接覆盖即可
        if (patchFlag & PatchFlags.TEXT) {
            if (n1.children !== n2.children) {
                hostSetElementText(/* 忽略参数 */)
            }
        }
    } else if (!optimized && dynamicChildren == null) {
        // 没有优化则全量对比
        patchProps(/* 忽略参数 */)
    }

    // 如果有绑定钩子函数,那么将通过queuePostRenderEffect的方式,在组件渲染完毕后,执行节点updated
    if ((vnodeHook = newProps.onVnodeUpdated) || dirs) {
        queuePostRenderEffect(() => {
            vnodeHook && invokeVNodeHook(/* 忽略参数 */)
            dirs && invokeDirectiveHook(/* 忽略参数 */)
        }, parentSuspense)
    }
}


processComponent函数 组件节点的对比创建

流程:

  1. 如果还未创建,则判断是否为COMPONENT_KEPT_ALIVE,是则激活keepAlive组件,否则正常创建宣传组件;
  2. 如果已创建,则调用shouldUpdateComponent判断是否需要更新组件;
  3. 需要更新,未挂载的异步组件需要特殊处理,已挂载则执行更新,同时检查更新队列是否已经有此组件在队列中,有则剔除出队列,避免子组件多次更新;
  4. 如果组件不需要更新,则把旧节点复用。
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) {
		if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
			// 对于已经被keepAlive缓存的组件做处理
			; (parentComponent!.ctx as KeepAliveContext).activate(
				n2,
				container,
				anchor,
				isSVG,
				optimized
			)
		} else {
			// 渲染组件
			mountComponent(
				n2,
				container,
				anchor,
				parentComponent,
				parentSuspense,
				isSVG,
				optimized
			)
		}
	} else {
		// 更新组件
		updateComponent(n1, n2, optimized)
	}
}

// 更新组件
const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
	const instance = (n2.component = n1.component)!;

	// 是否需要更新
	if (shouldUpdateComponent(n1, n2, optimized)) {
		if (
			__FEATURE_SUSPENSE__ &&
			instance.asyncDep &&
			!instance.asyncResolved
		) {
			// 尚未加载异步组件只需要更新子组件和props即可
			updateComponentPreRender(instance, n2, optimized)
			return
		} else {
			// next代表此次即将更新的vnode
			instance.next = n2
			
			// 检查更新队列是否有现组件,有则移除出队列,避免子组件多次更新
			invalidateJob(instance.update)
			
			// 执行更新
			instance.update()
		}
	} else {
		// 做一次copy达到复用
		n2.component = n1.component
		n2.el = n1.el
		instance.vnode = n2
	}
}

patchChildren函数 子节点对比

注:patchKeyedChildren函数较复杂,下文会单独详解
流程:

  1. 判断是否为绑定动态属性的v-for节点,如果是,则使用patchKeyedChildren(优化对比,性能好),否则使用patchUnkeyedChildren(硬对比,耗性能)
  2. 对于文本节点,如果旧节点为数组列表节点,则删除后直接覆盖,因为是文本,所以速度很快,不需要额外优化。
  3. 如果新旧节点都为数组,则使用patchKeyedChildren进行对比,如果不为数组,则卸载删除旧节点;
  4. 如果旧节点为文本节点,新节点为其他类型,则将文本置空;
  5. 如果旧节点为空,新节点为数组列表,则直接新增挂载;

下面是流程伪代码


if (有标签动态特性) {
    // 有使用自定义组件外部使用了key进行列表渲染
    if (是否为v-for并且有绑定key的节点) {
        diff优化对比
        return
    } else if (是否为v-for并且没有绑定key的节点) {
        硬对比
        return
    }
}

if (旧节点是文本) {
    if (新节点是数组) {
        卸载删除
    }

    if (文本不相等) {
        替换即可
    }
} else {
    if (旧节点是数组) {
        if (新节点是数组) {
            diff优化对比
        } else 新节点不为数组 {
            删除旧的
        }
    } else 旧节点是文本 {
        if (新节点非文本类型) {
            置空旧节点
        }

        if (到这里旧节点就是空) {
            新增挂载新节点
        }
    }
}
const patchChildren: PatchChildrenFn = (
    n1,
    n2,
    container,
    anchor,
    parentComponent,
    parentSuspense,
    isSVG,
    slotScopeIds,
    optimized = false
) => {
    const c1 = n1 && n1.children
    const prevShapeFlag = n1 ? n1.shapeFlag : 0
    const c2 = n2.children

    // patchFlag代表此元素里部分属性是动态的,非静态,在diff时只会对patchFlag>0的值做对比
    const { patchFlag, shapeFlag } = n2;
    if (patchFlag > 0) {
    	// v-for子节点处理
        if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
            // 有设置key数组子元素对比
            // 当一组节点中有些有有些没有key,可会走到这里
            patchKeyedChildren(/* 忽略参数 */)
            return
        } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
            // unkeyed
            // 没有key属性的元素对比
            // 直接对比新旧相同长度的部分(patch),删除减少的部分(unmountChildren),直接增加比旧长度长的部分(mountChildren)
            patchUnkeyedChildren(/* 忽略参数 */)
            return
        }
    }

    // 对于子节点来说,有三种可能:文本、数组、空
    // 文本节点处理
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {

        // 如果新节点为文本类型,旧节点为数组类型,则删除旧节点
        // text children fast path
        if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
            unmountChildren(/* 忽略参数 */)
        }

        //   如果新旧直接不一样则直接覆盖旧内容
        //   一样就不用,因为是文本 stirng
        if (c2 !== c1) {
            hostSetElementText(/* 忽略参数 */)
        }
    } else {

        // 普通数组列表节点处理
        if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {

            // 如果新旧节点都为数组列表,则开始对比diff流程
            if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
                patchKeyedChildren(/* 忽略参数 */)
            } else {
                // 如果新节点不为数组,则删除旧节点
                unmountChildren(/* 忽略参数 */)
            }
        } else {
            // 旧节点为文本类型,新节点非文本类型,则把vnode文本置为空
            if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
                hostSetElementText(container, '')
            }
            
            // 旧节点非数组列表(到这里已经代表等于空了),新节点为数组列表,则直接新增挂载
            if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
                mountChildren(/* 忽略参数 */)
            }
        }
    }
}

patchKeyedChildren函数 diff对比

这里就是vNode节点diff的函数,令无数前端面试者倒在这道题上,这里也会做详解,并画图解释流程。先看下源码部分

源码部分

先解释一部分函数

  • cloneIfMounted: 该子节点是否能复用,vnode.el存在则是可服用的 vnode
  • normalizeVNode: 优化Vnode,根据传入的类型进行处理。空或boolean:创建一个空的注释节点,数组列表,创建一个Fragment把Vnode包起来,对象使用cloneIfMounted处理,其他当做文本节点处理。
  • isSameVNodeType: 对比新旧节点的tag(标签名)以及传入的key是否相同
export function cloneIfMounted(child: VNode): VNode {
    return child.el === null || child.memo ? child : cloneVNode(child)
}

export function normalizeVNode(child: VNodeChild): VNode {
	//	空或boolean:创建一个空的注释节点
    if (child == null || typeof child === 'boolean') {
        // empty placeholder
        return createVNode(Comment)
    } else if (isArray(child)) {
        // 数组列表,创建一个Fragment把Vnode包起来
        return createVNode(
            Fragment,
            null,
            // #3666, avoid reference pollution when reusing vnode
            child.slice()
        )
    } else if (typeof child === 'object') {
    	// 对象使用cloneIfMounted处理
        // already vnode, this should be the most common since compiled templates
        // always produce all-vnode children arrays
        return cloneIfMounted(child)
    } else {
        // strings and numbers
        // 其他当做文本节点处理
        return createVNode(Text, null, String(child))
    }
}

// 对比新旧节点的tag(标签名)以及传入的key是否相同
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
    return n1.type === n2.type && n1.key === n2.key
}

源码:

const patchKeyedChildren = (
    c1: VNode[],
    c2: VNodeArrayChildren,
    container: RendererElement,
    parentAnchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
) => {
    let i = 0
    const l2 = c2.length
    let e1 = c1.length - 1
    let e2 = l2 - 1

    // 1、从前(头)开始进行对比
    while (i <= e1 && i <= e2) {
        const n1 = c1[i]

        //   是否能优化重用vnode
        const n2 = (c2[i] = optimized
            ? cloneIfMounted(c2[i] as VNode)
            : normalizeVNode(c2[i]))

        //    新旧节点的tag(标签名)以及传入的key是否相同
        if (isSameVNodeType(n1, n2)) {
            patch(/* 忽略参数 */)
        } else {
            break
        }
        i++
    }

    // 2、从后(尾)开始进行对比
    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(/* 忽略参数 */)
        } else {
            break
        }
        e1--
        e2--
    }

    // 前后遍历完,只剩中间部分不同的部分

    if (i > e1) {
        // 3、对于简单的左右两边新增:左边或右边有新增的数据
        // (在只有一边的情况下才会走进这里)

        // 简单的往右边push新增数据,从前开始遍历的while就能完成对比
        // (a b)
        // (a b) c
        // i = 2, e1 = 1, e2 = 2

        // 简单往左边unshift新增数据,从后开始遍历的while就能完成对比
        // (a b)
        // c (a b)
        // i = 0, e1 = -1, e2 = 0
        if (i <= e2) {
            //   插入index
            const nextPos = e2 + 1;
            // 插入基准vnode
            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])),
                   /* 忽略参数 */
                   )
                i++
            }
        }
    } else if (i > e2) {
        // 4、对于简单的左右两边删除:左边或右边有删除的数据
        // (在只有一边的情况下才会走进这里)
        
        // 简单的往右边删除n个节点,从前开始遍历的while就能完成对比
        // (a b) c
        // (a b)
        // i = 2, e1 = 2, e2 = 1

        // 简单的往左边push n个节点,从前开始遍历的while就能完成对比
        // a (b c)
        // (b c)
        // i = 0, e1 = 0, e2 = -1
        while (i <= e1) {
            // 那么删除旧节点
            unmount(/* 忽略参数 */)
            i++
        }
    } else {
        // 5、剩下的就是中间一块不确定,是否新增 or 删除 or 复用
        // 如果新旧头尾都不一样,也会是整个列表进行对比。

        // 例子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

        // 例子2:新比旧短
        // [i ... e1 + 1]: a b [c d e z l] f g
        // [i ... e2 + 1]: a b [e d c h] f g
        // i = 2, e1 = 6, e2 = 5
        
        // 例子3: 纯变换位置
        // [i ... e1 + 1]: a b [h c d e] f g
        // [i ... e2 + 1]: a b [e d c h] f g
        // i = 2, e1 = 5, e2 = 5
        const s1 = i // prev starting index
        const s2 = i // next starting index

        // 5.1 将不确定操作的新节点部分放进map,已设置的key作为map的key
        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)
            }
        }
	
        // 5.2 对比中间不确定操作的节点
        let j
        let patched = 0

        // 计算新节点中还有几个节点需要被patched
        const toBePatched = e2 - s2 + 1

        // 是否需要移动标记
        let moved = false

        // 记录这次对比的旧节点到新节点最长可复用到哪里
        // 比如例子一maxNewIndexSoFar会等于4
        let maxNewIndexSoFar = 0

        // 这是一个新节点对应旧节点的数组  index = 新节点需要对比的集合Index  value = 旧节点index + 1
        // 做最长递增子序列时使用
        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) {
                // 如果比对的时候i超过了新节点需要对比的部分(需要对比的新节点比旧节点短),则删除旧节点
                // 比如 old [a, b, c ,d, e]  new [ a, c, b, e] 旧节点的 d是此时需要unmount的节点
                unmount(prevChild, parentComponent, parentSuspense, true)
                continue
            }

            let 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] as VNode)
                    ) {
                        newIndex = j
                        break
                    }
                }
            }


            if (newIndex === undefined) {
                // 如果旧节点对应的新节点map里面找不到,则代表不存在,卸载删除
                unmount(prevChild, parentComponent, parentSuspense, true)
            } else {
                // 如果旧节点在新节点中能复用,则记录下来
                newIndexToOldIndexMap[newIndex - s2] = i + 1
                if (newIndex >= maxNewIndexSoFar) {
                    maxNewIndexSoFar = newIndex
                } else {
                    moved = true
                }

                // 对比新旧变化
                patch(/* 忽略参数 */)
                patched++
            }
        }

        // 5.3 如果新节点有旧节点移动的迹象,拿到newIndexToOldIndexMap计算最长递增子序列
        // 这样可以操作最少的dom来更新
        // getSequence返回的是传入数组的index
        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

            // 到这里剩下的只有三种情况, 该删除的节点前面逻辑已经删掉了
            // 1、节点位置没有变,只是被夹在改变的数据中
            // 2、节点改变了位置
            // 3、新增节点

            if (newIndexToOldIndexMap[i] === 0) {
                // 如果新节点的元素在旧节点里面没有,代表不可复用
                // 创建并挂载新节点
                patch(
                    null,
                    nextChild, 
                    /* 忽略参数 */
                )
            } else if (moved) {

                if (j < 0 || i !== increasingNewIndexSequence[j]) {
                    // 如果不属于最佳子序列中,则代表可以移动的元素
                    move(nextChild, container, anchor, MoveType.REORDER)
                } else {
                    // 属于最佳子序列中,则什么都不用动,留着此节点复用
                    j--
                }
            }
        }
    }
}

流程

大致流程:

  1. 从前往后遍历对比是否能重用,遇到无法重用的节点则退出循环;
  2. 从后往前遍历对比是否能重用,遇到无法重用的节点则退出循环;
  3. 如果只是简单的只有左或右一边的新增或者删除,则直接操作,新增或删除节点,退出流程,如果是复杂不确定操作,那么往下走;
  4. 经历前两个头尾对比后,剩下那一块则是不确定操作(新增? or 删除? or 复用?)的一组节点(接下来使用List别名来代替);
  5. List 创建成一个新节点对应旧节点的数组newIndexToOldIndexMap,index = 新节点需要对比的集合Index ,value = 旧节点index + 1;
  6. 遍历旧节点List,如果在新节点里找到,则代表可以复用,记录下来并且如果有移动(index改变)则会有move标志,如果不可复用,则代表不存在,执行卸载删除;
  7. 到这里剩下的节点这剩下新增或复用了,如果需要移动节点,则会有个最长递增子序列计算过程:记录旧节点在新节点的位置列出数字数组,通过计算算出可以在移动节点最少的情况下完成对新节点的操作;
  8. 如果需要新增,则直接插入,需要移动,算出是否是最长递增子序列中的最优节点之一,是则不懂,否则去做移动操作;

此函数逻辑分支较多,常作为面试题,下面将会以图的形式贴上几个场景的流程;

场景流程

1、往尾部push插入元素,常见场景如分页插入
那么在第一个if判断 if (i > e1) 已经知道新的比旧的长并且是往右边插入,那么只需创建挂载新的节点即可
vue3.0源码解析,patch&diff过程_第1张图片

2、往尾部插入两个元素
如果是这种情况,第一个while对比不会运行,直接退出,在第二个while对比时,e1根据遍历会慢慢减到-1,最终进入 if (i > e1) 中,只需创建挂载新的节点即可
vue3.0源码解析,patch&diff过程_第2张图片
3、删除头部两个元素
如果是删除左边,第一个while对比不会运行,直接退出,在第二个while对比时,e1根据遍历会慢慢减到1,最终进入else if (i > e2) 中,循环遍历调用unmount卸载删除节点
vue3.0源码解析,patch&diff过程_第3张图片
4、头尾部分元素相同,中间部分节点变换位置;

假设a-p列表元素都有key属性,新的列表 d、I、M换了位置;
vue3.0源码解析,patch&diff过程_第4张图片
第一步,头和尾的对比
vue3.0源码解析,patch&diff过程_第5张图片
走到代码的else逻辑,此时提取出还未对比的部分为keyToNewIndexMap,再创建新节点对应旧节点的index—newIndexToOldIndexMap,此时keyToNewIndexMap和newIndexToOldIndexMap的值,判断到有移动的操作,此时moved变量为true

// 新列表key对应的index
keyToNewIndexMap = {
    'm': 3,
    'e': 4,
    'f': 5,
    'g': 6,
    'h': 7,
    'd': 8,
    'j': 9,
    'k': 10,
    'l': 11,
    'i': 12
}
//新列表节点的Index(减去已经对比的元素长度)对应旧节点的index位置(原位置)
newIndexToOldIndexMap = [ 13, 5, 6, 7, 8, 4, 10, 11, 12, 9]

那么接下来只需调用move去移动元素就可以了
首先会计算出newIndexToOldIndexMap 的最长递增子序列

getSequence([ 13, 5, 6, 7, 8, 4, 10, 11, 12, 9])
结果(返回传入数组的index): [1, 2, 3, 4, 6, 7, 8]

那么此时需要移动的就是下标为:0、5、9,对应的就是d、i、m
至此,此次对比完成

5、头尾部分元素相同,中间节点包括移动、新增、删除操作
删除(灰色):l、m
移动(其他彩色):e、f、i、j
新增(红色):x
vue3.0源码解析,patch&diff过程_第6张图片

第一步头尾对比就直接忽略,前面四个已经说清楚了,这里就不在重复了

从keyToNewIndexMap这行 开始

新列表key对应的index

keyToNewIndexMap = {
    'i': 4,
    'j': 5,
    'g': 6,
    'h': 7,
    'f': 8,
    'e': 9,
    'k': 10,
    'x': 11,
}
// 此时
l2 = 15
e1 = 12
e2 = 11
i = 4

// 需要对比的节点长度
toBePatched = e2 - s2 + 1 = 11 - 4 + 1 = 8;

然后创建newIndexToOldIndexMap,并做删除与新增流程
开始遍历旧的去掉头尾已经对比完剩下的不相同节点,尝试找出可以重用的地方,发现除了I、m,都可重用,并且有移动的节点,此时moved标记激活为true。
I、m两个节点也是在这一步被删除了
vue3.0源码解析,patch&diff过程_第7张图片

// 得出
newIndexToOldIndexMap = [ 9, 10, 7, 8, 6, 5, 11, 0]

往下走调用getSequence计算出newIndexToOldIndexMap 的最长递增子序列

//得出
getSequence([ 9, 10, 7, 8, 6, 5, 11, 0])
[2, 3, 6]

代表着除了下标2、3、6的元素,其余都需要去移动
从尾部开始遍历newIndexToOldIndexMap,如果value为0,则代表需要新增节点,那么创建挂载,如果不属于最佳子序列结果中的下标元素,则移动;
执行后,此次对比完成

最后

本文简单介绍了patch的过程,随着源码讲解了vue3对于patch过程的很多优化,希望能帮助读者了解vue的渲染过程。
欢迎大家提出疑问和问题,谢谢!

你可能感兴趣的:(vue,vue.js,javascript,vue)