Vue 基础体系 · 第 15/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 性能优化:更新边界、长列表、异步组件、Bundle 和指标

Vue 性能优化的核心不是“让每一行代码更快”,而是减少不必要的工作,并让必须执行的工作尽量晚执行、少执行、可观测。

一个页面的交互成本通常可以拆成几部分:

Tinteraction=TJS+Tstyle+Tlayout+Tpaint+TnetworkT_{\text{interaction}} = T_{\text{JS}} + T_{\text{style}} + T_{\text{layout}} + T_{\text{paint}} + T_{\text{network}}

其中:

  • TJST_{\text{JS}}:响应式更新、组件渲染、事件处理、业务计算;
  • TstyleT_{\text{style}}:重新计算 CSS 样式;
  • TlayoutT_{\text{layout}}:计算元素尺寸和位置;
  • TpaintT_{\text{paint}}:绘制像素;
  • TnetworkT_{\text{network}}:下载 HTML、JavaScript、CSS、图片和接口数据。

Vue 主要影响第一项,也会间接影响后面几项:如果 Vue 更新了过多节点,就可能触发更大的样式、布局和绘制成本。因此,性能优化需要同时回答五个问题:

  1. 一次状态变化会触发哪些组件更新?
  2. 更新会访问和生成多少 DOM?
  3. 非首屏代码能否延迟下载和执行?
  4. 产物中是否包含不必要的代码?
  5. 真实用户是否确实感受到了改善?

一、更新边界:一次响应式变化究竟会更新什么

1.1 响应式依赖决定“谁可能更新”

Vue 3 的响应式系统可以抽象成如下过程:

const state = reactive({
  count: 0
})

const doubled = computed(() => state.count * 2)

computed 求值时,Vue 会记录:

doubled 的计算函数
    └── 读取 state.count
            └── 建立依赖关系

之后执行:

state.count++

Vue 会找到读取过 state.count 的副作用,并安排它们重新执行。

可以把某个响应式属性 ss 的订阅者集合表示为:

D(s)={e1,e2,,en}D(s) = \{e_1, e_2, \ldots, e_n\}

状态变化后,理论上需要通知:

eD(s),schedule(e)\forall e \in D(s),\quad \text{schedule}(e)

但“被调度”不等于“立刻执行”。Vue 会对更新进行批处理,多个同步状态变化通常会在同一个异步更新周期中合并。

count.value++
count.value++
count.value++

这段代码不会简单地同步执行三次完整 DOM 更新。Vue 会将相关更新放入调度队列,在当前同步代码执行完后统一处理。

这并不意味着三次赋值没有成本:

  • 三次赋值仍然会触发依赖通知;
  • 计算属性可能被标记为失效多次;
  • 事件处理函数本身仍然执行三次;
  • 如果中间穿插了强制布局读取或同步副作用,批处理收益会降低。

因此,批处理解决的是“重复渲染调度”,不是所有重复计算。

1.2 组件更新边界是组件实例的渲染副作用

每个组件实例都有自己的渲染过程。可以简化为:

响应式状态变化
    ↓
组件渲染副作用被调度
    ↓
重新执行 render
    ↓
生成新的虚拟节点树
    ↓
与旧树比较
    ↓
只对必要的 DOM 做 patch

这里有三个容易混淆的边界:

  1. 响应式依赖边界:哪些状态被某个渲染函数读取;
  2. 组件边界:父组件和子组件是不同的组件实例;
  3. DOM patch 边界:虚拟 DOM 比较后,哪些真实 DOM 节点需要修改。

父组件重新渲染,不等于所有子组件都必然完整更新。Vue 会根据组件的 props、插槽和子树特征判断子组件是否需要更新。但是,如果父组件每次都传递新的对象、数组或函数,子组件就可能被迫重新处理这些变化。

例如:

<script setup lang="ts">
import { ref } from 'vue'

const activeId = ref(1)
const items = [
  { id: 1, name: 'Alpha' },
  { id: 2, name: 'Beta' },
  { id: 3, name: 'Gamma' }
]
</script>

<template>
  <ItemRow
    v-for="item in items"
    :key="item.id"
    :item="item"
    :active-id="activeId"
  />
</template>

activeId1 变成 2 时,三个 ItemRow 都会收到新旧不同的 active-id,这是合理的,因为每一行的选中状态都可能变化。

但如果每个子组件只关心“自己是否选中”,可以把状态计算移到父组件:

<template>
  <ItemRow
    v-for="item in items"
    :key="item.id"
    :item="item"
    :active="item.id === activeId"
  />
</template>

这仍然会计算每一行的布尔值,但它明确表达了更新语义:只有旧的 active 行和新的 active 行真正发生了 active 属性变化。对于子组件内部有大量渲染工作的场景,这种稳定的、语义化的 props 更容易形成有效的更新边界。

1.3 不稳定对象会扩大更新范围

下面的代码每次父组件渲染都会创建一个新对象:

<ItemRow :options="{ compact: true, color: 'blue' }" />

即使对象内容相同,引用也不同:

oldOptions !== newOptions // true

如果 ItemRow 依赖对象引用判断变化,它会认为 options 已经更新。

