Skip to content

10 · Web 性能优化:治理手段 ​

本篇解决什么问题: 优化不是"背一堆技巧",而是先定位瓶颈,再对症下药。 本篇按 加载 → 运行时 → 体积 → 长列表 → 图片字体 分类给出治理手段, 每条都说明解决什么问题、代价是什么。

📌 与 A25 的分工:A25 讲怎么度量与诊断(找到瓶颈),本篇讲怎么治。 📌 与 A04 的分工:A04 讲定位(原理层),本篇讲治理(手段层)。


开篇速览 ​

瓶颈典型症状手段
加载慢白屏久、LCP 差资源压缩、预连接、按需加载、缓存
运行时卡操作掉帧、输入延迟长任务拆分、虚拟列表、避免布局抖动
体积大首屏下载几 MBTree 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-virtualizedReact
vue-virtual-scroller / @tanstack/vue-virtualVue
@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 才只触发合成

动手练习 ​

  1. 对自己项目跑一次 Lighthouse,按"加载/运行时/体积"归类 Top 3 问题,各选一条手段实施并复测。
  2. 造一个 10000 行的表格,先体会卡顿,再接入虚拟滚动对比滚动帧率(Performance 面板)。
  3. 把项目里的 moment 换成 dayjs,用 rollup-plugin-visualizer 对比前后产物体积。

相关文档 ​