Vue3源码解析--双向绑定

Vue3源码解析--双向绑定

在Vue中双向绑定主要是指响应式数据改变后对应的DOM发生变化,还有用<input v-model>这种DOM改变影响响应式数据的方式也属于双向绑定,其本质都是响应式数据改变所发生的一系列变化,其中包括响应式方法触发,新的VNode生成,新旧VNode的diff过程,对应需要改变DOM节点的生成和渲染。整体流程如图所示:

image.png

我们修改一下上一章节的demo代码,让其触发一次响应式数据变化,如下代码所示:

<div id="app">
  <div>
    {{name}}
  </div>
  <p>123</p>
</div>
const app = Vue.createApp({
  data(){
    return {
      attr : 'attr',
      name : 'abc'
    }
  },
  mounted(){
    setTimeout(()=>{
        // 改变响应式数据
        this.name = 'efg'
    },1000*5)
    
  }
}).mount("#app")
复制代码

当修改this.name时,页面上对应的name值会对应的发生变化,整个过程到最后的DOM变化在源码层面执行过程如下图所示(顺序从下往上)。

image.png

上述流程包括响应式方法触发,新的VNode生成,新旧VNode的对比diff过程,对应需要改变DOM节点的生成和渲染。当执行setElementText方法时,页面的DOM就被修改了,代码如下所示:

setElementText: (el, text) => {
  el.textContent = text// 修改为efg
}
复制代码

下面,就这些流程进行一一讲解。

响应式触发

在之前的响应式原理中,在创建响应式数据时,会对监听进行收集,在源码reactivity/src/effect.ts中,track方法,其核心代码如下所示:

export function track(target: object, type: TrackOpTypes, key: unknown) {
  ...
  // 获取当前target对象对应depsMap
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  // 获取当前key对应dep依赖
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }
  if (!dep.has(activeEffect)) {
    // 收集当前的effect作为依赖
    dep.add(activeEffect)
    // 当前的effect收集dep集合作为依赖
    activeEffect.deps.push(dep)
  }
}
复制代码

收集完监听后,会得到targetMap,在触发监听trigger时,从targetMap拿到当前的target

name是一个响应式数据,所以在触发name值修改时,会进入对应的Proxy对象中handlerset方法,在源码reactivity/src/baseHandlers.ts中,其核心代码如下所示:

function createSetter() {
   ...
   // 触发监听
   trigger(target, TriggerOpTypes.SET, key//name, value//efg, oldValue//abc)
   ...
}
复制代码

从而进入trigger方法触发监听,在源码reactivity/src/effect.ts中,trigger方法,其核心代码如下所示:

export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
  ...
  // 获取当前target的依赖映射表
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    // never been tracked
    return
  }
  // 声明一个集合和方法,用于添加当前key对应的依赖集合
  const effects = new Set<ReactiveEffect>()
  const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
    if (effectsToAdd) {
      effectsToAdd.forEach(effect => effects.add(effect))
    }
  }

  // 声明一个调度方法
  const run = (effect: ReactiveEffect) => {
    if (effect.options.scheduler) {
      effect.options.scheduler(effect)
    } else {
      effect()
    }
  }
  // 根据不同的类型选择使用不同的方式将当前key的依赖添加到effects
  ...
  // 循环遍历 按照一定的调度方式运行对应的依赖
  effects.forEach(run)
}
复制代码

trigger方法总结下来,做了如下事情:

  • 首先获取当前target对应的依赖映射表,如果没有,说明这个target没有依赖,直接返回,否则进行下一步。
  • 然后声明一个ReactiveEffect集合和一个向集合中添加元素的方法。
  • 根据不同类型选择使用不同的方式向ReactiveEffect中添加当前key对应的依赖。
  • 声明一个调度方式,根据我们传入ReactiveEffect函数中不同的参数选择使用不同的调度run方法,并循环遍历执行。

上面的步骤会比较绕,只需要记住trigger方法的最终目的是调度方法的调用,即运行ReactiveEffect对象中绑定run方法,那么ReactiveEffect是什么呢?如何绑定对应的run方法?我们来看下ReactiveEffect的实现,在源码reactivity/src/effect.ts中,其核心代码如下所示:

