Vue3读源码系列(八):性能优化

199 阅读7分钟

vue3利用模板的静态编译做了很多的性能优化,比如:静态提升、事件缓存、patchFlags和Block块的概念。接下来我们来具体看看他们是怎么做的(由于涉及到编译过程比较复杂,我们只看处理后的结果,这里可以借助Vue 3 Template Explorer网站来查看模板的编译结果)

静态提升

我们写个简单的模板来看看编译后的render函数

<div>
  <div class="cls blue">Hello World</div>
  <div :id="app" class="cls blue">{{ msg }}</div>
</div>

编译后:

import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock, pushScopeId as _pushScopeId, popScopeId as _popScopeId } from "vue"

const _withScopeId = n => (_pushScopeId("scope-id"),n=n(),_popScopeId(),n)
const _hoisted_1 = /*#__PURE__*/ _withScopeId(() => /*#__PURE__*/_createElementVNode("div", { class: "cls blue" }, "Hello World", -1 /* HOISTED */))
const _hoisted_2 = ["id"]

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _hoisted_1,
    _createElementVNode("div", {
      id: _ctx.app,
      class: "cls blue"
    }, _toDisplayString(_ctx.msg), 9 /* TEXT, PROPS */, _hoisted_2)
  ]))
}

可以看到对于第一个div子元素,由于他是完全静态的,所以他的vnode创建会被提升到外部作用域使用_hoisted_1保存起来,这样它的vnode就只会在组件挂载时被创建一次,后续更新则会直接引用_hoisted_1,避免了vnode的重复创建。静态提升当然不仅限于完全静态的元素,可以看到_hoisted_2保存了dynamicProps数组,因为动态的props属性有哪些也是固定的。当然还有其他的静态提升情况,读者可以自己写案例观察探索。

事件缓存

<div>
  <div @click="addNum">Hello World</div>
  <div @click="a + b">{{ msg }}</div>
</div>

编译后

import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("div", {
      onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.addNum && _ctx.addNum(...args)))
    }, "Hello World"),
    _createElementVNode("div", {
      onClick: _cache[1] || (_cache[1] = $event => (_ctx.a + _ctx.b))
    }, _toDisplayString(_ctx.msg), 1 /* TEXT */)
  ]))
}

可以看到对于事件,都会使用_cache进行缓存,这样的好处是在判断子组件是否需要更新而进行porps浅层比较的时候不会因为重新创建事件处理函数而发生不必要的更新(主要针对事件处理逻辑直接写在模板中,如上文的@click="a + b")

patchFlags

patchFlags直译过来是补丁标志,我们大概能猜出来他是用来干什么的,他的作用是标记一个vnode元素哪里是动态的,是未来可能发生更新的,这样我们就能实现靶向更新。我们来看一个示例:

<div>{{ name }}</div>
<div :class="cls">hello</div>
<div :class="cls">{{ hello }}</div>
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, normalizeClass as _normalizeClass, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    _createElementVNode("div", null, _toDisplayString(_ctx.name), 1 /* TEXT */),
    _createElementVNode("div", {
      class: _normalizeClass(_ctx.cls)
    }, "hello", 2 /* CLASS */),
    _createElementVNode("div", {
      class: _normalizeClass(_ctx.cls)
    }, _toDisplayString(_ctx.hello), 3 /* TEXT, CLASS */)
  ], 64 /* STABLE_FRAGMENT */))
}

patchFlag是做为_createElementVNode的第四个参数传入的。我们看第一个div的vnode,该div只有内容是动态的,所以他的patchFlag是1,用二进制表示也是1。对于第二个div他的class是动态的,所以他的patchFlag是2,用二进制表示则是10,也就是1<<0的运算结果。第三个div的class和内容都是动态的,那么他的patchFlag就是1 | 2结果是3。由此相信你已经看出来规律了,patchFlag还有更多的值用来表示不同的靶向更新(完整的内容可以看shared/src/patchFlags.ts)。有了这些标志将来我们在更新的时候就可以通过vnode的patchFlag值来快速的获知该vnode需要更新什么,而不是死板的进行所有内容或者属性的比较。
下面来看一下代码中的行为:

