第 11 章

静态提升与 PatchFlag:编译期性能标注的原理与效果

第11章:静态提升与 PatchFlag——编译期性能标注的原理与效果

Vue 3 的 diff 算法本身并不比 Vue 2 快——快的原因是它需要 diff 的东西少了 90%。静态提升与 PatchFlag 让编译器替运行时做了大量预分析工作。

本章核心问题:为什么说 Vue 3 的虚拟 DOM diff 是"精确制导"的?编译器如何在构建时就知道哪些节点是动态的?PatchFlag 的位运算是怎么工作的?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

Vue 2 的性能问题:重复创建静态 VNode

在 Vue 2 中,每次组件更新都会重新执行整个 render 函数,创建所有 VNode(包括从不变化的静态内容)。考虑这个例子:

<!-- Vue 2 组件模板 -->
<template>
  <div>
    <h1>网站标题</h1>                  <!-- 永远不变 -->
    <p>固定描述文本</p>                <!-- 永远不变 -->
    <p>当前时间:{{ currentTime }}</p> <!-- 每秒变化 -->
    <footer>版权所有 2024</footer>     <!-- 永远不变 -->
  </div>
</template>

每次 currentTime 变化,Vue 2 的更新过程:

  1. 重新执行 render 函数,创建新的根 VNode
  2. 创建新的 <h1> VNode("网站标题")
  3. 创建新的 <p> VNode("固定描述文本")
  4. 创建新的 <p> VNode(带有新的时间字符串)
  5. 创建新的 <footer> VNode("版权所有 2024")
  6. 对新旧 VNode 树做完整 diff

在步骤 2、3、5 中创建的 VNode 和上次完全相同,但 Vue 2 不知道这一点,因为 Vue 2 的 render 函数没有"哪些节点是静态的"这一信息。

实测数据(MacBook Pro M2,10000 个列表项,其中 9900 个静态,100 个动态):

Vue 3 的解决方案:两步走

第一步:静态提升(Static Hoisting)

编译器识别出从不变化的节点,将其对应的 VNode 创建代码提升到 render 函数外部,作为模块级常量。这样这些 VNode 对象只在模块加载时创建一次,之后每次渲染都复用同一个对象。

// Vue 2 编译结果(简化)
function render() {
  return h('div', [
    h('h1', '网站标题'),        // 每次都创建新对象
    h('p', '固定描述文本'),     // 每次都创建新对象
    h('p', '当前时间:' + this.currentTime),
    h('footer', '版权所有 2024')  // 每次都创建新对象
  ])
}

// Vue 3 编译结果(简化)
const _hoisted_1 = createVNode('h1', null, '网站标题')      // 提升
const _hoisted_2 = createVNode('p', null, '固定描述文本')   // 提升
const _hoisted_3 = createVNode('footer', null, '版权所有 2024') // 提升

function render(_ctx) {
  return createVNode('div', null, [
    _hoisted_1,    // 复用,不创建新对象
    _hoisted_2,    // 复用
    createVNode('p', null, '当前时间:' + _ctx.currentTime, 1), // 1 = TEXT
    _hoisted_3     // 复用
  ])
}

第二步:PatchFlag 标注

对于必须保留在 render 函数内部的动态节点,编译器在创建 VNode 时加入一个整数标志位(PatchFlag),精确指明"哪个方面是动态的"。

PatchFlag 完整表格