export class ReactiveEffect<T = any> {
  ...
  constructor(
    public fn: () => T, // 传入回调方法
    public scheduler: EffectScheduler | null = null,// 调度函数
    scope?: EffectScope | null
  ) {
    recordEffectScope(this, scope)
  }

  run() {
    if (!this.active) {
      return this.fn()
    }
    if (!effectStack.includes(this)) {
      try {
        ...
        // 执行绑定的方法
        return this.fn()
      } finally {
        ...
      }
    }
  }
}
复制代码

上面代码中,在其构造函数时,将创建时的传入的回调函数进行了run绑定,同时在Vue的组件挂载时,会创建一个ReactiveEffect对象,在源码runtime-core/src/renderer.ts中,其核心代码如下所示:

// setupRenderEffect()方法
...
const effect = new ReactiveEffect(
  componentUpdateFn,// run方法绑定,该方法包括VNode生成逻辑
  () => queueJob(instance.update),
  instance.scope // track it in component's effect scope
)
复制代码

通过ReactiveEffect,就将响应式和VNode逻辑进行了链接,其本身就是一个基于发布/订阅模式的事件对象,track负责订阅即收集监听,trigger负责发布即触发监听,effect是桥梁,存储事件数据。

同时ReactiveEffect也向外暴露了Composition API的effect方法,可以自定义的添加监听收集,在源码reactivity/src/effect.ts中,其核心代码如下所示:

export function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  if (isEffect(fn)) {
    fn = fn.raw
  }
  // 创建ReactiveEffect对象
  const effect = createReactiveEffect(fn, options)
  if (!options.lazy) {
    effect()
  }
  return effect
}
复制代码

使用时,如下代码所示:

// this.name改变时会触发这里
Vue.effect(()=>{
  console.log(this.name)
})
复制代码

结合之前响应式原理章节的讲解,我们将这个响应式触发的过程总结成流程图,便于读者理解,如图所示。

image.png

当响应式触发完成以后,就会进入VNode生成环节。

生成新VNode

在响应式逻辑中,创建ReactiveEffect时传入了componentUpdateFn,当响应式触发时,便会进入这个方法,在源码runtime-core/src/renderer.ts中,其核心代码如下所示:

const componentUpdateFn = () => {
  // 首次渲染 直接找到对应DOM挂载即可,无需对比新旧VNode
  if (!instance.isMounted) {
    ...
    instance.isMounted = true
    ...
  } else {
    let { next, bu, u, parent, vnode } = instance
    let originNext = next
    let vnodeHook: VNodeHook | null | undefined
    
    // 判断是否是父组件带来的更新
    if (next) {
      next.el = vnode.el
      // 子组件更新
      updateComponentPreRender(instance, next, optimized)
    } else {
      next = vnode
    }
    ...
    // 获取新的VNode(根据新的响应式数据,执行render方法得到VNode)
    const nextTree = renderComponentRoot(instance)
    // 从subTree字段获取旧的VNode
    const prevTree = instance.subTree
    // 将新值赋值给subTree字段
    instance.subTree = nextTree
    
    // 进行新旧VNode对比
    patch(
      prevTree,
      nextTree,
      // teleport判断
      hostParentNode(prevTree.el!)!,
      // fragment判断
      getNextHostNode(prevTree),
      instance,
      parentSuspense,
      isSVG
    )
  }
}
复制代码

其中,对于新VNode的生成,主要是靠renderComponentRoot方法,这在之前虚拟DOM章节中也用到过,其内部会执行组件的render方法,通过render方法就可以获取到新VNode,同时将新VNode赋值给subTree字段,以便下次对比使用。

之后会进入patch方法,进行虚拟DOM的对比diff。

虚拟DOM diff

虚拟DOM的diff过程核心是patch方法,它主要会利用compile阶段的patchFlag(或者type)来处理不同情况下的更新,这也可以理解为是一种分而治之的策略。在该方法内部,并不是直接通过当前的VNode节点去暴力的更新DOM节点,而是对新旧两个VNode节点的patchFlag来分情况判断进行比较,然后通过对比结果找出差异的属性或节点进行按需更新,从而减少不必要的开销,提升性能。

patch的过程中主要完成以下几件事情:

  • 创建需要新增的节点。
  • 移除已经废弃的节点。
  • 移动或修改需要更新的节点。

