10 · Web 性能优化:治理手段
本篇解决什么问题: 优化不是"背一堆技巧",而是先定位瓶颈,再对症下药。 本篇按 加载 → 运行时 → 体积 → 长列表 → 图片字体 分类给出治理手段, 每条都说明解决什么问题、代价是什么。
📌 与 A25 的分工:A25 讲怎么度量与诊断(找到瓶颈),本篇讲怎么治。 📌 与 A04 的分工:A04 讲定位(原理层),本篇讲治理(手段层)。
开篇速览
| 瓶颈 | 典型症状 | 手段 |
|---|---|---|
| 加载慢 | 白屏久、LCP 差 | 资源压缩、预连接、按需加载、缓存 |
| 运行时卡 | 操作掉帧、输入延迟 | 长任务拆分、虚拟列表、避免布局抖动 |
| 体积大 | 首屏下载几 MB | Tree Shaking、分包、依赖替换 |
| 长列表 | 滚动卡顿 | 虚拟滚动 |
| 图片/字体 | 流量大、CLS 抖动 | 现代格式、响应式、尺寸预留 |
一、加载性能
1.1 关键渲染路径优化
| 手段 | 做法 | 效果 |
|---|---|---|
| 关键 CSS 内联 | 首屏必需样式直接写进 <style>,其余异步加载 | 减少一次阻塞请求 |
JS 放底部 / defer | <script defer> 或 type="module"(默认 defer) | 不阻塞 HTML 解析 |
| 非关键 JS 异步 | async 或动态 import() | 不阻塞首屏 |
| 资源提示 | preconnect / dns-prefetch / preload | 提前建连、提前下载 |
html
<link rel="preconnect" href="https://api.example.com"> <!-- 提前 DNS+TCP+TLS -->
<link rel="dns-prefetch" href="https://cdn.example.com">
<link rel="preload" as="font" type="font/woff2" href="/font.woff2" crossorigin>
<link rel="preload" as="image" href="/hero.webp"> <!-- 首屏大图 -->⚠️
preload别滥用:它会抢占带宽,只给首屏真正关键的资源用。
1.2 传输层
| 手段 | 说明 |
|---|---|
| Brotli / gzip 压缩 | Brotli 比 gzip 小 15~20%,现代浏览器都支持 |
| HTTP/2 / HTTP/3 | 多路复用,减少连接开销 |
| CDN | 就近分发,降低 RTT |
| 内容 hash + 长缓存 | 见 A16 |
1.3 按需加载
js
// 路由级分包(收益最大)
const UserPage = lazy(() => import('./pages/User'))
// 交互后才加载(如点击才需要的重组件)
button.onclick = async () => { const { exportExcel } = await import('./excel'); exportExcel() }二、运行时性能:守住 16.67ms
回顾 A09:一帧只有 16.67ms,超过就掉帧。
2.1 拆分长任务
js
// ❌ 一个 500ms 的任务 → 丢 30 帧
function processAll(items) { items.forEach(heavy) }
// ✅ 分片:每片控制在 5~16ms,让出主线程
async function processChunked(items) {
for (let i = 0; i < items.length; i += 100) {
processChunk(items.slice(i, i + 100))
await new Promise(r => setTimeout(r, 0)) // 让出主线程
// 或 await scheduler.yield()(如果支持)
}
}
// ✅✅ 更好:挪到 Web Worker(见 A22)2.2 避免布局抖动(Layout Thrashing)
js
// ❌ 读写交替 → 每次读都强制同步布局(Forced Reflow)
els.forEach(el => { el.style.width = '100px'; console.log(el.offsetHeight) })
// ✅ 先批量读,再批量写
const heights = els.map(el => el.offsetHeight) // 读
els.forEach((el, i) => { el.style.height = heights[i] + 'px' }) // 写2.3 其他高频手段
| 手段 | 说明 |
|---|---|
| 防抖 / 节流 | resize、scroll、input 必用(见 A18) |
| 事件委托 | 一个监听代替 N 个(列表项点击) |
content-visibility | 跳过屏幕外元素的渲染 |
will-change 谨慎用 | 创建合成层,滥用会吃显存 |
用 transform/opacity 做动画 | 只触发合成,不触发布局与绘制 |
css
/* 长列表/长页面的大杀器 */
.card { content-visibility: auto; contain-intrinsic-size: 0 200px; }三、包体积治理
3.1 定位
bash
npm i -D rollup-plugin-visualizer # 或 webpack-bundle-analyzer看三件事:谁最大、有没有重复依赖、有没有该摇掉却没摇掉的。
3.2 常见替换
| 大而全 | 轻量替代 | 节省 |
|---|---|---|
moment (~70KB) | dayjs / date-fns(按需) | ~60KB |
lodash(全量) | lodash-es + 按需引入 | ~50KB+ |
echarts(全量) | 按需引入 echarts/core + 组件 | 数百 KB |
antd(全量) | 按需(默认支持 ES module tree shaking) | 视使用量 |
js
// ✅ echarts 按需
import * as echarts from 'echarts/core'
import { BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
echarts.use([BarChart, GridComponent, TooltipComponent, CanvasRenderer])3.3 Tree Shaking 与分包
见 A21 · 构建工具原理——重点是 ESM + sideEffects + 生产模式。
四、长列表:虚拟滚动
问题:渲染 10000 个 DOM 节点 → 内存爆、滚动卡。
方案:只渲染可视区域 + 少量缓冲的节点。
┌──────────────┐
│ 可视区(约 10 项)│ ← 只渲染这些
│ 缓冲 3 项 │
└──────────────┘
滚动时:回收离开视口的节点,复用 DOM(或改 key/内容)tsx
// 用法示例(react-window / vue-virtual-scroller)
import { FixedSizeList } from 'react-window'
<FixedSizeList height={600} itemCount={10000} itemSize={50} width="100%">
{({ index, style }) => <Row style={style} data={list[index]} />}
</FixedSizeList>| 库 | 框架 |
|---|---|
react-window / react-virtualized | React |
vue-virtual-scroller / @tanstack/vue-virtual | Vue |
@tanstack/virtual-core | 框架无关 |
关键细节:
| 要点 | 说明 |
|---|---|
| 每项高度必须已知(或能测量) | 不定高用 VariableSizeList + 预估高度 |
| 不要给每项加复杂计算 | 渲染函数要轻 |
key 用业务 ID | 见 A20 |
💡 分页 vs 虚拟滚动:数据量 < 几百条用分页更简单; 需要连续滚动体验 + 数据量大才上虚拟滚动。
五、图片与字体
5.1 图片
| 手段 | 做法 |
|---|---|
| 现代格式 | WebP / AVIF(比 JPEG 小 30~50%) |
| 响应式 | srcset + sizes,按屏幕给不同尺寸 |
| 懒加载 | <img loading="lazy">(首屏图不要懒加载,会拖慢 LCP) |
| 尺寸预留 | 必须写 width/height 或 aspect-ratio,否则 CLS 抖动 |
| 解码提示 | decoding="async" |
html
<img src="hero.webp" width="1200" height="600" alt="…"
fetchpriority="high" decoding="async"> <!-- 首屏图:高优先级 -->
<img src="item.webp" loading="lazy" width="300" height="200" alt="…"> <!-- 屏幕外 -->5.2 字体
| 问题 | 解法 |
|---|---|
| FOIT(文字不可见) | font-display: swap(先用系统字体,加载完替换) |
| CLS 抖动 | 用 size-adjust / font-size-adjust 让回退字体与 Web 字体尺寸接近 |
| 体积大 | 子集化(只保留用到的字符,中文尤其重要) |
| 加载阻塞 | preload + crossorigin |
css
@font-face {
font-family: 'MyFont';
src: url('/myfont.woff2') format('woff2');
font-display: swap; /* ✅ 避免文字不可见 */
}六、优化的正确顺序
先测量,再优化,否则容易优化错地方:
① 用 Lighthouse / Web Vitals 找出**真实瓶颈**(见 A25)
② 定位到具体类别(加载 / 运行时 / 体积)
③ 应用对应手段
④ 重新测量,验证收益
⑤ 记录到性能预算,防止劣化(见 A25)收益排序(经验):
| 优先级 | 手段 | 典型收益 |
|---|---|---|
| ⭐⭐⭐⭐⭐ | 图片优化(格式/尺寸/懒加载) | 常能省 50%+ 体积 |
| ⭐⭐⭐⭐⭐ | 依赖替换(moment→dayjs 等) | 数百 KB |
| ⭐⭐⭐⭐ | 路由级 Code Splitting | 首屏减半 |
| ⭐⭐⭐⭐ | 虚拟滚动(长列表) | 卡顿 → 流畅 |
| ⭐⭐⭐ | 长任务拆分 / Worker | 交互响应 |
| ⭐⭐ | 缓存策略 | 二次访问 |
自检清单
- [ ] 能按"加载/运行时/体积/长列表/图片字体"对号入座找手段
- [ ] 能说出
defer/async/preload/preconnect各自的用途与滥用风险 - [ ] 能写出"先读后写"避免布局抖动的代码
- [ ] 知道虚拟滚动的适用场景与"每项高度已知"的前提
- [ ] 知道图片必须预留尺寸(CLS)与
font-display: swap(FOIT)
常见误区
| ❌ 误区 | ✅ 事实 |
|---|---|
"首屏图片也要 loading=lazy" | 会拖慢 LCP;首屏图应 fetchpriority="high" |
"will-change 加上就变快" | 滥用会创建过多合成层,吃显存反而更慢 |
| "优化就是压缩图片" | 先测量;瓶颈可能在 JS 长任务或依赖体积 |
| "虚拟滚动一定比分页好" | 数据量小时分页更简单;虚拟滚动增加复杂度 |
| "动画用什么属性都一样" | 用 transform/opacity 才只触发合成 |
动手练习
- 对自己项目跑一次 Lighthouse,按"加载/运行时/体积"归类 Top 3 问题,各选一条手段实施并复测。
- 造一个 10000 行的表格,先体会卡顿,再接入虚拟滚动对比滚动帧率(Performance 面板)。
- 把项目里的
moment换成dayjs,用rollup-plugin-visualizer对比前后产物体积。
相关文档
- 怎么度量与定位瓶颈 → A25 · 前端性能度量与诊断
- 布局与重排原理 → A06 · CSS 布局体系 · A04 · 浏览器与网络
- 主线程与长任务 → A09 · JavaScript 运行时 · A22 · Web 平台能力
- 打包体积 → A21 · 构建工具原理