可以将静态配置移到组件外部:

<script setup lang="ts">
const rowOptions = {
  compact: true,
  color: 'blue'
}
</script>

<template>
  <ItemRow :options="rowOptions" />
</template>

或者使用 computed 缓存确实需要响应式计算的对象:

const rowOptions = computed(() => ({
  compact: compact.value,
  color: theme.value
}))

但不要为了“稳定引用”而滥用 computed。如果对象很小、子组件更新代价很低,额外的缓存管理并没有实际收益。优化应建立在测量或明确的更新路径分析上。

1.4 computed 适合派生值,不适合副作用

computed 的核心属性是惰性求值和缓存:

const visibleItems = computed(() => {
  return items.value.filter(item => item.visible)
})

在依赖没有变化时,多次读取 visibleItems.value 通常复用上一次结果。设计算法成本为 CC,读取次数为 kk

  • 没有缓存:理想情况下成本接近 kCkC
  • 有缓存且依赖未变化:成本接近 CC

但当依赖发生变化时,计算仍然要重新执行。computed 不会把 O(n)O(n) 的过滤变成 O(1)O(1)

错误示例:

const result = computed(() => {
  localStorage.setItem('result', JSON.stringify(items.value))
  return items.value.length
})

这里把副作用放进了计算函数。由于计算函数可能因读取、渲染或调度策略而重新执行,副作用次数难以作为业务逻辑依赖。应改为:

const result = computed(() => items.value.length)

watch(result, value => {
  localStorage.setItem('result', String(value))
})

watch 适合“状态变化后执行副作用”,例如请求、日志、持久化和订阅管理;它不是替代 computed 的通用计算工具。

1.5 watch 的深度监听可能把局部变化扩大为全量遍历

const state = reactive({
  filters: {
    keyword: '',
    tags: []
  },
  rows: []
})

watch(
  state,
  value => {
    save(value)
  },
  { deep: true }
)

深度监听需要递归访问对象内部结构,以建立依赖。对象规模为 nn 时,一次建立或重新遍历依赖的成本通常与 nn 相关。若 rows 很大,而持久化真正只需要 filters,可以缩小监听源:

watch(
  () => ({
    keyword: state.filters.keyword,
    tags: [...state.filters.tags]
  }),
  filters => {
    save(filters)
  },
  { deep: true }
)

更进一步,可以明确监听原子字段:

watch(
  [() => state.filters.keyword, () => state.filters.tags],
  ([keyword, tags]) => {
    save({ keyword, tags })
  }
)

deep: true 并非错误,但它表达的是“对象内部任意层级变化都重要”。如果实际业务并不需要这个语义,深度监听就是扩大更新边界的来源。

1.6 shallowRef 适合大型外部对象,但会改变更新语义

当对象由第三方库管理,或内部结构很大但只需要整体替换时,可以考虑:

const chartInstance = shallowRef<Chart | null>(null)

function replaceData(data: ChartData) {
  chartInstance.value = createChart(data)
}

shallowRef 只追踪 .value 的替换,不递归把内部对象转换为深层响应式对象。

因此下面的修改不会触发依赖:

chartInstance.value?.setOption(option)

这通常是合理的,因为图表库自己管理内部更新。但如果模板直接依赖:

{{ chartInstance.value?.title }}

然后只修改 chartInstance.value.title,视图不会自动更新。使用 shallowRef 的前提是:内部变化由外部系统管理,Vue 只负责整体引用边界。


二、稳定的更新边界:从组件拆分到局部更新

组件拆分不是越细越快。组件实例本身也有渲染、调度和 props 比较成本。合理拆分的目标是让变化局部化:

全局页面状态变化
    ├── 搜索条件区域更新
    ├── 统计摘要更新
    └── 列表区域不更新

而不是:

任意状态变化
    └── 页面根组件重新生成全部列表、全部卡片和全部弹窗

一个常见的错误是把所有状态放在页面根组件,再通过多层 props 传递:

const pageState = reactive({
  user: {},
  filters: {},
  table: {
    rows: [],
    selectedIds: []
  },
  modal: {
    visible: false
  }
})

如果多个子树都读取整个 pageState,任何字段变化都可能使多个渲染函数重新执行。更合理的方式是让组件读取自己需要的最小状态:

const filters = reactive({
  keyword: '',
  status: 'all'
})

const selectedIds = ref<Set<number>>(new Set())
const modalVisible = ref(false)

这不是说把所有对象拆成单字段就一定更好,而是要使状态的所有权和读取关系清晰。状态边界越清晰,越容易回答“修改这个字段会触发哪些组件”。

v-oncev-memo 的边界

v-once 会让一个子树只渲染一次:

<StaticLegalNotice v-once />

它适用于挂载后确实不会变化的内容。如果其中包含依赖响应式状态的内容,使用 v-once 会冻结视图,造成正确性问题。

v-memo 可以根据依赖数组跳过子树更新:

<div
  v-for="item in items"
  :key="item.id"
  v-memo="[item.id === selectedId]"
>
  <ExpensiveRow :item="item" />
</div>

这里的意图是:只有该行的选中状态变化时,才重新处理这段子树。