在整个过程中都会用到patchFlag进行判断,在AST树到render再到VNode生成过程中,就会根据节点的类型打上对应的patchFlag,光有patchFlag还不够,还要依赖于shapeFlag的设置,在源码中对应的createVNode方法,如下代码所示:

function _createVNode(
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
  props: (Data & VNodeProps) | null = null,
  children: unknown = null,
  patchFlag: number = 0,
  dynamicProps: string[] | null = null,
  isBlockNode = false
): VNode {
  const shapeFlag = isString(type)
  ...
  const vnode = {
      __v_isVNode: true,
      ["__v_skip" /* SKIP */]: true,
      type,
      props,
      key: props && normalizeKey(props),
      ref: props && normalizeRef(props),
      scopeId: currentScopeId,
      children: null,
      component: null,
      suspense: null,
      ssContent: null,
      ssFallback: null,
      dirs: null,
      transition: null,
      el: null,
      anchor: null,
      target: null,
      targetAnchor: null,
      staticCount: 0,
      shapeFlag,
      patchFlag,
      dynamicProps,
      dynamicChildren: null,
      appContext: null
  };
  ...
  return vnode
}
复制代码

_createVNode方法主要用来标准化VNode,同时添加上对应的shapeFlagpatchFlag。其中,shapeFlag的值是一个数字,每种不同的shapeFlag代表不同的VNode类型,而shapeFlag又是依据之前在生成AST树时的NodeType而定,所以shapeFlag的值和NodeType很像,如下所示:

export const enum ShapeFlags {
  ELEMENT = 1, // 元素 string
  FUNCTIONAL_COMPONENT = 1 << 1, // 2 function
  STATEFUL_COMPONENT = 1 << 2, // 4 object
  TEXT_CHILDREN = 1 << 3, // 8 文本
  ARRAY_CHILDREN = 1 << 4, // 16 数组
  SLOTS_CHILDREN = 1 << 5, // 32 插槽
  TELEPORT = 1 << 6, // 64 teleport
  SUSPENSE = 1 << 7, // 128 suspense
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,// 256 keep alive 组件
  COMPONENT_KEPT_ALIVE = 1 << 9, // 512 keep alive 组件
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT // 组件
}
复制代码

patchFlag代表在更新时采用不同的策略,其具体每种含义如下所示:

export const enum PatchFlags {
  // 动态文字内容
  TEXT = 1,
  // 动态 class
  CLASS = 1 << 1,
  // 动态样式
  STYLE = 1 << 2,
  // 动态 props
  PROPS = 1 << 3,
  // 有动态的key,也就是说props对象的key不是确定的
  FULL_PROPS = 1 << 4,
  // 合并事件
  HYDRATE_EVENTS = 1 << 5,
  // children 顺序确定的 fragment
  STABLE_FRAGMENT = 1 << 6,

  // children中有带有key的节点的fragment
  KEYED_FRAGMENT = 1 << 7,
  // 没有key的children的fragment
  UNKEYED_FRAGMENT = 1 << 8,
  // 只有非props需要patch的,比如`ref`
  NEED_PATCH = 1 << 9,
  // 动态的插槽
  DYNAMIC_SLOTS = 1 << 10,
  ...
  // 特殊的flag,不会在优化中被用到,是内置的特殊flag
  ...SPECIAL FLAGS
  // 表示他是静态节点,他的内容永远不会改变,对于hydrate的过程中,不会需要再对其子节点进行diff
  HOISTED = -1,
  // 用来表示一个节点的diff应该结束
  BAIL = -2,
}
复制代码

包括shapeFlagpatchFlag,和他的名字含义一致,其实就是一系列的标志,来标识一个节点该如何进行更新的,其中CLASS = 1 << 1这种方式表示位运算,就是利用每个patchFlag取二进制中的某一位数来表示,这样可以更加方便扩展,例如TEXT | CLASS可以得到0000000011,这个值可以表示他即有TEXT的特性,也有CLASS的特性,如果需要新加一个flag,直接用新数num左移1位即可即:1 << num

shapeFlag可以理解成VNode的类型,而patchFlag则更像VNode变化的类型。

例如在demo代码中,我们给props绑定响应式变量attr,如下代码所示:

...
<div :data-a="attr"></div>
...
复制代码

得到的patchFlag就是8(1<<3)。在源码compiler-core/src/transforms/transformElement.ts中可以看到对应设置逻辑,核心代码如下所示:

