Vue 3 设计与实现

第 18 章 性能工程与最佳实践

作者 杨艺韬 · 14,810 字 · 发布于 · 更新于

第 18 章 性能工程与最佳实践

本章要点

  • Vue 3 的性能优化哲学:编译时优化 + 运行时精细控制的双轨策略
  • 编译器的静态节点缓存(cacheStatic,3.5 之前叫 hoistStatic):不变的 VNode 只创建一次、后续 render 复用
  • PatchFlag 与 Block Tree:精确追踪动态节点,跳过静态内容的 Diff
  • 响应式系统的性能陷阱:深层响应式、大型数组、不必要的依赖收集
  • shallowRef / shallowReactive / triggerRef 的精细控制策略
  • 组件级优化:v-once、v-memo、defineAsyncComponent 与代码分割
  • 虚拟列表与大数据渲染的底层实现原理
  • 内存泄漏的检测与预防:闭包陷阱、全局事件、定时器清理
  • DevTools Performance 面板:如何定位 Vue 应用的性能瓶颈
  • 从源码角度理解每个优化手段的收益与代价

性能不是事后修补,而是架构决策。Vue 3 从设计之初就将性能作为核心目标——Proxy 替代 Object.defineProperty、编译器的静态分析、Block Tree 的精确 Diff、Tree-shaking 友好的模块化设计。这些底层改进让 Vue 3 在多数基准测试中明显领先 Vue 2。

但框架的性能上限只决定了地板,应用的实际表现取决于开发者如何使用它。本章将从源码层面剖析每个优化手段的原理,让你不仅知道"该怎么做",更理解"为什么这样做有效"。

一个老工程师的"性能观"

在开始逐条拆解技术细节之前,先分享一个贯穿全章的判断框架——性能工作的三层优先级。

这是我做过十余个中大型前端项目,踩过各种坑之后总结的结构:

  1. 第一层:让 render 不跑多余的工作。这是最大块的收益来源。render 是否在组件没变时也被触发?computed 是否被错误地失效?响应式依赖是否收集过度?这一层解决好,省下的往往是最大一块无效工作量。
  2. 第二层:让 DOM 操作最小化。哪怕 render 跑了,真正落地到 DOM 的变更应该最小。VDOM diff 的精确度、Block Tree 的跳过机制、key 的使用——都属于这一层。做好这一层,能在第一层基础上进一步压缩真正落到 DOM 的操作。
  3. 第三层:让浏览器绘制最快。layout thrashing 的规避、transform 取代 top/left、requestAnimationFrame 的使用——这些是给前两层已经做好之后的"收尾打磨",不是门面救火的手段。

这个优先级的顺序不能颠倒。我见过团队把时间花在第三层,纠结于"这个动画用 left 还是 transform",但 render 层面一次状态改动触发了 300 个组件重渲染——你在三层上省的几毫秒,被第一层的几百毫秒无效工作全部吃掉。

本章的组织方式也是按这个优先级来的:先讲编译时和响应式(第一层)、再讲 Block Tree 和 diff(第二层)、最后讲虚拟滚动和绘制(第三层)。这条路径上的每个技术点都不是"技巧",而是"层级"——掌握层级,技巧自然落位。

一个让我反复引用的"第一性"事实

Vue 的性能优化有一个终极来源:你声明式写的模板里,真正会变的东西通常只占一小部分。

一个 500 行模板的组件,每次状态改动可能只有 3 个绑定真正变化。朴素的框架会每次重走全部 500 行的对比;Vue 3 通过编译期 + 运行期的协作,把工作量压到那 3 个变量的范围。

这个"500 → 3"的落差就是 Vue 3 所有性能优化的共同母题——静态节点缓存是让不变的 VNode 只创建一次、后续 render 根本不重建、Block Tree 是把动态节点登记出来让 diff 只过这几个、PatchFlag 是把动态属性精确到 class/style/text 中的某一个让 patch 只对比被标记的那几项。本章的每一节都是这个母题的一个变奏。读的时候带着这个心智框架,所有优化手段会突然变得互相连通。

《React 19 内核探秘》专栏第 5 章讨论 Fiber diff 时提过一个相似的命题——但 React 的答案是"让 diff 本身变得可打断",Vue 的答案是"干脆不让 diff 做无用功"。哲学不同,但优化压力的来源完全一致。

18.1 编译时优化

编译时优化是 Vue 3 最深刻的性能革命——它把许多原本要运行时反复计算的事情,在构建期一次算完。这种"把工作往前推"的思路在系统软件里有一个专用术语:at-compile-time specialization,rustc 的 monomorphization、数据库的 prepared statement 都是同构的套路。

Vue 3 能做到这一点的关键是——模板是受限的 DSL 而不是任意代码。下面每一节展示的都是"在模板的约束下,编译器能看出什么、能提前做什么"。看的时候请持续体会:模板的"不够灵活"恰恰是它最大的价值——灵活意味着运行时未知,未知意味着必须保守处理;不灵活意味着编译时确定,确定意味着运行时可以省事。

静态提升(Static Hoisting)与静态节点缓存