v-memo 不是通用缓存。若子树依赖了没有列入数组的值,就可能显示旧内容:

<div v-memo="[item.id === selectedId]">
  {{ item.name }}
</div>

item.name 变化而选中状态不变时,这段内容可能被跳过更新。使用它时必须完整列出影响子树输出的依赖,并通过性能分析证明跳过更新的收益大于维护成本。它属于 Vue 模板优化能力,具体支持范围应以项目使用的 Vue 版本文档为准。


三、长列表:避免把数据规模直接映射成 DOM 数量

3.1 长列表的成本不只是数组遍历

假设有 nn 条记录,每条记录平均生成 mm 个 DOM 节点,则初始 DOM 规模大致为:

Ndomn×mN_{\text{dom}} \approx n \times m

n=50,000n = 50{,}000 时,即使单行只有十几个节点,也会带来:

  • 初始虚拟 DOM 创建和 patch 成本;
  • 大量真实 DOM 节点分配;
  • 样式计算和布局成本;
  • 滚动时更大的浏览器管理开销;
  • 事件、观察器和组件实例的内存占用;
  • 更新一行时可能需要在大子树中进行比较。

因此,v-for 本身不是问题。问题是把“不需要同时显示的 50,000 条数据”都挂载成 DOM。

3.2 key 保证身份,不会减少节点数量

<div v-for="item in items" :key="item.id">
  {{ item.name }}
</div>

key 的作用是告诉 Vue:不同渲染结果中的节点身份如何对应。它有助于插入、删除、排序和复用时保持正确的节点关联。

key 不会让 50,000 条记录变成 100 条 DOM。以下写法仍然会生成全部节点:

<div v-for="item in hugeItems" :key="item.id">
  ...
</div>

错误的 key 会导致状态错位:

<div v-for="(item, index) in items" :key="index">
  <input v-model="item.name">
</div>

当列表头部插入一条数据时,索引仍然相同,但原有 DOM 节点可能被复用于另一条记录,输入焦点、过渡状态或局部组件状态就可能跟随索引而不是跟随数据身份。

对于有稳定唯一标识的数据,应使用:

:key="item.id"

3.3 虚拟列表把 DOM 数量限制为可视窗口规模

虚拟列表(virtual list)只渲染当前视口附近的记录。假设:

  • 列表总高度为 HH
  • 每行固定高度为 hh
  • 视口高度为 VV
  • 预渲染缓冲区为 bb 行。

可视行数约为:

k=Vh+2bk = \left\lceil \frac{V}{h} \right\rceil + 2b

虚拟化后,DOM 数量从 O(n)O(n) 降到接近 O(k)O(k),而 kk 通常只与视口高度和缓冲区有关。

固定行高时,滚动位置 ss 可以直接计算起始索引:

i=shi = \left\lfloor \frac{s}{h} \right\rfloor

然后渲染:

const start = Math.max(0, i - buffer)
const end = Math.min(items.length, i + visibleCount + buffer)
const visibleItems = items.slice(start, end)

为了让滚动条仍然反映完整列表,需要保留一个占位容器:

<script setup lang="ts">
import { computed, ref } from 'vue'

interface Row {
  id: number
  name: string
}

const items = ref<Row[]>([])
const scrollTop = ref(0)

const rowHeight = 40
const viewportHeight = 480
const buffer = 5

const visibleCount = Math.ceil(viewportHeight / rowHeight)

const start = computed(() => {
  const first = Math.floor(scrollTop.value / rowHeight)
  return Math.max(0, first - buffer)
})

const end = computed(() => {
  return Math.min(
    items.value.length,
    start.value + visibleCount + buffer * 2
  )
})

const visibleItems = computed(() => {
  return items.value.slice(start.value, end.value)
})

const offsetY = computed(() => start.value * rowHeight)
const totalHeight = computed(() => items.value.length * rowHeight)

function onScroll(event: Event) {
  const target = event.currentTarget as HTMLElement
  scrollTop.value = target.scrollTop
}
</script>

<template>
  <div class="viewport" @scroll="onScroll">
    <div
      class="spacer"
      :style="{ height: `${totalHeight}px` }"
    >
      <div
        class="rows"
        :style="{ transform: `translateY(${offsetY}px)` }"
      >
        <div
          v-for="item in visibleItems"
          :key="item.id"
          class="row"
          :style="{ height: `${rowHeight}px` }"
        >
          {{ item.name }}
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.viewport {
  height: 480px;
  overflow: auto;
}

.row {
  box-sizing: border-box;
  border-bottom: 1px solid #eee;
}
</style>

这个示例的前提是每行高度固定为 40px。如果 rowHeight 不准确,滚动位置与实际内容就会逐渐偏离,表现为跳动、空白或滚动条比例错误。

3.4 动态高度虚拟列表更复杂

动态高度列表不能简单使用:

offsetY=i×h\text{offsetY} = i \times h

因为每一行的高度 hih_i 不同:

offset(i)=j=0i1hj\text{offset}(i) = \sum_{j=0}^{i-1} h_j