标志名 含义
TEXT 1 文本内容是动态的({{ msg }}
CLASS 2 class 绑定是动态的(:class
STYLE 4 style 绑定是动态的(:style
PROPS 8 至少一个非 class/style 的 prop 是动态的
FULL_PROPS 16 动态 key 的 prop(:[key]="val"),需要完整 diff
HYDRATE_EVENTS 32 有事件监听器(仅 SSR 水合时使用)
STABLE_FRAGMENT 64 稳定 Fragment(子节点顺序不会改变)
KEYED_FRAGMENT 128 带 key 的 v-for Fragment
UNKEYED_FRAGMENT 256 不带 key 的 v-for Fragment
NEED_PATCH 512 需要 patch,但不是 props/text/class/style
DYNAMIC_SLOTS 1024 组件有动态 slot(会影响子节点优化)
DEV_ROOT_FRAGMENT 2048 开发模式下的根 Fragment(仅开发用)
HOISTED -1 已静态提升,永远不需要 diff
BAIL -2 编译器放弃优化,回退到完整 diff

位运算组合:多个 flag 可以用位 OR 组合:

// 既有动态文本,又有动态 class
createVNode("p", { class: _ctx.cls }, _ctx.msg, 3)  // 3 = 1 | 2 = TEXT | CLASS

// 检测是否有某个 flag:
const flag = 3  // TEXT | CLASS
flag & PatchFlags.TEXT  // = 1 (truthy,有 TEXT)
flag & PatchFlags.STYLE // = 0 (falsy,无 STYLE)

Level 2 · 它是怎么运行的(3-5年经验)

静态提升的完整判断流程

静态提升的核心在于"是否静态"的判断。节点是静态的,当且仅当:

节点本身静态的条件(AND 关系):
├─ 没有动态属性绑定(无 :attr、v-bind)
├─ 没有动态事件处理(无 @event、v-on)——除了 v-once 的事件
├─ 不是组件(组件的 props 可能被父级动态传入)
├─ 没有 v-if、v-for、v-slot
├─ 没有 ref
└─ 所有子节点也是静态的(递归条件)

一旦满足所有条件,节点被提升。否则节点保留在 render 函数内,但仍然可能带有 PatchFlag 来优化 diff。

提升示意图

模板:
<div>                              ← 不提升(有动态子节点)
  <h1>标题</h1>                   ← 提升
  <div class="info">              ← 不提升(有动态子节点)
    <span>固定</span>             ← 提升
    <span>{{ count }}</span>      ← 不提升(动态插值)
  </div>
</div>

提升后的代码:
const _h1 = createVNode("h1", null, "标题", -1)     // 提升
const _span = createVNode("span", null, "固定", -1)  // 提升

function render(_ctx) {
  return createVNode("div", null, [
    _h1,
    createVNode("div", { class: "info" }, [
      _span,
      createVNode("span", null, toDisplayString(_ctx.count), 1)
    ])
  ])
}

Props 静态提升

不仅 VNode 节点本身可以提升,静态的 props 对象也可以提升:

<div id="main" class="container" data-version="3">
  {{ dynamic }}
</div>

编译结果:

// props 对象也提升(不是每次渲染都创建新对象)
const _hoisted_props = {
  id: "main",
  class: "container",
  "data-version": "3"
}

function render(_ctx) {
  return createVNode("div", _hoisted_props, toDisplayString(_ctx.dynamic), 1)
}

为什么 props 提升重要?

diff 时的 PatchFlag 利用

运行时 diff 逻辑(packages/runtime-core/src/vnode.tspackages/runtime-core/src/renderer.ts)在 patch 节点时检查 PatchFlag:

带 PatchFlag 的 patch 路径(精确更新)

// 简化的 patchElement 逻辑
function patchElement(n1: VNode, n2: VNode) {
  const el = (n2.el = n1.el)
  const { patchFlag, dynamicProps } = n2
  
  if (patchFlag > 0) {
    // 有 PatchFlag,走精确更新路径
    
    if (patchFlag & PatchFlags.FULL_PROPS) {
      // 动态 key,必须完整 diff props
      patchProps(el, n2, n1.props, n2.props, /* ... */)
    } else {
      if (patchFlag & PatchFlags.CLASS) {
        // 只更新 class
        if (n1.props!.class !== n2.props!.class) {
          hostPatchProp(el, 'class', null, n2.props!.class)
        }
      }
      if (patchFlag & PatchFlags.STYLE) {
        // 只更新 style
        hostPatchProp(el, 'style', n1.props!.style, n2.props!.style)
      }
      if (patchFlag & PatchFlags.PROPS) {
        // 只更新指定的动态 props(dynamicProps 数组里的)
        for (let i = 0; i < dynamicProps!.length; i++) {
          const key = dynamicProps![i]
          const prev = n1.props![key]
          const next = n2.props![key]
          if (next !== prev) {
            hostPatchProp(el, key, prev, next)
          }
        }
      }
    }
    
    if (patchFlag & PatchFlags.TEXT) {
      // 只更新文本内容
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children as string)
      }
    }
  } else if (patchFlag === PatchFlags.BAIL) {
    // 编译器放弃优化,完整 diff
    patchProps(el, n2, n1.props, n2.props, /* ... */)
  }
  // patchFlag === -1 (HOISTED) 的节点永远不会进入 diff,因为 Block 机制
  // 已经将它们排除在 dynamicChildren 之外
}

精确更新 vs 完整 diff 的对比

完整 diff(无 PatchFlag):
1. 遍历所有旧 props → 检查是否在新 props 中(O(n))
2. 遍历所有新 props → 更新变化的(O(n))
3. 比较 children(递归)

精确更新(有 PatchFlag=3, TEXT|CLASS):
1. 检查 class 是否变化 → 更新
2. 检查 textContent 是否变化 → 更新
3. 完成

对于一个有 10 个属性的元素,完整 diff 需要检查 20 个键,精确更新只需要检查 2 个——对于 1000 个这样的元素,性能差异约 10 倍。

ASCII 图:PatchFlag 在渲染循环中的作用

组件更新触发
     │
     ▼
patch(oldVNode, newVNode)
     │
     ├─ patchFlag === -1 (HOISTED)?
     │    └─ YES → skip(跳过,不做任何处理)
     │    └─ NO ↓
     │
     ├─ patchFlag > 0?
     │    └─ YES → 精确更新路径
     │         ├─ flag & TEXT  → 更新文本(如果变了)
     │         ├─ flag & CLASS → 更新 class(如果变了)
     │         ├─ flag & STYLE → 更新 style(如果变了)
     │         └─ flag & PROPS → 只更新 dynamicProps 中的属性
     │    └─ NO ↓
     │
     └─ 无 patchFlag → 完整 diff(props + children)
          ├─ 遍历所有旧 props
          ├─ 遍历所有新 props
          └─ 递归比较 children

Level 3 · 设计文档与源码(资深开发者)

PatchFlag 的源码定义

文件路径packages/shared/src/patchFlags.ts

export const enum PatchFlags {
  /**
   * 动态文本内容
   * e.g. `<div>{{ msg }}</div>`
   */
  TEXT = 1,

  /**
   * 动态 class 绑定
   * e.g. `<div :class="cls">`
   */
  CLASS = 1 << 1,  // 2

  /**
   * 动态 style 绑定
   * e.g. `<div :style="sty">`
   */
  STYLE = 1 << 2,  // 4

  /**
   * 有除了 class/style 之外的动态 props
   * 这些 props 的 key 在编译时已知,存放在 dynamicProps 数组中
   * e.g. `<div :id="id" :title="title">` → dynamicProps: ['id', 'title']
   */
  PROPS = 1 << 3,  // 8

  /**
   * 动态 prop key(运行时才能确定 key 名)
   * e.g. `<div :[key]="val">`
   * 无法优化,需要完整 diff props
   */
  FULL_PROPS = 1 << 4,  // 16

  /**
   * 有事件监听器(仅 SSR hydration 时使用)
   */
  HYDRATE_EVENTS = 1 << 5,  // 32

  /**
   * Fragment 的子节点顺序稳定(不会因条件变化而改变)
   * 可以安全地对子节点做靶向 diff
   */
  STABLE_FRAGMENT = 1 << 6,  // 64

  /**
   * 带 key 的 v-for Fragment
   * 使用带 key 的 diff 算法
   */
  KEYED_FRAGMENT = 1 << 7,  // 128

  /**
   * 不带 key 的 v-for Fragment
   * 使用简单的 diff 算法(patch 同位置节点)
   */
  UNKEYED_FRAGMENT = 1 << 8,  // 256

  /**
   * 需要 patch 但不在以上类别中
   * 如:ref 绑定、v-model(非 input 元素)
   */
  NEED_PATCH = 1 << 9,  // 512

  /**
   * 组件有动态 slot 内容
   */
  DYNAMIC_SLOTS = 1 << 10,  // 1024

  /**
   * 开发模式下,根节点是 Fragment(仅用于 DevTools)
   */
  DEV_ROOT_FRAGMENT = 1 << 11,  // 2048

  /**
   * 表示 VNode 已静态提升
   * diff 时直接跳过
   */
  HOISTED = -1,

  /**
   * 编译器放弃优化,回退到完整 diff
   * 出现在:
   * - 有 v-for 但 key 不稳定
   * - 有动态 slot
   * - 编译器无法确定节点结构
   */
  BAIL = -2,
}

静态提升的实现:hoist 函数

文件路径packages/compiler-core/src/transforms/hoistStatic.ts

function walk(
  node: ParentNode,
  context: TransformContext,
  doNotHoistNode: boolean = false,
) {
  const { children } = node
  const originalCount = children.length
  let hoistedCount = 0
  
  for (let i = 0; i < children.length; i++) {
    const child = children[i]
    
    // 只处理元素节点
    if (
      child.type === NodeTypes.ELEMENT &&
      child.tagType === ElementTypes.ELEMENT
    ) {
      const staticType = getStaticType(child)
      
      if (staticType !== StaticType.NOT_STATIC) {
        if (staticType === StaticType.HAS_RUNTIME_CONSTANT) {
          child.codegenNode!.patchFlag = PatchFlags.HOISTED + '  /* HOISTED */'
        }
        // 提升:将 codegenNode 替换为 context.hoist() 的返回值
        child.codegenNode = context.hoist(child.codegenNode!)
        hoistedCount++
        continue
      }
    }
    
    // 非静态节点:递归处理子节点
    if (child.type === NodeTypes.ELEMENT) {
      // 检查 props 是否可以提升
      const codegenNode = child.codegenNode!
      if (
        codegenNode.type === NodeTypes.VNODE_CALL &&
        codegenNode.props
      ) {
        const staticType = getStaticType(codegenNode.props as any)
        if (staticType !== StaticType.NOT_STATIC) {
          // props 对象是静态的,单独提升 props
          codegenNode.props = context.hoist(codegenNode.props as any) as any
        }
      }
      
      // 递归进入子节点
      if (child.children) {
        walk(child, context)
      }
    }
  }
  
  // 如果所有子节点都被提升,父节点的子节点处理也可以优化
  if (hoistedCount && hoistedCount === originalCount) {
    // 所有子节点都提升了,可以进一步优化父节点
    // 将 children 数组本身也提升
  }
}

patchElement 的完整精确更新路径

文件路径packages/runtime-core/src/renderer.ts

const patchElement = (
  n1: VNode,
  n2: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  namespace: ElementNamespace,
  slotScopeIds: string[] | null,
  optimized: boolean,
) => {
  const el = (n2.el = n1.el!)
  let { patchFlag, dynamicProps, shapeFlag } = n2
  // 老节点有 BAIL flag 时,新节点继承
  patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS
  
  const oldProps = n1.props || EMPTY_OBJ
  const newProps = n2.props || EMPTY_OBJ
  
  if (patchFlag > 0) {
    if (patchFlag & PatchFlags.FULL_PROPS) {
      // 动态 key,完整 diff props
      patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense, namespace)
    } else {
      // 精确更新各属性
      if (patchFlag & PatchFlags.CLASS) {
        if (oldProps.class !== newProps.class) {
          hostPatchProp(el, 'class', null, newProps.class, namespace)
        }
      }
      if (patchFlag & PatchFlags.STYLE) {
        hostPatchProp(el, 'style', oldProps.style, newProps.style, namespace)
      }
      if (patchFlag & PatchFlags.PROPS) {
        // dynamicProps 是编译时确定的动态 prop 列表
        for (let i = 0; i < dynamicProps!.length; i++) {
          const key = dynamicProps![i]
          const prev = oldProps[key]
          const next = newProps[key]
          if (next !== prev || key === 'value') {
            hostPatchProp(el, key, prev, next, namespace, n1.children as VNode[], ...)
          }
        }
      }
    }
    
    if (patchFlag & PatchFlags.TEXT) {
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children as string)
      }
    }
  } else if (!optimized) {
    // 无 patchFlag 且非 optimized 模式:完整 diff
    patchProps(el, n2, oldProps, newProps, ...)
  }
  
  // 处理 children
  const prevShapeFlag = n1.shapeFlag
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 新子节点是文本
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      unmountChildren(n1.children as VNode[], ...)
    }
    if (n2.children !== n1.children) {
      hostSetElementText(el, n2.children as string)
    }
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 新子节点是数组
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      patchChildren(n1, n2, el, ...)
    }
  }
}

