前端性能优化

前端性能优化

学到这里手上已经能做出完整项目了, 这篇解决”做得快不快”。先立一条总原则, 和后端调优完全一致: 测量优先, 没有 profile 就没有优化。
前端的 profile 工具就是 Lighthouse 和 Performance 面板 (调试篇提过), 凭感觉加缓存加懒加载, 和凭感觉加索引一样属于玄学。

性能指标: 先知道”快”怎么定义

Google 定义的 Core Web Vitals 是行业通用标尺, 三个指标先用人话过一遍:

  • LCP (最大内容绘制): 页面最大那块内容 (通常是首屏大图或标题) 画出来花了多久, 衡量”用户觉得页面出来了没”。目标 2.5 秒内。
  • CLS (累积布局偏移): 页面加载过程中内容跳来跳去的程度 (图片没占位、广告突然插入都会推高它), 目标 0.1 以内。正要点按钮结果按钮被挤走点了广告, 就是 CLS 灾难现场。
  • 交互延迟: 用户点了之后多久有反应。老指标叫 FID, 现在被更严格的 INP 取代了, 目标 200ms 内。

Chrome DevTools 的 Lighthouse 面板一键出分, 优化前后各跑一次, 数字说话。

加载优化: 让首屏来得更早

路由懒加载

默认情况下打包工具把所有页面打进一个 JS 文件, 用户打开首页却要下载全站代码。懒加载在 03 路由篇已经是标准写法了:

代码块JAVASCRIPT · 4 行收起展开
const routes = [
  // component 写成动态 import, 每个页面单独成包, 访问到才下载
  { path: '/about', component: () => import('./views/AboutView.vue') }
]

页面内的重组件 (图表、编辑器) 同理用 defineAsyncComponent (02 篇), 用户点开才加载。

图片: 最容易白捡的优化

图片经常占页面体积大头, 三板斧:

代码块HTML · 7 行收起展开
<img
  src="photo-small.webp"
  srcset="photo-small.webp 300w, photo-medium.webp 600w, photo-large.webp 1200w"
  sizes="(max-width: 600px) 300px, 600px"
  alt="照片"
  loading="lazy"
>

WebP 格式比 JPG 小三成起步; srcset 让手机下小图桌面下大图; loading="lazy" 是浏览器原生的图片懒加载, 滚到附近才下载, 一个属性白捡。

字体

自定义字体加载慢会让文字迟迟不显示:

代码块CSS · 5 行收起展开
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: swap;   /* 先拿系统字体顶上, 下完再换, 文字不空窗 */
}

缓存策略

Vite 构建产物的文件名自带 hash (index.abc123.js), 内容一变 hash 就变。这给了服务器放心开长缓存的底气:

代码块NGINX · 11 行收起展开
# 带 hash 的静态资源: 缓存一年, 反正内容变了文件名就变
location ~* \.(js|css|png|webp|woff2)$ {
  expires 1y;
  add_header Cache-Control "public, immutable";
}

# index.html: 永远不缓存, 它是指向最新 hash 文件的那张"目录"
location /index.html {
  expires -1;
  add_header Cache-Control "no-cache";
}

这套”内容寻址 + 入口不缓存”的组合拳是前端部署的标准答案, 原理想通了配置就不会写反。

渲染优化: 让页面跑得更顺

虚拟滚动

一万条数据直接 v-for, 一万个 DOM 节点, 滚动必卡。虚拟滚动的思路: 屏幕一次只能显示十几条, 那就只渲染可视区这十几条, 滚动时动态换内容, 用 padding/transform 撑出总高度骗过滚动条。手写一个乞丐版理解原理:

代码块VUE · 33 行收起展开
<script setup>
import { computed, ref } from 'vue'

const items = ref(Array.from({ length: 10000 }, (_, i) => `Item ${i}`))
const itemHeight = 50
const containerHeight = 600
const scrollTop = ref(0)

const visibleCount = Math.ceil(containerHeight / itemHeight)

// 核心: 由滚动位置算出该显示哪一段
const visibleItems = computed(() => {
  const start = Math.floor(scrollTop.value / itemHeight)
  return items.value.slice(start, start + visibleCount)
    .map((item, i) => ({ item, index: start + i }))
})

const totalHeight = computed(() => items.value.length * itemHeight)
const offsetY = computed(() => Math.floor(scrollTop.value / itemHeight) * itemHeight)
</script>

<template>
  <div :style="{ height: containerHeight + 'px', overflowY: 'auto' }"
       @scroll="scrollTop = $event.target.scrollTop">
    <div :style="{ height: totalHeight + 'px', position: 'relative' }">  <!-- 撑高度骗滚动条 -->
      <div :style="{ transform: `translateY(${offsetY}px)` }">           <!-- 把可见段推到正确位置 -->
        <div v-for="{ item, index } in visibleItems" :key="index" :style="{ height: itemHeight + 'px' }">
          {{ item }}
        </div>
      </div>
    </div>
  </div>
</template>

真实项目用现成的 vue-virtual-scroller, 它处理了不定行高等一堆脏活。

防抖与节流