要支持这种列表,通常需要:

  1. 为未测量行提供估计高度;
  2. 挂载后测量真实高度;
  3. 更新高度缓存;
  4. 计算滚动位置与索引之间的映射;
  5. 处理测量导致的滚动位置修正;
  6. 在图片加载、字体替换或内容变化后重新测量。

这类算法常使用前缀和、二分查找或专门的虚拟滚动库。Vue 官方核心并没有提供一个通用的内置虚拟列表组件,项目通常需要自行实现或选择维护良好的第三方库。选择库时应验证:

  • 是否支持 Vue 3;
  • 是否支持固定高度和动态高度;
  • 是否处理键盘导航和焦点;
  • 是否支持服务端渲染;
  • 是否允许自定义滚动容器;
  • 数据更新和插入删除时是否保持滚动位置。

3.5 虚拟化会改变可访问性和交互语义

虚拟列表中,屏幕阅读器只能访问当前挂载的行。若列表需要无障碍支持,应补充:

<div
  role="list"
  :aria-rowcount="items.length"
>
  <div
    v-for="(item, index) in visibleItems"
    :key="item.id"
    role="listitem"
    :aria-posinset="start + index + 1"
    :aria-setsize="items.length"
  >
    {{ item.name }}
  </div>
</div>

实际属性应根据使用的 ARIA 角色和组件语义调整,不能机械添加。还要处理:

  • Tab 焦点离开视口后如何恢复;
  • 键盘上下移动时如何滚动到目标行;
  • 删除当前行后焦点落在哪里;
  • 行内输入框被回收时如何保存状态;
  • 鼠标拖拽或文本选择跨行时是否仍然可用。

因此,虚拟化不是“把 v-for 换成切片”这么简单,而是对渲染模型和交互模型的共同改变。

3.6 分页、增量加载和虚拟化解决不同问题

三者的边界不同:

  • 分页:减少一次请求和一次业务处理的数据量;
  • 增量加载:随着用户操作逐步获取更多数据;
  • 虚拟化:即使数据已经在内存中,也只保留少量 DOM。

例如,接口返回 100,000 条数据,再使用虚拟列表,只能减少 DOM 成本,不能消除:

  • 100,000 条 JSON 的下载成本;
  • JSON 解析成本;
  • 响应式代理或数据处理成本;
  • 内存占用。

如果用户通常只查看几十条数据,服务端分页或筛选往往比“全量下载加虚拟化”更合理。


四、异步组件:延迟代码加载,而不是隐藏初始化成本

4.1 异步组件的工作过程

同步组件:

import SettingsPanel from './SettingsPanel.vue'

通常会把组件模块作为当前依赖图的一部分参与构建。异步组件:

import { defineAsyncComponent } from 'vue'

const SettingsPanel = defineAsyncComponent(
  () => import('./SettingsPanel.vue')
)

import() 会形成一个异步模块边界。运行时大致经过:

首次渲染异步组件
    ↓
执行 loader
    ↓
请求对应 chunk
    ↓
下载并执行模块
    ↓
解析组件
    ↓
挂载组件

它主要优化的是首屏 JavaScript 的下载和执行路径,不会让组件本身的渲染工作消失。

如果首屏不需要设置面板,异步组件可以减少首屏阻塞;但用户第一次打开设置面板时,会增加一次等待。性能取舍是:

首屏收益首次访问该功能的延迟\text{首屏收益} \quad \leftrightarrow \quad \text{首次访问该功能的延迟}

4.2 完整的异步组件错误处理

<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
import LoadingPanel from './LoadingPanel.vue'
import ErrorPanel from './ErrorPanel.vue'

const SettingsPanel = defineAsyncComponent({
  loader: () => import('./SettingsPanel.vue'),
  loadingComponent: LoadingPanel,
  errorComponent: ErrorPanel,
  delay: 200,
  timeout: 10_000,
  suspensible: false,
  onError(error, retry, fail, attempts) {
    const isNetworkError =
      error instanceof TypeError ||
      /Failed to fetch|Importing a module script failed/i.test(
        String(error)
      )

    if (isNetworkError && attempts <= 2) {
      retry()
    } else {
      fail()
    }
  }
})
</script>

<template>
  <button @click="show = true">
    打开设置
  </button>

  <SettingsPanel v-if="show" />
</template>

需要注意几个状态:

状态 说明
loading loader 尚未完成,且超过 delay
resolved 模块加载成功并渲染
error 超时或加载失败,交给错误组件或错误处理
retrying onError 调用 retry() 后重新尝试

onError 的参数含义是:

  • error:本次加载错误;
  • retry:重新调用 loader;
  • fail:终止加载并进入失败状态;
  • attempts:已尝试次数。

重试必须有上限。无限重试会让网络离线或 chunk 部署不一致时形成循环请求。生产环境还应记录错误原因,区分:

  • 用户网络暂时失败;
  • CDN 返回 404;
  • 新版本发布后旧页面引用了已删除的 chunk;
  • 模块执行阶段抛出异常;
  • CSP 或跨域策略阻止加载。

timeout 表示异步组件加载等待时间,不是接口请求的通用超时机制。接口请求仍应由请求层使用 AbortController 或请求库单独管理。

4.3 Suspense 的错误边界不能混为一谈

异步组件可以放在 Suspense 中:

<Suspense>
  <template #default>
    <SettingsPanel />
  </template>

  <template #fallback>
    <LoadingPanel />
  </template>
</Suspense>

Suspense 可以协调异步依赖的 pending 和 resolved 状态,但它不是完整的错误边界,也不会自动替代所有网络错误处理。异步组件的 loader 失败仍应通过 errorComponentonError 或外层错误处理机制呈现可恢复状态。

Suspense 的具体行为,尤其是服务端渲染和嵌套异步依赖,具有版本和渲染模式相关性。Vue 3 中该能力在实际项目中应按当前版本文档和 SSR 测试结果使用,不应假定客户端和服务端行为完全相同。

4.4 路由懒加载是异步组件的常见应用

以 Vue Router 为例:

import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      component: () => import('./views/HomeView.vue')
    },
    {
      path: '/reports',
      component: () => import('./views/ReportsView.vue')
    }
  ]
})

export default router

访问 / 时,报告页代码可以不进入首屏加载路径。路由懒加载适合页面级边界,因为页面之间通常存在清晰的访问条件。

但不应把所有组件都拆成异步组件。若一个小组件总会在首屏显示,额外的网络请求、模块解析和 loading 状态可能抵消拆分收益。

4.5 预加载和预取需要有触发条件

如果用户把鼠标移到“报告”按钮后,大概率会点击,可以在交互意图形成时预加载:

let reportPromise: Promise<typeof import('./views/ReportsView.vue')> | undefined

function preloadReports() {
  reportPromise ??= import('./views/ReportsView.vue')
}

模板中:

<button
  @mouseenter="preloadReports"
  @focus="preloadReports"
  @click="openReports"
>
  报告
</button>

预加载不是免费操作。它会增加并发网络请求,可能与首屏关键资源竞争带宽。低带宽设备、移动网络和数据节省模式下,应谨慎使用;预加载的依据应是用户行为概率和资源大小,而不是“所有页面都提前加载”。


五、Bundle:构建产物如何影响加载和执行

5.1 Bundle 不只是一个 JavaScript 文件

Bundle 是构建工具根据模块依赖图生成的交付产物,可能包括:

  • JavaScript entry;
  • 异步 JavaScript chunk;
  • CSS 文件;
  • source map;
  • 字体和图片资源;
  • manifest 或其他映射文件。

使用 Vite 时,开发服务器采用原生 ESM 的方式提供模块,生产构建通常通过 Rollup 相关能力生成优化后的静态产物。开发环境里看到的模块请求数量,不能直接等同于生产环境的请求结构和性能。

5.2 静态导入和动态导入形成不同依赖边界

import { createApp } from 'vue'
import App from './App.vue'

这是静态导入。构建工具可以在构建期分析依赖。

const module = await import('./feature')

这是动态导入。它可以形成异步加载边界。

但是,动态导入路径必须足够静态,让构建工具能够枚举可能的模块。例如:

const page = await import(`./pages/${name}.vue`)

这类路径的处理依赖构建工具的 glob 规则,可能生成多个候选模块或上下文 chunk。不要假定它只会生成一个精确文件,也不要把用户输入直接拼成任意模块路径。

在 Vite 中,如果确实需要按目录生成候选模块,可以使用版本支持的 import.meta.glob

const pages = import.meta.glob('./pages/*.vue')

async function loadPage(name: string) {
  const loader = pages[`./pages/${name}.vue`]
  if (!loader) {
    throw new Error(`Unknown page: ${name}`)
  }

  return loader()
}

这里 pages 是一个“路径到加载函数”的映射;调用对应函数时才会加载模块。路径必须来自受控集合,不能把未校验的用户输入作为模块路径。

5.3 Tree-shaking 依赖静态模块结构和副作用信息

Tree-shaking 是构建工具删除未被使用的导出代码。它更容易处理:

import { formatDate } from './date'

formatDate(value)

而下面的代码会削弱静态分析:

const utils = await import('./date')
utils[methodName](value)

如果一个库提供:

import { debounce } from 'some-library'

通常比引入整个命名空间更容易让构建工具删除未使用代码,但实际结果仍取决于库的发布格式、sideEffects 声明和构建配置。不能仅凭 import 写法断言最终体积一定更小,必须检查构建产物。

CSS 也存在副作用问题:

import './global.css'

即使 JavaScript 没有显式使用导出,样式导入仍然需要保留。错误标记包的副作用信息,可能导致必要的 CSS 被错误删除。

5.4 体积指标至少要区分三种大小

一个 chunk 可以有:

  1. 原始大小:构建后未压缩的字节数;
  2. 压缩后大小:gzip 或 Brotli 传输大小;
  3. 解压后的执行成本:浏览器解压、解析、编译和执行时间。

因此,下面两种结论都可能错误:

  • “文件只有 100 KB,所以一定很快”;
  • “gzip 后只有 20 KB,所以执行没有问题”。

大量代码即使压缩率很高,仍可能带来明显的解析和执行成本。对于移动设备,JavaScript 的 CPU 成本可能比传输成本更突出。