// packages/runtime-core/src/renderer.ts
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!)
  // 从vnode中解构出patchFlag
  let { patchFlag, dynamicChildren, dirs } = n2
  patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS
  const oldProps = n1.props || EMPTY_OBJ
  const newProps = n2.props || EMPTY_OBJ
  let vnodeHook: VNodeHook | undefined | null

  // disable recurse in beforeUpdate hooks
  parentComponent && toggleRecurse(parentComponent, false)
  if ((vnodeHook = newProps.onVnodeBeforeUpdate)) {
    invokeVNodeHook(vnodeHook, parentComponent, n2, n1)
  }
  if (dirs) {
    invokeDirectiveHook(n2, n1, parentComponent, 'beforeUpdate')
  }
  parentComponent && toggleRecurse(parentComponent, true)

  ...

  const areChildrenSVG = isSVG && n2.type !== 'foreignObject'
  // 先更新children
  // 如果有dynamicChildren,说明有动态节点,进行动态节点的patch
  if (dynamicChildren) {
    ...
  } else if (!optimized) {
    ...
  }
  // 根据patchFlag进行不同的patch操作
  if (patchFlag > 0) {
    // 更新fullprops
    if (patchFlag & PatchFlags.FULL_PROPS) {
      // element props contain dynamic keys, full diff needed
      patchProps(
        el,
        n2,
        oldProps,
        newProps,
        parentComponent,
        parentSuspense,
        isSVG
      )
    } else {
      // class
      // this flag is matched when the element has dynamic class bindings.
      // 更新class
      if (patchFlag & PatchFlags.CLASS) {
        if (oldProps.class !== newProps.class) {
          hostPatchProp(el, 'class', null, newProps.class, isSVG)
        }
      }

      // style
      // this flag is matched when the element has dynamic style bindings
      // 更新style
      if (patchFlag & PatchFlags.STYLE) {
        hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG)
      }

      // props
      // This flag is matched when the element has dynamic prop/attr bindings
      // other than class and style. The keys of dynamic prop/attrs are saved for
      // faster iteration.
      // Note dynamic keys like :[foo]="bar" will cause this optimization to
      // bail out and go through a full diff because we need to unset the old key
      // 更新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
    // This flag is matched when the element has only dynamic text children.
    // 更新test
    if (patchFlag & PatchFlags.TEXT) {
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children as string)
      }
    }
  } else if (!optimized && dynamicChildren == null) {
    ...
  }
  ...
}

Block块

引入Block块的目的是为了避免传统diff算法在对比静态节点时产生的不必要的性能损耗。由于模版结构的稳定性,vue3的编译器会在编译时标记动态节点,方式就是上面说的patchFlags,被标记的动态节点被收集到dynamicChildren中(拥有dynamicChildren的vnode被称之为Block块),在此后的更新中如果存在dynamicChildren且Block的结构是稳定的,那么我们就只用更新dynamicChildren数组即可。这种操作让我们将更新时对树结构的遍历变成了对单层数组的遍历,大大提升了更新效率和性能。
vue3中将模版的根节点作为Block(此外还有可能造成vnode树结构不稳定的带有v-if、v-else-if、v-else、v-for的节点等,这些情况将在后续说明),那么它是怎么实现dynamicChildren的收集的呢?

dynamicChildren收集

从生成的render函数我们可以发现,createVNode的执行顺序是从内到外的,且执行前会先执行openBlock,所以源码中使用栈结构来收集:

// packages/runtime-core/src/vnode.ts
export const blockStack: (VNode[] | null)[] = []
export let currentBlock: VNode[] | null = null
export function openBlock(disableTracking = false) {
   将当前的 block 压入栈中
  blockStack.push((currentBlock = disableTracking ? null : []))
}
export function closeBlock() {
  blockStack.pop()
  currentBlock = blockStack[blockStack.length - 1] || null
}

再来看createVNode的行为

