Files
2026-07-13 21:36:50 +08:00

350 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: performance-optimization
description: 优化应用程序性能。当存在性能需求、怀疑性能回归,或需要改进 Core Web Vitals 及加载时间时使用。当性能分析揭示出需要修复的瓶颈时使用。
---
# 性能优化
## 概述
先测量,再优化。不经过测量的性能工作等于猜测——而猜测会导致过早优化,增加复杂度却没有改善真正重要的指标。先做性能分析,找到真正的瓶颈,修复它,再次测量。只优化测量证明确实重要的部分。
## 何时使用
- 规格说明中存在性能要求(加载时间预算、响应时间 SLA)
- 用户或监控报告行为缓慢
- Core Web Vitals 分数低于阈值
- 你怀疑某个变更引入了性能退化
- 构建处理大数据集或高流量的功能时
**何时不应使用:** 在获得问题证据之前不要优化。过早优化会增加复杂度,其成本超过带来的性能收益。
## Core Web Vitals 目标值
| 指标 | 良好 | 待改善 | 差 |
|--------|------|-------------------|------|
| **LCP**(最大内容绘制) | ≤ 2.5s | ≤ 4.0s | > 4.0s |
| **INP**(下次绘制的交互延迟) | ≤ 200ms | ≤ 500ms | > 500ms |
| **CLS**(累积布局偏移) | ≤ 0.1 | ≤ 0.25 | > 0.25 |
## 优化工作流程
```
1. 测量 → 使用真实数据建立基线
2. 识别 → 找到真正的瓶颈(而非假设的瓶颈)
3. 修复 → 针对特定瓶颈进行修复
4. 验证 → 再次测量,确认改善效果
5. 防护 → 添加监控或测试以防止退化
```
### 步骤 1:测量
两种互补的方式——两者都使用:
- **合成测试(Lighthouse、DevTools Performance 面板):** 受控环境,可复现。最适合 CI 回归检测和隔离特定问题。
- **真实用户监控(web-vitals 库、CrUX):** 真实条件下的真实用户数据。需要用它来验证修复是否确实改善了用户体验。
**前端:**
```bash
# 合成测试:Chrome DevTools 中的 Lighthouse(或 CI
# Chrome DevTools → Performance 面板 → 录制
# Chrome DevTools MCP → 性能追踪
# 真实用户监控:代码中使用 Web Vitals 库
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(console.log);
onINP(console.log);
onCLS(console.log);
```
**后端:**
```bash
# 响应时间日志记录
# 应用性能监控(APM
# 带计时信息的数据库查询日志
# 简单计时
console.time('db-query');
const result = await db.query(...);
console.timeEnd('db-query');
```
### 从何处开始测量
根据症状决定优先测量的内容:
```
什么变慢了?
├── 首次页面加载
│ ├── 打包文件过大? --> 测量打包体积,检查代码分割
│ ├── 服务器响应慢? --> 在 DevTools Network 瀑布图中测量 TTFB
│ │ ├── DNS 耗时长? --> 为已知来源添加 dns-prefetch / preconnect
│ │ ├── TCP/TLS 耗时长? --> 启用 HTTP/2,检查边缘节点部署,保持长连接
│ │ └── 等待(服务器)耗时长? --> 对后端做性能分析,检查查询和缓存
│ └── 渲染阻塞资源? --> 检查网络瀑布图中 CSS/JS 的阻塞情况
├── 交互感觉卡顿
│ ├── 点击时界面冻结? --> 分析主线程,查找长任务(>50ms)
│ ├── 表单输入延迟? --> 检查重复渲染、受控组件的开销
│ └── 动画卡顿? --> 检查布局抖动、强制回流
├── 页面导航后
│ ├── 数据加载? --> 测量 API 响应时间,检查瀑布请求链
│ └── 客户端渲染? --> 分析组件渲染时间,检查是否存在 N+1 请求
└── 后端 / API
├── 单个端点慢? --> 分析数据库查询,检查索引
├── 所有端点都慢? --> 检查连接池、内存、CPU
└── 间歇性慢? --> 检查锁竞争、GC 暂停、外部依赖
```
### 步骤 2:识别瓶颈
按类别划分的常见瓶颈:
**前端:**
| 症状 | 可能原因 | 调查方法 |
|---------|-------------|---------------|
| LCP 慢 | 大图、渲染阻塞资源、服务器慢 | 检查网络瀑布图、图片尺寸 |
| CLS 高 | 未指定尺寸的图片、延迟加载的内容、字体偏移 | 检查布局偏移归因 |
| INP 差 | 主线程上繁重的 JavaScript、大型 DOM 更新 | 检查 Performance 追踪中的长任务 |
| 初始加载慢 | 打包文件过大、网络请求过多 | 检查打包体积、代码分割 |
**后端:**
| 症状 | 可能原因 | 调查方法 |
|---------|-------------|---------------|
| API 响应慢 | N+1 查询、缺少索引、查询未优化 | 检查数据库查询日志 |
| 内存增长 | 引用泄漏、无界缓存、大数据包 | 堆快照分析 |
| CPU 飙升 | 同步重型计算、正则回溯 | CPU 性能分析 |
| 高延迟 | 缺少缓存、重复计算、网络跳数 | 在整个堆栈中追踪请求 |
### 步骤 3:修复常见反模式
#### N+1 查询(后端)
```typescript
// 错误:N+1 —— 每个任务都做一次查询来获取所属用户
const tasks = await db.tasks.findMany();
for (const task of tasks) {
task.owner = await db.users.findUnique({ where: { id: task.ownerId } });
}
// 正确:单次查询包含关联
const tasks = await db.tasks.findMany({
include: { owner: true },
});
```
#### 无限制的数据获取
```typescript
// 错误:获取所有记录
const allTasks = await db.tasks.findMany();
// 正确:分页并限制条数
const tasks = await db.tasks.findMany({
take: 20,
skip: (page - 1) * 20,
orderBy: { createdAt: 'desc' },
});
```
#### 缺少图片优化(前端)
```html
<!-- 错误:未指定尺寸,未做格式优化 -->
<img src="/hero.jpg" />
<!-- 正确:首屏 / LCP 图片 —— 艺术指导 + 分辨率切换,高优先级 -->
<!--
两种技术结合:
- 艺术指导(media):不同断点使用不同的裁切/构图
- 分辨率切换(srcset + sizes):按屏幕密度选择合适文件大小
-->
<picture>
<!-- 移动端:竖版裁切(8:10 -->
<source
media="(max-width: 767px)"
srcset="/hero-mobile-400.avif 400w, /hero-mobile-800.avif 800w"
sizes="100vw"
width="800"
height="1000"
type="image/avif"
/>
<source
media="(max-width: 767px)"
srcset="/hero-mobile-400.webp 400w, /hero-mobile-800.webp 800w"
sizes="100vw"
width="800"
height="1000"
type="image/webp"
/>
<!-- 桌面端:横版裁切(2:1 -->
<source
srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w"
sizes="(max-width: 1200px) 100vw, 1200px"
width="1200"
height="600"
type="image/avif"
/>
<source
srcset="/hero-800.webp 800w, /hero-1200.webp 1200w, /hero-1600.webp 1600w"
sizes="(max-width: 1200px) 100vw, 1200px"
width="1200"
height="600"
type="image/webp"
/>
<img
src="/hero-desktop.jpg"
width="1200"
height="600"
fetchpriority="high"
alt="主图描述"
/>
</picture>
<!-- 正确:折叠线以下的图片 —— 懒加载 + 异步解码 -->
<img
src="/content.webp"
width="800"
height="400"
loading="lazy"
decoding="async"
alt="内容图片描述"
/>
```
#### 不必要的重复渲染(React)
```tsx
// 错误:每次渲染都创建新对象,导致子组件重复渲染
function TaskList() {
return <TaskFilters options={{ sortBy: 'date', order: 'desc' }} />;
}
// 正确:稳定的引用
const DEFAULT_OPTIONS = { sortBy: 'date', order: 'desc' } as const;
function TaskList() {
return <TaskFilters options={DEFAULT_OPTIONS} />;
}
// 对开销大的组件使用 React.memo
const TaskItem = React.memo(function TaskItem({ task }: Props) {
return <div>{/* 开销大的渲染 */}</div>;
});
// 对开销大的计算使用 useMemo
function TaskStats({ tasks }: Props) {
const stats = useMemo(() => calculateStats(tasks), [tasks]);
return <div>{stats.completed} / {stats.total}</div>;
}
```
#### 打包体积过大
```typescript
// 现代打包工具(Vite、webpack 5+)通过 Tree Shaking 自动处理具名导入,
// 前提是依赖以 ESM 形式发布且 package.json 中标记了 `sideEffects: false`。
// 在修改导入方式之前先做性能分析——真正的收益来自代码分割和懒加载。
// 正确:对重量级、不常用的功能使用动态导入
const ChartLibrary = lazy(() => import('./ChartLibrary'));
// 正确:路由级别的代码分割,包裹在 Suspense 中
const SettingsPage = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<SettingsPage />
</Suspense>
);
}
```
#### 缺少缓存(后端)
```typescript
// 缓存频繁读取、极少变更的数据
const CACHE_TTL = 5 * 60 * 1000; // 5 分钟
let cachedConfig: AppConfig | null = null;
let cacheExpiry = 0;
async function getAppConfig(): Promise<AppConfig> {
if (cachedConfig && Date.now() < cacheExpiry) {
return cachedConfig;
}
cachedConfig = await db.config.findFirst();
cacheExpiry = Date.now() + CACHE_TTL;
return cachedConfig;
}
// 静态资源的 HTTP 缓存头
app.use('/static', express.static('public', {
maxAge: '1y', // 缓存 1 年
immutable: true, // 永不重新验证(文件名中使用内容哈希)
}));
// API 响应的 Cache-Control
res.set('Cache-Control', 'public, max-age=300'); // 5 分钟
```
## 性能预算
设定预算并执行:
```
JavaScript 打包文件:< 200KB gzip 压缩后(初始加载)
CSS< 50KB gzip 压缩后
图片:< 200KB 每张(首屏以上区域)
字体:< 100KB 总共
API 响应时间:< 200msp95
可交互时间:< 3.5s(4G 网络下)
Lighthouse 性能评分:≥ 90
```
**在 CI 中执行:**
```bash
# 打包体积检查
npx bundlesize --config bundlesize.config.json
# Lighthouse CI
npx lhci autorun
```
## 参见
详细的性能检查清单、优化命令及反模式参考,请参见 `references/performance-checklist.md`
## 常见借口
| 借口 | 现实 |
|---|---|
| "我们以后再优化" | 性能债会不断累积。立即修复明显的反模式,微优化可以推迟。 |
| "在我机器上跑得很快" | 你的机器不是用户的机器。在代表性的硬件和网络条件下做性能分析。 |
| "这个优化是显而易见的" | 如果你没有测量,你就不知道。先做性能分析。 |
| "用户不会注意到 100ms 的差异" | 研究表明 100ms 的延迟会影响转化率。用户比你以为的更敏感。 |
| "框架会处理好性能" | 框架可以防止一些问题,但无法修复 N+1 查询或过大的打包文件。 |
## 危险信号
- 没有性能分析数据支撑的优化
- 数据获取中的 N+1 查询模式
- 没有分页的列表接口
- 未指定尺寸、未使用懒加载或响应式尺寸的图片
- 未经审核不断增大的打包体积
- 生产环境中没有性能监控
- 到处使用 `React.memo``useMemo`(过度使用和不足使用同样有害)
## 验证
每次与性能相关的变更后:
- [ ] 存在变更前后的测量数据(具体数值)
- [ ] 已识别并修复了具体的瓶颈
- [ ] Core Web Vitals 在"良好"阈值范围内
- [ ] 打包体积没有显著增大
- [ ] 新增的数据获取代码中没有 N+1 查询
- [ ] 性能预算在 CI 中通过(如果已配置)
- [ ] 现有测试仍然通过(优化没有破坏功能行为)