构建产物分析应至少查看:

  • 初始 entry 依赖;
  • 首屏同步导入;
  • 最大异步 chunk;
  • 重复依赖;
  • source map 中的模块来源;
  • 第三方库占比;
  • CSS 是否被某个入口意外全量引入。

Vite 的基础构建命令:

npm run build

典型输出会列出若干产物及其大小,但终端大小通常是构建文件大小,不等同于真实网络传输大小,也不直接等同于浏览器执行时间。要获得压缩后报告,可以使用项目中的压缩配置或构建分析插件,并在真实服务器上验证响应头和缓存行为。

5.5 Bundle 拆分的真实取舍

拆分过少:

首屏 entry
 └── 所有页面、编辑器、图表、管理功能

结果是首屏下载和执行路径过长。

拆分过多:

首屏
 ├── 组件 A chunk
 ├── 组件 B chunk
 ├── 工具 C chunk
 ├── 样式 D chunk
 └── 依赖 E chunk

结果可能是:

  • 请求数量增加;
  • 关键路径存在串行依赖;
  • 小 chunk 的请求和解析开销占比上升;
  • 缓存命中策略更复杂;
  • 版本发布后旧 chunk 清理不当导致加载失败。

合理边界通常来自以下条件:

  • 路由边界;
  • 大型编辑器、图表、地图等功能边界;
  • 低频管理功能边界;
  • 有明显用户操作才能进入的弹窗边界。

不要仅以“文件数量越多越好”或“单 chunk 越大越差”作为规则。

5.6 动态 chunk 与发布一致性

一个已打开的旧页面可能引用旧版本的异步 chunk:

旧 index.html
    ↓
请求 /assets/Reports-abc123.js
    ↓
发布后该文件被删除
    ↓
动态 import 失败

这不是 Vue 异步组件独有的问题,而是静态资源发布和缓存策略的问题。生产交付通常需要:

  • 带内容哈希的静态资源文件;
  • 新旧版本资源在一段时间内共存;
  • HTML 与静态资源有相容的缓存策略;
  • CDN 或对象存储发布过程尽量原子化;
  • 动态 import 失败时提示刷新或执行有限重试。

如果服务端在部署时立即删除旧资源,用户停留在旧页面再打开懒加载页面,就可能遇到 404。异步组件的 onError 可以处理用户体验,但不能替代发布策略。


六、性能指标:从“感觉变快”到可验证证据

6.1 指标要对应具体阶段

常见 Web 性能指标包括:

  • TTFB(Time to First Byte):从请求开始到收到首字节的时间,主要反映网络、服务器和缓存路径;
  • FCP(First Contentful Paint):首次绘制文本、图片或非空白内容的时间;
  • LCP(Largest Contentful Paint):视口内最大内容元素完成绘制的时间,常用于描述主要内容出现速度;
  • INP(Interaction to Next Paint):用户交互到下一次绘制完成的延迟,关注整个页面生命周期中的交互响应;
  • CLS(Cumulative Layout Shift):页面无预期布局移动的累积得分;
  • 长任务(Long Task):主线程连续执行较长时间而无法及时响应输入的任务,常用于定位 JavaScript 阻塞。

这些指标不是同一层面的量:

服务器/缓存 ── TTFB
        ↓
HTML、CSS、JS 下载执行 ── FCP / LCP
        ↓
用户操作和主线程工作 ── INP / Long Task
        ↓
尺寸预留和异步内容插入 ── CLS

一个页面可以 LCP 良好但 INP 很差:首屏显示很快,但点击筛选时执行了 500ms 的大计算。也可以 INP 良好但 LCP 很差:交互处理轻量,但首屏主图或主模块加载很晚。

6.2 实验室数据和真实用户数据回答不同问题

实验室数据在固定设备、网络和脚本下运行,例如 Lighthouse、Chrome DevTools Performance。它适合:

  • 复现某个长任务;
  • 查看主线程火焰图;
  • 分析请求瀑布;
  • 对比一次代码改动;
  • 检查布局偏移来源。

**真实用户监控(RUM)**收集实际用户环境下的指标,适合:

  • 发现低端设备问题;
  • 发现特定地区或网络问题;
  • 按页面、版本、设备、连接类型分组;
  • 验证发布后是否整体改善;
  • 观察 p75、p90 等分位数。

平均值容易掩盖尾部问题。假设 5 个用户的交互延迟为:

40ms, 50ms, 60ms, 80ms, 1200ms

平均值为:

40+50+60+80+12005=286ms\frac{40+50+60+80+1200}{5}=286\text{ms}

但最后一个用户的体验完全不同。生产分析通常更关注分位数,并结合样本量、页面类型和设备分组解释结果。具体阈值会随 Web Vitals 版本和评估方式调整,应以当前官方定义为准,不应把历史阈值当作永久规范。

6.3 用 Performance API 为业务操作建立测量点

浏览器提供了 User Timing API,可以对搜索操作进行测量:

import { nextTick } from 'vue'