静态提升是最朴素也最有效的一种编译优化——把"每次 render 都要重建的不变 VNode"只创建一次、后续所有 render 复用同一个引用。3.5 之前它的实现是字面意义上的"提升":静态 VNode 被挪到模块顶层的 _hoisted_n 常量里;3.5 起(PR #11067)改成了按组件实例缓存——存进 render 函数的 _cache[n],对应的 transform 文件也从 hoistStatic.ts 改名为 cacheStatic.ts。原因是模块级常量会被所有实例共享且永不释放(#5256、#9219、#10959 都是这类内存问题)。下文沿用"静态提升"这个约定俗成的叫法,但请记住 rc.4 里它的真实形态是 _cache[n]。

这个优化的直觉来源很简单:一个组件 render 一次可能创建几十个 VNode 对象(每个 VNode 都是一次内存分配);组件频繁更新时(比如每秒 30 帧的动画),这些分配累积起来就是显著的 GC 压力。如果编译器看出"这个 VNode 里没有任何动态内容(全是静态字符串、静态属性、静态嵌套结构)",第一次 render 时把它存进实例的 _cache 数组,那后续 render 每次跑就只是"引用同一个 VNode"——不分配、不创建,纯复用。

能复用的前提是"这个 VNode 的身份在多次 render 之间可以是同一个对象"。这正是 Vue 团队早期在这项优化上反复讨论的点:一个已经挂载过的 VNode 身上带着 el,如果同一个对象又被放到另一个位置去挂载,el 就会被覆盖、卸载时也会删错节点。Vue 的解法是 cloneIfMounted(runtime-core/src/vnode.ts):规范化子节点时,发现一个 VNode 已经挂载过(el 非空)或者带 CACHED 标记,就 cloneVNode 一份再用——被复用的只是"描述",真正参与挂载的永远是新对象。这是"为了安全地复用而付出的微小代价"——比起每次 render 都重建整棵子树,一次浅克隆小到可以忽略。

Vue 3 编译器最重要的优化之一是静态提升——将不会变化的 VNode 在首次渲染时缓存到 _cache 里,避免每次渲染都重复创建:

// 模板
// <div>
//   <span class="title">固定标题</span>
//   <span>{{ message }}</span>
// </div>

// ❌ 未优化:每次渲染都创建所有 VNode
function render(_ctx) {
  return createVNode('div', null, [
    createVNode('span', { class: 'title' }, '固定标题'),
    createVNode('span', null, _ctx.message)
  ])
}

// ✅ 静态节点缓存后(rc.4 实际产物):静态节点只在第一次 render 时创建
function render(_ctx, _cache) {
  return (openBlock(), createElementBlock('div', null, [
    _cache[0] || (_cache[0] = createElementVNode(
      'span', { class: 'title' }, '固定标题', -1 /* CACHED */
    )),  // 第二次起直接命中缓存,不重新创建
    createElementVNode('span', null, toDisplayString(_ctx.message), 1 /* TEXT */)
  ]))
}

编译器是如何判断哪些节点可以缓存的?

// compiler-core/src/transforms/cacheStatic.ts(精简)
function walk(
  node: ParentNode,
  parent: ParentNode | undefined,
  context: TransformContext,
  doNotHoistNode: boolean = false,
  inFor = false,
) {
  const { children } = node
  const toCache: (PlainElementNode | TextCallNode)[] = []
  for (let i = 0; i < children.length; i++) {
    const child = children[i]
    // 只有普通元素和文本调用有资格被缓存
    if (
      child.type === NodeTypes.ELEMENT &&
      child.tagType === ElementTypes.ELEMENT
    ) {
      const constantType = doNotHoistNode
        ? ConstantTypes.NOT_CONSTANT
        : getConstantType(child, context)
      if (constantType > ConstantTypes.NOT_CONSTANT) {
        if (constantType >= ConstantTypes.CAN_CACHE) {
          // 标记为 CACHED(-1),并加入待缓存列表
          ;(child.codegenNode as VNodeCall).patchFlag = PatchFlags.CACHED
          toCache.push(child)
          continue
        }
      } else {
        // 节点本身有动态子节点,但它的 props 可能是常量——单独提升 props
        // ...
      }
    }
    // 继续向下遍历 ...
  }
  // 遍历结束后,把 toCache 里的节点包成 _cache[n] || (_cache[n] = ...)
}

// 静态类型的分级(compiler-core/src/ast.ts)
export enum ConstantTypes {
  NOT_CONSTANT = 0, // 包含动态绑定
  CAN_SKIP_PATCH,   // 可以跳过 Patch
  CAN_CACHE,        // 可以缓存(3.5 之前叫 CAN_HOIST)
  CAN_STRINGIFY,    // 可以序列化为字符串(最高级别)
}

静态字符串化

比静态提升更激进的一步是——对连续的大段静态 HTML 直接编译成字符串。这段 VNode 不再逐节点创建,而是直接成为一个 createStaticVNode('<div class="foo">...</div>', 5) 调用,挂载时浏览器一次 innerHTML 就搞定。

这个优化的切入点是阈值——Vue 编译器只对"足够大的一段连续静态节点"做字符串化。compiler-dom/src/transforms/stringifyStatic.ts 里的 StringifyThresholds 给了两个条件,满足其一即可:累计节点数达到 NODE_COUNT = 20,或带属性的元素数(静态属性也算)达到 ELEMENT_WITH_BINDING_COUNT = 5。阈值过小的话字符串化反而不如 VNode(innerHTML 解析 HTML 也是要钱的),过大的话错过很多可优化场景——这两个数字是 Vue 团队权衡后的经验值。

当连续的静态节点数量达到阈值(20 个节点,或 5 个带属性的元素),编译器会将它们直接序列化为 HTML 字符串:

// 大量静态内容
// <div>
//   <p>段落 1</p>
//   <p>段落 2</p>
//   ... (20+ 个静态段落)
//   <p>段落 N</p>
//   <p>{{ dynamic }}</p>
// </div>

// 编译结果:静态部分变成一个字符串,同样放进 _cache
function render(_ctx, _cache) {
  return (openBlock(), createElementBlock('div', null, [
    _cache[0] || (_cache[0] = createStaticVNode(
      '<p>段落 1</p><p>段落 2</p>...<p>段落 N</p>',
      20  // 节点数量(hydration 时要靠它认领节点)
    )),
    createElementVNode('p', null, toDisplayString(_ctx.dynamic), 1 /* TEXT */)
  ]))
}

createStaticVNode 只是创建一个 type === Static、staticCount = N 的 VNode;真正的挂载由 mountStaticNode 交给宿主的 insertStaticContent,用 <template>.innerHTML 一次性解析所有静态节点,比逐个 createElement 快得多:

// packages/runtime-core/src/renderer.ts:569
const mountStaticNode = (n2, container, anchor, namespace) => {
  // 静态节点只会出现在 compiler-dom + runtime-dom 的组合里,
  // 所以这里可以断言 hostInsertStaticContent 存在
  ;[n2.el, n2.anchor] = hostInsertStaticContent!(
    n2.children as string, container, anchor, namespace, n2.el, n2.anchor,
  )
}

// packages/runtime-dom/src/nodeOps.ts:99(精简)
insertStaticContent(content, parent, anchor, namespace, start, end) {
  const before = anchor ? anchor.previousSibling : parent.lastChild
  if (start && (start === end || start.nextSibling)) {
    // 已经有一份挂载过的 DOM(同一个缓存 VNode 再次挂载):直接 cloneNode
    while (true) {
      parent.insertBefore(start!.cloneNode(true), anchor)
      if (start === end || !(start = start!.nextSibling)) break
    }
  } else {
    // 首次挂载:innerHTML 一次解析
    templateContainer.innerHTML = unsafeToTrustedHTML(
      namespace === 'svg' ? `<svg>${content}</svg>`
        : namespace === 'mathml' ? `<math>${content}</math>` : content,
    ) as string
    const template = templateContainer.content
    // (svg / mathml 需要剥掉外层包裹)
    parent.insertBefore(template, anchor)
  }
  // 返回首尾节点,供后续移除和 hydration 使用
  return [
    before ? before.nextSibling! : parent.firstChild!,
    anchor ? anchor.previousSibling! : parent.lastChild!,
  ]
}

PatchFlag 精确追踪

PatchFlag 是 Vue 3 最独特的设计之一——编译器看模板时就能判断"这个节点哪些部分可能变",把这个信息用 bit flag 塞到 VNode 上,运行时 patch 只处理 flag 里标记的部分。

举个具体例子说明威力:模板 <div :class="cls">Hello {{ name }}</div>。编译器分析后标记这个 VNode 的 patchFlag 为 TEXT | CLASS,告诉运行时"这个节点的 text 子节点会变、class 属性会变、其他一切都是静态的"。运行时 patch 到这个节点时,跳过 style / id / 其他属性 / children 数组里非文本的部分——直接只 patch text 和 class。相比 Vue 2 每个节点都要遍历全部属性做对比,这种"精确制导"省下的工作量在大型应用里非常可观。

PatchFlag 的所有值也是位掩码形式,原因和 ShapeFlag 一样——多个维度可以 OR 组合(一个节点可能同时 TEXT + CLASS + STYLE 都变)、位判断也比字符串比较廉价得多。

PatchFlag 是 Vue 3 编译器最精巧的优化。它在编译时为每个动态节点标记"哪些部分是动态的",运行时只比较标记的部分:

// 编译器生成的 PatchFlag
const enum PatchFlags {
  TEXT = 1,              // 动态文本
  CLASS = 1 << 1,        // 动态 class
  STYLE = 1 << 2,        // 动态 style
  PROPS = 1 << 3,        // 动态非 class/style 的属性
  FULL_PROPS = 1 << 4,   // 有动态 key 的属性
  NEED_HYDRATION = 1 << 5, // 需要 hydration 的事件监听
  STABLE_FRAGMENT = 1 << 6, // 子节点顺序不变的 Fragment
  KEYED_FRAGMENT = 1 << 7,  // 有 key 的 Fragment
  UNKEYED_FRAGMENT = 1 << 8, // 无 key 的 Fragment
  NEED_PATCH = 1 << 9,  // 需要非 props 的 patch(ref、指令等)
  DYNAMIC_SLOTS = 1 << 10, // 动态插槽
  DEV_ROOT_FRAGMENT = 1 << 11, // 开发模式的根 Fragment
  CACHED = -1,           // 编译器缓存的静态节点(3.5 之前叫 HOISTED)
  BAIL = -2              // 放弃优化
}

// 模板:<div :class="cls" :style="stl" :id="id" @click="handler">
// 编译后(SFC 默认开启 cacheHandlers,事件处理器被缓存,所以 onClick 不算动态属性):
createVNode('div', {
  class: _ctx.cls,
  style: _ctx.stl,
  id: _ctx.id,
  onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handler && _ctx.handler(...args)))
}, null,
  PatchFlags.CLASS | PatchFlags.STYLE | PatchFlags.PROPS,
  // ↑ 位运算组合:class + style + props
  ['id']  // 动态属性名列表(props 时需要;不开 cacheHandlers 时是 ['id', 'onClick'])
)

运行时如何利用 PatchFlag:

// runtime-core/src/renderer.ts - patchElement(精简)
function patchElement(
  n1: VNode,
  n2: VNode,
  parentComponent: ComponentInternalInstance | null,
  // ...
) {
  const el = (n2.el = n1.el!)
  const oldProps = n1.props || EMPTY_OBJ
  const newProps = n2.props || EMPTY_OBJ
  let { patchFlag, dynamicChildren } = n2
  // 旧节点若是 FULL_PROPS(比如 cloneVNode 合并过 props),新节点也要跟着退化
  patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS

  // (children 先于 props 处理:有 dynamicChildren 走 patchBlockChildren,否则 patchChildren)

  if (patchFlag > 0) {
    // 有 PatchFlag,精确更新
    if (patchFlag & PatchFlags.FULL_PROPS) {
      // 动态 key,需要全量 diff props
      patchProps(el, oldProps, newProps, parentComponent, namespace)
    } else {
      // 按位检查,只更新变化的部分
      if (patchFlag & PatchFlags.CLASS) {
        if (oldProps.class !== newProps.class) {
          hostPatchProp(el, 'class', null, newProps.class, ...)
        }
      }
      if (patchFlag & PatchFlags.STYLE) {
        hostPatchProp(el, 'style', oldProps.style, newProps.style, ...)
      }
      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]
          if (next !== prev || key === 'value') {
            hostPatchProp(el, key, prev, next, ...)
          }
        }
      }
    }
    // TEXT 与 FULL_PROPS 互不排斥,放在 if/else 之外单独检查
    if (patchFlag & PatchFlags.TEXT) {
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children as string)
      }
    }
  } else if (!optimized && dynamicChildren == null) {
    // 没有 PatchFlag 也没有 Block 信息,回退到全量 diff
    patchProps(el, oldProps, newProps, parentComponent, namespace)
  }
}

PatchFlag 的威力在于把"比较全部属性"缩小为"只比较动态的那部分":CLASS / STYLE / TEXT 用一次位运算就能判定走哪条分支,PROPS 分支仍要按 dynamicProps 线性比对,但比对范围只是动态属性、不再是全部属性。一个有 10 个属性但只有 1 个是动态的元素,传统 Diff 需要比较 10 次,PatchFlag 只需比较 1 次。

Block Tree 与动态节点收集

Block Tree 是前面三项优化的集成装置——静态提升节省了 VNode 创建、静态字符串化压缩了大段静态节点、PatchFlag 把动态节点的变更方面精确标记;Block Tree 则把所有 dynamic VNode 登记在一个扁平数组里,让运行时 diff 一次性扫过这个数组、完全跳过静态节点。

这是第 11 章重点讲过的内容。本章在性能视角下补充一个观察:Block Tree 让 diff 的复杂度从 O(template_size) 降到 O(dynamic_nodes),而后者通常只占 template_size 的一小部分。这个数量级的降低是 Vue 3 相对 Vue 2 性能提升的主要来源之一——不是常数因子的改良,是复杂度的改良。本专栏第 11 章对这块的 diff 细节有详细拆解。