Level 4 · 边界与陷阱(全体适用)

陷阱1:在 render 函数(JSX)中使用静态内容无法被自动提升

静态提升发生在编译阶段,只对 SFC <template> 模板有效。如果你使用手写的 render 函数或 JSX,编译器无法分析,不会做静态提升:

// ❌ JSX/render 函数:每次渲染都创建所有 VNode
export default defineComponent({
  render() {
    return h('div', [
      h('h1', '静态标题'),           // 每次渲染都创建新对象
      h('p', this.dynamicText),
    ])
  }
})

如果必须用 render 函数,可以手动"提升"

// ✅ 手动提升静态 VNode
const _staticTitle = h('h1', '静态标题')  // 模块级常量

export default defineComponent({
  render() {
    return h('div', [
      _staticTitle,            // 复用
      h('p', this.dynamicText),
    ])
  }
})

陷阱2:带有非 prop 的动态 key 导致 FULL_PROPS,性能退化

<!-- 动态绑定的属性名 -->
<div :[dynamicKey]="value">内容</div>

由于 dynamicKey 在运行时才能确定,编译器无法做精确优化,PatchFlag 会是 FULL_PROPS(16),导致每次更新都做完整的 props diff。

// 编译结果
createVNode("div", {
  [_ctx.dynamicKey]: _ctx.value
}, "内容", 16 /* FULL_PROPS */)