export async function measureSearch(
  run: () => void | Promise<void>
) {
  const id = `search-${crypto.randomUUID()}`

  performance.mark(`${id}-start`)

  await run()
  await nextTick()

  performance.mark(`${id}-end`)
  performance.measure(id, `${id}-start`, `${id}-end`)

  const entry = performance.getEntriesByName(id).at(-1)

  if (entry) {
    console.log({
      name: entry.name,
      duration: entry.duration
    })
  }

  performance.clearMarks(`${id}-start`)
  performance.clearMarks(`${id}-end`)
  performance.clearMeasures(id)
}

调用:

await measureSearch(async () => {
  keyword.value = input.value
  await fetchRows()
})

这里的 nextTick() 只表示 Vue 已完成当前更新队列对应的 DOM 更新等待点。它不保证浏览器已经完成后续样式计算、布局和绘制,也不等于“用户已经看见新内容”。如果要研究绘制时机,应结合浏览器 Performance 面板、Paint Timing 或实际 Web Vitals 数据。

测量函数还应明确测量范围:

  • 只测 API 请求;
  • 测 API 加上数据处理;
  • 测数据处理加 Vue 更新;
  • 测更新到下一帧;
  • 测用户点击到可继续交互。

没有明确范围的“耗时”不能用于比较。

6.4 通过 Long Task 定位主线程阻塞

const observer = new PerformanceObserver(list => {
  for (const entry of list.getEntries()) {
    console.log('long task:', {
      startTime: entry.startTime,
      duration: entry.duration
    })
  }
})

observer.observe({ type: 'longtask', buffered: true })

该 API 需要浏览器支持,生产采集应进行能力检测,并注意数据量和隐私。Long Task 只能说明主线程存在较长任务,不能直接说明是 Vue 导致的。还需要结合调用栈和业务标记判断来源:

  • 大量 JSON 解析;
  • 正则或排序;
  • 图表渲染;
  • Vue 组件更新;
  • 第三方脚本;
  • 同步布局读取导致的浏览器工作。

6.5 Vue 的性能标记和开发工具

Vue 的开发环境可以配合 Vue Devtools 检查组件更新和组件树。Vue 还提供了 app.config.performance 配置,用于在支持的浏览器性能时间线中记录相关性能标记:

import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

if (import.meta.env.DEV) {
  app.config.performance = true
}

app.mount('#app')

这属于开发诊断配置,不应无条件在生产开启。性能标记可以帮助定位组件级别的工作,但不能替代真实用户指标,也不能直接说明某个组件更新就是用户感知的瓶颈。


七、一个完整的诊断流程

性能问题应从现象进入,而不是从优化手段开始。

第一步:确认问题属于哪个阶段

现象 优先检查
首屏空白时间长 TTFB、HTML、同步 JS、CSS、LCP 资源
页面显示后仍卡顿 JS 执行、长任务、第三方脚本
输入框延迟 watch、过滤、排序、组件更新、布局读取
滚动卡顿 DOM 数量、动态布局、滚动事件、图片解码
打开低频功能慢 动态 chunk、网络、异步组件 loading
发布后偶发模块加载失败 静态资源缓存、旧 chunk 保留、CDN 发布顺序
页面元素跳动 图片尺寸、异步内容占位、字体和布局

第二步:建立基线

记录至少以下内容:

版本号
页面路由
设备和浏览器
网络条件
数据规模
LCP / INP / CLS
首屏 JS 传输大小
最大同步 chunk
长任务数量和持续时间
列表 DOM 数量

没有数据规模的性能结论通常不具备可复现性。“列表很快”必须说明是 100 条还是 100,000 条;“Bundle 变小”必须说明比较的是原始大小、gzip 大小还是 Brotli 大小。

第三步:沿更新路径定位

以搜索列表为例:

输入事件
  ↓
keyword.value 改变
  ↓
watch / computed
  ↓
过滤、排序或请求
  ↓
rows.value 替换
  ↓
列表组件重新渲染
  ↓
DOM patch
  ↓
样式、布局、绘制

逐段计时后才能判断应该:

  • 缩小 watch 依赖;
  • 将昂贵计算移出每次输入;
  • 使用防抖,但注意防抖只减少调用次数,不降低单次调用成本;
  • 使用分页或服务端过滤;
  • 使用虚拟列表;
  • 拆分组件更新边界;
  • 延迟加载低频功能。

第四步:修改一个变量并验证回归

例如,将全量列表改为虚拟列表后,需要同时验证:

  • DOM 节点数量是否下降;
  • 滚动时长任务是否减少;
  • 行高计算是否正确;
  • 键盘焦点是否正常;
  • 列表更新是否出现错位;
  • 内存是否回收;
  • 移动设备上的 INP 是否改善。

如果只看到 Bundle 减少,却没有看到 LCP 或 INP 改善,说明优化可能没有命中真实瓶颈,或者瓶颈位于其他阶段。


八、常见失败方案与反例

8.1 用 nextTick 代替性能优化

items.value = expensiveResult
await nextTick()

nextTick 只用于等待 Vue 更新后的 DOM 时机,例如读取更新后的元素尺寸。它不会减少 expensiveResult 的计算量,也不会减少要渲染的列表项。

如果问题是 50,000 行 DOM,增加 nextTick 只是在等待同样多的工作。

8.2 用 setTimeout 把工作“伪装成异步”

setTimeout(() => {
  hugeList.value = process(data)
}, 0)