Block Tree 是配合 PatchFlag 使用的更高层次优化。它将组件的 VNode 树"拍平",直接追踪所有动态节点:

graph TD
    subgraph "传统 VNode 树(需要逐层遍历)"
        A1["div"] --> B1["header(静态)"]
        A1 --> C1["main"]
        C1 --> D1["p(静态)"]
        C1 --> E1["span(动态 :class)"]
        C1 --> F1["p(静态)"]
        A1 --> G1["footer(静态)"]
    end
    subgraph "Block Tree(直接定位动态节点)"
        A2["div(Block Root)"]
        A2 -.->|dynamicChildren| E2["span(PatchFlag: CLASS)"]
    end
    style E1 fill:#c0392b,color:#fff
    style E2 fill:#c0392b,color:#fff
    style A2 fill:#42b883,color:#333
// Block 的创建(packages/runtime-core/src/vnode.ts:308 与 :393;源码里 openBlock 的返回类型标注为 void)
export function openBlock(disableTracking = false) {
  blockStack.push(
    (currentBlock = disableTracking ? null : [])
  )
}

export function createBlock(
  type: VNodeTypes,
  props?: Record<string, any> | null,
  children?: any,
  patchFlag?: number,
  dynamicProps?: string[]
): VNode {
  return setupBlock(
    createVNode(type, props, children, patchFlag, dynamicProps, true)
  )
}

function setupBlock(vnode: VNode) {
  // 将收集到的动态节点附加到 Block 根节点
  // (isBlockTreeEnabled 被 v-once 减到 0 时,不记录 dynamicChildren)
  vnode.dynamicChildren =
    isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR as any) : null

  // 关闭当前 Block
  closeBlock()

  // Block 自己一定会被 patch,所以注册为父 Block 的动态子节点
  if (isBlockTreeEnabled > 0 && currentBlock) {
    currentBlock.push(vnode)
  }
  return vnode
}

// 每个动态节点在创建时自动注册到当前 Block(createBaseVNode 末尾)
if (
  isBlockTreeEnabled > 0 &&
  // Block 节点不追踪自己
  !isBlockNode &&
  // 有父 Block
  currentBlock &&
  // 有 patchFlag 说明更新时需要 patch;组件节点总要 patch(要把实例传给下一个 vnode)
  (vnode.patchFlag > 0 || shapeFlag & ShapeFlags.COMPONENT) &&
  // NEED_HYDRATION 只服务 hydration,若是唯一的 flag 则不算动态节点
  vnode.patchFlag !== PatchFlags.NEED_HYDRATION
) {
  currentBlock.push(vnode)
}

Patch 阶段利用 dynamicChildren 直接跳过静态子树:

// 示意,对应 packages/runtime-core/src/renderer.ts:1001——源码是
// `const patchBlockChildren: PatchBlockChildrenFn = (...) => {…}`,这里改写成
// function 形式并省略了部分参数
function patchBlockChildren(
  oldChildren: VNode[],
  newChildren: VNode[],
  // ...
) {
  for (let i = 0; i < newChildren.length; i++) {
    const oldVNode = oldChildren[i]
    const newVNode = newChildren[i]
    // 直接 patch 动态节点,不遍历整棵树
    patch(oldVNode, newVNode, /* ... */)
  }
}

18.2 响应式系统的性能优化

响应式是 Vue 核心魔力所在——写 state 就像写普通变量,UI 自动跟随。但"自动"是有代价的:每一次属性读取都经 Proxy 拦截,每一次修改都可能触发 effect 重跑。当数据结构复杂、组件数量多时,这些"看不见的开销"会在真实业务里累积成可见的卡顿。

本节讲的所有手段都是给响应式系统减负——让它只在真正需要响应的地方响应,在不需要的地方保持"愚钝"。本专栏第 4、5 章讲过响应式内部怎么做 track / trigger,本节是那一套机制的性能侧用户指南。

避免不必要的深层响应

深层响应是 Vue reactive 的默认语义——reactive(obj) 递归把所有嵌套属性变成响应式。这个默认值对简单业务对象很友好:你改任何字段都会自动触发更新。但对大对象(例如 10000 条的列表、复杂的配置树)就成了负担——每个字段代理一次、每次读取过一次 Proxy,内存和 CPU 都被消耗。

reactive() 默认创建深层响应式代理——对象的每一层嵌套都会被代理。对于大型、层次很深的数据结构,这是巨大的开销:

// ❌ 性能隐患:10000 个对象每个都被深度代理
const state = reactive({
  items: generateItems(10000) // 每个 item 有 10+ 个嵌套属性
})

// 源码中的深层代理逻辑(reactivity/src/baseHandlers.ts,精简)
class BaseReactiveHandler implements ProxyHandler<Target> {
  constructor(
    protected readonly _isReadonly = false,
    protected readonly _isShallow = false,
  ) {}

  get(target: Target, key: string | symbol, receiver: object): any {
    const isReadonly = this._isReadonly, isShallow = this._isShallow
    // ...(ReactiveFlags / 数组方法 instrumentation 等分支略)
    const res = Reflect.get(target, key, receiver)

    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key)
    }

    if (isShallow) {
      return res // shallowReactive:到此为止,不再往下代理
    }

    // ...(isRef(res) 时的自动解包分支略)

    if (isObject(res)) {
      // 关键:访问嵌套对象时,惰性递归代理
      return isReadonly ? readonly(res) : reactive(res)
      // 每次访问都会查 proxyMap / 创建代理,虽然有缓存,但仍有开销
    }

    return res
  }
}

// ✅ 优化:使用 shallowRef 或 shallowReactive
const state = shallowReactive({
  items: generateItems(10000) // 只有 items 属性是响应式的,内部不代理
})

// 需要更新时:
function updateItem(index: number, newData: Partial<Item>) {
  // shallowReactive 只追踪 state 自身的属性:state.items 拿到的是原始数组,
  // 直接写 state.items[index] 不会触发更新——要替换整个数组
  const items = state.items.slice()
  items[index] = { ...items[index], ...newData }
  state.items = items
}

shallowRef 与 triggerRef

shallowRef 和 triggerRef 是"我知道我在做什么、请让响应式系统闭嘴" 的 API。适用于两种场景:

  1. 整体替换的大对象:比如一个从后端加载的数据集,你总是整个换(data.value = newList),不会修改内部字段——那用 shallowRef 比 ref 省掉一大堆无效的深层代理。
  2. 手动触发更新:比如一个很大的 Map 或 Set(不想为它付出整体代理的开销),你自己管理状态、需要更新时手动调 triggerRef 通知 effect。这比硬挤进 reactive 体系更干净。

这些 API 是 Vue 对开发者经验的让渡——"我们不强制你用默认响应式,如果你有性能或结构上的特殊需要,这些低级 API 给你完全控制权"。这种"有默认值但允许 opt-out"的设计在顶级框架里反复出现——React 的 useMemo / useCallback、Svelte 的 $state.raw、SolidJS 的 untrack,都是同一思想。

shallowRef 只追踪 .value 的变化,不代理内部结构:

const data = shallowRef<BigDataStructure>({
  matrix: Array(1000).fill(null).map(() => Array(1000).fill(0)),
  metadata: { /* ... */ }
})

// ❌ 这不会触发更新(内部修改不被追踪)
data.value.matrix[0][0] = 42

// ✅ 方式 1:替换整个值
data.value = { ...data.value, matrix: newMatrix }

// ✅ 方式 2:手动触发更新
data.value.matrix[0][0] = 42
triggerRef(data) // 强制触发依赖更新

// triggerRef 的实现(reactivity/src/ref.ts,rc.4):
// 直接对 ref 的订阅者链表做一次"推标记 + 刷新",不比较值
export function triggerRef(ref: Ref): void {
  const dep = (ref as unknown as RefImpl).dep
  if (dep !== undefined && dep.subs !== undefined) {
    propagate(dep.subs)
    shallowPropagate(dep.subs)
    if (!batchDepth) {
      flush()
    }
  }
}

computed 的惰性求值与缓存

computed 的性能价值常被低估——很多人把它当"能获得响应式的函数"来用,但它真正的威力在自动化的缓存。

一个 computed 的生命周期:第一次读取时计算、缓存结果并订阅依赖;依赖变化时标 dirty 但不立刻重算;下次读取时如果 dirty 才重算、否则返回缓存。这个"读取驱动的惰性求值"比手动 memoization 聪明的地方在于——它不依赖你手动列出依赖,而是靠响应式系统自动发现。

对比:手动 memoization 你得写 useMemo(() => fn(a, b), [a, b]),依赖数组漏了任何一个会导致陈旧值、多了任何一个会导致无效重算。computed 的依赖是运行时自动捕获的——fn 里读了什么、就订阅什么,完全不出错。这是响应式系统相比 vanilla JS 最实在的工程价值之一:用依赖自动化换掉依赖手动维护。

computed 是 Vue 性能优化的重要工具,但误用也会带来问题:

// computed 的内部实现(reactivity/src/computed.ts,rc.4 精简)
// 3.6 的 computed 直接实现 ReactiveNode,既是订阅者(有 deps)也是被订阅者(有 subs)
export class ComputedRefImpl<T = any> implements ReactiveNode {
  _value: T | undefined = undefined
  subs: Link | undefined = undefined
  deps: Link | undefined = undefined
  // 初始就是 Dirty:第一次读取时才计算
  flags: ReactiveFlags = ReactiveFlags.Mutable | ReactiveFlags.Dirty