...
// 每次都按位与可以将多个数值进行设置
if (hasDynamicKeys) {
  patchFlag |= PatchFlags.FULL_PROPS
} else {
  if (hasClassBinding && !isComponent) {
    patchFlag |= PatchFlags.CLASS
  }
  if (hasStyleBinding && !isComponent) {
    patchFlag |= PatchFlags.STYLE
  }
  if (dynamicPropNames.length) {
    patchFlag |= PatchFlags.PROPS
  }
  if (hasHydrationEventBinding) {
    patchFlag |= PatchFlags.HYDRATE_EVENTS
  }
}
复制代码

一切准备就绪,下面进入patch方法,在源码runtime-core/src/renderer.ts中,其核心代码如下所示:

const patch: PatchFn = (
  n1,
  n2,
  container,
  anchor = null,
  parentComponent = null,
  parentSuspense = null,
  isSVG = false,
  slotScopeIds = null,
  optimized = false
) => {
  // 新旧VNode是同一个对象,就不再对比
  if (n1 === n2) {
    return
  }
  // patching & 不是相同类型的 VNode,则从节点树中卸载
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1)
    unmount(n1, parentComponent, parentSuspense, true)
    n1 = null
  }
    // PatchFlag 是 BAIL 类型,则跳出优化模式
  if (n2.patchFlag === PatchFlags.BAIL) {
    optimized = false
    n2.dynamicChildren = null
  }

  const { type, ref, shapeFlag } = n2
  switch (type) { // 根据 Vnode 类型判断
    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)
      }
      break
    case Fragment: // Fragment 类型
      processFragment(/* 忽略参数 */)
      break
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) { // 元素类型
        processElement(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else if (shapeFlag & ShapeFlags.COMPONENT) { // 组件类型
        ...
      } else if (shapeFlag & ShapeFlags.TELEPORT) { // TELEPORT 类型
        ...
      } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) { // SUSPENSE 类型
        ...
      }
  }
}
复制代码

其中,n1为旧VNode,n2为新VNode,如果新旧VNode是同一个对象,就不再对比,如果当旧节点存在,并且新旧节点不是同一类型时,则将旧节点从节点树中卸载,这时还没有用到patchFlag,在往下看通过switch case来判断节点类型,并分别对不同节点类型执行不同的操作,这里用到了ShapeFlag,对于常用的HTML元素类型,则会进入default分支,我们以ELEMENT为例,进入processElement方法,在源码runtime-core/src/renderer.ts中,其核心代码如下所示:

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) {
    mountElement(
      n2,
      container,
      anchor
      ...
    )
  } else {
    patchElement(
      n1,
      n2,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  }
}
复制代码

processElement方法的逻辑相对简单,只是多加了一层判断,当没有旧节点时,直接走渲染流程,这也是调用根实例初始化createApp时会执行到的逻辑。真正进行对比,会进入patchElement方法,在源码runtime-core/src/renderer.ts中,其核心代码如下所示:

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

  // 触发一些钩子
  if ((vnodeHook = newProps.onVnodeBeforeUpdate)) {
    invokeVNodeHook(vnodeHook, parentComponent, n2, n1)
  }
  if (dirs) {
    invokeDirectiveHook(n2, n1, parentComponent, 'beforeUpdate')
  }
  ...
  // 当新VNode有动态节点时,优先更新动态节点(效率提升)
  if (dynamicChildren) {
    patchBlockChildren(
      n1.dynamicChildren!,
      dynamicChildren,
      el,
      parentComponent,
      parentSuspense,
      areChildrenSVG,
      slotScopeIds
    )
    if (__DEV__ && parentComponent && parentComponent.type.__hmrId) {
      traverseStaticChildren(n1, n2)
    }
  } else if (!optimized) {// 全量diff
    // full diff
    patchChildren(
      n1,
      n2,
      el,
      null,
      parentComponent,
      parentSuspense,
      areChildrenSVG,
      slotScopeIds,
      false
    )
  }
  // 根据不同的patchFlag,走不同的更新逻辑
  if (patchFlag > 0) {

    if (patchFlag & PatchFlags.FULL_PROPS) {
      // 如果元素的 props 中含有动态的 key,则需要全量比较
      patchProps(
        el,
        n2,
        oldProps,
        newProps,
        parentComponent,
        parentSuspense,
        isSVG
      )
    } else {
      // 动态class
      if (patchFlag & PatchFlags.CLASS) {
        if (oldProps.class !== newProps.class) {
          hostPatchProp(el, 'class', null, newProps.class, isSVG)
        }
      }

      // 动态style
      if (patchFlag & PatchFlags.STYLE) {
        hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG)
      }

      // 动态props
      if (patchFlag & PatchFlags.PROPS) {
        // if the flag is present then dynamicProps must be non-null
        const propsToUpdate = n2.dynamicProps!
        for (let i = 0; i < propsToUpdate.length; i++) {
          const key = propsToUpdate[i]
          const prev = oldProps[key]
          const next = newProps[key]
          // #1471 force patch value
          if (next !== prev || key === 'value') {
            hostPatchProp(
              el,
              key,
              prev,
              next,
              isSVG,
              n1.children as VNode[],
              parentComponent,
              parentSuspense,
              unmountChildren
            )
          }
        }
      }
    }

    // 插值表达式text
    if (patchFlag & PatchFlags.TEXT) {
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children as string)
      }
    }
  } else if (!optimized && dynamicChildren == null) {
    // 全量diff
    patchProps(
      el,
      n2,
      oldProps,
      newProps,
      parentComponent,
      parentSuspense,
      isSVG
    )
  }
  ...
}
复制代码

