Skip to content

编译器:编译过程中的优化细节

前言

在开启本篇章之前,我们先来思考一个问题,假设有以下模板:

html
<template>
  <p>hello world</p>
  <p>{{ msg }}</p>
</template>>

其中一个 p 标签的节点是一个静态的节点,第二个 p 标签的节点是一个动态的节点,如果当 msg 的值发生了变化,那么理论上肉眼可见最优的更新方案应该是只做第二个动态节点的 diff,而无需进行第一个 p 标签节点的 diff

如果熟悉 Vue 2.x 的小伙伴可能会知道,在 Vue 2.x 版本中在编译过程中有一个叫做 optimize 的阶段,会进行标记静态根节点的操作,被标记为静态根节点的节点,一方面会生成一个 staticRenderFns,首次渲染会以这个静态根节点 vnode 进行缓存,后续渲染会直接取缓存中的,从而避免重复渲染;另一方面生成的 vnode 会带有 isStatic = true 的属性,将会在 diff 过程中被跳过。但 Vue 2.x 对静态节点进行缓存就是一种空间换时间的优化策略,为了避免过度优化,在 Vue 2.x 中,识别静态根节点是需要满足:

  1. 子节点是静态节点;
  2. 子节点不是只有一个静态文本节点的节点。

所以,上面的示例第一个 p 标签在 Vue 2.x 中不会被判定位静态根节点,也就无法进行优化。

关于 Vue 2.x 如何做的编译时优化,这里只是简单进行了介绍,想了解更多的小伙伴可以参考这里:入口开始,解读 Vue2 源码(七)—— $mount 内部实现 --- compile optimize标记节点

那么 Vue 3 呢?还是和 Vue 2 一样吗?答案显然是否定的,首先我们前面介绍了对于静态的节点,Vue 3 首先会进行静态提升,也就是相当于缓存了静态节点的 vnode,那 diff 过程呢?会跳过吗?本小节我们来一探究竟。

PatchFlags

是什么?

首先,我们需要认识一个 PatchFlags 这个属性,它是一个枚举类型,里面是一些二进制操作的值,用来标记在节点的 patch 类型。具体的枚举内容如下:

js
export const enum PatchFlags {
  // 动态文本的元素
  TEXT = 1,
  
  // 动态 class 的元素
  CLASS = 1 << 1,
  
  // 动态 style 的元素
  STYLE = 1 << 2,
  
  // 动态 props 的元素
  PROPS = 1 << 3,
  
  // 动态 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,
  
  // 带有 ref、指令的元素
  NEED_PATCH = 1 << 9,
  
  // 动态的插槽
  DYNAMIC_SLOTS = 1 << 10,
  
  // 静态节点
  HOISTED = -1,
  
  // 不是 render 函数生成的元素,如 renderSlot
  BAIL = -2,
}

这些二进制的值是通过左移操作符 << 生成的,关于左移操作符,我们在《响应式原理:副作用函数探秘》篇章中已经介绍过了,在这里也是一种二进制操作的体现:

js
TEXT = 0000000001; 
CLASS = 0000000010; 
STYLE = 0000000100;

这里通过二进制来表示 PatchFlags 可以方便我们做很多属性的判断,比如 TEXT | STYLE 来得到 0000000101,表示 patchFlag 既有 TEXT 属性也有 STYLE 属性,当需要进行判断有没有 STYLE 属性时,只需要 FLAG & STYLE > 0就行。

什么时候生成的?

在搞清楚 patchFlags 的一些定义和使用基础后,那它是什么时候被赋值到 vnode 节点上的呢?前言中的模板字符串在 compiler 阶段会被转成一个 render 函数的字符串代码:

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

const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "hello world", -1 /* HOISTED */)

export function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    _hoisted_1,
    _createElementVNode("p", null, _toDisplayString(msg), 1 /* TEXT */)
  ], 64 /* STABLE_FRAGMENT */))
}

这里可以看出,render 函数内是通过 createElementVNode 方法来创建 vnode 的,该函数的第四个参数就代表着 patchFlag。对于我们上面的示例,其中 <p>hello world</p>hoisted,对应的 patchFlag = -1<p></p> 是动态文字节点,对应的 patchFlag = 1

有什么用?

接下来看看其实际使用案例,还是拿之前的 patchElement 函数来说:

js
const patchElement = (n1, n2, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
  let { patchFlag, dynamicChildren, dirs } = n2
  // 如果 patchFlag 不存在,那么就设置成 FULL_PROPS,意味着要全量 props 比对
  patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS
  const oldProps = n1.props || EMPTY_OBJ
  const newProps = n2.props || EMPTY_OBJ

  const areChildrenSVG = isSVG && n2.type !== 'foreignObject'
  if (dynamicChildren) {
    patchBlockChildren(
      n1.dynamicChildren!,
      dynamicChildren,
      el,
      parentComponent,
      parentSuspense,
      areChildrenSVG,
      slotScopeIds
    )
  } else if (!optimized) {
    // full diff
    patchChildren(
      n1,
      n2,
      el,
      null,
      parentComponent,
      parentSuspense,
      areChildrenSVG,
      slotScopeIds,
      false
    )
  }

  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) {
        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) {
    patchProps(
      el,
      n2,
      oldProps,
      newProps,
      parentComponent,
      parentSuspense,
      isSVG
    )
  }
  
}

这里涉及到两个比较重点的事儿,一个是和 dynamicChildren 相关,另一个是和动态 props 相关。我们先看和动态 props 相关的内容。