  constructor(public fn: ComputedGetter<T>, private readonly setter) {}

  get value(): T {
    const flags = this.flags
    if (
      flags & ReactiveFlags.Dirty ||
      // Pending:上游可能变了,但要先"拉"一次校验(checkDirty)确认
      (flags & ReactiveFlags.Pending && checkDirty(this.deps!, this))
    ) {
      // 只有确实脏了才重算;值真的变了再通知下游
      if (this.update()) {
        const subs = this.subs
        if (subs !== undefined) shallowPropagate(subs)
      }
    } else if (flags & ReactiveFlags.Pending) {
      this.flags = flags & ~ReactiveFlags.Pending
    }
    // 被谁读取,就和谁建立链接(依赖收集)
    if (activeSub !== undefined) {
      link(this, activeSub)
    } else if (activeEffectScope !== undefined) {
      link(this, activeEffectScope)
    }
    return this._value!
  }
}

和 3.4 的 DirtyLevels 多级枚举、3.5 的 EffectFlags.DIRTY + globalVersion 版本计数都不同,rc.4 用 Dirty / Pending 两个标志位区分"确定脏了"和"上游可能变了":依赖更新时 propagate 沿链表统一标 Pending(packages/reactivity/src/system.ts:180),reactive 属性写入再由紧随其后的 shallowPropagate 把直接订阅者升成 Dirty(packages/reactivity/src/system.ts:351);读取时再由 checkDirty 沿依赖链向上拉一次校验,上游值没变就直接清掉 Pending,不重算。这套"推标记 + 拉校验"的细节在第 6 章(Alien Signals)里有完整拆解。

// ❌ 计算属性链过长
const a = ref(1)
const b = computed(() => a.value * 2)
const c = computed(() => b.value + 1)
const d = computed(() => c.value * 3)
const e = computed(() => d.value + b.value) // 依赖了 d 和 b

// a 变化 → a 自身标 Dirty,b、c、d、e 都只被标 Pending(ref 写入只做 propagate)
// 如果 e 在模板中被使用,读取 e 时 checkDirty 会沿 d → c → b 逐级向上校验并重算

// ✅ 减少中间层,直接计算
const result = computed(() => {
  const base = a.value * 2
  const step = base + 1
  return step * 3 + base
})

watchEffect 的副作用清理

watchEffect 的陷阱集中在副作用上——订阅了一个 WebSocket、注册了一个事件监听、启动了一个 setInterval——这些副作用必须在 effect 重跑或组件卸载时清理,否则泄漏。Vue 提供的 onCleanup 参数就是为这个场景设计的。

很多项目的"内存越跑越胖"问题根源就在这里——写 watchEffect 时只想着"当 X 变了我要做 Y",忘了"当 X 再变的时候上一次的 Y 要先撤销"。特别是涉及网络请求的场景(请求 A 还没回来、请求 B 又发了,结果 A 的响应覆盖了 B 的响应)——这既是性能问题,也是正确性问题。onCleanup 能帮你正确地 abort 上一次请求,是避免"race condition in async effects"的标准工具。

// ❌ 内存泄漏:每次触发都创建新的订阅
watchEffect(() => {
  const subscription = eventBus.on('data', (data) => {
    processData(data)
  })
  // subscription 永远不会被清理!
})

// ✅ 使用 onCleanup 清理副作用
watchEffect((onCleanup) => {
  const subscription = eventBus.on('data', (data) => {
    processData(data)
  })
  onCleanup(() => {
    subscription.unsubscribe() // 下次执行前清理
  })
})

// ✅ 3.5 起也可以用 onWatcherCleanup()(reactivity/src/watch.ts),
// 它不依赖回调参数,可以在 watcher 内部调用的任意 composable 里注册清理
watchEffect(() => {
  const subscription = eventBus.on('data', processData)
  onWatcherCleanup(() => subscription.unsubscribe())
})

18.3 组件级优化

组件级优化的核心在一句话:告诉框架"这块我保证不变"。你的断言越精确,框架省的工作越多。

v-once:一次性渲染

v-once 是最"强"的一种断言——"这块我保证永不变"。一旦挂上,那段 VNode 第一次 render 后就被标记为"永不参与任何后续 diff"。这在静态内容区域(如页头版权信息、静态宣传文案)非常有用。

但要提醒一点:v-once 是双刃剑。我见过团队为了"让这个报表快"到处随手加 v-once——等到产品经理要求报表支持"切换数据源重新渲染"时,才发现这些 v-once 都得逐一摘掉。过早的 v-once 是一种耦合债——把"这块不需要更新"的临时业务事实固化到代码里,以后需求变就得改代码。所以好的经验法则:默认不用 v-once;当 DevTools 显示某区块重渲染确实是瓶颈时再加。

// <div v-once>
//   <ComplexChart :data="staticData" />
// </div>

// 编译后(codegen.ts genCacheExpression):缓存 VNode,后续渲染直接返回缓存
function render(_ctx, _cache) {
  return _cache[0] || (
    setBlockTracking(-1, true),  // 暂停 Block 追踪;第二个参数标记"在 v-once 里"
    (_cache[0] = createElementVNode('div', null, [
      createVNode(ComplexChart, { data: _ctx.staticData })
    ])).cacheIndex = 0,
    setBlockTracking(1),   // 恢复
    _cache[0]
  )
}

setBlockTracking(-1, true) 是关键——它把 isBlockTreeEnabled 减到 0,暂停了动态节点收集,这意味着 v-once 内部的动态绑定不会被加入 dynamicChildren,后续更新会完全跳过这个子树。第二个参数 inVOnce = true 会给当前 Block 打上 hasOnce 标记,让卸载时不走"只卸 dynamicChildren"的快速路径,避免漏掉 v-once 里嵌套的组件。

v-memo:条件性缓存

v-memo 是比 v-once 柔软一个层级的断言——"除非这些依赖变了、否则这块不变"。语法上传一个依赖数组,运行时对比数组浅相等则跳过整段 diff。

这个"浅相等即跳过"的语义和 React.memo 的 props shallow-compare 模式完全一致,也和 React.useMemo 的依赖数组一样。同一套思路在 Vue 和 React 里表现为不同的 API,但心智模型可以无缝迁移。如果你之前写过 React.memo,看到 v-memo 基本零学习成本——只是把"整个 props 浅比"换成"你指定的依赖数组浅比",粒度更细。

v-memo 是 Vue 3.2 引入的更灵活的缓存指令(commit 3b64508e3 feat: v-memo,git tag --contains 的首个 tag 是 v3.2.0-beta.1;运行时的浅比对实现见 packages/runtime-core/src/helpers/withMemo.ts:23-40):

// <div v-for="item in list" :key="item.id" v-memo="[item.selected]">
//   <HeavyComponent :data="item" />
// </div>

// 编译后(v-for 上的 v-memo 走 renderList 传给 renderItem 回调的第 4 个参数 _cached,
//         renderList 本身的第 3、4 个形参是 cache 与 index,见 packages/runtime-core/src/helpers/renderList.ts:61-66):
function render(_ctx, _cache) {
  return (openBlock(true), createElementBlock(Fragment, null,
    renderList(_ctx.list, (item, __, ___, _cached) => {
      // v-memo 检查:已挂载、key 相同、memo 依赖没变 → 直接返回缓存
      const _memo = ([item.selected])
      if (_cached && _cached.el && _cached.key === item.id && isMemoSame(_cached, _memo)) return _cached
      const _item = (openBlock(), createElementBlock('div', { key: item.id }, [
        createVNode(HeavyComponent, { data: item })
      ]))
      _item.memo = _memo
      return _item
    }, _cache, 0),
    128 /* KEYED_FRAGMENT */
  ))
}

// 不在 v-for 上的 v-memo 则编译成 withMemo(deps, render, _cache, index)
// runtime-core/src/helpers/withMemo.ts
export function isMemoSame(cached: VNode, memo: any[]): boolean {
  const prev: any[] = cached.memo!
  if (prev.length != memo.length) {
    return false
  }
  for (let i = 0; i < prev.length; i++) {
    if (hasChanged(prev[i], memo[i])) {
      return false
    }
  }
  // 返回缓存时也要让父 Block 追踪到它——
  // 否则下次父 Block patch 时 dynamicChildren 会对不上位
  if (isBlockTreeEnabled > 0 && currentBlock) {
    currentBlock.push(cached)
  }
  return true
}

v-memo 在长列表中的效果惊人。假设一个 1000 项的列表,只有 1 项的 selected 状态变了,传统渲染需要 diff 1000 个组件,v-memo 只需 diff 1 个(其余 999 项只做一次依赖数组的浅比对)。

KeepAlive 的缓存策略

KeepAlive 解决的是跨路由或跨条件渲染时的组件状态保留问题。典型场景:Tab 切换时你不希望"切回来丢失已输入的表单"、路由前进后退时希望列表滚动位置回到原处。没 KeepAlive 时组件一 unmount 就全部丢掉、再切回来要重新初始化(重新拉数据、重新绑定事件、重新算布局)——用户体验和请求成本都很差。

KeepAlive 的实现思路其实和浏览器的 bfcache(back-forward cache)异曲同工:把 DOM 节点和组件实例保留在内存里、不拆开、不销毁生命周期。唯一的区别是 KeepAlive 主动控制这个缓存是否生效,bfcache 是浏览器隐式管理。《React 19 内核探秘》提过 React 有一个 Offscreen 概念对标 KeepAlive,但到 React 19.0 仍只以 unstable_Activity 的实验性 API 导出。

