性能优化
一台发动机造好之后,工程师要做的不是加更多零件,而是减少每一处不必要的摩擦。Vue 应用也是如此——功能完成只是起点,让它在真实用户的设备上流畅运行,才是交付的终点。前一章你完成了管理后台的实战开发,现在我们进入更深的领域:让你的 Vue 应用跑得更快、加载得更少、响应得更及时。
📋 开篇自测
在进入正文之前,检验一下你的性能直觉:
- 一个社交动态流页面渲染了 5000 条数据,用户滚动时明显卡顿。你的第一反应是换一台更快的服务器,还是先看前端渲染做了什么?如果问题在前端,你会如何排查?
- 你在一个组件中用
reactive()包装了一个包含 2 万条记录的数组,发现页面初始化耗时超过 3 秒。你觉得问题出在哪里?有没有更轻量的替代方案? - 打包产物体积达到了 1.8 MB,用户首次打开需要 6 秒以上的白屏等待。你会从哪些维度入手缩减体积?
如果这三个问题你能清晰作答,可以选读感兴趣的小节;否则,请跟随本章逐一攻破。
一、性能度量——先诊断,再开药
性能优化最大的忌讳是「凭感觉优化」。你可能觉得「首页加载好像有点慢」,然后花两天时间优化了一个不是瓶颈的组件,结果用户体验毫无改善。就像医生不会在没做检查的情况下开处方,我们也需要先建立一套客观的度量体系,用数据定位问题,用数据验证效果。
本节介绍三种互补的度量工具:Core Web Vitals 从用户视角量化体验,Vue DevTools 从组件视角定位渲染瓶颈,Lighthouse 从工程视角给出综合诊断。
1.1 Core Web Vitals:用户体验的三把标尺
Google 提出的 Core Web Vitals 已经成为业界衡量网页性能的通用标准。它从用户真实感知出发,定义了三个核心指标:
| 指标 | 全称 | 含义 | 合格阈值 |
|---|---|---|---|
| LCP | Largest Contentful Paint | 最大内容元素绘制完成的时间,衡量「页面什么时候看起来加载好了」 | ≤ 2.5 秒 |
| INP | Interaction to Next Paint | 页面在整个生命周期内所有交互中最慢的 P98 响应延迟(从交互到下一帧绘制),衡量「页面整体交互流畅度」 | ≤ 200 毫秒 |
| CLS | Cumulative Layout Shift | 页面元素在加载过程中发生位移的累积量,衡量「页面会不会跳来跳去」 | ≤ 0.1 |
以「社交动态流」应用为例:当用户打开首页时,动态列表区域通常是 LCP 元素;用户点击「点赞」按钮后从交互到页面视觉更新完成的延迟就是 INP;而图片瀑布流在加载过程中如果没有预留高度、导致内容不断下移,就会产生高 CLS。
你可以在代码中通过 web-vitals 库直接采集这些指标:
// src/utils/performanceMonitor.js
import { onLCP, onINP, onCLS } from 'web-vitals'
function reportToAnalytics(metric) {
console.log(`[Perf] ${metric.name}: ${metric.value.toFixed(2)}`)
// 实际项目中发送到监控平台
// navigator.sendBeacon('/api/perf-metrics', JSON.stringify(metric))
}
export function initPerfMonitor() {
onLCP(reportToAnalytics)
onINP(reportToAnalytics)
onCLS(reportToAnalytics)
}
在应用入口调用 initPerfMonitor(),你就能在控制台(或监控后台)实时看到真实用户的体验数据,而不是自己电脑上的「自我感觉良好」。
为什么要在代码中采集而不只是开发时看一眼?因为你的开发机往往是高性能的 MacBook,网络是千兆光纤。而真实用户可能在用三年前的安卓手机、连着拥挤的公共 Wi-Fi。代码埋点采集到的是真实分布——你可能发现 P90(90% 用户)的 LCP 在 4.5 秒以上,远超 2.5 秒的合格线,而你在开发环境中只看到了 0.8 秒。
1.2 Vue DevTools 性能面板
Chrome 浏览器安装 Vue DevTools 插件后,切换到 Timeline 选项卡,可以按组件粒度查看每次渲染的耗时。
操作步骤:
- 打开 DevTools → Vue 面板 → 点击 Timeline 标签
- 点击 “Start recording”,然后在页面上执行操作(如滚动动态列表、切换评论区)
- 停止录制后,面板会列出每个组件在本轮更新中的渲染时间
这个面板的核心价值在于:它帮你精确定位是哪个组件拖慢了整体渲染。 你可能会发现一个「通知徽章」组件每帧都在重渲染,而它的数据其实根本没变——这就是接下来要解决的问题。
举一个真实场景:在社交动态流页面中,用户每次滚动都会触发 feedList 数据的变更。如果 NotificationBadge 组件依赖了 feedList 所在 store 中的某个状态,即使通知数据没有变化,它也会被标记为需要更新。通过 DevTools 性能面板,你可以一眼看到这个组件在每次更新周期中都出现了——这就是优化的起点。
1.3 Lighthouse 审计
Lighthouse 是 Chrome 内置的综合审计工具,它会从 Performance、Accessibility、Best Practices、SEO 四个维度给出评分和具体建议。
在 DevTools 的 Lighthouse 面板中选择「Mobile」设备模式(移动端条件更苛刻,暴露问题更彻底),点击「Analyze page load」。审计完成后,重点关注两个区域:
- Diagnostics(诊断):列出影响性能的具体问题,如「Reduce unused JavaScript」「Serve images in next-gen formats」
- Treemap(依赖树状图):可视化展示每个 JS 模块的体积占比,帮你发现「谁在吃你的流量」
一个实践准则:在性能优化之前,先用 Lighthouse 跑一次基线分数;每完成一轮优化后再跑一次,对比提升幅度。没有数据支撑的优化,都可能是无用功甚至反向优化。
二、渲染优化——让 DOM 更新只做该做的事
Vue 的响应式系统会自动追踪依赖并在数据变化时更新 DOM,但「自动」不等于「最优」。Vue 内部通过 PatchFlag 静态标记、静态提升(hoistStatic)、事件监听缓存(cacheHandler)等编译时优化,已经大幅减少了运行时的 diff 开销——编译器只会对带有动态绑定的节点打上标记,diff 时只对比这些标记节点,而非全量对比整棵虚拟 DOM 树。
但编译器的优化是通用的,它无法理解你的业务语义。渲染优化的核心思路是:在编译器优化的基础上,利用业务知识进一步减少不必要的 DOM 更新次数,减少每次更新涉及的 DOM 节点数。
2.1 v-once 与 v-memo:精准控制重渲染
v-once 让元素及其子元素只渲染一次,后续数据变化不再更新该部分 DOM。它适用于纯静态内容:
<template>
<!-- 社交动态流顶部的平台口号,永远不会变 -->
<header v-once class="feed-banner">
<h1>发现精彩动态</h1>
<p>关注你感兴趣的人,查看他们分享的生活</p>
</header>
</template>
v-memo 则更加灵活——它接受一个依赖数组,只有数组中的值变化时才重新渲染。这在列表场景中威力巨大:
<template>
<div class="feed-list">
<!--
每条动态只在 item.id 或 item.likeCount 变化时才重渲染
其他属性(如 item.author.avatar)变化不会触发此条的 DOM 更新
-->
<div
v-for="item in feedList"
:key="item.id"
v-memo="[item.id, item.likeCount]"
class="feed-card"
>
<img :src="item.author.avatar" class="avatar" />
<div class="feed-body">
<span class="author-name">{{ item.author.nickname }}</span>
<p class="feed-text">{{ item.content }}</p>
<span class="like-count">{{ item.likeCount }} 赞</span>
</div>
</div>
</div>
</template>
注意:
v-memo是 Vue 3.2+ 的特性。在选择依赖项时要谨慎——如果遗漏了会变化的关键数据,可能导致界面不更新,造成显示 bug。
2.2 虚拟列表:渲染 5000 条数据的正确姿势
假设你的社交动态流有 5000 条数据,如果全部渲染为真实 DOM 节点,浏览器至少要创建 5000 个 <div> 及其子元素。这会导致两个严重问题:首次渲染极慢,滚动时因为大量 DOM 节点的重排/重绘而持续卡顿。
虚拟列表的原理像一扇固定大小的窗户:你站在窗前看风景,虽然外面的风景绵延千里,但你视野中看到的始终只有窗户框住的那一片。虚拟列表只渲染视口范围内可见的那几十条数据,当用户滚动时动态替换内容。
借助 vue-virtual-scroller(Vue 3 请使用 v2.x 版本)这样的社区库,实现非常简洁:
<!-- src/components/VirtualFeedList.vue -->
<template>
<RecycleScroller
class="feed-scroller"
:items="feedList"
:item-size="120"
key-field="id"
v-slot="{ item }"
>
<div class="feed-card">
<img :src="item.author.avatar" class="avatar" />
<div class="feed-body">
<span class="author-name">{{ item.author.nickname }}</span>
<p class="feed-text">{{ item.content }}</p>
<footer class="feed-meta">
<span>{{ item.likeCount }} 赞</span>
<span>{{ item.commentCount }} 评论</span>
</footer>
</div>
</div>
</RecycleScroller>
</template>
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
defineProps({
feedList: {
type: Array,
required: true
}
})
</script>
<style scoped>
.feed-scroller {
height: 100vh;
overflow-y: auto;
}
.feed-card {
display: flex;
align-items: flex-start;
padding: 16px;
border-bottom: 1px solid #eee;
height: 120px;
box-sizing: border-box;
}
</style>
优化效果:同样 5000 条数据,DOM 节点数从 5000+ 降低到几十个,首次渲染时间从数秒降至毫秒级,滚动帧率稳定在 60fps。
需要注意的是,虚拟列表有一个前提条件:你需要知道每个列表项的高度(或提供一个合理的估算值)。item-size="120" 意味着每条动态固定高度 120px。如果内容高度不固定,可以使用 DynamicScroller 组件,它会在首次渲染时测量每个元素的实际高度并缓存。不过,动态高度模式的性能略低于固定高度模式,因为它需要额外的测量和缓存开销。
2.3 组件懒加载与异步组件
并非所有组件在页面初始化时都需要加载。评论区抽屉、图片查看器、分享弹窗——这些「用户触发后才出现」的组件,完全可以延迟到需要时再加载:
<!-- src/views/FeedDetail.vue -->
<template>
<article class="feed-detail">
<FeedContent :data="feedData" />
<!-- 评论区在用户点击"查看评论"后才加载 -->
<button @click="showComments = true">
查看评论({{ feedData.commentCount }})
</button>
<!-- 注意:Suspense 目前仍是实验性功能,API 可能在未来版本中变化 -->
<Suspense v-if="showComments">
<template #default>
<CommentThread :feed-id="feedData.id" />
</template>
<template #fallback>
<div class="loading-skeleton">评论加载中...</div>
</template>
</Suspense>
</article>
</template>
<script setup>
import { ref, toRef, defineAsyncComponent } from 'vue'
import FeedContent from '@/components/FeedContent.vue'
// 异步组件:只在真正需要时才加载评论区的 JS 代码
const CommentThread = defineAsyncComponent(() =>
import('@/components/CommentThread.vue')
)
const showComments = ref(false)
const props = defineProps({
feedData: { type: Object, required: true }
})
// 使用 toRef 将 prop 转为独立的响应式引用,便于传递给 composable
const feedRef = toRef(props, 'feedData')
</script>
defineAsyncComponent 配合动态 import() 实现了代码层面的按需加载——评论区组件的代码不会出现在首屏 JS 包中,只在用户需要时才通过网络下载。结合 <Suspense> 组件,还能在加载过程中展示骨架屏,避免界面空白。
路由层面的懒加载更是标配:
// src/router/index.js
const routes = [
{
path: '/',
component: () => import('@/views/FeedHome.vue')
},
{
path: '/feed/:id',
component: () => import('@/views/FeedDetail.vue')
},
{
path: '/profile/:userId',
component: () => import('@/views/UserProfile.vue')
},
{
path: '/notifications',
component: () => import('@/views/NotificationCenter.vue')
}
]
每个路由页面被打包为独立的 chunk,用户访问哪个页面才下载哪个 chunk,首屏只需要加载入口路由的代码。
组合使用的最佳实践:路由级懒加载解决的是「页面之间」的按需加载,defineAsyncComponent 解决的是「页面内部」的按需加载。两者配合,可以将首屏 JS 体积压缩到极致。以社交动态流应用为例,用户打开首页时,只加载 FeedHome 页面和其中立即可见的组件代码;评论区、图片查看器、分享弹窗等交互触发的组件,全部延迟到用户操作时才加载。
三、响应式优化——不是所有数据都需要被追踪
Vue 3 的响应式系统基于 Proxy,性能相比 Vue 2 的 Object.defineProperty 已经有了质的飞跃——不再需要逐个属性设置 getter/setter,也不存在无法侦测新增属性的问题。但 Proxy 依然有成本:每一个被 reactive() 或 ref() 包装的对象,其所有属性(包括嵌套对象)都会被递归地设置拦截器。当数据量大到一定程度,这个初始化成本就不可忽视了。
想象一下,你从后端获取了一个包含 2 万条社交动态的数组,每条动态有 author(包含 id、nickname、avatar、bio 等字段)、images(包含多张图片的 url、width、height)、comments 等嵌套结构。如果用 reactive() 包装这个数组,Vue 会递归遍历每一层对象设置 Proxy,这个过程可能消耗上百毫秒甚至数秒。而实际上,你可能只需要追踪数组引用的变化(比如加载下一页时追加数据),并不需要追踪每条动态内部的每个字段。
3.1 shallowRef 与 shallowReactive:只追踪表层
shallowRef 和 shallowReactive 只对对象的第一层属性设置响应式拦截,内部嵌套的对象不会被递归代理。
<!-- src/components/ImageWaterfall.vue -->
<script setup>
import { shallowRef, triggerRef } from 'vue'
// 图片瀑布流数据可能有上千条,每条包含 url、width、height、exif 等字段
// 使用 shallowRef 避免对每条数据的深层属性设置响应式
const waterfallImages = shallowRef([])
async function loadImages(page) {
const res = await fetch(`/api/feed-images?page=${page}`)
const newImages = await res.json()
// 替换整个数组引用来触发更新(shallowRef 只追踪 .value 的引用变化)
waterfallImages.value = [...waterfallImages.value, ...newImages]
}
// 如果需要修改数组内某一项的属性后触发更新,使用 triggerRef 手动通知
function toggleImageLike(index) {
waterfallImages.value[index].liked = !waterfallImages.value[index].liked
triggerRef(waterfallImages) // 手动触发依赖更新
}
</script>
对比使用 ref():如果 waterfallImages 包含 2000 张图片数据,ref() 会递归地对每张图片的所有字段(url、width、height、exif 对象等)都建立 Proxy,初始化可能需要上百毫秒;而 shallowRef() 只对 .value 这一层做拦截,内部数据保持原始 JavaScript 对象,初始化几乎零成本。
3.2 Object.freeze 冻结大列表
如果某些数据在整个生命周期内都不会变化(比如从后端拉取的配置表、省市区字典),可以用 Object.freeze() 彻底冻结它,Vue 检测到冻结对象后会跳过响应式转换:
// src/composables/useFeedCategories.js
import { ref } from 'vue'
export function useFeedCategories() {
const categories = ref([])
async function fetchCategories() {
const res = await fetch('/api/feed-categories')
const data = await res.json()
// 分类列表在应用运行期间不会变化,冻结它
categories.value = Object.freeze(data)
}
return { categories, fetchCategories }
}
Object.freeze() 让 Vue 3 内部的 reactive() 直接跳过该对象的 Proxy 包装,等于告诉框架:“这些数据是只读的常量,不用费心追踪了。”
Vue 3 还提供了专门的 markRaw() API 来达到类似效果——它标记一个对象使其永远不会被转换为响应式代理,但不会像 Object.freeze() 那样禁止属性修改。适用于那些不需要响应式追踪但仍需要修改的对象(如第三方库实例、ECharts 实例等):
import { markRaw } from 'vue'
import * as echarts from 'echarts'
// ECharts 实例不需要被 Vue 追踪,使用 markRaw 避免不必要的 Proxy 包装
const chartInstance = markRaw(echarts.init(chartDom))
3.3 避免不必要的响应式包装
一个常见的反模式是把「工具性数据」也塞进响应式系统。并非所有变量都需要 ref() 或 reactive()——只有驱动视图更新的数据才需要响应式:
<script setup>
import { ref } from 'vue'
// 正确:需要驱动视图的数据使用 ref
const feedList = ref([])
const isLoading = ref(false)
// 正确:不驱动视图的变量使用普通 let/const
let currentPage = 1 // 页码只在请求参数中用到,不显示在界面上
let abortController = null // 请求取消控制器,纯工具变量
const PAGE_SIZE = 20 // 常量
async function loadNextPage() {
if (isLoading.value) return
isLoading.value = true
abortController = new AbortController()
const res = await fetch(
`/api/feeds?page=${currentPage}&size=${PAGE_SIZE}`,
{ signal: abortController.signal }
)
const data = await res.json()
feedList.value.push(...data.items)
currentPage++
isLoading.value = false
}
</script>
把 currentPage 和 abortController 从响应式中「释放」出来,减少了 Proxy 拦截的触发次数,也让代码的意图更加清晰:看到 ref() 就知道这个数据会影响界面,看到普通变量就知道它只是逻辑辅助。
四、打包优化——让用户少下载一个字节
应用的加载速度与打包产物体积直接挂钩。打包优化的目标是:只让用户下载他当前需要的代码,一个多余的字节也不要。
4.1 Tree-shaking:摇掉死代码
Tree-shaking(直译为「摇树」)的原理很直观:把一棵树使劲摇,枯死的叶子自然掉落,留下的都是鲜活的绿叶。在打包时,「枯叶」就是那些被引入但从未实际使用的代码。
Vue 3 的 API 设计天然支持 Tree-shaking。Vue 2 时代,所有功能挂载在一个全局实例上,打包工具无法判断哪些功能被用到了:
// Vue 2 —— 无法 Tree-shaking
import Vue from 'vue'
Vue.nextTick(() => {})
// 即使只用了 nextTick,整个 Vue 运行时都会被打包
Vue 3 改为了模块化导出,打包工具可以精确分析依赖图:
// Vue 3 —— 天然支持 Tree-shaking
import { nextTick, ref } from 'vue'
nextTick(() => {})
// 只有 nextTick 和 ref 的代码会出现在最终产物中
// 其他如 onMounted、watch、provide/inject 等未引用的 API 会被移除
确保 Tree-shaking 生效的实践要点:
- 使用 ES Module 语法:
import/export是静态声明,打包工具在编译阶段就能确定依赖关系。CommonJS 的require()是运行时执行的,打包工具无法在编译阶段判断哪些模块会被用到,只能全量打包。 - 检查第三方库的 sideEffects 声明:在
package.json中确认库标注了"sideEffects": false(或准确标注了副作用文件列表)。所谓「副作用」是指模块被导入时会执行一些全局操作(如注册 polyfill、修改 window 对象),打包工具不敢移除有副作用的模块。 - 避免「桶文件」中无脑地
export *:桶文件(barrel file)是指index.js中统一导出目录下所有模块的模式。如果写成export * from './moduleA',打包工具可能无法判断moduleA的哪些导出被实际使用,导致 Tree-shaking 失效。更安全的做法是显式列出导出项。
4.2 代码分割与动态 import
代码分割是指将一个大的 JS 包拆分成多个小 chunk,按需加载。Vite 和 Webpack 都内置了基于 import() 的自动代码分割能力。
在路由懒加载的基础上,你还可以对重型第三方库做按需加载:
<!-- src/views/FeedHome.vue -->
<script setup>
import { ref, onMounted } from 'vue'
const trendChartRef = ref(null)
onMounted(async () => {
// ECharts 体积约 800KB,只在需要图表的页面动态加载
const echarts = await import('echarts/core')
const { BarChart } = await import('echarts/charts')
const { GridComponent, TooltipComponent } = await import('echarts/components')
const { CanvasRenderer } = await import('echarts/renderers')
echarts.use([BarChart, GridComponent, TooltipComponent, CanvasRenderer])
const chart = echarts.init(trendChartRef.value)
chart.setOption({
xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'] },
yAxis: { type: 'value' },
series: [{ type: 'bar', data: [820, 932, 601, 1034, 790], name: '动态发布量' }],
tooltip: { trigger: 'axis' }
})
})
</script>
<template>
<div ref="trendChartRef" style="width: 100%; height: 400px;"></div>
</template>
这样做的效果是:不含图表的页面(如个人资料页)完全不会加载 ECharts 的代码,节省了约 800KB 的下载量。
此外,注意这里使用了 ECharts 的按需引入方式(echarts/core + 单独引入图表类型和组件),而非 import * as echarts from 'echarts'。前者只打包你实际使用的图表类型(如柱状图)和功能组件(如 Tooltip),后者则会把所有图表类型(饼图、雷达图、地图等)全部打包。两者的体积差距可以达到 3-5 倍。
4.3 依赖分析:看见你的包里装了什么
优化的前提是看见问题。rollup-plugin-visualizer(Vite 项目)或 webpack-bundle-analyzer(Webpack 项目)可以生成依赖体积的可视化图表:
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
vue(),
visualizer({
open: true, // 构建后自动打开分析页面
gzipSize: true, // 显示 gzip 压缩后的体积
filename: 'dist/bundle-analysis.html'
})
]
})
执行 npm run build 后,浏览器会自动打开一个交互式图表,每个模块用矩形面积表示体积大小。你常常会在这里发现惊喜——比如一个只用了 debounce 函数的项目,竟然打包了整个 lodash(约 70KB gzip);或者 moment.js 把所有语言包都带进来了(约 230KB gzip)。找到这些「体积刺客」后,替换为更轻量的方案(如 lodash-es 按需导入、用 dayjs 替代 moment),往往能立竿见影地缩减产物体积。
4.4 Gzip / Brotli 压缩
在打包工具层面完成代码精简后,还可以对产物进行传输层压缩。现代浏览器都支持 Gzip 和 Brotli 解压,服务端只需在响应头中声明 Content-Encoding: gzip(或 br),浏览器就会自动解压。
你可以在构建阶段预生成压缩文件,避免服务端实时压缩的 CPU 开销:
// vite.config.js
import viteCompression from 'vite-plugin-compression'
export default defineConfig({
plugins: [
vue(),
viteCompression({
algorithm: 'gzip',
threshold: 10240, // 只压缩 10KB 以上的文件
ext: '.gz'
}),
viteCompression({
algorithm: 'brotliCompress',
threshold: 10240,
ext: '.br'
})
]
})
配合 Nginx 的 gzip_static on; 配置,服务器会优先返回预压缩的 .gz 文件,省去了实时压缩的开销。Gzip 通常可以将文本类资源压缩到原始体积的 30% 左右,Brotli 更进一步,能达到 20%-25%。
五、运行时优化——让每一帧都不浪费
打包优化解决的是「加载快」,运行时优化解决的是「用起来快」。以下技巧聚焦于应用运行过程中的 CPU 和内存效率。
5.1 computed 的缓存机制——白嫖的性能红利
computed 最被低估的特性是它的惰性缓存:只有当依赖的响应式数据实际变化时,计算函数才会重新执行;在依赖未变的情况下,无论访问多少次,返回的都是缓存值。
<!-- src/components/FeedStats.vue -->
<script setup>
import { computed } from 'vue'
const props = defineProps({
feedList: { type: Array, required: true }
})
// 假设 feedList 有 5000 条数据
// filteredHotFeeds 只在 feedList 引用变化时重新计算
// 模板中多处引用 filteredHotFeeds 不会触发重复计算
const filteredHotFeeds = computed(() => {
return props.feedList
.filter(item => item.likeCount > 100)
.sort((a, b) => b.likeCount - a.likeCount)
.slice(0, 50)
})
// 反面教材:在模板中直接写方法调用
// 每次组件重渲染都会重新执行过滤和排序,即使 feedList 没变
// <div v-for="item in getHotFeeds()">...</div> ← 不要这样做
</script>
<template>
<section class="hot-feeds">
<h3>热门动态({{ filteredHotFeeds.length }})</h3>
<ul>
<li v-for="feed in filteredHotFeeds" :key="feed.id">
{{ feed.content }} - {{ feed.likeCount }} 赞
</li>
</ul>
</section>
</template>
准则:凡是涉及过滤、排序、格式化等派生逻辑,优先使用 computed 而非方法调用。
为什么差距这么大?原因在于 Vue 的渲染机制:组件每次重渲染时,模板中所有的表达式和方法调用都会重新执行。如果模板中写了 getHotFeeds(),那么即使 feedList 没有变化,只要组件因为任何其他原因(比如父组件传入的 prop 变化)重渲染,这个方法就会重新执行一次完整的 filter + sort + slice 操作。而 computed 有内置的脏检查机制——它知道自己依赖的是 props.feedList,只有这个引用变了才会重新计算,否则直接返回上一次的结果。
这条规则看似基础,但在实际代码审查中,方法调用替代 computed 的情况仍然极为常见,尤其是在从 Options API 迁移到 Composition API 的过程中,开发者容易将 methods 中的逻辑原样搬到 <script setup> 中定义为普通函数,忘记了 computed 这个利器。
5.2 watchEffect vs watch:选择正确的监听方式
watchEffect 自动收集依赖,适合「副作用随依赖变化而执行」的场景;watch 需要显式指定监听源,适合「需要获取新旧值对比」或「需要精确控制监听范围」的场景。
选择错误的监听方式会导致不必要的副作用执行:
<script setup>
import { ref, watch, watchEffect } from 'vue'
const selectedCategory = ref('all')
const searchKeyword = ref('')
const feedList = ref([])
// 场景 1:根据筛选条件重新请求数据
// 使用 watch —— 精确监听特定源,避免无关变量变化触发请求
watch(
[selectedCategory, searchKeyword],
async ([category, keyword]) => {
const params = new URLSearchParams({ category, keyword })
const res = await fetch(`/api/feeds?${params}`)
feedList.value = await res.json()
},
{ immediate: true } // 组件挂载时立即执行一次
)
// 场景 2:根据动态列表数据更新页面标题
// 使用 watchEffect —— 自动追踪依赖,代码更简洁
watchEffect(() => {
document.title = feedList.value.length > 0
? `动态流(${feedList.value.length} 条)`
: '社交动态'
})
</script>
易踩的坑:在 watchEffect 中执行异步请求时,如果回调内部访问了多个响应式变量,任何一个变化都会重新触发请求。这可能导致请求风暴。遇到这种情况,改用 watch 并显式列出监听源,是更安全的选择。
5.3 事件监听清理:别让幽灵监听器吃内存
在组件中手动添加的事件监听器(window.addEventListener、IntersectionObserver、WebSocket 连接等),如果在组件卸载时不清理,会造成内存泄漏——组件已经销毁了,但监听器仍然持有对组件数据的引用,垃圾回收器无法释放这块内存。
<!-- src/components/NotificationBadge.vue -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const unreadCount = ref(0)
let eventSource = null // SSE 连接,不需要响应式
function handleNotification(event) {
const data = JSON.parse(event.data)
unreadCount.value = data.unreadCount
}
onMounted(() => {
// 建立 Server-Sent Events 连接,实时接收通知推送
eventSource = new EventSource('/api/notifications/stream')
eventSource.addEventListener('notification', handleNotification)
})
onUnmounted(() => {
// 组件卸载时必须关闭连接、移除监听
if (eventSource) {
eventSource.removeEventListener('notification', handleNotification)
eventSource.close()
eventSource = null
}
})
</script>
<template>
<div class="notification-badge">
<span class="bell-icon">🔔</span>
<span v-if="unreadCount > 0" class="badge">{{ unreadCount }}</span>
</div>
</template>
如果你使用组合式函数封装此类逻辑,推荐在函数内部就完成清理注册,让调用方无需操心:
// src/composables/useNotificationStream.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useNotificationStream() {
const unreadCount = ref(0)
let eventSource = null
function handleMessage(event) {
const data = JSON.parse(event.data)
unreadCount.value = data.unreadCount
}
onMounted(() => {
eventSource = new EventSource('/api/notifications/stream')
eventSource.addEventListener('notification', handleMessage)
})
onUnmounted(() => {
if (eventSource) {
eventSource.removeEventListener('notification', handleMessage)
eventSource.close()
eventSource = null
}
})
return { unreadCount }
}
组件中只需一行调用:
<script setup>
import { useNotificationStream } from '@/composables/useNotificationStream'
const { unreadCount } = useNotificationStream()
</script>
清理逻辑被封装在组合式函数内部,使用者不可能忘记清理——这就是组合式 API 在工程层面的优雅之处。
内存泄漏是一种隐蔽的性能问题:它不会让页面立即崩溃,而是在用户长时间使用应用的过程中逐渐吞噬内存。比如在单页应用中,用户多次切换页面,如果每次进入通知页面都创建一个 SSE 连接而不清理,几十次切换后可能就会有几十个存活的连接和监听器占据内存。在 DevTools 的 Memory 面板中做一次堆快照对比(Heap Snapshot Comparison),可以帮你找到这些「僵尸对象」。
六、网络优化——让资源更早到达
前面几节优化的是「代码本身的效率」,但对用户来说,一个请求从发出到返回之间的等待时间,往往比代码执行时间长得多。网络延迟是前端性能中最不可控但影响最大的环节。以下策略聚焦于如何让关键资源更早到达浏览器,以及如何利用缓存减少重复下载。
6.1 资源预加载与预取
浏览器提供了 <link rel="preload"> 和 <link rel="prefetch"> 两种资源提示:
- preload:告诉浏览器「这个资源当前页面马上就要用,请立即开始下载」,用于关键资源(如首屏字体、关键 CSS)
- prefetch:告诉浏览器「这个资源下一步可能会用到,在空闲时提前下载」,用于预判用户的下一步操作
在 Vue Router 中,可以结合路由守卫实现智能预取:
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('@/views/FeedHome.vue')
},
{
path: '/feed/:id',
component: () => import('@/views/FeedDetail.vue')
}
]
})
// 当用户停留在首页时,空闲预取详情页的代码
router.afterEach((to) => {
if (to.path === '/') {
// requestIdleCallback 在浏览器空闲时执行,不影响当前页面性能
requestIdleCallback(() => {
import('@/views/FeedDetail.vue') // 预取详情页 chunk
})
}
})
export default router
Vite 在生产构建中也会自动为动态 import() 生成 <link rel="modulepreload">,但你可以通过手动预取来覆盖更复杂的业务场景。
6.2 图片懒加载
在图片瀑布流场景中,用户首屏只能看到前几行图片,但如果一次性加载全部图片,会造成大量无效的网络请求和内存占用。图片懒加载的原理是:图片进入视口前不发起请求,进入视口后才开始加载。
基于 IntersectionObserver API 可以轻松实现:
<!-- src/components/LazyImage.vue -->
<template>
<img
ref="imgRef"
:src="loaded ? src : placeholder"
:alt="alt"
class="lazy-image"
/>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const props = defineProps({
src: { type: String, required: true },
alt: { type: String, default: '' },
placeholder: {
type: String,
default: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"%3E%3C/svg%3E'
}
})
const imgRef = ref(null)
const loaded = ref(false)
let observer = null
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loaded.value = true
observer.disconnect() // 加载后不再观察
}
},
{ rootMargin: '200px' } // 提前 200px 开始加载,让用户感受不到延迟
)
observer.observe(imgRef.value)
})
onUnmounted(() => {
observer?.disconnect()
})
</script>
在图片瀑布流中使用:
<!-- src/components/ImageWaterfall.vue -->
<template>
<div class="waterfall-grid">
<div
v-for="img in waterfallImages"
:key="img.id"
class="waterfall-item"
>
<LazyImage :src="img.url" :alt="img.description" />
<p class="image-caption">{{ img.description }}</p>
</div>
</div>
</template>
rootMargin: '200px' 是一个实用技巧:让图片在距离视口 200px 时就开始加载,用户滚动到该位置时图片已经加载完成,实现无感知的懒加载体验。
另外值得一提的是,现代浏览器原生支持了 <img loading="lazy"> 属性,可以在不写任何 JS 的情况下实现图片懒加载。但它有两个局限:一是无法自定义加载时机(没有 rootMargin 这样的控制能力),二是需要图片的 width 和 height 属性已知,否则可能引起布局抖动(CLS 升高)。对于精细控制的瀑布流场景,基于 IntersectionObserver 的自定义方案仍然是更优选择。
6.3 Service Worker 缓存策略
Service Worker 是运行在浏览器后台的独立线程,它可以拦截网络请求并根据策略返回缓存资源。对于社交动态流这类读多写少的场景,合理的缓存策略能极大提升再次访问的速度。
借助 vite-plugin-pwa 可以快速集成 Service Worker:
// vite.config.js
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
// 预缓存所有构建产物(HTML、CSS、JS)
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
// API 数据:使用 NetworkFirst 策略
// 优先从网络获取最新数据,网络不可用时回退到缓存
urlPattern: /^https:\/\/api\..*\/feeds/,
handler: 'NetworkFirst',
options: {
cacheName: 'feed-api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 // 缓存 1 小时
}
}
},
{
// 用户头像图片:使用 CacheFirst 策略
// 头像很少变化,优先使用缓存,减少网络请求
urlPattern: /^https:\/\/cdn\..*\/avatars/,
handler: 'CacheFirst',
options: {
cacheName: 'avatar-image-cache',
expiration: {
maxEntries: 500,
maxAgeSeconds: 60 * 60 * 24 * 30 // 缓存 30 天
}
}
},
{
// 瀑布流图片:使用 StaleWhileRevalidate 策略
// 先返回缓存(保证速度),同时后台更新缓存(保证新鲜度)
urlPattern: /^https:\/\/cdn\..*\/feed-images/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'feed-image-cache',
expiration: {
maxEntries: 200,
maxAgeSeconds: 60 * 60 * 24 * 7 // 缓存 7 天
}
}
}
]
}
})
]
})
三种核心缓存策略的选择思路:
| 策略 | 适用场景 | 特点 |
|---|---|---|
| NetworkFirst | API 接口数据 | 优先网络,离线时回退缓存,保证数据新鲜度 |
| CacheFirst | 不常变化的资源(头像、字体、图标) | 优先缓存,缓存不存在时才请求网络,加载极快 |
| StaleWhileRevalidate | 变化频率中等的资源(动态图片) | 先返回缓存再后台更新,兼顾速度和新鲜度 |
🤔 思考题
-
v-memo 的边界:如果在
v-memo的依赖数组中漏掉了一个会变化的关键字段(比如评论数commentCount),会产生什么后果?你在实际开发中会如何避免这种问题? -
shallowRef 的取舍:使用
shallowRef替代ref可以提升初始化性能,但你需要手动调用triggerRef()来通知视图更新。你觉得在什么场景下这种取舍是值得的?什么场景下反而会增加复杂度? -
Service Worker 的风险:Service Worker 缓存策略配置不当可能导致用户始终看到旧数据或旧版本的应用。你会设计什么机制来确保用户能及时获取到应用的重要更新?
-
虚拟列表的局限性:虚拟列表要求每个列表项的高度是固定的(或可预估的)。如果社交动态的每条内容长度不同、图片尺寸各异,导致每条动态的高度完全不可预测,你会如何处理?
-
Tree-shaking 的失效场景:你引入了一个第三方 UI 组件库,发现即使只使用了其中两三个组件,打包体积却几乎没有减少。可能的原因是什么?你会如何排查和解决?
📝 结尾自测
完成本章学习后,检验你的掌握程度:
-
Core Web Vitals 的三个核心指标分别是什么?各自衡量用户体验的哪个维度?对于一个社交动态流页面,你能分别举出影响这三个指标的具体因素吗?
-
在一个渲染了 3000 条评论的组件中,你会选择
v-memo、虚拟列表还是两者结合?请说明你的判断依据和各方案的优劣。 -
请解释
shallowRef与ref在响应式追踪深度上的区别。写出一段使用shallowRef管理大列表数据的代码,并说明何时需要调用triggerRef()。 -
一个项目的打包产物有 2MB,你需要将其缩减到 500KB 以下。请列出你的优化步骤(至少包含依赖分析、代码分割、Tree-shaking、传输压缩四个环节),并说明每个步骤预期的体积缩减效果。
-
watchEffect和watch在性能层面有什么区别?在什么场景下使用watchEffect可能导致不必要的副作用执行?请举出具体代码示例说明。
性能优化不是一次性的冲刺,而是贯穿整个项目生命周期的持续过程。最好的性能优化习惯不是在项目末期「集中治理」,而是在每次提交代码时就带着性能意识:这个
ref()有没有必要?这个组件需要懒加载吗?这个依赖有更轻量的替代品吗?当这种思维成为本能,你写出的代码天然就是高性能的。
从下一章开始,我们进入精通篇。你将深入 Vue 3 的引擎室,从响应式系统和虚拟 DOM 的底层原理出发,理解那些 API 背后到底发生了什么。
购买课程解锁全部内容
渐进式到全面掌控:12 章系统精通 Vue 3
¥29.90