前端 Vue 专栏 11:Vue 性能优化与工程实践
前言
Vue 项目能够正常运行,不代表它已经具有良好的性能。
开发中经常遇到:
首页需要等待很久才能显示;
打包后的 JavaScript 文件过大;
输入搜索内容时页面明显卡顿;
渲染几千条数据时浏览器失去响应;
切换页面回来后需要重新请求数据;
页面使用时间越长,占用内存越多。
这些问题分别涉及:
加载性能;
更新性能;
资源体积;
渲染数量;
组件生命周期;
内存管理。
性能优化不是背诵一份“优化清单”,也不是看到 v-for 就使用虚拟列表。更合理的流程是:
发现问题
↓
使用工具测量
↓
定位真正瓶颈
↓
选择对应优化方法
↓
再次测量效果
这一篇只围绕前端秋招和普通业务开发中的高频内容展开:
怎样区分加载性能和更新性能;
怎样定位 Vue 页面性能问题;
路由懒加载为什么能减少首屏体积;
computed、v-if、v-show 应该怎样选择;
怎样避免无意义的组件更新;
大列表为什么卡顿,应该怎样处理;
KeepAlive 能解决什么问题;
定时器和事件监听为什么可能造成内存泄漏;
面试中如何系统回答 Vue 性能优化。
本文不会深入 Vue 编译器优化标记、虚拟 DOM 源码、浏览器渲染引擎内部算法和复杂构建工具配置。
一、先区分两类性能问题
Vue 官方文档把前端性能主要分为两个方向:
页面加载性能
页面更新性能
1. 页面加载性能
用户第一次打开网站时:
请求 HTML
↓
下载 JavaScript、CSS、图片
↓
解析和执行 JavaScript
↓
创建 Vue 应用
↓
页面显示并可以交互
如果首屏 JavaScript 太大、图片太大或请求链路太长,就会出现:
白屏时间长;
主要内容显示慢;
按钮很久以后才能点击。
常见优化方向:
生产构建;
减小资源体积;
代码分割;
路由懒加载;
图片优化;
浏览器缓存;
选择合适的渲染方式。
2. 页面更新性能
应用已经显示出来以后,用户进行操作:
输入搜索内容;
展开列表;
切换选中项;
修改表单;
滚动长列表。
如果组件更新范围过大或 DOM 数量过多,就会出现卡顿。
常见优化方向:
减少不必要的组件更新;
保持 props 稳定;
缓存重复计算;
减少同时渲染的 DOM;
使用防抖、节流;
及时清理副作用。
面试时不要把所有优化手段混在一起。先说明是在优化首屏加载,还是优化页面运行时更新,答案会更有条理。
二、优化之前先测量
性能优化最常见的错误是:
没有发现真实问题
↓
凭感觉修改代码
↓
代码更复杂
↓
性能却没有明显提升
常用工具:
| 工具 | 主要用途 |
|---|---|
| Chrome Network | 查看请求数量、资源体积和加载时间 |
| Chrome Performance | 分析长任务、脚本执行和渲染耗时 |
| Lighthouse / PageSpeed Insights | 检查页面加载与体验指标 |
| Vue Devtools | 查看组件状态和组件更新情况 |
| 构建产物分析工具 | 查看哪些依赖占用较大体积 |
Network 面板重点看什么
哪个请求最慢;
JS、CSS、图片分别有多大;
是否出现重复请求;
静态资源是否命中缓存;
是否存在很长的串行请求链。
Performance 面板重点看什么
用户操作后是否出现长任务;
JavaScript 执行是否耗时;
是否频繁触发布局和绘制;
哪个操作期间页面掉帧。
面试中可以先说:
我会先复现问题并使用 Network、Performance 和 Vue Devtools 判断瓶颈属于网络加载、JavaScript 执行还是组件渲染,然后再选择优化方案。
这比直接罗列十几个 API 更符合实际开发流程。
第一部分:加载性能
三、生产环境必须使用生产构建
开发环境中的 Vue 和 Vite 会提供:
开发警告;
热更新;
调试信息;
额外的开发检查。
这些能力方便开发,但不应该原样部署到生产环境。
Vite 项目执行:
npm run build
通常会生成:
dist/
├── index.html
└── assets/
├── index-abc123.js
├── index-def456.css
└── logo-8f31c2.png
生产构建会完成:
源码转换;
代码压缩;
无用代码删除;
资源哈希命名;
按需进行代码分割。
所以不能把:
npm run dev
启动的开发服务器当作正式生产服务器。
四、减少首屏 JavaScript 体积
SPA 第一次进入时通常需要下载并执行 JavaScript。如果首屏包过大,就会增加:
下载时间;
JavaScript 解析时间;
JavaScript 执行时间;
低性能设备的主线程压力。
常见原因:
所有页面一次性打包;
引入了很大的第三方库;
整包导入但只使用少量功能;
重复引入功能相似的依赖;
从未检查构建产物组成。
引入依赖前先判断
不要为了一个简单功能引入体积很大的库:
项目是否已经有相同能力;
浏览器原生 API 是否能够完成;
能否只引入需要的模块;
这个依赖是否支持 ESM 和 tree shaking;
增加的体积是否值得。
Tree Shaking 是什么
Tree Shaking 可以理解为:
构建工具在生产构建时移除确定没有被使用的模块代码。
例如:
import { formatDate } from './utils.js'
formatDate(new Date())
如果 utils.js 还导出了很多未使用函数,符合静态分析条件时,构建工具可以在生产产物中删除它们。
Tree Shaking 更适合基于 ESM 的静态导入:
import { formatDate } from './utils.js'
秋招阶段只需要理解作用,不需要深入摇树算法和所有副作用配置。
五、代码分割和路由懒加载
如果项目有十几个页面,却把所有页面代码都打进首屏文件:
首页代码
用户页面代码
订单页面代码
后台页面代码
报表页面代码
↓
第一次访问全部下载
用户首次只看首页,却下载了暂时用不到的页面代码。
代码分割会把一个大包拆成多个 chunk:
index.js
UserView.js
OrderView.js
ReportView.js
Vue Router 中常用动态 import 实现路由懒加载:
const routes = [
{
path: '/',
component: () => import('@/views/HomeView.vue')
},
{
path: '/users',
component: () => import('@/views/UserView.vue')
},
{
path: '/orders',
component: () => import('@/views/OrderView.vue')
}
]
第一次进入首页时:
加载首页必要代码
↓
暂时不加载订单页面代码
第一次进入订单页时:
匹配 /orders
↓
请求 OrderView 对应的 chunk
↓
加载完成后渲染页面
作用是:
减少首屏一次性下载的 JavaScript;
把暂时不需要的页面延迟到访问时加载。
需要注意:
懒加载不是减少项目总代码量,而是改变代码的加载时机。
路由懒加载也不是越碎越好。首屏马上需要的很小组件没有必要全部拆成独立请求,优先对页面级和较大的非首屏功能进行分割。
六、异步组件适合什么场景
非路由组件也可以按需加载:
import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent(
() => import('./HeavyChart.vue')
)
适合:
点击以后才打开的大型弹窗;
不一定会展示的图表;
折叠区域中的复杂组件;
非首屏的大型编辑器。
路由组件直接使用:
component: () => import('./Page.vue')
通常不需要再套一层 defineAsyncComponent()。
普通小组件如果首屏立即需要,直接同步导入反而更简单。
七、图片和静态资源优化
很多页面加载慢,瓶颈并不在 Vue,而在图片。
常见问题:
展示 300 像素图片,却上传了 4000 像素原图;
列表一次加载大量非可视区域图片;
使用不合适的图片格式;
图片没有设置尺寸,加载后造成页面跳动。
常见优化:
根据展示尺寸压缩图片;
合理使用 WebP、AVIF 等格式;
非首屏图片使用懒加载;
为图片设置 width 和 height;
稳定资源使用浏览器和 CDN 缓存。
原生图片懒加载:
<img
src="/images/product.webp"
loading="lazy"
width="320"
height="240"
alt="商品图片"
>
这不是 Vue 特有优化,但在真实前端项目中往往比微调组件代码更有效。
八、SPA怎样理解
纯客户端 SPA 的首次流程通常是:
服务器返回 HTML 外壳
↓
浏览器下载 JavaScript
↓
JavaScript 执行并请求数据
↓
页面主要内容出现
第二部分:更新性能
九、computed 为什么有助于避免重复计算
模板中可以直接调用方法:
<p>{{ calculateTotal() }}</p>
function calculateTotal() {
return items.value.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
}
组件每次重新渲染时,方法都会重新执行。
如果结果只依赖响应式数据,可以使用 computed:
const totalPrice = computed(() => {
return items.value.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
})
<p>{{ totalPrice }}</p>
computed 会根据响应式依赖进行缓存:
items 没变化
→ 复用之前的计算结果
items 发生变化
→ 重新计算 totalPrice
但不要得出“所有函数都改成 computed”的结论:
需要根据状态得到一个值 → computed
响应点击并产生操作 → method
需要执行副作用 → watch / 事件处理
十、v-if 和 v-show 怎样选择
v-if:
<UserPanel v-if="visible" />
条件为 false 时,组件对应 DOM 不存在;切换时可能创建或销毁组件。
v-show:
<UserPanel v-show="visible" />
组件和 DOM 会创建,只是通过 CSS display 控制显示和隐藏。
选择原则:
初始条件很少成立、切换频率低 → v-if
初始就需要创建、切换非常频繁 → v-show
例如权限区域可能使用 v-if:
<AdminPanel v-if="isAdmin" />
频繁打开和关闭的小面板可以考虑 v-show:
<ToolPanel v-show="panelVisible" />
v-show 不支持 <template>,也不能与 v-else 组合,这是使用时需要注意的语义区别。
十一、稳定的 key 为什么重要
列表:
<UserItem
v-for="user in users"
:key="user.id"
:user="user"
/>
稳定且唯一的 key 帮助 Vue 判断:
哪个节点仍然存在;
哪个节点新增了;
哪个节点被删除了;
哪个节点只是改变了位置。
不建议在会排序、插入和删除的列表中使用索引:
<UserItem
v-for="(user, index) in users"
:key="index"
/>
因为列表结构变化以后,同一个 index 可能对应了不同业务数据,导致组件状态错误复用,也不利于正确更新。
优先使用:
:key="user.id"
key 不只是性能提示,更重要的是表达节点身份。
十二、保持传给子组件的 props 稳定
子组件收到的 props 发生变化时,子组件可能需要更新。
下面的列表把 activeId 传给每一项:
<UserItem
v-for="user in users"
:key="user.id"
:user="user"
:active-id="activeId"
/>
当 activeId 从 1 变成 2,所有 UserItem 收到的 activeId 都变化了。
可以由父组件提前计算每一项是否激活:
<UserItem
v-for="user in users"
:key="user.id"
:user="user"
:active="user.id === activeId"
/>
这时真正发生变化的通常只有:
原来激活的项目:true → false
新激活的项目: false → true
其他项目: false → false
核心原则是:
尽量让没有业务变化的子组件继续收到相同 props,减少无意义更新。
不要为了追求绝对稳定而把简单代码过度复杂化。这个优化在大量重复子组件中更有价值。
十三、大列表为什么会卡顿
假设一次渲染一万条数据:
<UserItem
v-for="user in users"
:key="user.id"
:user="user"
/>
即使 Vue 的 Diff 很快,浏览器仍然需要处理:
一万个组件或节点;
大量 DOM 创建;
样式计算;
布局;
绘制;
事件和响应式依赖。
因此大列表的第一优化方向不是研究 Diff 源码,而是:
减少同一时间真正创建的 DOM 数量。
常见方案:
服务端分页;
前端分页;
分批加载;
无限滚动;
虚拟列表。
虚拟列表是什么
屏幕一次只能看到十几条数据,就只渲染可视区域附近的项目:
总数据:10000 条
屏幕可见:20 条
实际 DOM:只保留可视区域附近几十条
滚动时根据位置替换显示内容,让用户感觉在浏览完整列表。
虚拟列表适合:
聊天记录;
大型表格;
日志列表;
大量商品或用户数据。
如果只有几十条数据,不需要为了优化引入虚拟列表。
十四、搜索为什么需要防抖
输入框直接请求:
<input
v-model="keyword"
@input="search"
>
用户快速输入:
v
vu
vue
可能连续发送三次请求。
防抖的作用是:
事件连续触发时重新计时,只在用户停止操作一段时间后执行最后一次。
简化实现:
function debounce(fn, delay) {
let timer
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => {
fn(...args)
}, delay)
}
}
使用:
const search = debounce(async keyword => {
const response = await fetch(
`/api/search?keyword=${encodeURIComponent(keyword)}`
)
result.value = await response.json()
}, 300)
防抖适合:
搜索框联想;
表单实时校验;
频繁变化后的最终保存。
节流更适合限制持续高频事件的执行频率:
scroll;
resize;
mousemove。
但使用浏览器原生能力或成熟组件已经满足要求时,不需要重复手写复杂版本。
十五、KeepAlive 能优化什么
动态组件或路由组件切换时,默认可能卸载旧组件:
离开列表页
↓
列表组件卸载
↓
返回列表页
↓
重新创建组件、重新请求数据
使用 <KeepAlive> 可以缓存组件实例:
<KeepAlive>
<component :is="currentComponent" />
</KeepAlive>
路由场景常见写法:
<RouterView v-slot="{ Component }">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
</RouterView>
缓存后再次回来,可以保留:
组件局部状态;
输入内容;
滚动相关状态;
已经加载的数据。
被缓存组件会涉及:
onActivated()
onDeactivated()
但 <KeepAlive> 不是越多越好:
缓存组件会占用内存;
返回页面是否需要最新数据要单独判断;
需要通过 include、exclude、max 控制范围。
适合缓存的是用户频繁往返、恢复原状态有价值的页面,不是整个应用的所有页面。
十六、大型只读数据与 shallowRef
Vue 的普通 ref 和 reactive 默认会对嵌套对象提供深层响应能力。
绝大多数业务数据直接使用它们即可。
只有当数据规模非常大、嵌套很深,并且业务把内部结构当作不可变数据时,才可能考虑:
const rows = shallowRef(largeRows)
shallowRef 只追踪 .value 的替换:
rows.value = newRows
直接修改内部数据不会触发相同的深层响应:
rows.value.push(newRow)
需要替换根值:
rows.value = [
...rows.value,
newRow
]
这是特定大数据场景的优化逃生口,不应成为普通状态的默认写法。面试知道适用条件即可。
第三部分:资源和生命周期
十七、及时清理定时器和事件监听
组件中创建了副作用:
const timer = setInterval(() => {
refreshData()
}, 5000)
组件卸载后如果定时器仍然运行,就可能继续:
执行回调;
请求接口;
引用组件相关数据;
占用浏览器资源。
应该清理:
import { onBeforeUnmount } from 'vue'
const timer = setInterval(() => {
refreshData()
}, 5000)
onBeforeUnmount(() => {
clearInterval(timer)
})
全局事件监听同样需要成对处理:
function handleResize() {
width.value = window.innerWidth
}
window.addEventListener('resize', handleResize)
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
})
注意添加和删除时必须使用同一个函数引用。下面写法无法正确删除之前的匿名函数:
window.addEventListener('resize', () => {})
window.removeEventListener('resize', () => {})
因为这是两个不同的函数对象。
常见需要清理的资源:
setInterval 和 setTimeout;
window、document 事件监听;
WebSocket;
第三方库实例;
手动创建的观察器和订阅。
十八、避免重复请求和过期响应
组件重复挂载可能重复请求相同数据:
进入页面 → 请求用户
离开页面
再次进入 → 再次请求同一用户
是否缓存取决于数据时效要求:
长期不变的字典数据 → 可以缓存较久
用户资料 → 可以短暂复用并适时刷新
余额、库存等实时数据 → 应优先保证新鲜度
不能把“不请求”简单等同于性能更好。过期数据可能造成业务错误。
搜索场景还可能发生响应乱序:
先请求 keyword=a
后请求 keyword=ab
↓
ab 先返回
a 后返回
↓
旧结果覆盖新结果
可以使用 AbortController 取消旧请求:
let controller
async function search(keyword) {
controller?.abort()
controller = new AbortController()
const response = await fetch(
`/api/search?keyword=${encodeURIComponent(keyword)}`,
{
signal: controller.signal
}
)
return response.json()
}
这既减少无意义工作,也避免旧响应覆盖新状态。
十九、一个列表页面怎样逐步优化
假设用户列表存在这些问题:
首屏加载所有后台页面代码;
一次渲染一万条用户;
每输入一个字符就请求接口;
选中用户后所有列表项都更新;
离开页面后 resize 监听仍然存在。
可以按顺序处理。
1. 路由懒加载
{
path: '/users',
component: () => import('@/views/UserListView.vue')
}
把用户页代码延迟到访问时加载。
2. 减少列表 DOM 数量
普通列表 → 分页
大量连续滚动数据 → 虚拟列表
3. 搜索防抖
用户停止输入约 300 毫秒后再请求,而不是每次按键都立即请求。
4. 保持 props 稳定
<UserItem
v-for="user in users"
:key="user.id"
:user="user"
:active="user.id === activeId"
/>
让没有改变激活状态的项目尽量不更新。
5. 清理副作用
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
})
6. 再次测量
重新使用 Network 和 Performance 检查:
首屏包是否变小;
列表 DOM 数量是否下降;
输入时是否仍然出现长任务;
无效请求是否减少;
组件离开后监听是否被清理。
这就是实际性能优化比“背 API”更重要的完整闭环。
二十、常见无效或过度优化
1. 没测量就开始优化
先找到瓶颈,再修改代码。
2. 所有组件都异步加载
首屏立即需要的小组件拆得过碎,可能增加加载管理成本,代码也更复杂。
3. 所有路由都 KeepAlive
大量缓存组件会增加内存占用,并可能让旧数据长期保留。
4. 所有模板都加 v-once 或 v-memo
它们只适合明确场景,错误使用可能导致页面不再正确更新。
5. 为几十条数据引入虚拟列表
虚拟列表本身有实现和维护成本,数据量不大时普通渲染更简单。
6. 为了少一次请求长期展示旧数据
缓存时间需要结合业务数据的新鲜度设计。
7. 只关注 Vue API,忽略图片和依赖体积
真实项目中,大图片和重型依赖经常比组件的一次普通更新更值得优先处理。
8. 认为组件越少越快
合理组件化有利于维护,也能缩小更新边界。只有在大列表等性能敏感位置出现大量无意义组件实例时,才需要评估减少抽象层级。
二十一、Vue 性能优化高频面试题
1. Vue 项目怎样进行性能优化
可以这样回答:
我会先通过 Network、Performance 和 Vue Devtools 定位瓶颈,再分别处理加载和更新性能。加载方面使用生产构建、控制依赖体积、路由懒加载、图片和缓存优化;更新方面使用稳定 key 和 props、computed、分页或虚拟列表、防抖节流,并在组件卸载时清理定时器和监听。优化完成后再次测量验证效果。
2. 路由懒加载有什么作用
可以这样回答:
路由懒加载使用动态 import 把页面代码拆成独立 chunk,在第一次访问对应路由时才加载,从而减少首屏一次性下载和执行的 JavaScript。它改变的是加载时机,不会减少项目总代码量。
3. computed 和 method 在性能上有什么区别
可以这样回答:
computed 会追踪响应式依赖,并在依赖没有变化时复用之前结果;模板中的 method 会在组件重新渲染时再次执行。需要根据状态得到派生值时适合 computed,响应事件和执行操作时使用 method。
4. v-if 和 v-show 怎样选择
可以这样回答:
v-if 在条件不成立时不会创建对应内容,切换时涉及创建和销毁,适合初始很少展示、切换不频繁的内容;v-show 会提前创建内容,通过 display 切换显示,适合频繁显示和隐藏。
5. 为什么不建议用 index 作为列表 key
可以这样回答:
列表插入、删除或排序后,相同 index 可能对应不同业务项,Vue 可能错误复用组件和局部状态。稳定唯一的业务 id 更能表达节点身份,也有利于准确更新。
6. 大列表怎样优化
可以这样回答:
大列表卡顿的根本原因通常是同时创建了大量 DOM、组件和响应式依赖。可以通过服务端分页、分批加载或虚拟列表减少同时渲染的节点数量,并配合稳定 key、稳定 props 和搜索防抖。
7. KeepAlive 有什么作用
可以这样回答:
KeepAlive 缓存动态组件实例,使组件切换后返回时可以保留局部状态,避免重复创建和部分重复请求。但缓存会占用内存,应通过 include、exclude 或 max 控制范围,并结合 activated、deactivated 生命周期处理业务。
8. v-once 和 v-memo 有什么区别
可以这样回答:
v-once 让内容只渲染一次,后续永久跳过更新;v-memo 根据依赖数组判断是否跳过子树更新。二者属于特定场景优化,普通业务中优先保证正确性,不应滥用。
9. 怎样避免组件内存泄漏
可以这样回答:
对组件中创建的定时器、全局事件监听、WebSocket、第三方实例和手动订阅,应在组件卸载时清理。添加和移除监听必须保留同一个函数引用,避免组件销毁后回调仍然运行并持有数据引用。
10. Tree Shaking 是什么
可以这样回答:
Tree Shaking 是构建工具基于 ESM 静态结构,在生产构建时移除确认未被使用的模块代码。它有助于减小产物体积,但还要关注依赖是否支持 ESM、是否存在副作用以及实际构建结果。
11. SPA 首屏慢可以怎样处理
可以这样回答:
先检查资源体积、请求链和 JavaScript 执行耗时,再通过生产构建、路由级代码分割、依赖和图片优化、缓存等方式降低首屏成本。如果业务对首屏内容和 SEO 非常敏感,还可以评估 SSR 或 SSG,但需要权衡工程复杂度。
12. 为什么性能优化后要再次测量
可以这样回答:
优化方案不一定命中真实瓶颈,也可能带来新的加载或维护成本。再次测量可以比较资源体积、渲染耗时和交互响应是否真正改善,避免只凭主观感觉判断效果。
二十二、本文知识地图
1. 优化流程
测量
↓
定位瓶颈
↓
选择方案
↓
实施优化
↓
再次测量
2. 加载性能
使用生产构建;
控制依赖和 JavaScript 体积;
路由懒加载和代码分割;
优化图片等静态资源;
合理配置缓存;
3. 更新性能
computed 缓存派生计算;
合理选择 v-if 和 v-show;
使用稳定 key;
保持子组件 props 稳定;
大列表分页或虚拟化;
高频事件防抖、节流。
4. 组件和资源
需要恢复状态的页面 → KeepAlive
非首屏大型组件 → 异步组件
定时器和全局监听 → 卸载时清理
5. 秋招优先级
第一优先:加载性能与更新性能的区别
第二优先:路由懒加载、代码分割和首屏体积
第三优先:computed、v-if、v-show、key
第四优先:大列表、虚拟列表、防抖节流
第五优先:KeepAlive 和副作用清理
第六优先:测量工具与完整优化流程
二十三、总结
Vue 性能优化首先要区分:
加载性能 → 页面第一次显示和可交互有多快
更新性能 → 页面运行后响应用户操作有多快
加载性能的重点是减少首屏需要下载和执行的内容:
使用生产构建;
控制第三方依赖;
利用 Tree Shaking;
使用路由懒加载和异步组件;
优化图片和缓存。
更新性能的重点是减少无意义计算、组件更新和 DOM 工作:
使用 computed 缓存派生值;
合理选择 v-if 和 v-show;
为列表提供稳定 key;
保持 props 稳定;
用分页或虚拟列表处理大数据;
对高频事件进行防抖或节流。
KeepAlive 可以缓存有恢复价值的组件,但会占用内存;v-once、v-memo 和 shallowRef 只适合明确的性能瓶颈,不应该成为默认写法。
组件卸载时,还要清理定时器、全局监听、WebSocket 和第三方实例,避免页面已经离开但副作用仍然运行。
对于秋招,最重要的不是背出最多的优化名词,而是能够按照下面的顺序回答:
先用工具测量
↓
区分加载还是更新问题
↓
找到资源、计算、组件或 DOM 瓶颈
↓
使用对应方案
↓
再次测量验证