// KeepAlive 的核心:LRU 缓存
const KeepAliveImpl = {
  setup(props, { slots }) {
    const cache = new Map<CacheKey, VNode>()
    const keys = new Set<CacheKey>()
    const instance = getCurrentInstance()!

    // 缓存当前组件
    function cacheSubtree() {
      if (pendingCacheKey != null) {
        cache.set(pendingCacheKey, getInnerChild(instance.subTree))
      }
    }

    onMounted(cacheSubtree)
    onUpdated(cacheSubtree)

    return () => {
      const children = slots.default!()
      const vnode = children[0]
      const key = vnode.key ?? vnode.type
      const cachedVNode = cache.get(key)

      if (cachedVNode) {
        // 命中缓存:复用组件实例
        vnode.el = cachedVNode.el
        vnode.component = cachedVNode.component
        // 标记为 KEPT_ALIVE,跳过挂载流程(走 activate)
        vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
        // LRU:把 key 挪到 Set 末尾,表示"最近用过"
        keys.delete(key)
        keys.add(key)
      } else {
        // 未命中:可能需要淘汰旧缓存
        keys.add(key)

        // LRU 淘汰:Set 的迭代顺序就是插入顺序,第一个即最久未用
        if (max && keys.size > parseInt(max as string, 10)) {
          pruneCacheEntry(keys.values().next().value!)
        }
      }

      // 标记为 SHOULD_KEEP_ALIVE,卸载时不销毁而是停用
      vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE

      return vnode
    }
  }
}

(缓存表与 LRU 队列的定义在 packages/runtime-core/src/components/KeepAlive.ts:114-115,淘汰逻辑在 packages/runtime-core/src/components/KeepAlive.ts:336-343(超出 max 时调 pruneCacheEntry 删最旧的一项),把子树写进缓存的时机是 onMounted / onUpdated——packages/runtime-core/src/components/KeepAlive.ts:236-237。)

18.4 列表渲染优化

前端性能问题的大头出在列表渲染——节点数量是问题规模的主变量。一个 10 条的列表怎么写都不会卡,一个 10000 条的列表写错一处就卡到爆。这一节讲的两件事(正确的 key + 虚拟滚动)几乎是所有中大型前端应用的性能救命稻草。

key 的重要性

key 几乎是 Vue 性能话题里被错用最多的 API。绝大多数的"列表卡顿"最终追查下来都是 key 的问题。

三种典型错误:

  1. 不写 key:列表更新退化为位置对比。如果列表只追加、只从尾部删除,位置对比恰好和身份对比等效,不会出 bug——于是很多开发者以为"不写 key 也能跑"。等到某天需要在列表头部插入或者排序,隐藏的 bug 全部爆出来(组件内部 state 错位、过渡动画乱套)。
  2. 用 index 作 key:和不写 key 等效。index 会随列表变化而变,根本不是"稳定身份"。
  3. key 重复:两条 item 拿到了同一个 key(比如数据里本来就有重复 id)。Diff 算法相信 key 唯一,遇到重复会出现未定义行为——Vue 开发模式下会 warn,生产模式下结果可能是某个 item 永远不更新。

正确的 key 是"数据本身的天然稳定标识"——数据库主键、UUID、业务上保证唯一不变的字段。这是列表渲染 bug 最少、Diff 效率最高的前提。

key 如何影响 Diff 的执行路径,紧接着看源码;这里先留一句抓手:任何时候写 v-for 忘了 :key,立刻补上——这是列表渲染里最便宜、收益最高的一次性投入。

key 不仅是一个"最佳实践",它直接影响 Diff 算法的执行路径:

// runtime-core/src/renderer.ts - patchKeyedChildren(简化)
function patchKeyedChildren(
  c1: VNode[], // 旧子节点
  c2: VNode[], // 新子节点
  container: RendererElement,
  // ...
) {
  let i = 0
  const l2 = c2.length
  let e1 = c1.length - 1
  let e2 = l2 - 1

  // 1. 从头部同步
  while (i <= e1 && i <= e2) {
    const n1 = c1[i]
    const n2 = c2[i]
    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container, ...)
    } else {
      break
    }
    i++
  }

  // 2. 从尾部同步
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1]
    const n2 = c2[e2]
    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container, ...)
    } else {
      break
    }
    e1--
    e2--
  }

  // 3. 新增节点
  if (i > e1 && i <= e2) {
    while (i <= e2) {
      patch(null, c2[i], container, ...)
      i++
    }
  }
  // 4. 删除节点
  else if (i > e2) {
    while (i <= e1) {
      unmount(c1[i], ...)
      i++
    }
  }
  // 5. 未知序列:最长递增子序列算法
  else {
    const s1 = i
    const s2 = i

    // 建立新节点 key → index 的映射
    const keyToNewIndexMap = new Map<string | number | symbol, number>()
    for (i = s2; i <= e2; i++) {
      keyToNewIndexMap.set(c2[i].key!, i)
    }

    // 寻找最长递增子序列,最小化 DOM 移动
    const toBePatched = e2 - s2 + 1
    const newIndexToOldIndexMap = new Array(toBePatched).fill(0)
    let moved = false
    let maxNewIndexSoFar = 0

    for (i = s1; i <= e1; i++) {
      const prevChild = c1[i]
      const newIndex = keyToNewIndexMap.get(prevChild.key!)

      if (newIndex === undefined) {
        unmount(prevChild, ...) // 旧节点不在新列表中,删除
      } else {
        newIndexToOldIndexMap[newIndex - s2] = i + 1
        if (newIndex >= maxNewIndexSoFar) {
          maxNewIndexSoFar = newIndex
        } else {
          moved = true // 检测到顺序变化
        }
        patch(prevChild, c2[newIndex], container, ...)
      }
    }

    // 用最长递增子序列确定哪些节点需要移动
    const increasingNewIndexSequence = moved
      ? getSequence(newIndexToOldIndexMap)
      : EMPTY_ARR

    // 从后向前遍历,确保插入位置正确
    let j = increasingNewIndexSequence.length - 1
    for (i = toBePatched - 1; i >= 0; i--) {
      const nextIndex = s2 + i
      const nextChild = c2[nextIndex]
      const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor

      if (newIndexToOldIndexMap[i] === 0) {
        // 新增节点
        patch(null, nextChild, container, anchor, ...)
      } else if (moved) {
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          // 需要移动
          move(nextChild, container, anchor, MoveType.REORDER)
        } else {
          j-- // 在递增子序列中,不需要移动
        }
      }
    }
  }
}

最长递增子序列(LIS)算法是 Vue 3 Diff 未知序列部分的核心手段(getSequence,在 packages/shared/src/subSequence.ts)——它把 DOM 移动次数压到最少。假设旧列表是 [A, B, C, D, E],新列表是 [B, D, A, C, E],尾部的 E 先被第 2 步同步掉,未知序列是 [B, D, A, C],newIndexToOldIndexMap 为 [2, 4, 1, 3],getSequence 得到的递增子序列对应 [A, C](这些节点保持相对顺序、原地不动),只需要移动 B 和 D。注意长度为 2 的递增子序列不止一个([B, D] 也是),算法取到哪一个取决于它的二分回溯实现——但移动次数都是 2。

虚拟滚动的实现原理

虚拟滚动的本质是欺骗视觉系统——用户以为自己在滚动一个 10000 条的长列表,其实 DOM 里始终只有视口里那十几条可见的元素。其他 9000 多条从没进入过 DOM,它们只存在于 JavaScript 数组里。

这个概念听起来像魔法,实际上原理朴素得惊人:监听 scrollTop,算出应该显示数组的哪一段,用 transform 或 padding 把可见段定位在滚动条应该的位置。难点不在原理,在细节——每一个滚动帧都要重算、每一次尺寸变化都要重测、滚动过快要不要 throttle、动态高度的 item 怎么处理、什么时候 preserveScrollPosition 什么时候不。

这些细节是"虚拟滚动库有 N 多个但生产级就那几个"的原因。读下面的实现骨架时,把自己想象成一个写库的作者——你会看到每一行代码都在处理一个具体的边界情况。

当列表项多达数万条时,即使 Diff 再快,渲染如此多的 DOM 节点本身就是瓶颈。虚拟滚动的核心思想是只渲染可视区域内的节点:

graph LR
    subgraph "可视区域"
        V1["Item 51"]
        V2["Item 52"]
        V3["..."]
        V4["Item 60"]
    end
    subgraph "缓冲区(上)"
        B1["Item 48"]
        B2["Item 49"]
        B3["Item 50"]
    end
    subgraph "缓冲区(下)"
        B4["Item 61"]
        B5["Item 62"]
        B6["Item 63"]
    end
    UP["↑ 上方占位<br/>height: 4700px"] --> B1
    B6 --> DOWN["↓ 下方占位<br/>height: 93700px"]
    style V1 fill:#42b883,color:#333
    style V2 fill:#42b883,color:#333
    style V3 fill:#42b883,color:#333
    style V4 fill:#42b883,color:#333
// 虚拟滚动的核心逻辑
interface VirtualScrollState {
  startIndex: number    // 渲染起始索引
  endIndex: number      // 渲染结束索引
  offsetTop: number     // 顶部占位高度
  offsetBottom: number  // 底部占位高度
}