之前的章节我们跳过了对 PatchFlags 内容的理解,到了这里,我们通过代码可以知道 Vue 在更新子节点时,首先也是利用 patchFlag 的能力,对子节点进行分类做出不同的处理,比如针对以下例子:

html
<template>
  <div :class="classNames" id='test'>
    hello world
  </div> 
</template>

得到的编译结果:

js
import { normalizeClass as _normalizeClass, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", {
    class: _normalizeClass(classNames),
    id: "test"
  }, " hello world ", 2 /* CLASS */))
}

此时 patchFlag & PatchFlags.CLASS > 0 则在 diff 过程中,需要进行 class 属性的 diff, 从而减少了对 id 属性的不必要 diff,提升了 props diff 过程中的性能。

dynamicChildren

另外,我们注意到,在编译后的 render 函数中会有一个 _openBlock() 函数的执行,我们来一起看一下其实现:

javascript
export const blockStack = []
export let currentBlock = null

export function openBlock(disableTracking = false) {
  blockStack.push((currentBlock = disableTracking ? null : []))
}

openBlock 实现比较通俗易懂,就是向 blockStackpush currentBlock。其中 currentBlock 是一个数组,用于存储动态节点。blockStack 则是存储 currentBlock 的一个 Block tree

然后我们接着看 createElementBlock 的实现:

js
export function createElementBlock(type, props, children, patchFlag, dynamicProps, shapeFlag) {
  return setupBlock(
    createBaseVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      shapeFlag,
      true /* isBlock */
    )
  )
}

function createBaseVNode(type, props = null, children = null, patchFlag = 0, dynamicProps = null, shapeFlag = type === Fragment ? 0 : ShapeFlags.ELEMENT, isBlockNode = false, needFullChildrenNormalization = false) {
  // ...
  // 添加动态 vnode 节点到 currentBlock 中
  if (
    isBlockTreeEnabled > 0 &&
    !isBlockNode &&
    currentBlock &&
    (vnode.patchFlag > 0 || shapeFlag & ShapeFlags.COMPONENT) &&
    vnode.patchFlag !== PatchFlags.HYDRATE_EVENTS
  ) {
    currentBlock.push(vnode)
  }
  
  return vnode
}


function setupBlock(vnode) {
  // 在 vnode 上保留当前 Block 收集的动态子节点
  vnode.dynamicChildren =
    isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR) : null
  // 当前 Block 恢复到父 Block
  closeBlock()
  // 节点本身作为父 Block 收集的子节点
  if (isBlockTreeEnabled > 0 && currentBlock) {
    currentBlock.push(vnode)
  }
  return vnode
}

createElementBlock 内部首先通过 createBaseVNode 创建 vnode 节点,在创建的过程中,会根据 patchFlag 的值进行判断是否是动态节点,如果发现 vnode 是一个动态节点,那么会被添加到 currentBlock 当中,然后在执行 setupBlock 函数的时候,将 currentBlock 赋值给 vnode.dynamicChildren 属性。

我们前面看 patchElement 的时候,有注意到函数体内部会进行是否有 dynamicChildren 属性进行不同的逻辑执行,前面的章节,我们只介绍了 patchChildren 完整的子节点 diff 算法,当 dynamicChildren 存在时,这里只会进行 patchBlockChildren 的动态节点 diff

js
const patchBlockChildren = (oldChildren, newChildren, fallbackContainer, parentComponent, parentSuspense, isSVG) => {
  for (let i = 0; i < newChildren.length; i++) {
    const oldVNode = oldChildren[i]
    const newVNode = newChildren[i]
    // 确定待更新节点的容器
    const container =
      // 对于 Fragment,我们需要提供正确的父容器
      oldVNode.type === Fragment ||
      // 在不同节点的情况下,将有一个替换节点,我们也需要正确的父容器
      !isSameVNodeType(oldVNode, newVNode) ||
      // 组件的情况,我们也需要提供一个父容器
      oldVNode.shapeFlag & 6 /* COMPONENT */
        ? hostParentNode(oldVNode.el)
        :
        // 在其他情况下,父容器实际上并没有被使用,所以这里只传递 Block 元素即可
        fallbackContainer
    patch(oldVNode, newVNode, container, null, parentComponent, parentSuspense, isSVG, true)
  }
}

patchBlockChildren 的实现很简单,遍历新的动态子节点数组,拿到对应的新旧动态子节点,并执行 patch 更新子节点即可。

这样一来,更新的复杂度就变成和动态节点的数量正相关,而不与模板大小正相关。这也是 Vue 3 做的一个重要的编译时优化的一部分。

总结

有了上面的一些介绍,我们还是回到前言的例子中:

xml
<template>
  <p>hello world</p>
  <p>{{ msg }}</p>
</template>

转成 vnode 后的结果大致为:

js
const vnode = {
  type: Symbol(Fragment),
  children: [
    { type: 'p', children: 'hello world' },
    { type: 'p', children: ctx.msg, patchFlag: 1 /* 动态的 text */ },
  ],
  dynamicChildren: [
    { type: 'p', children: ctx.msg, patchFlag: 1 /* 动态的 text */ },
  ]
}

此时组件内存在了一个静态的节点 <p>hello world</p>,在传统的 diff 算法里,还是需要对该静态节点进行不必要的 diff。所以 Vue3 先通过 patchFlag 来标记动态节点 <p></p>, 然后配合 dynamicChildren 将动态节点进行收集,从而完成在 diff 阶段只做靶向更新的目的。

前端知识体系 · wcrane