总结 Vue 性能优化方式及原理
原文主要以 Vue 2 的性能经验为背景。本文尽可能保留原有主题和示例,但把 Vue 2.7、Vue 3.5、Nuxt 4 和 Core Web Vitals 分开说明。
版本基线:Vue 2.7 是 Vue 2 的最终 minor 版本,Vue 2 已于 2023 年 12 月 31 日结束官方维护;Vue 3.5 使用 Proxy 响应式、编译器优化和现代 renderer。旧项目可以继续阅读 Vue 2 小节,新项目优先采用 Vue 3。
一、先测量,再优化
性能优化不是“把所有技巧都用一遍”,而是:
明确问题
↓
区分首屏加载性能和交互更新性能
↓
使用 Performance、Vue DevTools、Lighthouse、CrUX/RUM 定位
↓
只修改瓶颈
↓
在相同数据和设备条件下回归
Vue 官方也建议区分 page-load performance 和 update performance。一次 Lighthouse 得分不能代表所有真实用户,更不能证明某个 Vue 写法一定更快。
二、插槽使用现代语法
2.1 Vue 2.7 和 Vue 3.5 推荐 v-slot / #
Vue 2.6 引入了统一的 v-slot 语法,Vue 2.7 仍然支持旧的 slot、slot-scope,但旧语法已经过时。Vue 3 使用 v-slot 或缩写 #:
<!-- Vue 2.7 / Vue 3 都可以使用的具名插槽写法 -->
<ChildPanel>
<template #header>
{{ title }}
</template>
</ChildPanel>
作用域插槽需要由子组件显式提供 slot props:
<!-- ChildList.vue -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot name="item" :item="item">
{{ item.name }}
</slot>
</li>
</ul>
</template>
<script setup lang="ts">
defineProps<{
items: Array<{ id: number; name: string }>
}>()
</script>
<!-- Parent.vue -->
<script setup lang="ts">
import ChildList from './ChildList.vue'
const items = [
{ id: 1, name: 'Vue' },
{ id: 2, name: 'Nuxt' },
]
</script>
<template>
<ChildList :items="items">
<template #item="{ item }">
<strong>{{ item.name }}</strong>
</template>
</ChildList>
</template>
旧写法仍可以在维护 Vue 2 项目时看到:
<!-- Vue 2 旧语法:可以迁移,但不要在新代码中继续扩散 -->
<ChildPanel>
<template slot="header">{{ title }}</template>
</ChildPanel>
原文根据编译结果得出“新语法一定让依赖从父组件转移到子组件”的结论过于绝对。插槽表达式的变量仍来自父组件作用域;Vue 3 的插槽以函数传递,子组件在渲染过程中调用它,有机会进行更精确的依赖跟踪,但是否减少更新要由具体组件树和性能分析验证。
三、使用 computed,但不要把缓存当成万能优化
computed 适合有明确依赖的同步派生值。它会缓存 getter 结果,依赖没有变化时重复访问可以复用结果:
<script setup lang="ts">
import { computed, ref } from 'vue'
const count = ref(1)
const superCount = computed(() => {
let result = count.value
for (let index = 0; index < 10_000; index++) {
result++
}
return result
})
</script>
<template>
<p>{{ superCount }}</p>
<button @click="count++">增加</button>
</template>
Options API 写法也仍然有效:
export default {
data: () => ({ count: 1 }),
computed: {
superCount() {
return this.count + 10_000
},
},
}
注意:
- computed getter 应该是纯函数,不要在 getter 中修改其他状态;
- 异步请求、日志、写缓存等副作用应使用
watch或事件处理函数; - 如果计算本身很便宜,强行增加 computed 不一定更快;
- Vue 3.4+ 的 computed 还有稳定值相关优化,但仍需根据实际依赖分析。
四、函数式组件:Vue 2 与 Vue 3 不要混用结论
4.1 Vue 2.7 的函数式组件
Vue 2 的函数式组件没有组件实例、响应式状态和常规生命周期,适合纯展示或包装组件:
// Vue 2.7
export default {
functional: true,
props: {
title: String,
},
render(h, { props }) {
return h('span', props.title)
},
}
Vue 2 SFC 还可以使用:
<!-- 仅 Vue 2 语法 -->
<template functional>
<div class="user-profile">{{ props.name }}</div>
</template>
4.2 Vue 3.5 不要为了性能机械改成 functional
Vue 3 已移除 { functional: true } 和 <template functional>。函数式组件只是一个接收 props/context 并返回 VNode 的函数:
// Vue 3
import { h, type FunctionalComponent } from 'vue'
const UserProfile: FunctionalComponent<{ name: string }> = props => {
return h('div', { class: 'user-profile' }, props.name)
}
export default UserProfile
不过在 Vue 3 中,普通组件的运行时开销已经降低,函数式组件相对有状态组件的优势通常很小。原文“500 个组件约 30ms,函数式组件约 10–15ms”的个人设备测试不能外推到 Vue 3.5 或生产环境。
更可靠的做法是先减少不必要的组件抽象、稳定传给子组件的 props,再使用 Vue DevTools 和 Performance 面板确认更新范围。参考:Vue 3 函数式组件迁移、Vue 性能指南。
五、按场景选择 v-if 和 v-show
两者的基本成本模型仍然是:
v-if是真正的条件渲染,条件为 false 时不会创建分支;切换时可能创建/卸载组件;v-show会先创建并保留 DOM,只切换display;初始化成本较高,但频繁切换通常更合适。
<!-- 低频显示、隐藏时不需要保留实例 -->
<UserProfile v-if="visible" :user="user" />
<!-- 高频切换且希望保留实例和 DOM -->
<UserProfile v-show="visible" :user="user" />
经验规则可以保留,但不能绝对化:
- 首次可能长期不可见、初始化很重的组件,通常先考虑
v-if或异步组件; - 高频显示/隐藏且保留状态比销毁更重要时,可以考虑
v-show; - 还要考虑请求、定时器、事件订阅、内存和可访问性;
- 如果只是一个简单元素,性能差异通常不值得复杂化。
Vue 3 支持无额外 DOM 的条件块:
<template v-if="visible">
<h2>标题</h2>
<p>说明</p>
</template>
参考:条件渲染。
六、keep-alive 是时间和空间的取舍
动态组件频繁切换时,可以缓存已经创建的组件实例:
<KeepAlive>
<component :is="currentComponent" />
</KeepAlive>
Vue 3 可以使用 include、exclude 和 max:
<KeepAlive
:include="['UserList', 'UserDetail']"
:exclude="['LoginView']"
:max="10"
>
<component :is="currentComponent" />
</KeepAlive>
缓存组件被切换出去时会进入 deactivated,重新显示时进入 activated,不会按普通卸载流程立即销毁。应在这两个钩子中暂停和恢复轮询、订阅等外部资源;被淘汰或父级卸载时再做最终清理。
<script setup lang="ts">
import { onActivated, onDeactivated, onUnmounted } from 'vue'
let timer: number | undefined
onActivated(() => {
timer = window.setInterval(() => {
// 刷新当前页面数据
}, 10_000)
})
onDeactivated(() => {
if (timer !== undefined) window.clearInterval(timer)
timer = undefined
})
onUnmounted(() => {
if (timer !== undefined) window.clearInterval(timer)
})
</script>
KeepAlive 会占用内存,缓存过多页面可能适得其反。它不是“初始化性能优化神器”,要和路由缓存、组件 name、数据刷新策略一起设计。
七、不要在同一元素上混用 v-if 和 v-for
这是 Vue 2 与 Vue 3 需要特别区分的地方。
7.1 Vue 2.7:v-for 优先
Vue 2 中同一元素同时使用时,v-for 的优先级高于 v-if:
<!-- Vue 2.7 可以访问 user,但每次渲染仍会遍历整个 users -->
<li
v-for="user in users"
v-if="user.isActive"
:key="user.id"
>
{{ user.name }}
</li>
推荐过滤列表:
export default {
computed: {
activeUsers() {
return this.users.filter(user => user.isActive)
},
},
}
7.2 Vue 3.5:v-if 优先
Vue 3 中优先级已经改变。同一元素的 v-if 不能访问同级 v-for 声明的变量,因此不要直接迁移上面的写法:
<!-- Vue 3 推荐:用 computed 过滤 -->
<li v-for="user in activeUsers" :key="user.id">
{{ user.name }}
</li>
如果必须保留原数组,可以使用 <template v-for>:
<template v-for="user in users" :key="user.id">
<li v-if="user.isActive">{{ user.name }}</li>
</template>
过滤大列表时,computed 可以避免在其他无关响应式状态更新时重复执行相同的过滤逻辑,但仍应结合数据规模和 profile 判断。参考:Vue 2 风格指南、Vue 3 v-if/v-for 迁移。
八、key 要描述业务身份
稳定 key 让 Vue 在列表变化时复用正确的节点或组件实例:
<ListItem
v-for="item in items"
:key="item.id"
:item="item"
/>
当列表会排序、插入、删除、过滤,或列表项包含 input 和组件内部状态时,不要使用 index:
<!-- 有重排风险时不推荐 -->
<ListItem
v-for="(item, index) in items"
:key="index"
:item="item"
/>
但“index 绝对不能用”也过于绝对。对静态、简单、不可重排、没有局部状态的列表,不写 key 或有意识地使用 index 可能是可接受的。关键是先判断列表身份是否会变化,而不是背诵禁令。
九、延迟渲染、异步组件和分帧
原文使用 requestAnimationFrame 逐帧增加优先级,将多个 Heavy 组件分散到不同帧:
<script>
export default {
data: () => ({ displayPriority: 0 }),
mounted() {
const step = () => {
requestAnimationFrame(() => {
this.displayPriority++
if (this.displayPriority < 4) step()
})
}
step()
},
methods: {
defer(priority) {
return this.displayPriority >= priority
},
},
}
</script>
<template>
<div>
<HeavyOne v-if="defer(1)" />
<HeavyTwo v-if="defer(2)" />
<HeavyThree v-if="defer(3)" />
</div>
</template>
这个方法只是在拆分时间,不会减少总工作量,而且会产生多次父组件更新。它不适合 LCP 内容、用户点击后必须立即出现的内容,也要注意占位高度,否则可能造成 CLS。
Vue 3 更常见的优先方案是异步组件和代码分割:
import { defineAsyncComponent } from 'vue'
const HeavyPanel = defineAsyncComponent(
() => import('./HeavyPanel.vue'),
)
Nuxt 4 还可以使用 Lazy 组件和 lazy hydration,把“下载代码”和“使 SSR HTML 具有交互性”分开处理。首屏主内容、关键图片和 SEO 内容不应为了“延迟”而盲目推后。
十、只读大数据:Vue 2 的 Object.freeze 与 Vue 3 的 shallowRef
10.1 Vue 2.7:只读树可以使用 Object.freeze
Vue 2 的 getter/setter 响应式会递归观察普通对象。对确定不会修改的大型数据使用 Object.freeze,可以阻止 Vue 2 对其进行响应式转换:
export default {
data() {
const rows = Array.from({ length: 10_000 }, (_, id) => ({
id,
name: `item-${id}`,
}))
return {
rows: Object.freeze(rows),
}
},
}
Object.freeze() 默认只冻结传入的那一层;Object.freeze(rows) 会冻结数组容器,但不会自动冻结每个 row 对象。Vue 2 从这个冻结数组根开始也不会把它转换为响应式数组,因此嵌套对象的原地修改不会得到 Vue 的更新通知,但 JavaScript 仍然允许修改这些未冻结的嵌套对象。若确实需要整棵只读树,要显式深度冻结,并接受不可变数据的约束。文章中的“40–50ms 变成 0–1ms”只是个人设备和数据形状下的样本,不能作为保证;如果数据需要更新,应该用新的根引用替换,而不是冻结后再原地修改。
10.2 Vue 3.5:优先考虑浅层响应式和不可变更新
Vue 3 的大规模不可变数据可以使用 shallowRef:
<script setup lang="ts">
import { shallowRef } from 'vue'
const rows = shallowRef(
Array.from({ length: 10_000 }, (_, id) => ({
id,
name: `item-${id}`,
})),
)
function replaceRows(nextRows: typeof rows.value) {
rows.value = nextRows
}
</script>
浅层 ref 只追踪 .value 的替换,不追踪嵌套对象的原地修改。因此必须遵守不可变更新约定:
rows.value = rows.value.map(row =>
row.id === targetId ? { ...row, name: 'new name' } : row,
)
这不是虚拟列表的替代品。数据量很大时,还要考虑分页、服务端筛选和只渲染可视区域。参考:Vue 性能指南:减少大型不可变数据的响应式开销。
十一、编译器、渲染函数和 JSX
模板、render function 和 JSX 最终都要产生 VNode,但模板更容易被 Vue 编译器静态分析。Vue 2 构建阶段会分析静态节点,生成类似 _m(0) 的静态树缓存;这属于 Vue 2 编译器内部实现。
不要直接复制或依赖 _m、staticRenderFns、isStatic 等私有字段。它们的名称和结构不是业务 API,文章中的 markStatic 片段也只是源码分析示意。
11.1 Vue 3.5 的 compiler-informed VDOM
Vue 3 在模板编译和运行时之间协同优化:
- 静态节点提升,避免每次重新创建;
- 静态文本合并,减少 DOM 创建;
- Patch Flags 标记可能变化的 text、class、style 或 props;
- Block Tree 收集动态后代,更新时跳过稳定的静态树;
- keyed children 在必要时使用更高效的比较和 LIS 移动策略。
这些优化通常由模板编译器自动完成。手写 JSX 不能天然得到更高性能,反而可能失去部分静态分析机会。只有模板表达力不足或确实需要动态生成 VNode 时,才选择 render function/JSX。
参考:Vue 3 渲染机制。
十二、v-once、v-memo 与稳定 props
对确定不会再变化的内容,Vue 提供了专门提示:
<div v-once>{{ staticMessage }}</div>
Vue 3 还支持 v-memo,但它应该用于经过 profile 证明有收益的局部大型子树,而不是到处添加:
<div v-memo="[item.id, item.version]">
<ExpensiveRow :item="item" />
</div>
父组件向列表项传递“真正影响该项的值”,通常比把整个变化中的全局状态传给每个子组件更稳定:
<!-- activeId 改变时,通常只有旧 active 项和新 active 项的 active prop 改变 -->
<ListItem
v-for="item in items"
:key="item.id"
:active="item.id === activeId"
/>
是否减少更新仍要通过组件更新分析验证。
十三、SSR、Nuxt 4 与 hydration
Vue SSR/SSG 可以让服务器或构建阶段先输出 HTML,改善首屏可见内容和 SEO,但也引入 hydration 一致性要求:服务端和客户端第一次渲染必须得到相同结构。
Nuxt 4 中,首屏数据优先使用 useFetch 或 useAsyncData,让数据进入 payload,避免客户端 hydration 时重复请求:
<script setup lang="ts">
const { data, error } = await useFetch('/api/articles')
</script>
不要在 SSR setup 中直接依赖 window、document、随机数或本地时区。浏览器专用逻辑放到 onMounted 或客户端插件中。随机数、无效 HTML 嵌套、服务端和客户端数据不一致都可能导致 hydration mismatch,进而造成修复、闪动和交互延迟。
Nuxt 4 还提供 route rules、预渲染、SWR/ISR、Lazy 组件和 lazy hydration。它们需要按路由和内容类型选择,不应把所有页面都强制改为 CSR 或把所有内容都延迟到客户端。
十四、性能指标与工具
当前 Core Web Vitals 的“良好”阈值通常按真实用户第 75 百分位判断:
| 指标 | 含义 | 良好阈值 |
|---|---|---|
| LCP | 最大内容绘制,反映主要内容加载速度 | ≤ 2.5s |
| INP | 交互到下一次绘制的响应性 | ≤ 200ms |
| CLS | 累积布局偏移,反映画面稳定性 | ≤ 0.1 |
FID 已被 INP 取代,TTI 主要作为历史或诊断指标。Lighthouse 是实验室诊断工具,不能代替 CrUX、PageSpeed Insights、Search Console 或自己的 RUM;实验室中可以用 TBT 作为潜在交互问题的 proxy,但生产 INP 必须看真实用户交互数据。
14.1 LCP
- 不要给 LCP 图片统一加
loading="lazy"; - 确保首屏主图片可以从初始 HTML 发现;
- 优化 TTFB、图片大小、格式和资源优先级;
- 避免在首屏执行过重的 JavaScript 和 hydration。
14.2 INP
- 减少事件处理器中的同步大计算;
- 拆分长任务,必要时使用 Web Worker;
- 减少不必要的组件更新和 hydration;
- 虚拟列表、异步组件和分帧渲染要用实际交互 profile 证明收益。
14.3 CLS
- 图片和视频声明
width/height或aspect-ratio; - 异步组件、广告和嵌入内容预留空间;
- 不要在内容出现后突然插入顶部区域;
- 过渡和延迟渲染要保持稳定布局。
十五、总结
性能优化的优先级可以概括为:
- 先用真实数据定位瓶颈;
- 首屏关注 HTML 可发现性、TTFB、LCP 和 hydration;
- 更新性能关注稳定 props、合理的响应式边界、长任务和 INP;
- 列表关注稳定 key、分页、虚拟列表和服务端筛选;
- 按 Vue 2.7 与 Vue 3.5 分开理解
v-if/v-for、functional component、响应式和编译器; - 任何“快了多少毫秒”的结论都必须带上设备、浏览器、数据规模和测量方式。
参考资料
原文作者:zhangzp。原文链接:总结 Vue 性能优化方式及原理。