关于 web-performance-web-performance
面向 Web 开发者的性能优化指南,涵盖 Core Web Vitals、资源预算和运行时模式。强调先测量并定位瓶颈,再选择优化方式。
主要功能
- 01Core Web Vitals 指标与改进
- 02Bundle 预算及 CI 检查
- 03按路由拆分代码
- 04长列表虚拟滚动
- 05输入防抖与策略性记忆化
适用场景
- 01排查页面加载缓慢
- 02定位交互卡顿与重复渲染
- 03为 Bundle 设置 CI 限额
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-performance-web-performance安装后在 Claude Code 中输入/web-performance-web-performance确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
metadata.yamlreference.mdexamples/code-splitting.mdcore.mdimage-optimization.mdweb-vitals.md详细介绍
Web 性能模式
快速指南: 三个数字决定页面是否够快:LCP 低于 2.5s、INP 低于 200ms、CLS 低于 0.1。包体积是影响前两项指标的杠杆,因此要为其设定预算——主包压缩后约为 200 KB,并按路由拆分。优化前先测量,优化后也要在生产环境中再次测量,因为实验室得分与真实用户的会话表现并不一致。启用 React Compiler 后,记忆化会自动处理;手写的
useMemo必须有性能分析结果作为依据。
详细资源:
- examples/core.md — 记忆化、虚拟滚动、防抖
- examples/code-splitting.md — 延迟加载路由、动态导入、摇树优化、预算强制执行
- examples/web-vitals.md — LCP、INP 和 CLS 模式,以及现场测量
- examples/image-optimization.md — 格式、响应式来源、延迟加载
- reference.md — 阈值和预算表、格式比较
适用路径
- 问题出在包体积 — 首次加载缓慢、主代码块过大或依赖过重;请参阅 examples/code-splitting.md。
- 问题出在运行时 — 列表卡顿、交互缓慢或重复渲染;请参阅 examples/core.md。
- 尚未进行任何测量 — 先添加监测,因为上述两类问题的解决方案不同,猜测可能会选错;请参阅 examples/web-vitals.md。
<critical_requirements>
优化性能之前
先分析性能,并在做任何更改前指出瓶颈。 每项优化都会牺牲可读性,而对从未缓慢过的代码进行优化毫无收益——性能分析器、包分析或现场测量能将猜测转化为目标。
先写下预算,再开发功能。 在 CI 中设定包体积上限和 Core Web Vitals 目标,意味着今后的每个依赖项都有衡量标准;若事后才添加,这些标准只能说明你已经超标多少。
测量真实会话,而不只是实验室测试。 实验室环境只有一种设备、一种网络和一个冷缓存;现场数据反映的是用户实际使用的设备分布,而两者在最重要的页面上往往最不一致。
只有到达某个路由时才加载该路由的代码。 按路由边界拆分,是大多数应用可采用的最大幅度缩减方式,因为这能避免用户为从未打开过的页面付出加载成本。
</critical_requirements>
自动检测: Core Web Vitals、LCP、INP、CLS、TTFB、包体积、包体积预算、代码拆分、延迟加载、摇树优化、记忆化、React Compiler、虚拟化、虚拟滚动、防抖、节流、性能预算、现场测量、RUM
适用范围:
- Core Web Vitals:各项指标的测量内容及其影响因素
- 包体积预算及其在 CI 中的强制执行
- 代码拆分、动态导入和摇树优化
- 渲染开销:记忆化、虚拟化、防抖,以及避免占用主线程
- 图片体积和格式选择
其他内容:
- 打包工具配置 — 分块策略和分析输出取决于所用的打包工具;本指南用于确定这些数字应设为何值。
- 服务器响应时间 — TTFB 是这里所有内容的上游指标;缓存、压缩和源站延迟属于服务层。
- 框架级渲染 — 页面采用服务端渲染、流式渲染还是静态渲染,由框架决定,且各自对 LCP 的影响不同。
- 数据获取和缓存 — 不必要的重复请求属于数据获取层的问题,不过它会在这里表现为 INP。
## 理念
性能优化通常会以两种方式出错:优化本来就不慢的部分,或者在没有预算的情况下不断添加功能,直到页面处处都变慢。这两种失败的根源都是衡量不当,而非技术问题。
因此,顺序是固定的——先设定预算,再构建,然后衡量,最后优化衡量结果指出的问题。
<decision_framework>
应该把精力花在哪里
Is the problem measured?
├─ NO → Measure it. A profiler for runtime, a bundle report for size,
│ field data for what users actually experience.
└─ YES → Is it load or interaction?
├─ Load (LCP, first paint) → How big is the initial download?
│ ├─ Over budget → Split by route, defer heavy dependencies,
│ │ drop or replace the largest one
│ └─ Within budget → It is the critical path: preload the LCP image,
│ remove render-blocking resources, cut TTFB
└─ Interaction (INP, jank) → What is holding the main thread?
├─ A long task → Break it up, or move it to a worker
├─ Re-rendering → Profile the tree, then memoise what the profile named
└─ Too many DOM nodes → Virtualise the list
**是否使用记忆化:**使用 React Compiler 时,答案通常是“编译器已经处理好了”。不使用时,只对频繁重新渲染、属性未变且渲染耗时明显的组件使用记忆化——不要对其他内容使用,因为比较本身也有开销。
**是否使用虚拟化:**超过大约 100 行后,DOM 就会成为开销所在,虚拟化便能带来收益。少于这个数量时,虚拟化弊大于利,而且无论如何都会影响页面内查找和浏览器原生滚动锚定。
</decision_framework>
核心模式
模式 1:包体积预算
预算按产物分别设定,并在 CI 中强制执行,因此,如果某个依赖让一个代码块体积翻倍,拉取请求就会失败,而不是等到上线后才发现。
export const BUNDLE_SIZE_BUDGETS_KB = {
MAIN_BUNDLE_GZIPPED: 200,
VENDOR_BUNDLE_GZIPPED: 150,
ROUTE_BUNDLE_GZIPPED: 100,
TOTAL_INITIAL_LOAD_GZIPPED: 500,
CRITICAL_CSS_INLINE: 14, // fits in the first TCP round trip
} as const;
这些数值依据慢速网络下的下载时间制定,而非个人偏好——相关表格和说明见 reference.md,强制执行方法见 examples/code-splitting.md。
模式 2:核心 Web 指标
每项指标的成因各不相同,因此,单一的“让它更快”任务很少能同时改善多项指标。
| 指标 | 衡量内容 | 常见成因 |
|---|---|---|
| LCP | 加载 | 未优化的主视觉图片、阻塞渲染的 CSS 或 JS、较慢的 TTFB |
| INP | 交互性 | 主线程上的长任务、JavaScript 过多 |
| CLS | 视觉稳定性 | 未指定尺寸的图片、后期注入的内容、字体替换 |
阈值和解决方法见 reference.md;相关模式见 examples/web-vitals.md。
模式 3:路由级代码拆分
每个延迟加载的路由都会成为独立的代码块,在访问该路由时才会获取。
import { lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./pages/dashboard"));
const Reports = lazy(() => import("./pages/reports"));
// Whatever the router hands you, the lazy component renders under one Suspense boundary
<Suspense fallback={<PageLoader />}>
{currentPage === "dashboard" ? <Dashboard /> : <Reports />}
</Suspense>;
拆分路由、体积较大的功能模块、对话框和首屏以下的部分。将首屏组件、错误边界和加载状态保留在主包中——延迟加载这些内容会在关键路径上增加一次往返。
完整代码见:examples/code-splitting.md
模式 4:延迟加载较重的依赖
只在某个交互中用到的大型库,应在该交互内部导入,而不是在文件顶部导入。
// "chart-library" stands for whichever heavy dependency the path actually needs
async function renderChart(container: HTMLElement, data: ChartData) {
const { createChart } = await import("chart-library");
return createChart(container, data);
}
这样,所有从未触发该路径的用户都无需加载整个库。
模式 5:策略性记忆化
先进行性能分析。使用 React Compiler 时,大部分工作已经由它替你完成。
// Worth it: sorting thousands of rows on every keystroke elsewhere in the tree
const sortedRows = useMemo(
() => [...rows].sort((a, b) => compareValues(a[sortColumn], b[sortColumn])),
[rows, sortColumn],
);
// Not worth it: the comparison costs more than the work
const doubled = useMemo(() => value * 2, [value]);
完整代码见:examples/core.md
模式 6:长列表虚拟化
只渲染可见区域,而不是整个集合,这样无论列表增长到多长,DOM 的大小都能保持恒定。
const visible = rows.slice(startIndex, endIndex);
<div style={{ height: rows.length * ROW_HEIGHT_PX }}>
<div style={{ transform: `translateY(${startIndex * ROW_HEIGHT_PX}px)` }}>
{visible.map((row) => (
<Row key={row.id} row={row} />
))}
</div>
</div>;
完整代码见:examples/core.md
模式 7:对输入驱动的工作进行防抖
等用户停止输入后再执行开销较大的响应操作,而不是每次按键都执行。下面的 useDebounced 只用了十几行 useRef 和 setTimeout,完整代码见 examples/core.md。
const debouncedSearch = useDebounced(performSearch, SEARCH_DEBOUNCE_MS);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
debouncedSearch(event.target.value);
};
仅当最终值重要时使用防抖——例如搜索、自动保存和验证。若中间值重要、但不需要频繁处理,则使用节流——例如滚动、调整大小和指针跟踪。
完整代码见:examples/core.md
<red_flags>
风险信号
运行时会出错:
- 延迟加载的组件上方没有
Suspense边界——React 会直接抛出错误,而不是等待。 - 备忘录钩子的依赖数组不稳定——每次渲染都会创建新对象或内联函数,导致备忘录永远无法命中,还要一直付出比较开销。
- 延迟加载错误边界或加载状态——本应在出错时渲染的内容本身却仍在下载。
出人意料的行为:
React.memo会进行浅比较,因此每次渲染都会创建的新对象属性会让它完全失效。- 备忘录机制会带来比较开销并保留引用;对于耗时不到几毫秒的操作,净效果是负面的。
- 代码分割会增加总体字节数——增加更多代码块和运行时开销——但首次加载仍然更快。
- 实验室评分和实际数据衡量的是不同人群;实验室审核结果为绿色,而实际场景中的 INP 为红色,这是常见情况,并不矛盾。
- 虚拟滚动会破坏页面内查找、指向屏幕外行的锚点链接,以及原生滚动恢复。
- 延迟加载的组件不会出现在服务器渲染的 HTML 中,因此它们到达时,首屏内容会发生位移。
- 命名空间导入(
import _ from "…")和require()都会妨碍摇树优化;重新导出整个目录的 barrel 文件也一样。 - 不注明压缩方式,预算就毫无意义——200 KB 的预算在未压缩、gzip 压缩和 brotli 压缩下是三个不同的限制。
- AVIF 并非通用格式;如果
<picture>没有 WebP 或 JPEG 来源,部分用户将无法显示图像。