这可以把任务推迟到当前调用栈之后,但任务仍会在主线程执行。如果 process(data) 持续很久,用户仍然会遇到长任务。

真正需要拆分计算时,可以:

  • 减少输入数据;
  • 分页或增量处理;
  • 使用 Web Worker;
  • 将大任务拆成多个可让出主线程的阶段;
  • 采用更合适的数据结构和算法。

8.3 用深度响应式包装第三方实例

const editor = ref(createEditor())

如果编辑器实例内部有复杂对象图,深层响应式转换可能带来额外开销,也可能影响第三方库行为。通常应使用:

import { markRaw, shallowRef } from 'vue'

const editor = shallowRef<Editor | null>(null)

onMounted(() => {
  editor.value = markRaw(createEditor())
})

markRaw 会让对象跳过响应式转换,但这改变了 Vue 对该对象内部变化的观察能力。只有当该实例由第三方系统自行管理时才适合使用,并且销毁时应释放资源:

onBeforeUnmount(() => {
  editor.value?.destroy()
  editor.value = null
})

8.4 只看 Lighthouse 分数,不看业务交互

Lighthouse 的一次实验室评分可能因为缓存、CPU 模拟、网络条件和页面状态变化而波动。它适合发现问题,但不能回答所有生产问题。

例如:

  • 首页 LCP 很好,但报告页首次打开异步 chunk 失败;
  • 首屏很快,但输入筛选在低端安卓设备上 INP 很差;
  • 桌面端 CLS 正常,但移动端广告位高度不稳定;
  • 新版本 Bundle 变小,但 RUM 中错误率因旧 chunk 删除而上升。

指标必须与页面、版本、设备和业务操作关联。


九、一个可执行的优化组合

下面是一个典型管理页面的合理分层:

首屏同步代码
  ├── 页面骨架
  ├── 筛选条件
  └── 当前可见列表行

按需加载
  ├── 图表面板
  ├── 富文本编辑器
  └── 低频设置弹窗

数据层
  ├── 服务端分页或筛选
  ├── 请求取消
  └── 缓存已访问页面

渲染层
  ├── 稳定 key
  ├── 稳定 props
  ├── 虚拟列表
  └── 明确局部更新边界

验证层
  ├── Bundle 分析
  ├── Performance Timeline
  ├── Devtools 组件更新
  └── RUM 的 LCP、INP、CLS

请求取消可以避免用户快速改变筛选条件时,旧请求覆盖新请求:

import { ref } from 'vue'

interface Row {
  id: number
  name: string
}

const rows = ref<Row[]>([])
const loading = ref(false)
const errorMessage = ref('')
let controller: AbortController | null = null

async function search(keyword: string) {
  controller?.abort()
  controller = new AbortController()

  loading.value = true
  errorMessage.value = ''

  try {
    const response = await fetch(
      `/api/rows?keyword=${encodeURIComponent(keyword)}`,
      {
        signal: controller.signal
      }
    )

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`)
    }

    const data = await response.json() as Row[]
    rows.value = data
  } catch (error) {
    if (error instanceof DOMException && error.name === 'AbortError') {
      return
    }

    errorMessage.value = '加载失败,请稍后重试'
    console.error(error)
  } finally {
    if (!controller.signal.aborted) {
      loading.value = false
    }
  }
}

这个实现处理了三个故障路径:

  1. 新请求开始时取消旧请求;
  2. 2xx 响应主动转为错误;
  3. 用户主动取消不显示为失败,但真正错误会进入错误状态。

如果列表仍然巨大,接口分页和前端虚拟化可以同时使用;如果用户只需查看当前页,则分页可能已经足够,不必再增加虚拟滚动的复杂度。


十、如何判断优化是否成立

一次优化至少应满足以下因果链:

明确瓶颈
  ↓
减少了某类工作
  ↓
对应阶段的测量值改善
  ↓
正确性、可访问性和错误恢复没有退化
  ↓
生产真实用户数据得到验证

例如,“将报告页面改为路由懒加载”成立的完整论证应是:

  1. 报告页面不属于首屏必需内容;
  2. 它及其图表库占据了首屏同步依赖;
  3. 改为动态导入后,首屏 entry 的传输和执行成本下降;
  4. 首屏 LCP 或主线程长任务得到改善;
  5. 用户进入报告页面时有明确 loading 和失败恢复;
  6. 发布后旧页面引用旧 chunk 时仍能处理资源失效;
  7. RUM 数据显示首屏收益没有被报告页错误率抵消。

同样,“引入虚拟列表”成立的论证应是:

  1. 数据规模足以使全量 DOM 成为瓶颈;
  2. 大多数时间只需显示视口附近的行;
  3. 行高、焦点、键盘导航和动态内容已经正确处理;
  4. DOM 数量和滚动长任务下降;
  5. 交互指标改善或至少没有退化。

Vue 性能优化最终优化的是一条完整链路:响应式依赖决定更新范围,组件边界决定工作隔离,长列表策略决定 DOM 规模,异步组件决定代码何时进入执行路径,Bundle 结构决定网络和解析成本,而性能指标负责验证这些改变是否真正改善了用户体验。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。