Vue3源码分析之更新流程

466 阅读17分钟

在上一篇文章# 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在编译阶段添加了优化标识PatchFlagsdynmiacChildren,可以根据这些标识在patch阶段对节点做出更精准的对比更新靶向更新
  • vue2在更新渲染的patch过程中,会进行所有节点的patch包含静态节点和动态节点;其diff计算算法主要运用了新旧节点的双端比较预判,主要针对新旧节点的前前、后后、前后、后前等4中情况的预判,在预判处理完还有剩余的情况:
    • 缓存旧节点未处理key值
    • 检查新节点key值是否存在旧节点,不存在即为新节点,直接挂载;存在则进行新旧节点的patchNode
  • vue3在更新渲染的patch过程中,只需要patch动态节点,节点本身根据PatchFlags进行精准的靶向更新;对节点children的diff算法,首先从新旧节点的头部和尾部扫描,找出相同节点;剩余的未知序列处理方面主要应用了最长递增子序列算法。