性能优化
性能优化不是玄学,而是有指标的工程:先测量、再定位瓶颈、最后针对性优化。本文按"加载 → 渲染 → 代码 → 网络 → 内存"五个维度拆解优化手段,并用 Web 性能指标统一衡量效果。
一、优化维度总览
| 维度 | 关注问题 | 核心手段 |
|---|---|---|
| 加载 | 资源体积大、请求多 | 压缩、CDN、缓存、懒加载、预加载 |
| 渲染 | 页面卡顿、白屏久 | 减少重排重绘、虚拟列表、骨架屏 |
| 代码 | JS 执行慢、事件频繁 | 防抖节流、事件委托、避免内存泄漏 |
| 网络 | 往返延迟、协议开销 | HTTP/2/3、连接复用、缓存命中 |
| 内存 | 内存持续上涨 | 及时释放引用、避免泄漏 |
铁律:先测量后优化。没有数据支撑的"优化"往往是在给别人的性能债买单。
二、加载优化
1. 资源压缩
| 资源 | 压缩手段 | 收益 |
|---|---|---|
| JS | 代码压缩(terser/esbuild)+ gzip/brotli | 体积减 60%~80% |
| CSS | 压缩 + 去除未使用规则(PurgeCSS) | 体积减 50% 以上 |
| HTML | 压缩空白与注释 | 少量 |
| 图片 | 有损/无损压缩 + 裁剪尺寸 | 视内容 50%~90% |
2. CDN 与缓存策略
- CDN:静态资源分发到离用户最近的节点,降低网络往返(RTT)。
- 缓存:HTML 走协商缓存
ETag;带指纹的静态资源(app.a1b2c3.js)走强缓存Cache-Control: max-age=31536000,内容变了指纹就变,天然失效。
http
# 带 hash 指纹的资源:一年强缓存
Cache-Control: public, max-age=31536000, immutable
# HTML:每次校验
Cache-Control: no-cache
ETag: "abc123"3. 懒加载(Lazy Load)
图片、组件、路由按需加载,首屏只加载必要资源:
javascript
// 图片懒加载:进入视口才加载
<img data-src="/img/big.png" loading="lazy" alt="懒加载图片">
// IntersectionObserver 自定义懒加载(兼容性更强)
const io = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
const img = e.target;
img.src = img.dataset.src;
io.unobserve(img);
}
});
});
document.querySelectorAll("img[data-src]").forEach(img => io.observe(img));
// 路由级代码分割(Vite/Webpack 通用语法)
const Home = () => import("@/views/Home.vue"); // 访问时才下载该路由的 chunk4. 预加载 preload 与 prefetch
| 指令 | 时机 | 用途 |
|---|---|---|
<link rel="preload"> | 当前页必须现在用 | 关键字体、首屏大图、主 JS |
<link rel="prefetch"> | 空闲时预取将来可能用 | 下一路由、悬浮目标页 |
<link rel="preconnect"> | 提前建立连接 | 第三方域名(CDN、API) |
html
<link rel="preload" href="/fonts/icon.woff2" as="font" crossorigin>
<link rel="preconnect" href="https://cdn.example.com">
<link rel="prefetch" href="/detail-page.js">5. HTTP/2 与 HTTP/3
- HTTP/2:多路复用(一个连接并发多个请求)、头部压缩(HPACK)、服务器推送(已较少使用)。
- HTTP/3:基于 QUIC/UDP,弱网下更快建连,减少队头阻塞。
| 协议 | 连接数 | 并发请求 | 队头阻塞 | 建连 |
|---|---|---|---|---|
| HTTP/1.1 | 每域名 6 个左右 | 串行/受限 | 严重 | TCP + TLS |
| HTTP/2 | 1 个 | 多路复用 | 应用层消除 | TCP + TLS |
| HTTP/3 | 1 个 | 多路复用 | 传输层消除 | QUIC 0-RTT |
配合 HTTP/2,资源合并策略要反转:HTTP/1.1 时代拼命合并文件减少请求数;HTTP/2 时代拆成小文件按需加载反而更快。
6. 图片优化
html
<!-- WebP + 回退:现代浏览器用 WebP,老浏览器用 PNG -->
<picture>
<source srcset="hero.webp" type="image/webp">
<img src="hero.png" alt="首图">
</picture>
<!-- 响应式图片:按视口宽度加载不同尺寸 -->
<img srcset="small.jpg 480w, large.jpg 1200w"
sizes="(max-width: 600px) 480px, 1200px"
src="large.jpg" alt="响应式">| 图片格式 | 特点 | 适用 |
|---|---|---|
| WebP | 体积小、支持透明与动画 | 照片与插画(默认首选) |
| AVIF | 压缩率更高 | 高压缩需求场景 |
| SVG | 矢量、无限缩放 | 图标、Logo |
| PNG | 无损、支持透明 | 需要无损的小图 |
三、渲染优化
1. 减少重排(reflow)与重绘(repaint)
- 重排:布局尺寸/位置变化,代价最高。
- 重绘:颜色/阴影等外观变化,代价次之。
- 读写分离:读布局属性会强制刷新渲染队列,避免在循环里"写后立刻读"。
javascript
// 差:循环内写样式 + 读布局,每次迭代都触发重排
for (let i = 0; i < list.length; i++) {
el.style.width = list[i].w + "px";
const h = el.offsetHeight; // 强制同步布局
}
// 好:批量写,最后统一读;或用 transform 触发合成层
el.style.width = "200px";
el.style.height = "100px"; // 一次重排
const h = el.offsetHeight; // 读一次| 触发重排的 CSS | 可避免的手段 |
|---|---|
width/height/margin/padding | 改用 transform 动画 |
top/left 定位 | 改用 transform: translate() |
| 频繁修改样式 | 合并类名、用 requestAnimationFrame 批量改 |
| 大量 DOM 增删 | DocumentFragment 一次性插入 |
2. 虚拟列表(Virtual List)
渲染上万行数据时只渲染可视区内的行,其余用占位撑起滚动条。这是长列表的最终解法:
javascript
// 核心思路:根据 scrollTop 计算可视区行号,只渲染这些行
function VirtualList({ total, rowHeight, viewportHeight, renderRow }) {
const [scrollTop, setScrollTop] = useState(0);
const start = Math.floor(scrollTop / rowHeight);
const visibleCount = Math.ceil(viewportHeight / rowHeight);
const rows = Array.from({ length: visibleCount + 2 }, (_, i) => start + i);
return (
<div onScroll={e => setScrollTop(e.target.scrollTop)}
style={{ height: viewportHeight, overflow: "auto" }}>
<div style={{ height: total * rowHeight, position: "relative" }}>
{rows.map(i => (
<div key={i} style={{ position: "absolute", top: i * rowHeight, height: rowHeight }}>
{renderRow(i)}
</div>
))}
</div>
</div>
);
}| 方案 | 1 万行 | 10 万行 |
|---|---|---|
| 全部渲染 | 卡顿 | 几乎不可用 |
| 分页 | 可用 | 可用但交互割裂 |
| 虚拟列表 | 流畅 | 流畅 |
3. requestIdleCallback:低优先级任务
把不紧急的任务放到浏览器空闲时间执行,不阻塞渲染:
javascript
// 空闲时上报统计、预计算等
requestIdleCallback(() => {
sendAnalytics(collectStats());
}, { timeout: 2000 }); // 最多等 2 秒,超时则强制执行4. 骨架屏(Skeleton Screen)
用占位骨架代替白屏,减少"内容跳变"的感知等待。纯 CSS 即可实现:
css
.skeleton {
background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}html
<!-- 数据到达前的占位 -->
<div class="skeleton" style="height: 120px"></div>
<div class="skeleton" style="height: 20px"></div>四、代码优化
1. 防抖与节流
| 手段 | 行为 | 适用 |
|---|---|---|
| 防抖(debounce) | 停止触发 N 毫秒后才执行 | 搜索框输入、窗口 resize 结束 |
| 节流(throttle) | N 毫秒内最多执行一次 | 滚动监听、拖拽、频繁点击 |
javascript
function debounce(fn, wait) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
function throttle(fn, interval) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= interval) { last = now; fn.apply(this, args); }
};
}
window.addEventListener("scroll", throttle(onScroll, 100));
input.addEventListener("input", debounce(onSearch, 300));2. 事件委托
把子元素的事件统一挂到父元素,只绑定一个监听器,动态新增的子元素自动生效:
javascript
// 差:每个 li 一个监听器,新增 li 还要重新绑定
document.querySelectorAll("li").forEach(li => li.onclick = handler);
// 好:委托到 ul,一次绑定,动态元素天然生效
ul.addEventListener("click", e => {
const li = e.target.closest("li");
if (li) handler(li);
});3. 减少闭包滥用
闭包会持有外部变量导致无法被回收。长生命周期对象里保存大量闭包,或循环中错误捕获索引,都可能引发内存问题:
javascript
// 差:循环内创建闭包,每个都持有 i 的引用场景(旧写法容易踩坑)
// 好:用 let 块级作用域或立即执行参数固化值
for (let i = 0; i < 5; i++) {
btns[i].onclick = () => console.log(i); // let 每次迭代独立绑定,正确输出 0-4
}4. 避免内存泄漏
| 泄漏来源 | 说明 | 防范 |
|---|---|---|
| 未解绑的监听器 | 组件销毁后仍被事件引用 | 移除时 removeEventListener / off |
| 定时器未清理 | setInterval 持续运行 | 销毁时 clearInterval |
| 全局变量堆积 | 数据挂到 window | 避免无谓的全局引用 |
| 闭包持有大对象 | 长生命周期闭包引用大数组 | 用后置空 ref = null |
javascript
class Timer {
#timer = null;
start() { this.#timer = setInterval(() => this.tick(), 1000); }
destroy() { clearInterval(this.#timer); this.#timer = null; } // 显式清理
}五、首屏性能指标
| 指标 | 全称 | 含义 | 理想值 |
|---|---|---|---|
| FP | First Paint | 首次绘制(任何像素) | 1.8s 以内 |
| FCP | First Contentful Paint | 首次内容绘制(文本/图片) | 1.8s 以内 |
| LCP | Largest Contentful Paint | 最大内容绘制(核心内容可见) | 2.5s 以内 |
| INP | Interaction to Next Paint | 交互响应延迟 | 200ms 以内 |
| CLS | Cumulative Layout Shift | 布局偏移累积量 | 0.1 以内 |
LCP 是首屏体验的核心指标,优化方向集中在:加快关键资源下载(preload)、减少 JS 阻塞、服务端直出首屏 HTML(SSR)。
六、性能监控
1. Performance API 手动测量
javascript
performance.mark("start");
// ...要测量的代码...
performance.mark("end");
performance.measure("my-task", "start", "end");
const [entry] = performance.getEntriesByName("my-task");
console.log(entry.duration, "ms");2. PerformanceObserver 被动监听
javascript
const observer = new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
if (entry.entryType === "largest-contentful-paint") {
console.log("LCP:", entry.startTime, "ms");
}
}
});
observer.observe({ type: "largest-contentful-paint", buffered: true });3. Web Vitals 上报
javascript
// 官方库一行接入,自动监听 CLS / LCP / INP / FCP / TTFB
import { onLCP, onCLS, onINP } from "web-vitals";
onLCP(report); // report({ name: "LCP", value: 2300, rating: "needs-improvement" })
onCLS(report);
onINP(report);上报的数据进入监控平台(Sentry、自建日志),按版本对比趋势,才能持续发现"哪个版本把性能改坏了"。
七、性能预算
性能预算:给关键指标设定可量化的上限,超限即构建失败或告警,把性能变成"契约"而非"愿望"。
json
{
"budgets": [
{ "resourceType": "script", "budget": 300, "metric": "transferSize" },
{ "resourceType": "image", "budget": 1500, "metric": "transferSize" },
{ "metric": "lcp", "budget": 2500 }
]
}bash
npx lighthouse https://your-site.com --budget-path=budget.json --output=html| 预算类型 | 示例值 | 目的 |
|---|---|---|
| JS 传输体积 | 单页 ≤ 300KB(压缩后) | 控制加载成本 |
| 图片体积 | 单图 ≤ 200KB | 控制 LCP |
| LCP | ≤ 2.5s(P75) | 守住首屏体验 |
| CLS | ≤ 0.1 | 防止布局跳动 |
性能优化的正确姿势是持续测量 + 预算约束:先接入 Web Vitals 与 Lighthouse 建立基线,再按维度逐项优化,最后用预算机制防止回退。优化永远为体验服务,指标是手段,用户感受才是目的。