processElement方法的开头,会执行一些钩子函数,然后会判断新节点是否有已经标识的动态节点(就是在静态提升那一部分的优化,将动态节点和静态节点进行分离),如果有就会优先进行更新(无需对比,这样更快),接下来通过patchProps方法更新当前节点的props,style,class等,主要逻辑如下:

  • 当patchFlag为FULL_PROPS时,说明此时的元素中,可能包含了动态的key,需要进行全量的props diff。
  • 当patchFlag为CLASS时,当新旧节点的class不一致时,此时会对class进行atch,而当新旧节点的class属性完全一致时,不需要进行任何操作。这个Flag标记会在元素有动态的class绑定时加入。
  • 当patchFlag为STYLE时,会对style 进行更新,这是每次patch都会进行的,这个Flag会在有动态style绑定时被加入。
  • 当 patchFlag为PROPS时,需要注意这个Flag会在元素拥有动态的属性或者attrs绑定时添加,不同于class和style,这些动态的prop或attrs的key会被保存下来以便于更快速的迭代。
  • 当patchFlag为TEXT时,如果新旧节点中的子节点是文本发生变化,则调用hostSetElementText进行更新。这个Flag会在元素的子节点只包含动态文本时被添加。

每种patchFlag对应的方法中,最终都会进入到DOM操作的逻辑,例如对于STYLE更新,会进入到setStyle方法,在源码runtime-dom/src/modules/style.ts中,其核心代码如下所示:

function setStyle(
  style: CSSStyleDeclaration,
  name: string,
  val: string | string[]
) {
  if (isArray(val)) { // 支持多个style同时设置
    val.forEach(v => setStyle(style, name, v))
  } else {
    if (name.startsWith('--')) {
      // custom property definition
      style.setProperty(name, val)// 操作dom
    } else {
      const prefixed = autoPrefix(style, name)
      if (importantRE.test(val)) {
        // !important
        style.setProperty(
          hyphenate(prefixed),
          val.replace(importantRE, ''),
          'important'
        )
      } else {
        style[prefixed as any] = val
      }
    }
  }
}
复制代码

对于一个VNode节点来说,除了属性(props,class,style等)外,其他的都叫做子节点内容,包括<div>hi</div>中的文本hi也属于子节点,对于子节点,会进入patchChildren方法,在源码runtime-core/src/renderer.ts中,其核心代码如下所示:

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

  const { patchFlag, shapeFlag } = n2
  if (patchFlag > 0) {
    // key 值是 Fragment:KEYED_FRAGMENT
    if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
      // this could be either fully-keyed or mixed (some keyed some not)
      // presence of patchFlag means children are guaranteed to be arrays
      patchKeyedChildren(
      ...
      )
      return
    } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
      // key 值是 UNKEYED_FRAGMENT
      patchUnkeyedChildren(
      ...
      )
      return
    }
  }

  // 新节点是文本类型子节点(单个子节点)
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 旧节点是数组类型,则直接用新节点覆盖
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
    }
    // 设置新节点
    if (c2 !== c1) {
      hostSetElementText(container, c2 as string)
    }
  } else {
    // 新节点是数组类型子节点(多个子节点)
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 新旧都会数组类型,则全量diff
        patchKeyedChildren(
        ...
        )
      } else {
        // no new children, just unmount old
        unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
      }
    } else {
      // 设置空字符串
      if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        hostSetElementText(container, '')
      }
      // mount new if array
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        mountChildren(
        ...
        )
      }
    }
  }
}
复制代码