export const createVNode = (
  __DEV__ ? createVNodeWithArgsTransform : _createVNode
) as typeof _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 {
  if (!type || type === NULL_DYNAMIC_COMPONENT) {
    if (__DEV__ && !type) {
      warn(`Invalid vnode type when creating vnode: ${type}.`)
    }
    type = Comment
  }
  // 判断是否已经是vnode 是的话clone返回
  if (isVNode(type)) {
    // createVNode receiving an existing vnode. This happens in cases like
    // <component :is="vnode"/>
    // #2078 make sure to merge refs during the clone instead of overwriting it
    // 克隆vnode
    const cloned = cloneVNode(type, props, true /* mergeRef: true */)
    if (children) {
      normalizeChildren(cloned, children)
    }
    if (isBlockTreeEnabled > 0 && !isBlockNode && currentBlock) {
      // 如果是组件节点
      if (cloned.shapeFlag & ShapeFlags.COMPONENT) {
        // 替换currentBlock中的vnode
        currentBlock[currentBlock.indexOf(type)] = cloned
      } else {
        // 将vnode添加到currentBlock中
        currentBlock.push(cloned)
      }
    }
    cloned.patchFlag |= PatchFlags.BAIL
    return cloned
  }

  // class component normalization.
  if (isClassComponent(type)) {
    type = type.__vccOpts
  }

  // 2.x async/functional component compat
  if (__COMPAT__) {
    type = convertLegacyComponent(type, currentRenderingInstance)
  }

  // class & style normalization.
  // 处理props
  if (props) {
    // for reactive or proxy objects, we need to clone it to enable mutation.
    props = guardReactiveProps(props)!
    let { class: klass, style } = props
    if (klass && !isString(klass)) {
      props.class = normalizeClass(klass)
    }
    if (isObject(style)) {
      // reactive state objects need to be cloned since they are likely to be
      // mutated
      if (isProxy(style) && !isArray(style)) {
        style = extend({}, style)
      }
      props.style = normalizeStyle(style)
    }
  }

  // encode the vnode type information into a bitmap
  const shapeFlag = isString(type)
    ? ShapeFlags.ELEMENT
    : __FEATURE_SUSPENSE__ && isSuspense(type)
    ? ShapeFlags.SUSPENSE
    : isTeleport(type)
    ? ShapeFlags.TELEPORT
    : isObject(type)
    ? ShapeFlags.STATEFUL_COMPONENT
    : isFunction(type)
    ? ShapeFlags.FUNCTIONAL_COMPONENT
    : 0

  if (__DEV__ && shapeFlag & ShapeFlags.STATEFUL_COMPONENT && isProxy(type)) {
    type = toRaw(type)
    ...
  }
  // 返回createBaseVNode调用结果
  return createBaseVNode(
    type,
    props,
    children,
    patchFlag,
    dynamicProps,
    shapeFlag,
    isBlockNode,
    true
  )
}

真正vnode创建发生在createBaseVNode

function createBaseVNode(
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
  props: (Data & VNodeProps) | null = null,
  children: unknown = null,
  patchFlag = 0,
  dynamicProps: string[] | null = null,
  shapeFlag = type === Fragment ? 0 : ShapeFlags.ELEMENT,
  isBlockNode = false,
  needFullChildrenNormalization = false
) {
  // 创建vnode对象
  const vnode = {
    __v_isVNode: true,
    __v_skip: true,
    type,
    props,
    key: props && normalizeKey(props),
    ref: props && normalizeRef(props),
    scopeId: currentScopeId,
    slotScopeIds: null,
    children,
    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,
    ctx: currentRenderingInstance
  } as VNode
  // 是否需要对子节点进行规范化
  if (needFullChildrenNormalization) {
    normalizeChildren(vnode, children)
    // normalize suspense children
    if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
      ;(type as typeof SuspenseImpl).normalize(vnode)
    }
  } else if (children) {
    // compiled element vnode - if children is passed, only possible types are
    // string or Array.
    vnode.shapeFlag |= isString(children)
      ? ShapeFlags.TEXT_CHILDREN
      : ShapeFlags.ARRAY_CHILDREN
  }

  // validate key
  if (__DEV__ && vnode.key !== vnode.key) {
    warn(`VNode created with invalid key (NaN). VNode type:`, vnode.type)
  }

  // track vnode for block tree
  // 收集vnode
  if (
    isBlockTreeEnabled > 0 &&
    // avoid a block node from tracking itself
    !isBlockNode &&
    // has current parent block
    currentBlock &&
    // 如果是动态节点或者是组件都会收集到currentBlock中
    (vnode.patchFlag > 0 || shapeFlag & ShapeFlags.COMPONENT) &&
    vnode.patchFlag !== PatchFlags.HYDRATE_EVENTS
  ) {
    // 将vnode添加到currentBlock中
    currentBlock.push(vnode)
  }

  if (__COMPAT__) {
    convertLegacyVModelProps(vnode)
    defineLegacyVNodeProperties(vnode)
  }

  return vnode
}