function useVirtualScroll<T>(
  items: Ref<T[]>,
  containerRef: Ref<HTMLElement | null>,
  options: {
    itemHeight: number  // 固定行高(变高度需要更复杂的方案)
    overscan?: number   // 上下缓冲区大小
  }
) {
  const { itemHeight, overscan = 5 } = options

  const state = reactive<VirtualScrollState>({
    startIndex: 0,
    endIndex: 0,
    offsetTop: 0,
    offsetBottom: 0
  })

  const visibleItems = computed(() => {
    return items.value.slice(state.startIndex, state.endIndex)
  })

  const totalHeight = computed(() => items.value.length * itemHeight)

  function onScroll() {
    const container = containerRef.value
    if (!container) return

    const scrollTop = container.scrollTop
    const viewportHeight = container.clientHeight

    // 计算可见范围
    const start = Math.floor(scrollTop / itemHeight)
    const visibleCount = Math.ceil(viewportHeight / itemHeight)

    // 加上缓冲区
    state.startIndex = Math.max(0, start - overscan)
    state.endIndex = Math.min(
      items.value.length,
      start + visibleCount + overscan
    )

    // 计算占位高度
    state.offsetTop = state.startIndex * itemHeight
    state.offsetBottom = (items.value.length - state.endIndex) * itemHeight
  }

  onMounted(() => {
    containerRef.value?.addEventListener('scroll', onScroll, { passive: true })
    onScroll() // 初始计算
  })

  // 用 onBeforeUnmount:到 onUnmounted 时模板 ref 已被置为 null,移除不掉监听
  onBeforeUnmount(() => {
    containerRef.value?.removeEventListener('scroll', onScroll)
  })

  return { visibleItems, state, totalHeight }
}

18.5 异步组件与代码分割

代码分割解决的不是"执行慢"而是"加载慢"——首屏下载多少字节的 JS 直接决定 FCP/TTI。一个几 MB 的 bundle 在移动网络下要好几秒才能下完、解析完,这期间屏幕一片白。

Vue 的异步组件把"组件定义"从"同步加载"解耦——让你声明式地把某个组件标记为"懒加载的",框架配合构建工具(Vite / Webpack)自动把它打成单独 chunk,只在真正需要的时候才下载。这是前端工程里"按需加载"最成熟的实现。

defineAsyncComponent 的加载策略

defineAsyncComponent 不只是"把 import 延迟到渲染时"——它还处理了一堆加载过程中的用户体验问题:加载中显示什么(loadingComponent)、加载失败显示什么(errorComponent)、多久算超时(timeout)、加载超过多久才开始显示 loading(delay,防止快速加载完时闪一下加载态——packages/runtime-core/src/apiAsyncComponent.ts:295-299 是 setTimeout(..., delay) 之后才把 delayed 置 false,配合 packages/runtime-core/src/apiAsyncComponent.ts:165 的 !delayed.value 判断)。