建议:避免使用动态属性名,改用固定属性名 + 条件渲染。

陷阱3:v-once 与静态提升的区别

v-once 和静态提升看起来相似,但有本质区别:

<!-- 静态提升:编译器自动识别,节点永远不更新 -->
<div>这是纯静态内容</div>

<!-- v-once:显式声明,首次渲染后不再更新(即使依赖变化) -->
<div v-once>{{ expensiveComputed }}</div>
// v-once 的编译结果(使用缓存机制,不是提升)
createVNode("div", null,
  _cache[0] || (_cache[0] = createTextVNode(toDisplayString(_ctx.expensiveComputed))),
  -1  // HOISTED,但通过 cache 实现
)

陷阱4:HOISTED 节点被修改导致跨渲染帧污染

静态提升的 VNode 被多次渲染复用同一个对象。如果在运行时直接修改这个 VNode 对象,会污染后续渲染:

// ❌ 直接修改 VNode 的属性(不应该这么做)
// 假设 hoisted VNode 对应 <div class="static">内容</div>
const vnode = /* 某种方式获取到了 hoisted VNode */
vnode.props.class = 'modified'  // ❌ 下次渲染还是用这个对象!

这在正常使用 Vue 的情况下不会发生(不要直接操作 VNode 对象),但在某些高级自定义渲染器场景下需要注意。


