跳到主要内容
预计阅读 64 分钟

性能优化

一台发动机造好之后,工程师要做的不是加更多零件,而是减少每一处不必要的摩擦。Vue 应用也是如此——功能完成只是起点,让它在真实用户的设备上流畅运行,才是交付的终点。前一章你完成了管理后台的实战开发,现在我们进入更深的领域:让你的 Vue 应用跑得更快、加载得更少、响应得更及时。


📋 开篇自测

在进入正文之前,检验一下你的性能直觉:

  1. 一个社交动态流页面渲染了 5000 条数据,用户滚动时明显卡顿。你的第一反应是换一台更快的服务器,还是先看前端渲染做了什么?如果问题在前端,你会如何排查?
  2. 你在一个组件中用 reactive() 包装了一个包含 2 万条记录的数组,发现页面初始化耗时超过 3 秒。你觉得问题出在哪里?有没有更轻量的替代方案?
  3. 打包产物体积达到了 1.8 MB,用户首次打开需要 6 秒以上的白屏等待。你会从哪些维度入手缩减体积?

如果这三个问题你能清晰作答,可以选读感兴趣的小节;否则,请跟随本章逐一攻破。


一、性能度量——先诊断,再开药

性能优化最大的忌讳是「凭感觉优化」。你可能觉得「首页加载好像有点慢」,然后花两天时间优化了一个不是瓶颈的组件,结果用户体验毫无改善。就像医生不会在没做检查的情况下开处方,我们也需要先建立一套客观的度量体系,用数据定位问题,用数据验证效果

本节介绍三种互补的度量工具:Core Web Vitals 从用户视角量化体验,Vue DevTools 从组件视角定位渲染瓶颈,Lighthouse 从工程视角给出综合诊断。

1.1 Core Web Vitals:用户体验的三把标尺

Google 提出的 Core Web Vitals 已经成为业界衡量网页性能的通用标准。它从用户真实感知出发,定义了三个核心指标:

指标全称含义合格阈值
LCPLargest Contentful Paint最大内容元素绘制完成的时间,衡量「页面什么时候看起来加载好了」≤ 2.5 秒
INPInteraction to Next Paint页面在整个生命周期内所有交互中最慢的 P98 响应延迟(从交互到下一帧绘制),衡量「页面整体交互流畅度」≤ 200 毫秒
CLSCumulative 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 选项卡,可以按组件粒度查看每次渲染的耗时。

操作步骤:

  1. 打开 DevTools → Vue 面板 → 点击 Timeline 标签
  2. 点击 “Start recording”,然后在页面上执行操作(如滚动动态列表、切换评论区)
  3. 停止录制后,面板会列出每个组件在本轮更新中的渲染时间

这个面板的核心价值在于:它帮你精确定位是哪个组件拖慢了整体渲染。 你可能会发现一个「通知徽章」组件每帧都在重渲染,而它的数据其实根本没变——这就是接下来要解决的问题。

举一个真实场景:在社交动态流页面中,用户每次滚动都会触发 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:只追踪表层

shallowRefshallowReactive 只对对象的第一层属性设置响应式拦截,内部嵌套的对象不会被递归代理。

<!-- 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>

currentPageabortController 从响应式中「释放」出来,减少了 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 生效的实践要点

  1. 使用 ES Module 语法import/export 是静态声明,打包工具在编译阶段就能确定依赖关系。CommonJS 的 require() 是运行时执行的,打包工具无法在编译阶段判断哪些模块会被用到,只能全量打包。
  2. 检查第三方库的 sideEffects 声明:在 package.json 中确认库标注了 "sideEffects": false(或准确标注了副作用文件列表)。所谓「副作用」是指模块被导入时会执行一些全局操作(如注册 polyfill、修改 window 对象),打包工具不敢移除有副作用的模块。
  3. 避免「桶文件」中无脑地 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 这样的控制能力),二是需要图片的 widthheight 属性已知,否则可能引起布局抖动(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 天
              }
            }
          }
        ]
      }
    })
  ]
})

三种核心缓存策略的选择思路:

策略适用场景特点
NetworkFirstAPI 接口数据优先网络,离线时回退缓存,保证数据新鲜度
CacheFirst不常变化的资源(头像、字体、图标)优先缓存,缓存不存在时才请求网络,加载极快
StaleWhileRevalidate变化频率中等的资源(动态图片)先返回缓存再后台更新,兼顾速度和新鲜度

🤔 思考题

  1. v-memo 的边界:如果在 v-memo 的依赖数组中漏掉了一个会变化的关键字段(比如评论数 commentCount),会产生什么后果?你在实际开发中会如何避免这种问题?

  2. shallowRef 的取舍:使用 shallowRef 替代 ref 可以提升初始化性能,但你需要手动调用 triggerRef() 来通知视图更新。你觉得在什么场景下这种取舍是值得的?什么场景下反而会增加复杂度?

  3. Service Worker 的风险:Service Worker 缓存策略配置不当可能导致用户始终看到旧数据或旧版本的应用。你会设计什么机制来确保用户能及时获取到应用的重要更新?

  4. 虚拟列表的局限性:虚拟列表要求每个列表项的高度是固定的(或可预估的)。如果社交动态的每条内容长度不同、图片尺寸各异,导致每条动态的高度完全不可预测,你会如何处理?

  5. Tree-shaking 的失效场景:你引入了一个第三方 UI 组件库,发现即使只使用了其中两三个组件,打包体积却几乎没有减少。可能的原因是什么?你会如何排查和解决?


📝 结尾自测

完成本章学习后,检验你的掌握程度:

  1. Core Web Vitals 的三个核心指标分别是什么?各自衡量用户体验的哪个维度?对于一个社交动态流页面,你能分别举出影响这三个指标的具体因素吗?

  2. 在一个渲染了 3000 条评论的组件中,你会选择 v-memo、虚拟列表还是两者结合?请说明你的判断依据和各方案的优劣。

  3. 请解释 shallowRefref 在响应式追踪深度上的区别。写出一段使用 shallowRef 管理大列表数据的代码,并说明何时需要调用 triggerRef()

  4. 一个项目的打包产物有 2MB,你需要将其缩减到 500KB 以下。请列出你的优化步骤(至少包含依赖分析、代码分割、Tree-shaking、传输压缩四个环节),并说明每个步骤预期的体积缩减效果。

  5. watchEffectwatch 在性能层面有什么区别?在什么场景下使用 watchEffect 可能导致不必要的副作用执行?请举出具体代码示例说明。


性能优化不是一次性的冲刺,而是贯穿整个项目生命周期的持续过程。最好的性能优化习惯不是在项目末期「集中治理」,而是在每次提交代码时就带着性能意识:这个 ref() 有没有必要?这个组件需要懒加载吗?这个依赖有更轻量的替代品吗?当这种思维成为本能,你写出的代码天然就是高性能的。

从下一章开始,我们进入精通篇。你将深入 Vue 3 的引擎室,从响应式系统和虚拟 DOM 的底层原理出发,理解那些 API 背后到底发生了什么。

购买课程解锁全部内容

渐进式到全面掌控:12 章系统精通 Vue 3

¥29.90