// defineAsyncComponent 的完整选项
const AsyncComponent = defineAsyncComponent({
  loader: () => import('./HeavyComponent.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  delay: 200,      // 200ms 后才显示 loading(避免闪烁)
  timeout: 10000,  // 10 秒超时
  suspensible: false, // 是否配合 Suspense

  onError(error, retry, fail, attempts) {
    if (attempts <= 3) {
      retry() // 自动重试
    } else {
      fail()
    }
  }
})

// 内部实现(runtime-core/src/apiAsyncComponent.ts,精简)
// 加载逻辑抽在 createAsyncComponentContext 里,VDOM 与 Vapor 两套运行时共用
function createAsyncComponentContext(source) {
  const { loader, onError: userOnError } = source
  let pendingRequest: Promise<C> | null = null
  let resolvedComp: C | undefined

  let retries = 0
  const retry = () => {
    retries++
    pendingRequest = null
    return load()
  }

  const load = (): Promise<C> => {
    let thisRequest: Promise<C>
    return (
      // 同一时刻只保留一个进行中的请求
      pendingRequest ||
      (thisRequest = pendingRequest =
        loader()
          .catch(err => {
            err = err instanceof Error ? err : new Error(String(err))
            if (userOnError) {
              return new Promise((resolve, reject) => {
                const userRetry = () => resolve(retry())
                const userFail = () => reject(err)
                userOnError(err, userRetry, userFail, retries + 1)
              })
            } else {
              throw err
            }
          })
          .then((comp: any) => {
            // 期间被 retry 替换过,就以最新请求为准
            if (thisRequest !== pendingRequest && pendingRequest) {
              return pendingRequest
            }
            // 兼容 import() 返回的 ES Module 命名空间
            if (comp && (comp.__esModule || comp[Symbol.toStringTag] === 'Module')) {
              comp = comp.default
            }
            resolvedComp = comp
            return comp
          }))
    )
  }
  return { load, getResolvedComp: () => resolvedComp, /* ... */ }
}

export function defineAsyncComponent(source) {
  if (isFunction(source)) source = { loader: source }
  const {
    loadingComponent, errorComponent,
    delay, timeout, suspensible = true,   // 注意:3.6 起 delay 不再有 200ms 默认值,不传即刻显示 loading
    hydrate: hydrateStrategy, // 3.5:Lazy Hydration 策略,见第 17 章
  } = source
  const { load, getResolvedComp, setPendingRequest } = createAsyncComponentContext(source)

  return defineComponent({
    name: 'AsyncComponentWrapper',
    __asyncLoader: load,
    __asyncHydrate(el, instance, hydrate) { /* 交给 performAsyncHydrate 按策略延后 */ },
    get __asyncResolved() { return getResolvedComp() },

    setup() {
      const instance = currentInstance!
      markAsyncBoundary(instance)

      // 已经加载过(比如第二次渲染):直接渲染
      let resolvedComp = getResolvedComp()
      if (resolvedComp) {
        return () => createInnerComp(resolvedComp!, instance)
      }

      // 在 Suspense 里(且 suspensible)或 SSR 中:返回 Promise,交给外层等待
      if ((suspensible && instance.suspense) || isInSSRComponentSetup) {
        return load().then(comp => () => createInnerComp(comp, instance))
      }

      // 否则自己管理 loaded / error / delayed 三个状态(含 delay 与 timeout 计时)
      const { loaded, error, delayed } = useAsyncComponentState(delay, timeout, onError, instance)

      load()
        .then(() => { loaded.value = true  })
        .catch(err => { onError(err); error.value = err })

      return () => {
        if (loaded.value && resolvedComp) {
          return createInnerComp(resolvedComp, instance)
        } else if (error.value && errorComponent) {
          return createVNode(errorComponent, { error: error.value })
        } else if (loadingComponent && !delayed.value) {
          return createInnerComp(loadingComponent, instance)
        }
      }
    },
  })
}

路由级代码分割

路由级分割是最常用、ROI 最高的分割策略——每个路由对应一个独立 chunk,访问到哪个路由才加载哪个。对多页后台系统特别有效,首屏只需要下首页那一小部分代码,其余代码按用户访问路径 on-demand 加载。

这块的实现细节涉及到 Vue Router 的导航流程——本专栏第 16 章(Vue Router 内核)讲过 navigate 函数里 "第 5 步:解析异步路由组件"就是专门处理路由懒加载的。读者应该有个整体感知:代码分割是编译链路 + 运行时框架 + 构建工具三者配合的结果,少一环都不成立。

代码分割带来的另一个问题是"部署后旧 chunk 失效"——第 16 章 16.7 节「懒加载失败怎么办」提到过。生产上建议配合 Vite 的 build.rollupOptions.output.chunkFileNames 定义好 content-hash 命名、在 CDN 保留旧 chunk 一段时间;配合 router.onError 做"chunk 加载失败 → 全局刷新"的兜底——两道防线共同保障用户不会因为部署而卡在"点击无反应"的状态。这是现代 SPA 和传统 MPA 最不同的运维挑战之一,值得单独做成团队的 checklist。

// 结合 Vue Router 的最佳实践
const routes = [
  {
    path: '/dashboard',
    component: () => import('./views/Dashboard.vue'),
    // Vite 会为每个动态 import 生成独立的 chunk
  },
  {
    path: '/settings',
    component: () => import('./views/Settings.vue'),
  },
  {
    path: '/reports',
    // webpack 魔法注释:父 chunk 加载后由浏览器空闲时 prefetch(Vite 不识别这条注释)
    component: () => import(/* webpackPrefetch: true */ './views/Reports.vue'),
  }
]

const router = createRouter({ routes, history: createWebHistory() })

// 预加载策略:在用户可能访问之前手动触发 import(),比如鼠标悬停在链接上时。
// 不要放进 beforeResolve——到那一步,目标路由的懒加载组件已经在 beforeRouteEnter
// 阶段被解析并替换掉了工厂函数(见第 16 章 16.7 节),再预加载为时已晚
function prefetchReports() {
  // 同一模块的 import() 只下载一次,之后导航时直接命中模块缓存
  import('./views/Reports.vue')
}
// <RouterLink to="/reports" @mouseenter="prefetchReports">报表</RouterLink>

18.6 内存管理与泄漏预防

这一节我想用"故事式"来讲——因为内存泄漏都是工程师犯过的具体错误。抽象讲"要释放引用、要解绑监听器"没用,必须看到真实的代码长什么样、在什么情境下被写下来、最后为什么泄漏。

前端内存泄漏的根源可以归纳为"对某个不再使用的对象的隐性引用"——对象本该被 GC 回收,但因为某处还有引用链指向它,就一直活着。组件 unmount 了但 event listener 还挂着、定时器还在 tick、watchEffect 还在订阅——这些都是典型模式。本节把最容易踩的四种逐个拆给你看。

常见的内存泄漏模式

下面列的四种模式是我在 code review 中见过的"真实在生产代码里出现过"的泄漏案例,不是理论假设。每一种都有明确的诊断方法和修复方法。

// ❌ 泄漏 1:全局事件未清理
export default {
  setup() {
    window.addEventListener('resize', handleResize)
    // 组件卸载后,handleResize 仍然被调用
    // 且通过闭包引用了组件的响应式数据

    // ✅ 修复
    onUnmounted(() => {
      window.removeEventListener('resize', handleResize)
    })
  }
}

// ❌ 泄漏 2:定时器未清理
export default {
  setup() {
    const timer = setInterval(() => {
      fetchData() // 组件卸载后仍在运行
    }, 5000)

    // ✅ 修复
    onUnmounted(() => clearInterval(timer))
  }
}

// ❌ 泄漏 3:第三方库实例未销毁
export default {
  setup() {
    let chart: ReturnType<typeof echarts.init> | undefined

    onMounted(() => {
      chart = echarts.init(chartRef.value)
      chart.setOption(/* ... */)
    })

    // ✅ 修复
    onUnmounted(() => {
      chart?.dispose()
    })
  }
}

// ⚠️ 模式 4:闭包让大对象常驻(严格说不算泄漏,而是占用过久)
export default {
  setup() {
    const hugeData = ref(loadHugeDataset()) // 100MB 数据

    const summary = computed(() => {
      // 这个 computed 通过闭包持有 hugeData 的引用
      return hugeData.value.reduce(/* ... */)
    })

    // 即使 hugeData 不再在模板中使用,
    // 只要 summary 存在(即组件存活期间),hugeData 就不会被 GC;
    // 组件卸载后二者会一起被回收——问题是这 100MB 在组件整个生命周期里白白占着

    // ✅ 修复:使用后释放
    const summaryValue = ref(null)
    onMounted(() => {
      const data = loadHugeDataset()
      summaryValue.value = data.reduce(/* ... */)
      // data 在函数结束后可被 GC
    })
  }
}

响应式系统的内存模型

Vue 3 响应式的内存模型比想象中"轻"——得益于 WeakMap 的使用。

理解这一点需要先知道 reactive 内部怎么组织:全局只有一个 targetMap(reactivity/src/dep.ts),key 是原始对象本身、value 是一个 Map<key, Dep>——每个属性对应一个 Dep,Dep 上挂着订阅它的 effect / computed 组成的双向链表(subs)。

如果这个 Map 用普通 Map,即使组件 unmount、reactive 对象不再被业务引用,Map 里还有一条 obj → subscribers 记录保持强引用,导致 obj 永远不会被 GC。Vue 3 聪明地选择了 WeakMap——key 上的引用不计入 GC 判断。对象一旦业务侧没有引用,即使 WeakMap 里还有 entry,也会被 GC 掉;WeakMap 的 entry 自动删除。

这种"用 WeakMap 实现天然 GC 友好的索引"是 JavaScript 现代内存管理的标配。V8 如何在 GC 标记阶段处理 WeakMap 的 ephemeron 语义超出本专栏范围——这里只要理解:Vue 的响应式系统不会因为自身结构阻止你的业务对象被 GC,是很硬核的设计选择。

// packages/reactivity/src/dep.ts(详见下面各行标注)
// 每个 reactive 对象的内存开销
// 1. 原始对象本身
// 2. Proxy 实例本身
// 3. 依赖映射 WeakMap<target, Map<key, Dep>>(Dep 的订阅者是 Link 双向链表,不是 Set)

// targetMap 的结构(packages/reactivity/src/dep.ts:42-44,全局的依赖追踪表;
// 下面的 class Dep 在源码里其实排在前面,:16 起,这里为讲解顺序挪到后面)
type KeyToDepMap = Map<any, Dep>
export const targetMap: WeakMap<object, KeyToDepMap> = new WeakMap()
//                      ^^^^^^^ WeakMap 允许 target 被 GC

class Dep implements ReactiveNode {
  _subs: Link | undefined = undefined   // 订阅者双向链表的头
  subsTail: Link | undefined = undefined
  constructor(private map: KeyToDepMap, private key: unknown) {}
  set subs(value: Link | undefined) {
    this._subs = value
    // 最后一个订阅者退订时,把自己从 Map 里删掉——不留空 Dep
    if (value === undefined) {
      this.map.delete(this.key)
    }
  }
}

// 当组件卸载时:
// 1. 组件的 ReactiveEffect 被停止(effect.stop())
// 2. effect 与所有 Dep 之间的 Link 被 unlink;某个 Dep 没有订阅者了就自删
// 3. 如果 target 不再被引用,WeakMap 允许整个条目被 GC

// reactivity/src/effect.ts
class ReactiveEffect {
  stop(): void {
    if (!this.active) {
      return
    }
    this.flags = EffectFlags.STOP
    // 沿 deps 链表逐个断开与上游 Dep 的连接
    let dep = this.deps
    while (dep !== undefined) {
      dep = unlink(dep, this)
    }
    // 自己也可能被别人订阅(作为 ReactiveNode)
    const sub = this.subs
    if (sub !== undefined) {
      unlink(sub)
    }
    cleanup(this) // 运行 onCleanup / onWatcherCleanup 注册的清理函数
  }
}

18.7 渲染性能分析

前面讲的都是"知道的可以怎么优化";这一节讲的是"不知道的怎么定位"——在真实的业务应用里,性能问题往往不在你以为的地方,必须靠工具测出来。

不依赖测量的优化是猜测——性能工程里通行的原则是"profile first, optimize second"。Vue 的场景下,这个原则具体化为三件事:先用 DevTools Timeline 找到是哪次 render 花了异常多的时间、再用 Performance 面板看这次 render 的火焰图里哪个函数占大头、最后用自定义性能埋点对比优化前后。一个完整的调优 cycle 就是这三步的循环。

Vue DevTools 的性能面板

Vue DevTools 的 Performance 面板是每个 Vue 开发者都应该熟练使用的工具。它能录制一段时间内所有组件的 render 次数、每次 render 耗时、触发原因——这些信息用纯代码是捞不到的。

Vue DevTools 提供了组件级别的渲染性能追踪:

// Vue 内部的性能追踪 hook(packages/runtime-core/src/renderer.ts:1583-1588)
// 注意:renderer 里 16 处 startMeasure/endMeasure 调用点全部只被 __DEV__ 包住,
// __DEV__ || __FEATURE_PROD_DEVTOOLS__ 只出现在 profiling.ts:28/:51(转发给 DevTools)
if (__DEV__) {
  // 组件渲染开始
  startMeasure(instance, 'render')

  const subTree = (instance.subTree = renderComponentRoot(instance))

  // 组件渲染结束
  endMeasure(instance, 'render')

  // Patch 开始
  startMeasure(instance, 'patch')

  patch(prevTree, nextTree, /* ... */)

  // Patch 结束
  endMeasure(instance, 'patch')
}

// 性能标记使用 Performance API(runtime-core/src/profiling.ts)
// 注意:写入 performance 时间线需要 app.config.performance = true
export function startMeasure(instance: GenericComponentInstance, type: string): void {
  if (instance.appContext.config.performance && isSupported()) {
    perf.mark(`vue-${type}-${instance.uid}`)
  }
  if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
    devtoolsPerfStart(instance, type, getNow()) // 同时发给 Vue DevTools
  }
}

export function endMeasure(instance: GenericComponentInstance, type: string): void {
  if (instance.appContext.config.performance && isSupported()) {
    const startTag = `vue-${type}-${instance.uid}`
    const endTag = startTag + `:end`
    const measureName = `<${formatComponentName(instance, instance.type)}> ${type}`
    perf.mark(endTag)
    perf.measure(measureName, startTag, endTag)
    perf.clearMeasures(measureName)
    perf.clearMarks(startTag)
    perf.clearMarks(endTag)
  }
  if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
    devtoolsPerfEnd(instance, type, getNow())
  }
}

自定义性能监控

DevTools 默认只在开发环境可用,线上要看真实用户的性能数据必须自己上报。自定义性能监控用 performance.mark / performance.measure API 给关键事件打点,然后配合 sentry / bugsnag / 自建的上报体系送到后端。这是从"开发期主观调优"走向"生产期数据驱动调优"的分水岭。

// 组件渲染次数追踪
function useRenderCount(componentName: string) {
  // __DEV__ 是 Vue 源码内部的构建常量,业务代码里用构建工具提供的开关(这里以 Vite 为例)
  if (import.meta.env.DEV) {
    let count = 0

    onRenderTracked((event) => {
      console.log(`[${componentName}] 依赖追踪:`, event)
    })

    onRenderTriggered((event) => {
      count++
      console.log(`[${componentName}] 第 ${count} 次重渲染,触发者:`, event)

      if (count > 50) {
        console.warn(
          `[${componentName}] 渲染次数过多(${count}),可能存在性能问题`
        )
      }
    })
  }
}

// 使用
export default {
  setup() {
    useRenderCount('MyComponent')
    // ...
  }
}

18.8 Tree-shaking 与包体积

Bundle size 是性能工作里最容易被忽视但最直接影响体验的维度——前面讲的 render 优化、diff 优化再好,如果首屏要下载几 MB 的 JS,用户还是要等好几秒才能看到内容。

Vue 3 的一大卖点就是 tree-shakable:没用到的 API 不会进你的 bundle。这在 Vue 2 是做不到的(因为 Vue 2 的 API 挂在 Vue 实例上、全量打包),Vue 3 通过"纯函数 API + ES Modules"的重构实现了这一点。下面拆这件事的两个层面——官方 API 的 tree-shake 语义、第三方组件库的按需导入策略。

