前端性能优化
前端性能优化
学到这里手上已经能做出完整项目了, 这篇解决”做得快不快”。先立一条总原则, 和后端调优完全一致: 测量优先, 没有 profile 就没有优化。
前端的 profile 工具就是 Lighthouse 和 Performance 面板 (调试篇提过), 凭感觉加缓存加懒加载, 和凭感觉加索引一样属于玄学。
性能指标: 先知道”快”怎么定义
Google 定义的 Core Web Vitals 是行业通用标尺, 三个指标先用人话过一遍:
- LCP (最大内容绘制): 页面最大那块内容 (通常是首屏大图或标题) 画出来花了多久, 衡量”用户觉得页面出来了没”。目标 2.5 秒内。
- CLS (累积布局偏移): 页面加载过程中内容跳来跳去的程度 (图片没占位、广告突然插入都会推高它), 目标 0.1 以内。正要点按钮结果按钮被挤走点了广告, 就是 CLS 灾难现场。
- 交互延迟: 用户点了之后多久有反应。老指标叫 FID, 现在被更严格的 INP 取代了, 目标 200ms 内。
Chrome DevTools 的 Lighthouse 面板一键出分, 优化前后各跑一次, 数字说话。
加载优化: 让首屏来得更早
路由懒加载
默认情况下打包工具把所有页面打进一个 JS 文件, 用户打开首页却要下载全站代码。懒加载在 03 路由篇已经是标准写法了:
代码块收起展开
const routes = [
// component 写成动态 import, 每个页面单独成包, 访问到才下载
{ path: '/about', component: () => import('./views/AboutView.vue') }
]页面内的重组件 (图表、编辑器) 同理用 defineAsyncComponent (02 篇), 用户点开才加载。
图片: 最容易白捡的优化
图片经常占页面体积大头, 三板斧:
代码块收起展开
<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" 是浏览器原生的图片懒加载, 滚到附近才下载, 一个属性白捡。
字体
自定义字体加载慢会让文字迟迟不显示:
代码块收起展开
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* 先拿系统字体顶上, 下完再换, 文字不空窗 */
}缓存策略
Vite 构建产物的文件名自带 hash (index.abc123.js), 内容一变 hash 就变。这给了服务器放心开长缓存的底气:
代码块收起展开
# 带 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 撑出总高度骗过滚动条。手写一个乞丐版理解原理:
代码块收起展开
<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 (工具库篇), 手写版本留作理解:
代码块收起展开
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
}
}
}避免无谓的重渲染
三个梯度, 按需要的克制程度排序:
代码块收起展开
<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 别预防性地撒满全场, 没有测量支撑的优化只会增加读代码的人的心智负担 (包括三个月后的自己)。
打包优化
先看清再动手, 装个可视化分析:
代码块收起展开
// 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. 资源预加载
代码块收起展开
<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-showvsv-if: 频繁切换用前者 (只切 display), 很少变的用后者 (不渲染就不占资源)。01 篇讲过, 属于性能敏感场景要想起来的知识。key用稳定业务 ID: 错误的 key 让 diff 大量误判, 既是 bug 源也是性能洞。- 事件委托: 一千行的列表别绑一千个 click, 绑在父容器上按
event.target分发 (JS 篇的事件冒泡在这里变现)。
性能监控
上线后想知道真实用户的体验, 用浏览器自带的 Performance API 打点:
代码块收起展开
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。