上面代码中,首先根据patchFlag进行判断:

  • 当patchFlag是存在key值的Fragment:KEYED_FRAGMENT,则调用patchKeyedChildren来继续处理子节点。
  • 当patchFlag是没有设置key值的Fragment: UNKEYED_FRAGMENT,则调用 patchUnkeyedChildren处理没有key值的子节点。

然后根据shapeFlag进行判断:

  • 如果新子节点是文本类型,而旧子节点是数组类型(含有多个子节点),则直接卸载旧节点的子节点,然后用新节点替换。
  • 如果旧子节点类型是数组类型,当新子节点也是数组类型,则调用patchKeyedChildren进行全量的diff,当新子节点不是数组类型,则说明不存在新子节点,直接从树中卸载旧节点即可。
  • 如果旧子节点是文本类型,由于已经在一开始就判断过新子节点是否为文本类型,那么此时可以肯定新子节点肯定不为文本类型,则可以直接将元素的文本置为空字符串。
  • 如果新子节点是类型为数组类型,而旧子节点不为数组,说明此时需要在树中挂载新子节点,进行mount操作即可。

无论多么复杂的节点数组嵌套最后,其实最后都会落到基本的DOM操作,包括创建节点或者删除节点,修改节点属性等等,但核心是针对新旧两个树,如何找到他们之间需要改变的节点,这就是diff的核心,真正的diff需要进入到patchUnkeyedChildrenpatchKeyedChildren来一探究竟。首先看下patchUnkeyedChildren方法,在源码runtime-core/src/renderer.ts中,其核心代码如下所示:

const patchUnkeyedChildren = () => {
  ...
c1 = c1 || EMPTY_ARR
  c2 = c2 || EMPTY_ARR
  const oldLength = c1.length
  const newLength = c2.length
  // 拿到新旧节点的最小长度
  const commonLength = Math.min(oldLength, newLength)
  let i
  // 遍历新旧节点,进行patch
  for (i = 0; i < commonLength; i++) {
    // 如果新节点已经挂载过了(已经过了各种处理),则直接clone一份,否则创建一个新的VNode节点
    const nextChild = (c2[i] = optimized
      ? cloneIfMounted(c2[i] as VNode)
      : normalizeVNode(c2[i]))
    patch()
  }
  // 如果旧节点的数量大于新节点数量
  if (oldLength > newLength) {
    // 直接卸载多余的节点
    unmountChildren( )
  } else {
    // old length < new length => 直接进行创建
    mountChildren()
  }
}
复制代码

主要逻辑是首先拿到新旧节点的最短公共长度,然后遍历公共部分,对公共部分再次递归执行patch方法,如果旧节点的数量大于新节点数量,直接卸载多余的节点,否则新建节点。

可以看到对于没有key的情况,diff比较简单,但是性能也相对较低,很少实现DOM的复用,更多的是创建和删除节点,这也是Vue推荐对数组节点添加唯一key值的原因。

下面是patchKeyedChildren方法,在源码runtime-core/src/renderer.ts中,其核心代码如下所示:

const patchKeyedChildren = () => {
  let i = 0
  const l2 = c2.length
  let e1 = c1.length - 1 // prev ending index
  let e2 = l2 - 1 // next ending index

  // 1.进行头部遍历,遇到相同节点则继续,不同节点则跳出循环
  while (i <= e1 && i <= e2) {...}

  // 2.进行尾部遍历,遇到相同节点则继续,不同节点则跳出循环
  while (i <= e1 && i <= e2) {...}

  // 3.如果旧节点已遍历完毕,并且新节点还有剩余,则遍历剩下的进行新增
  if (i > e1) {
    if (i <= e2) {...}
  }

  // 4.如果新节点已遍历完毕,并且旧节点还有剩余,则直接卸载
  else if (i > e2) {
    while (i <= e1) {...}
  }

  // 5.新旧节点都存在未遍历完的情况
  else {
    // 5.1创建一个map,为剩余的新节点存储键值对,映射关系:key => index
    // 5.2遍历剩下的旧节点,新旧数据对比,移除不使用的旧节点
    // 5.3拿到最长递增子序列进行移动或者新增挂载
  }
}
复制代码