这里将创建的vnode push到上面openBlock创建的currentBlock中。
现在收集到了动态节点,那么怎么赋值到Block节点的dynamicChildren呢?关键在于createElementBlock

export function createElementBlock(
  type: string | typeof Fragment,
  props?: Record<string, any> | null,
  children?: any,
  patchFlag?: number,
  dynamicProps?: string[],
  shapeFlag?: number
) {
  // Block实际也是一个vnode 所以返回的是一个vnode
  return setupBlock(
    createBaseVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      shapeFlag,
      true /* isBlock */
    )
  )
}
function setupBlock(vnode: VNode) {
  // save current block children on the block vnode
  // 赋值dynamicChildren为currentBlock
  vnode.dynamicChildren =
    isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR as any) : null
  // close block
  closeBlock()
  // a block is always going to be patched, so track it as a child of its
  // parent block
  if (isBlockTreeEnabled > 0 && currentBlock) {
    currentBlock.push(vnode)
  }
  return vnode
}

由于render是由内到外的执行顺序,所以setupBlock执行的时候render函数内部的createVNode已经执行完毕,动态节点也已经push到currentBlock中,所以正好赋值到dynamicChildren。

Block树

上文提到过带有v-if、v-for等的节点都要作为Block节点,而这些节点也会被收集到dynamicChildren中,因此就形成了Block树。那么为什么带有v-if等指令的节点要被标记为Block呢?

v-if节点

对于v-if其实很好理解,如果v-if和v-else绑定的分别是两颗不同结构的树形节点,如果我们只分别收集更新他们内部的动态节点,就会忽略静态的结构节点,这样v-if切换的时候结构或者绑定vnode节点的类型就得不到更新。因此才要把v-if等对应的节点包装成Block放入到父Block的dynamicChildren中,这样在更新过程中会通过判断isSameVNodeType,绑定v-if和v-else的节点肯定是不同的vnode,所以会直接卸载旧的创建新的,这样以来就没有问题了。下面来看源码具体的实现

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
    patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS
    const oldProps = n1.props || EMPTY_OBJ
    const newProps = n2.props || EMPTY_OBJ
    let vnodeHook: VNodeHook | undefined | null

    // disable recurse in beforeUpdate hooks
    parentComponent && toggleRecurse(parentComponent, false)
    if ((vnodeHook = newProps.onVnodeBeforeUpdate)) {
      invokeVNodeHook(vnodeHook, parentComponent, n2, n1)
    }
    if (dirs) {
      invokeDirectiveHook(n2, n1, parentComponent, 'beforeUpdate')
    }
    parentComponent && toggleRecurse(parentComponent, true)

    if (__DEV__ && isHmrUpdating) {
      ...
    }

    const areChildrenSVG = isSVG && n2.type !== 'foreignObject'
    // 如果有dynamicChildren,说明有动态节点,进行动态节点的patch
    if (dynamicChildren) {
      // patch新旧dynamicChildren
      patchBlockChildren(
        n1.dynamicChildren!,
        dynamicChildren,
        el,
        parentComponent,
        parentSuspense,
        areChildrenSVG,
        slotScopeIds
      )
      if (__DEV__ && parentComponent && parentComponent.type.__hmrId) {
        traverseStaticChildren(n1, n2)
      }
    } else if (!optimized) {
      // full diff
      // 如果没有dynamicChildren,说明没有动态节点,直接进行子节点的patch diff算法
      patchChildren(
        n1,
        n2,
        el,
        null,
        parentComponent,
        parentSuspense,
        areChildrenSVG,
        slotScopeIds,
        false
      )
    }
    // 根据patchFlag进行不同的patch操作
    ...
  }