Vue 3 的 Tree-shaking 设计

Vue 3 为了 tree-shakable 做了一个API 架构上的大手术——从 Vue 2 的"所有 API 挂在 Vue 实例上"(Vue.nextTick、Vue.set)改成"每个 API 都是独立的 ES Module export"(import { nextTick } from 'vue';Vue.set 在 Vue 3 里已不再需要,没有对应导出)。

这个改动在使用体验上只是多写一次 import,但在 bundler 层面是决定性的——因为 ES Modules 的 import 可以被静态分析,bundler 能精确知道你用了哪些 API、没用哪些;Vue 2 的实例挂载模式下,bundler 只能保守地打包整个 Vue。这个差别让 Vue 3 的"只用 ref / reactive"的最小应用 bundle 明显小于 Vue 2 的全量运行时——Vue 2 无论用不用,整个运行时都在 bundle 里。

《Vite 设计与实现》深入讲过 ES Modules 的 tree-shaking 机制;《微前端源码精讲》讲到 Module Federation 时也涉及到 ESM 的动态加载特性——这三个专栏放一起读,你会看到ESM 是整个现代前端基础设施的共同底座,Vue 3 的架构改造只是这个趋势里的一个节点。

Vue 3 的 API 全部采用命名导出,使得未使用的功能可以被打包工具移除:

// Vue 3:只导入使用的 API
import { ref, computed, watch } from 'vue'
// Transition、KeepAlive、Teleport 等如果未使用,不会进入最终 bundle

// 编译时特性标志(Feature Flags)
// 通过 define 插件在编译时替换为常量
if (__VUE_OPTIONS_API__) {
  // Options API 相关代码
  // 如果设置为 false,这整块代码会被 tree-shake
}

if (__VUE_PROD_DEVTOOLS__) {
  // 生产环境 DevTools 支持
  // 默认 false,会被移除
}

// vite.config.ts 中配置
export default defineConfig({
  define: {
    __VUE_OPTIONS_API__: false,  // 不用 Options API 就把相关运行时代码剔掉
    __VUE_PROD_DEVTOOLS__: false,
    __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
  }
})

按需导入组件库

现代组件库(Element Plus / Naive UI / Vant 4)都支持"按需自动导入"——用 unplugin-vue-components / unplugin-auto-import 这类插件,你写了 <ElButton> 它自动解析并只打包 Button 组件。相比 Vue 2 时代满屏的 import { Button } from 'element-ui',开发体验和 bundle 优化同时兼顾。

推荐的配置模板见各组件库官方文档;本专栏不在这里展开。这类"开发时人工友好、构建时精确分析"的工具链是 Vue 3 生态相对 Vue 2 最肉眼可见的进步之一。

// ❌ 全量导入:引入整个组件库
import ElementPlus from 'element-plus'
app.use(ElementPlus) // 整个组件库进入 bundle

// ✅ 按需导入:只引入使用的组件
import { ElButton, ElInput, ElTable } from 'element-plus'
app.component('ElButton', ElButton)

// ✅ 更好的方式:使用 unplugin-vue-components 自动按需导入
// vite.config.ts
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    Components({
      resolvers: [ElementPlusResolver()]
      // 模板中使用 <ElButton> 时自动导入,无需手动 import
    })
  ]
})

18.9 性能优化检查清单

前面八节讲的每个点都值得反复咀嚼,但真正做项目的时候你需要一份可落地的清单——不是背诵知识点,是按顺序过的检查流程。下面的清单我在多次带队优化中反复提炼,基本可以覆盖绝大部分前端性能工作。

graph TD
    A["性能问题"] --> B{"首屏慢?"}
    A --> C{"交互卡顿?"}
    A --> D{"包体积大?"}
    B -->|是| B1["SSR / SSG"]
    B -->|是| B2["路由懒加载"]
    B -->|是| B3["关键 CSS 内联"]
    B -->|是| B4["预加载 / 预连接"]
    C -->|是| C1["检查不必要的重渲染"]
    C -->|是| C2["v-memo / v-once"]
    C -->|是| C3["虚拟滚动"]
    C -->|是| C4["shallowRef/Reactive"]
    C -->|是| C5["Web Worker"]
    D -->|是| D1["Tree-shaking"]
    D -->|是| D2["动态导入"]
    D -->|是| D3["Gzip / Brotli"]
    D -->|是| D4["关闭 Options API"]
    style A fill:#c0392b,color:#fff
    style B fill:#f39c12,color:#333
    style C fill:#f39c12,color:#333
    style D fill:#f39c12,color:#333

以下是从源码层面总结的性能优化优先级:

优化手段 影响范围 实施难度 源码原理
路由懒加载 首屏体积 低 动态 import(),由 Vue Router 在导航时解析(路由组件不需要 defineAsyncComponent)
v-memo 列表渲染 低 isMemoSame 跳过整个子树的 patch
shallowRef/Reactive 大数据响应 中 避免 Proxy 递归代理内部属性
computed 缓存 派生数据 低 Dirty / Pending 标志 + 读取时 checkDirty 惰性求值
虚拟滚动 长列表 中 只渲染可视区 DOM,上下占位
KeepAlive 页面切换 低 LRU 缓存组件实例,避免销毁重建
关闭 Options API 包体积 低 __VUE_OPTIONS_API__ 编译时剔除
SSR/SSG 首屏速度 高 服务端渲染 + Hydration
Web Worker 计算密集 高 将计算移出主线程

18.10 本章小结

这一章看似讲"怎么让 Vue 跑得快",其实更大的收获是——怎么在一个复杂系统里思考性能。

Vue 3 的性能哲学可以浓缩成一条线:把能在编译期确定的事情在编译期做完,让运行时只做不可避免的工作。静态节点缓存、静态字符串化、PatchFlag、Block Tree——四个由编译器驱动的优化全部围绕这条主线(Block 的收集发生在运行时,但 openBlock / createBlock 调用是编译器生成的)。响应式优化、KeepAlive、异步组件——三个运行期手段围绕的是"在不可避免的工作里让开发者有精确控制权"。这两条线一头一尾扎起来,是 Vue 3 性能工程的全部。

延伸阅读的几个方向

从本章出发,能串起本系列里一串相关的主线:

  • 本专栏第 4、5 章:响应式的原理。本章第 18.2 节每个"避免某某"的建议,根源都在那两章讲的 track/trigger 机制。
  • 本专栏第 11 章:VDOM 和 Diff。本章讲的 Block Tree / PatchFlag 都在第 11 章有完整源码拆解。
  • 本专栏第 12 章:调度器与异步更新。本章没细讲的"一次微任务内合并多次状态变化"就是调度器做的事。
  • 《Vite 设计与实现》:整个专栏都是讲"现代构建工具如何配合框架做 tree-shaking、代码分割、HMR"。本章第 18.5 / 18.8 的内容在 Vite 视角下还会反复出现。
  • 《React 19 内核探秘》:对比视角。React 和 Vue 在性能命题上选了不同的路,对比读能看出"面对同一个问题,不同假设下的不同解法"——这种能力在工程中极有价值。

终极建议

不要为了"优化而优化"。前面讲的所有手段,只有在 DevTools Performance 能明确指出瓶颈时才上。过早的优化不止浪费时间、还污染代码——一个普通的 v-for 加了 v-memo,一个简单的组件包了 KeepAlive,代码复杂度上升、阅读成本上升,但真实收益几乎为零。

性能优化的本质是减少不必要的工作。Vue 3 在框架层面做了大量优化:

  1. 编译时:静态节点缓存减少 VNode 创建、PatchFlag 精确标记动态内容、Block Tree 按编译器埋好的 openBlock 在运行时拍平动态节点
  2. 运行时:Proxy 惰性代理、computed 惰性求值与缓存、最长递增子序列最小化 DOM 移动
  3. 组件级:v-once 一次性渲染、v-memo 条件缓存、KeepAlive LRU 缓存、异步组件延迟加载
  4. 应用级:Tree-shaking 移除未使用代码、路由懒加载分割 chunk、SSR 优化首屏

理解这些优化的源码原理,比死记优化手段更重要。当你知道 shallowRef 之所以更快是因为它避免了 Proxy 递归代理,你就能准确判断什么场景该用它、什么场景不需要。

性能优化的铁律是:先测量,再优化。不要过早优化,不要凭直觉优化,用 Chrome DevTools、Vue DevTools 和 Lighthouse 找到真正的瓶颈,然后对症下药。


思考题

  1. Vue 3.5 把静态提升从模块级的 _hoisted_n 常量改成了实例级的 _cache[n](PR #11067)。模块级常量在什么场景下会成为内存问题?改成按实例缓存又付出了什么代价?如何权衡内存与 CPU 的取舍?

  2. PatchFlag 使用位运算组合多种标记。为什么选择位运算而不是数组或 Set?从 V8 引擎的角度分析它的性能优势。

  3. 假设一个 computed 的 getter 函数执行耗时 100ms,它的 5 个依赖中有一个频繁变化(每秒 60 次)。使用 computed 还是手动 watch + debounce 更合适?分析两种方案的执行时序。

  4. 虚拟滚动在变高度列表(每行高度不固定)场景下会遇到什么难题?设计一个支持变高度的虚拟滚动方案。

  5. Vue 3 的 Block Tree 在 v-if / v-for 场景下会退化为什么?为什么这些结构需要创建新的 Block?