高频事件 (输入、滚动、resize) 不做限流就是请求风暴。概念在 JS 篇的事件基础上加一层: 防抖是”停止触发 N 毫秒后才执行一次” (搜索框), 节流是”N 毫秒内最多执行一次” (滚动加载)。
日常直接用 lodash-es 的 debounce/throttle (工具库篇), 手写版本留作理解:

代码块JAVASCRIPT · 18 行收起展开
export function debounce(fn, delay = 300) {
  let timer = null
  return (...args) => {
    if (timer) clearTimeout(timer)          // 又触发了? 重新计时
    timer = setTimeout(() => fn(...args), delay)
  }
}

export function throttle(fn, delay = 300) {
  let lastTime = 0
  return (...args) => {
    const now = Date.now()
    if (now - lastTime >= delay) {          // 冷却时间没到就无视
      fn(...args)
      lastTime = now
    }
  }
}

避免无谓的重渲染

三个梯度, 按需要的克制程度排序:

代码块VUE · 13 行收起展开
<script setup>
// 派生值用 computed 缓存, 别写成每次渲染都跑的普通表达式 (01 篇讲过缓存机制)
const expensiveItems = computed(() => items.value.filter(item => item.price > 100))
</script>

<template>
  <div v-once>{{ neverChanges }}</div>   <!-- 真正永不变的内容, 渲染一次后跳过 -->

  <!-- v-memo: 数组里的依赖没变就跳过这行的重渲染, 只用在测量证实的热点上 -->
  <div v-for="item in list" :key="item.id" v-memo="[item.id, item.name]">
    {{ item.name }}
  </div>
</template>

v-memo 别预防性地撒满全场, 没有测量支撑的优化只会增加读代码的人的心智负担 (包括三个月后的自己)。

打包优化

先看清再动手, 装个可视化分析:

代码块JAVASCRIPT · 6 行收起展开
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer'

export default {
  plugins: [visualizer({ open: true, gzipSize: true })]   // 构建完自动打开体积地图
}

构建后的 stats.html 一眼看清谁是胖子, 惯犯就那几个: 全量引入的组件库 (改按需, 工具库篇配过)、moment 这类重日期库 (换 Day.js)、编辑器和图表 (改懒加载)。

Tree-shaking (构建时把没用到的导出摇掉) 想生效, 前提是全程 ES Module 的 import/export 写法, 这也是装库要挑 lodash-es 而不是 lodash 的原因。

网络优化

1. HTTP/2 与 HTTP/3

现代服务器和浏览器默认就有, 重点是知道它带来了什么: 多路复用让一个连接并行跑多个请求, 老 HTTP/1.1 时代”雪碧图、资源合并”那批减少请求数的奇技淫巧基本退役了。协议细节在计网笔记里 (见文末延伸阅读)。

2. CDN

把静态资源分发到离用户最近的节点。对我们这种自部署小项目, Cloudflare Pages 这类托管平台自带 CDN, 基本不用操心; 传统部署可以把 vue 这类大依赖 external 出去走公共 CDN, 但引入了外部依赖的可用性风险, 小项目别折腾。

3. 资源预加载

代码块HTML · 3 行收起展开
<link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>  <!-- 关键资源提前抢跑 -->
<link rel="preconnect" href="https://api.example.com">               <!-- 提前握手, 首个 API 请求省一轮 -->
<link rel="prefetch" href="/next-page.js">                           <!-- 闲时预取下一页, 低优先级 -->

Vue 特定的几条

  • v-show vs v-if: 频繁切换用前者 (只切 display), 很少变的用后者 (不渲染就不占资源)。01 篇讲过, 属于性能敏感场景要想起来的知识。
  • key 用稳定业务 ID: 错误的 key 让 diff 大量误判, 既是 bug 源也是性能洞。
  • 事件委托: 一千行的列表别绑一千个 click, 绑在父容器上按 event.target 分发 (JS 篇的事件冒泡在这里变现)。

性能监控

上线后想知道真实用户的体验, 用浏览器自带的 Performance API 打点:

代码块JAVASCRIPT · 5 行收起展开
performance.mark('search-start')
await searchData()
performance.mark('search-end')
performance.measure('search', 'search-start', 'search-end')
console.log(performance.getEntriesByName('search')[0].duration, 'ms')

配合上报就是最朴素的性能监控系统, 商业方案 (Sentry 等) 底层也是这套 API。

优化清单

开发阶段: 路由懒加载、组件按需导入、图片 WebP + lazy、大列表虚拟滚动、高频事件防抖节流、派生值走 computed。

构建阶段: 体积分析过一遍、开 Gzip/Brotli、去掉 console、文件名 hash。

部署阶段: 静态资源长缓存 + index.html 不缓存、HTTP/2、能上 CDN 就上。

我的优化原则

测量优先, 数字说话; 先优化加载再优化运行 (用户先要看得到, 才谈得上用得顺); 挑收益大成本低的先做; 别为性能牺牲功能和可读性。工具就四样: Lighthouse、Performance 面板、构建分析、Vue DevTools。

延伸阅读