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

11 KiB
Raw Permalink Blame History

name, description
name description
performance-optimization 优化应用程序性能。当存在性能需求、怀疑性能回归,或需要改进 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): 真实条件下的真实用户数据。需要用它来验证修复是否确实改善了用户体验。

前端:

# 合成测试: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);

后端:

# 响应时间日志记录
# 应用性能监控(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 查询(后端)

// 错误: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 },
});

无限制的数据获取

// 错误:获取所有记录
const allTasks = await db.tasks.findMany();

// 正确:分页并限制条数
const tasks = await db.tasks.findMany({
  take: 20,
  skip: (page - 1) * 20,
  orderBy: { createdAt: 'desc' },
});

缺少图片优化(前端)

<!-- 错误:未指定尺寸,未做格式优化 -->
<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

// 错误:每次渲染都创建新对象,导致子组件重复渲染
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>;
}

打包体积过大

// 现代打包工具(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>
  );
}

缺少缓存(后端)

// 缓存频繁读取、极少变更的数据
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.5s4G 网络下)
Lighthouse 性能评分:≥ 90

在 CI 中执行:

# 打包体积检查
npx bundlesize --config bundlesize.config.json

# Lighthouse CI
npx lhci autorun

参见

详细的性能检查清单、优化命令及反模式参考,请参见 references/performance-checklist.md

常见借口

借口 现实
"我们以后再优化" 性能债会不断累积。立即修复明显的反模式,微优化可以推迟。
"在我机器上跑得很快" 你的机器不是用户的机器。在代表性的硬件和网络条件下做性能分析。
"这个优化是显而易见的" 如果你没有测量,你就不知道。先做性能分析。
"用户不会注意到 100ms 的差异" 研究表明 100ms 的延迟会影响转化率。用户比你以为的更敏感。
"框架会处理好性能" 框架可以防止一些问题,但无法修复 N+1 查询或过大的打包文件。

危险信号

  • 没有性能分析数据支撑的优化
  • 数据获取中的 N+1 查询模式
  • 没有分页的列表接口
  • 未指定尺寸、未使用懒加载或响应式尺寸的图片
  • 未经审核不断增大的打包体积
  • 生产环境中没有性能监控
  • 到处使用 React.memouseMemo(过度使用和不足使用同样有害)

验证

每次与性能相关的变更后:

  • 存在变更前后的测量数据(具体数值)
  • 已识别并修复了具体的瓶颈
  • Core Web Vitals 在"良好"阈值范围内
  • 打包体积没有显著增大
  • 新增的数据获取代码中没有 N+1 查询
  • 性能预算在 CI 中通过(如果已配置)
  • 现有测试仍然通过(优化没有破坏功能行为)