Vue 基础体系 · 第 15/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 性能优化:更新边界、长列表、异步组件、Bundle 和指标
Vue 性能优化的核心不是“让每一行代码更快”,而是减少不必要的工作,并让必须执行的工作尽量晚执行、少执行、可观测。
一个页面的交互成本通常可以拆成几部分:
其中:
- :响应式更新、组件渲染、事件处理、业务计算;
- :重新计算 CSS 样式;
- :计算元素尺寸和位置;
- :绘制像素;
- :下载 HTML、JavaScript、CSS、图片和接口数据。
Vue 主要影响第一项,也会间接影响后面几项:如果 Vue 更新了过多节点,就可能触发更大的样式、布局和绘制成本。因此,性能优化需要同时回答五个问题:
- 一次状态变化会触发哪些组件更新?
- 更新会访问和生成多少 DOM?
- 非首屏代码能否延迟下载和执行?
- 产物中是否包含不必要的代码?
- 真实用户是否确实感受到了改善?
一、更新边界:一次响应式变化究竟会更新什么
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 的副作用,并安排它们重新执行。
可以把某个响应式属性 的订阅者集合表示为:
状态变化后,理论上需要通知:
但“被调度”不等于“立刻执行”。Vue 会对更新进行批处理,多个同步状态变化通常会在同一个异步更新周期中合并。
count.value++
count.value++
count.value++
这段代码不会简单地同步执行三次完整 DOM 更新。Vue 会将相关更新放入调度队列,在当前同步代码执行完后统一处理。
这并不意味着三次赋值没有成本:
- 三次赋值仍然会触发依赖通知;
- 计算属性可能被标记为失效多次;
- 事件处理函数本身仍然执行三次;
- 如果中间穿插了强制布局读取或同步副作用,批处理收益会降低。
因此,批处理解决的是“重复渲染调度”,不是所有重复计算。
1.2 组件更新边界是组件实例的渲染副作用
每个组件实例都有自己的渲染过程。可以简化为:
响应式状态变化
↓
组件渲染副作用被调度
↓
重新执行 render
↓
生成新的虚拟节点树
↓
与旧树比较
↓
只对必要的 DOM 做 patch
这里有三个容易混淆的边界:
- 响应式依赖边界:哪些状态被某个渲染函数读取;
- 组件边界:父组件和子组件是不同的组件实例;
- 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>
当 activeId 从 1 变成 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 通常复用上一次结果。设计算法成本为 ,读取次数为 :
- 没有缓存:理想情况下成本接近 ;
- 有缓存且依赖未变化:成本接近 。
但当依赖发生变化时,计算仍然要重新执行。computed 不会把 的过滤变成 。
错误示例:
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 }
)
深度监听需要递归访问对象内部结构,以建立依赖。对象规模为 时,一次建立或重新遍历依赖的成本通常与 相关。若 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-once 和 v-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 长列表的成本不只是数组遍历
假设有 条记录,每条记录平均生成 个 DOM 节点,则初始 DOM 规模大致为:
当 时,即使单行只有十几个节点,也会带来:
- 初始虚拟 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)只渲染当前视口附近的记录。假设:
- 列表总高度为 ;
- 每行固定高度为 ;
- 视口高度为 ;
- 预渲染缓冲区为 行。
可视行数约为:
虚拟化后,DOM 数量从 降到接近 ,而 通常只与视口高度和缓冲区有关。
固定行高时,滚动位置 可以直接计算起始索引:
然后渲染:
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 动态高度虚拟列表更复杂
动态高度列表不能简单使用:
因为每一行的高度 不同:
要支持这种列表,通常需要:
- 为未测量行提供估计高度;
- 挂载后测量真实高度;
- 更新高度缓存;
- 计算滚动位置与索引之间的映射;
- 处理测量导致的滚动位置修正;
- 在图片加载、字体替换或内容变化后重新测量。
这类算法常使用前缀和、二分查找或专门的虚拟滚动库。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 的下载和执行路径,不会让组件本身的渲染工作消失。
如果首屏不需要设置面板,异步组件可以减少首屏阻塞;但用户第一次打开设置面板时,会增加一次等待。性能取舍是:
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 失败仍应通过 errorComponent、onError 或外层错误处理机制呈现可恢复状态。
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 可以有:
- 原始大小:构建后未压缩的字节数;
- 压缩后大小:gzip 或 Brotli 传输大小;
- 解压后的执行成本:浏览器解压、解析、编译和执行时间。
因此,下面两种结论都可能错误:
- “文件只有 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
平均值为:
但最后一个用户的体验完全不同。生产分析通常更关注分位数,并结合样本量、页面类型和设备分组解释结果。具体阈值会随 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
}
}
}
这个实现处理了三个故障路径:
- 新请求开始时取消旧请求;
- 非
2xx响应主动转为错误; - 用户主动取消不显示为失败,但真正错误会进入错误状态。
如果列表仍然巨大,接口分页和前端虚拟化可以同时使用;如果用户只需查看当前页,则分页可能已经足够,不必再增加虚拟滚动的复杂度。
十、如何判断优化是否成立
一次优化至少应满足以下因果链:
明确瓶颈
↓
减少了某类工作
↓
对应阶段的测量值改善
↓
正确性、可访问性和错误恢复没有退化
↓
生产真实用户数据得到验证
例如,“将报告页面改为路由懒加载”成立的完整论证应是:
- 报告页面不属于首屏必需内容;
- 它及其图表库占据了首屏同步依赖;
- 改为动态导入后,首屏 entry 的传输和执行成本下降;
- 首屏 LCP 或主线程长任务得到改善;
- 用户进入报告页面时有明确 loading 和失败恢复;
- 发布后旧页面引用旧 chunk 时仍能处理资源失效;
- RUM 数据显示首屏收益没有被报告页错误率抵消。
同样,“引入虚拟列表”成立的论证应是:
- 数据规模足以使全量 DOM 成为瓶颈;
- 大多数时间只需显示视口附近的行;
- 行高、焦点、键盘导航和动态内容已经正确处理;
- DOM 数量和滚动长任务下降;
- 交互指标改善或至少没有退化。
Vue 性能优化最终优化的是一条完整链路:响应式依赖决定更新范围,组件边界决定工作隔离,长列表策略决定 DOM 规模,异步组件决定代码何时进入执行路径,Bundle 结构决定网络和解析成本,而性能指标负责验证这些改变是否真正改善了用户体验。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 测试体系:Vitest、Vue Test Utils、组件测试和端到端测试
- 下一篇:Vue 可访问性与交互质量:语义、键盘、焦点、ARIA 和动效
- 延伸:Vue 响应式原理:ref、reactive、computed、watch 与依赖追踪
- 延伸:Vue 生产交付:环境配置、静态资源、缓存、灰度和回滚
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论