进入patchBlockChildren执行

const patchBlockChildren: PatchBlockChildrenFn = (
  oldChildren,
  newChildren,
  fallbackContainer,
  parentComponent,
  parentSuspense,
  isSVG,
  slotScopeIds
) => {
  // 循环新dynamicChildren
  for (let i = 0; i < newChildren.length; i++) {
    const oldVNode = oldChildren[i]
    const newVNode = newChildren[i]
    // Determine the container (parent element) for the patch.
    // 获取container元素
    const container =
      // oldVNode may be an errored async setup() component inside Suspense
      // which will not have a mounted element
      oldVNode.el &&
      // - In the case of a Fragment, we need to provide the actual parent
      // of the Fragment itself so it can move its children.
      (oldVNode.type === Fragment ||
        // - In the case of different nodes, there is going to be a replacement
        // which also requires the correct parent container
        !isSameVNodeType(oldVNode, newVNode) ||
        // - In the case of a component, it could contain anything.
        oldVNode.shapeFlag & (ShapeFlags.COMPONENT | ShapeFlags.TELEPORT))
        ? hostParentNode(oldVNode.el)!
        : // In other cases, the parent container is not actually used so we
          // just pass the block element here to avoid a DOM parentNode call.
          fallbackContainer
    // patch每一个新旧dynamicChildren item
    patch(
      oldVNode,
      newVNode,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      true
    )
  }
}

进入patch

const patch: PatchFn = (
    n1, // 旧vnode
    n2, // 新vnode
    container, // 容器 container._vnode === n1
    anchor = null, // 锚点
    parentComponent = null,
    parentSuspense = null,
    isSVG = false,
    slotScopeIds = null,
    optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
  ) => {
    if (n1 === n2) {
      return
    }

    // patching & not same type, unmount old tree
    // 如果新旧节点不同,卸载旧节点 这里会卸载掉v-if或者v-else结果为false的节点
    if (n1 && !isSameVNodeType(n1, n2)) {
      anchor = getNextHostNode(n1)
      unmount(n1, parentComponent, parentSuspense, true)
      // n1置为空 下面process时就会进行挂载操作 而不是更新操作
      n1 = null
    }
    // 使用手动编写的渲染函数或者插槽生成的vnode,应始终完全进行 不使用dynamicChildren
    if (n2.patchFlag === PatchFlags.BAIL) {
      optimized = false
      n2.dynamicChildren = null
    }
    // shapeFlag作用:主要用于element、component、teleport、suspense
    const { type, ref, shapeFlag } = n2
    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
      case Fragment:
        processFragment(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
        break
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          // 处理element类型
          processElement(
            n1,
            n2,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          // 处理component类型
          processComponent(
            n1,
            n2,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
        } else if (shapeFlag & ShapeFlags.TELEPORT) {
          ...
        } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
          ...
        } else if (__DEV__) {
          warn('Invalid VNode type:', type, `(${typeof type})`)
        }
    }

    // set ref
    if (ref != null && parentComponent) {
      setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
    }
  }

如果直接看源码理不清脉络写一个简单的例子debugger走一遍会更加清晰

v-for

当我们使用v-for去遍历动态的数据源时,这些遍历出来的节点就可能会是动态的,因为他们的顺序、数量都可能因为数据源的变化而改变,这样的话我们单凭遍历dynamicChildren是不可能实现节点顺序的调整,所以对于这种情况我们就需要回归原始的diff操作。
对于绑定v-for的节点都会被包裹到一个Fragment中,所有的的v-for item都会被作为一个Block放入到Fragment的dynamicChildren中,vue3中有关于Fragment有是否稳定的概念,使用PatchFlags.STABLE_FRAGMENT标记稳定的Fragment节点,如果v-for使用的是稳定的数据源比如数字或者静态的数组,那么包裹他们的Fragment也会被标记为稳定的,此时是可以使用dynamicChildren的。如果数据源是动态的,那么就不能使用dynamicChildren而是必须要使用children去进行diff算法(由于本章篇幅过长,vue3的diff算法将在下章讨论)。