patchKeyedChildren方法是整个diff的核心,其内部包括了具体算法和逻辑,用代码讲解起来比较复杂,这里用一个简单的例子来说明该方法到底做了些什么,如下代码所示有两个数组:

// 旧数组
["a", "b", "c", "d", "e", "f", "g", "h"]
// 新数组
["a", "b", "d", "f", "c", "e", "x", "y", "g", "h"]
复制代码

上面数组中每个元素代表key,执行步骤如下:

  • 1.从头到尾开始比较,[a,b]是sameVnode,进入patch,到[c]停止。
  • 2.从尾到头开始比较,[h,g]是sameVnode,进入patch,到[f]停止。
  • 3.判断旧数据是否已经比较完毕,多余的说明是新增的,需要mount,例子中没有。
  • 4.判断新数据是否已经比较完毕,多余的说明是删除的,需要unmount,例子中没有。
    • 进入到这里,说明顺序被打乱,进入到5:
    • 5.1创建一个还未比较的新数据index的Map:[{d:2},{f:3},{c:4},{e:5},{x:6},{y:7}]。
    • 5.2根据未比较完的数据长度,建一个填充0的数组 [0,0,0,0,0],然后循环一遍旧剩余数据,找到未比较的数据的索引arr:[4(d),6(f),3(c),5(e),0,0],如果没有在新剩余数据里找到,说明是删除就unmount掉,找到了就和之前的patch一下。
    • 5.3从尾到头循环之前的索引arr,是0的,说明是新增的数据,就mount进去,非0的,说明在旧数据里,我们只要把它们移动到对应index的前面就行了,如下:
      • 把f移动到c之前。
      • 把d移动到f之前。
      • 移动之后,c自然会到e前面,这可以由之前的arr索引祭祀按最长递增子序列来找到[3,5],这样[3,5]对应的c和e就无需移动了。

至此,这就是整个patchKeyedChildren方法中diff的核心内容和原理,当然还有很多代码细节,各位读者感兴趣可以阅读patchKeyedChildren完整源码。

完成真实DOM 修改

无论多么复杂的节点数组嵌套最后,其实最后都会落到基本的DOM操作,包括创建节点或者删除节点,修改节点属性等等,当拿到diff后的结果时,会调用对应的DOM操作方法,这部分逻辑在源码runtime-dom\src\nodeOps.ts中,存放的都是一些工具方法,其核心代码如下所示:

export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
  // 插入元素
  insert: (child, parent, anchor) => {
    parent.insertBefore(child, anchor || null)
  },
  // 删除元素
  remove: child => {
    const parent = child.parentNode
    if (parent) {
      parent.removeChild(child)
    }
  },
  // 创建元素
  createElement: (tag, isSVG, is, props): Element => {
   ...
  },
  // 创建文本
  createText: text => doc.createTextNode(text),
  // 创建注释
  createComment: text => doc.createComment(text),
  // 设置文本
  setText: (node, text) => {
    node.nodeValue = text
  },
  // 设置文本
  setElementText: (el, text) => {
    el.textContent = text
  },
  parentNode: node => node.parentNode as Element | null,

  nextSibling: node => node.nextSibling,

  querySelector: selector => doc.querySelector(selector),
  // 设置元素属性
  setScopeId(el, id) {
    el.setAttribute(id, '')
  },
  // 克隆DOM
  cloneNode(el) {
    ...
  },

  // 插入静态内容,包括处理SVG元素
  insertStaticContent(content, parent, anchor, isSVG) {
    ...
  }
}
复制代码

这部分逻辑都是常规的DOM操作,比较简单,各位读者直接阅读源码即可。至此就完成了整个双向绑定流程。

猜你喜欢

转载自juejin.im/post/7042571906486108173