本章小结

  1. Vue 3 的性能优势来自"减少工作量"而非"加速算法":静态提升把不变的 VNode 移出渲染循环,PatchFlag 把 diff 的范围缩小到真正动态的部分。同样的算法,处理 100 个节点比处理 10000 个节点快 100 倍。

  2. PatchFlag 是编译时信息的运行时桥梁:编译器在构建时分析出"这个节点的哪些方面是动态的",通过整数 flag 传递给运行时,运行时据此做精确的最小化更新,避免了运行时再次分析的开销。

  3. 位运算让多个 flag 高效组合:PatchFlag 使用 2 的幂次方值,多个 flag 用位 OR 组合,检测用位 AND。这是一种在 JS 中常见的低开销标志位技术,每次判断只需一次位运算。

  4. 静态提升有严格的全树静态要求:一个节点的任何子孙节点含有动态内容,这个节点就无法被提升。这意味着深度嵌套的大树中,即使只有一个动态节点,也会阻止其所有祖先被提升——这是设计上的保守选择,保证正确性优先。

  5. JSX/render 函数无法享受自动静态提升:这些场景需要开发者手动管理"哪些 VNode 是静态的",通过将其声明为模块级常量来实现手动"提升"。这是选择 SFC + 模板语法的一个重要性能理由。

本章评分
4.8  / 5  (31 评分)

💬 留言讨论