前端性能预算清单:先控制会变慢的地方
页面是怎么慢慢变慢的
我在一个电商项目里见过一个典型场景:首页首屏加载从 1.8 秒退化到 4.2 秒,整个过程持续了八个月,没有一次 PR 是导致退化的直接原因。
退化路径大致是这样的——第一季度加了一个日期处理库,bundle 多了 60 KB;第二季度产品要求首页加三张运营 banner,图片没有走 CDN 压缩;第三季度客服系统接入了一个第三方聊天组件,首屏多了一个阻塞脚本;第四季度做了一次依赖升级,moment 被替换成 dayjs,但旧依赖没有被清理干净。每一步单独看都不严重,合在一起就是首屏时间翻了一倍多,跳出率涨了 12%。
这不是孤例。根据 AlterSquare 2026 年对多个团队的调研,大型前端代码库的 bundle 体积平均每季度增长 5-8%,如果不做干预,一年后首屏资源体积可以翻倍 1。SARC Publisher 的研究更具体:第三方库占 bundle 体积的 68%,而系统性地引入 tree-shaking 和性能预算后,团队可以实现约 30% 的体积缩减 2。
性能问题的特征决定了它适合用「预算」来管理,而不是靠上线前突击优化。预算的作用是在变更发生时就拦住风险,而不是等风险变成事故再去排查。
性能预算的核心逻辑
MDN 对性能预算的定义很直接:对资源文件大小、页面加载指标或特定行为设定的上限,一旦超出就需要团队讨论是否接受 3。这个定义里有两个关键点:「上限」和「团队讨论」。上限意味着性能不是抽象目标,而是可度量的约束;团队讨论意味着性能决策是工程协作的一部分,不是某个人的事后补救。
Google 的 Core Web Vitals 为预算提供了明确的阈值基准。截至 2026 年,三项核心指标的「良好」阈值是:
| 指标 | 含义 | 良好阈值 | 需要改进 | 差 |
|---|---|---|---|---|
| LCP(Largest Contentful Paint) | 最大内容绘制时间 | ≤ 2.5s | 2.5s - 4.0s | > 4.0s |
| INP(Interaction to Next Paint) | 交互到下次绘制延迟 | ≤ 200ms | 200ms - 500ms | > 500ms |
| CLS(Cumulative Layout Shift) | 累积布局偏移 | < 0.1 | 0.1 - 0.25 | > 0.25 |
INP 在 2024 年正式替代了 FID(First Input Delay)。FID 只测量首次交互延迟,INP 覆盖整个页面的所有交互,更能反映真实的响应性体验 4。TTFB(Time to First Byte)作为辅助指标,建议控制在 800ms 以内。
把 Vitals 阈值直接当预算用是一种做法,但更实际的方式是根据项目实际情况设定更严格的内部标准。比如,如果竞品首页 LCP 在 2.0 秒左右,把预算设在 2.5 秒就意味着没有余量——任何一次依赖变更或图片调整都可能让指标变红。
性能预算需要覆盖的维度远不止 Vitals。一个完整的预算体系至少包含以下几个层面:
| 预算维度 | 控制对象 | 典型阈值示例 | 检测时机 |
|---|---|---|---|
| JavaScript bundle | 首屏 JS 总体积 | 初始包 ≤ 170KB(gzip 后) | 构建时 |
| 单个 chunk | 路由级代码体积 | 单个 chunk ≤ 50KB | 构建时 |
| 图片资源 | 单张图片大小和格式 | 首屏图片 ≤ 100KB,使用 WebP/AVIF | 构建时或上传时 |
| 字体文件 | 自定义字体体积 | 单字体 ≤ 50KB,woff2 格式 | 构建时 |
| 首屏请求数 | HTML 直接触发的请求总量 | ≤ 15 个请求 | 监控时 |
| 接口响应时间 | 首屏关键接口 P95 | ≤ 300ms | 运行时监控 |
| LCP | 最大内容绘制 | ≤ 2.0s(内部标准) | RUM 监控 |
| INP | 交互响应延迟 | ≤ 150ms(内部标准) | RUM 监控 |
| CLS | 布局稳定性 | < 0.05(内部标准) | RUM 监控 |
内部标准比 Google 阈值更严格,目的是留出缓冲空间。预算不是及格线,是提前量。
三个实际场景
案例一:依赖膨胀导致 bundle 退化
场景:一个 B 端管理后台,初始 bundle 体积 120KB(gzip 后),运行半年后涨到 280KB。团队没有明确的体积预算,每次加新依赖时,PR 描述里偶尔会提到「增加了 X KB」,但没有人把它当作阻塞问题。
翻车点:某一天产品反馈后台页面在移动端打开越来越慢,尤其是一些低配安卓设备,打开编辑表单需要等 3-4 秒才有响应。排查发现主 bundle 已经接近 300KB,解析和执行时间直接拖慢交互。
修复方式:引入 size-limit 作为 CI 检查,设定初始包不超过 170KB 的预算。同时用 webpack-bundle-analyzer 定位到三个可以按需引入的大依赖(一个图表库、一个日期处理库、一个表单验证库),改成动态导入后体积降回 145KB。
// ❌ 直接全量导入 —— 图表库整包打进主 bundle
import { Chart, BarChart, LineChart, PieChart } from 'recharts'
// ✅ 只导入需要的组件 —— 配合 tree-shaking 减少 80KB+
import { BarChart } from 'recharts'
// 其他图表类型用到时再动态加载
const loadLineChart = () => import('recharts').then(m => m.LineChart)// size-limit 配置示例(.size-limit.cjs)
module.exports = [
{
path: 'dist/index.js',
limit: '170 KB', // gzip 后的体积上限
gzip: true,
running: false // 不测量运行时性能,只检查打包体积
},
{
path: 'dist/charts.js',
limit: '50 KB', // 单个 chunk 的独立预算
gzip: true,
running: false
}
]size-limit 的优势在于它不只是检查文件大小,而是模拟真实加载成本——包括 gzip 压缩后的传输体积和解析执行时间。这让预算更接近用户的实际感受 5。
案例二:首屏图片未优化导致 LCP 超标
场景:一个内容型网站,首页 hero 区域有一张全宽 banner 图。设计稿给的原图是 3200×1800 的 PNG,体积 2.4MB。开发直接使用了这张图,没有做格式转换和尺寸适配。
翻车点:Lighthouse 跑出来 LCP 4.8 秒,LCP 元素就是这张 banner 图。在 4G 网络下,光下载这张图就需要 3 秒以上。更糟的是,图片加载完成后触发了布局偏移——因为 HTML 里没有预留图片的占位空间,CLS 也超标了。
修复方式:将图片转为 AVIF 格式并提供 WebP 降级,同时根据设备 DPR 和视口宽度提供多个尺寸。在 HTML 中明确设置图片的 width 和 height 属性,消除布局偏移。
<!-- ❌ 直接用原图,无尺寸适配,无占位 -->
<img src="/hero-banner.png" alt="首页横幅" />
<!-- ✅ 响应式图片 + 现代格式 + 明确尺寸 -->
<img
src="/hero-banner-800w.webp"
srcset="
/hero-banner-400w.avif 400w,
/hero-banner-800w.avif 800w,
/hero-banner-1200w.avif 1200w
"
sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
type="image/avif"
width="1200"
height="675"
alt="首页横幅"
loading="eager"
fetchpriority="high"
/>首屏关键图片应该用 loading="eager" 和 fetchpriority="high" 告诉浏览器优先加载,而不是走默认的懒加载。懒加载适合首屏以下的图片,用在 LCP 元素上反而会让 LCP 变慢。
案例三:第三方脚本阻塞交互导致 INP 超标
场景:一个 SaaS 产品的官网接入了三个第三方脚本:Google Analytics、Hotjar 热图和一个在线客服组件。三个脚本都通过 <script> 标签直接嵌入 <head>,都是同步加载。
翻车点:用户首次点击页面按钮时,页面需要 600ms 以上才有响应。INP 在 CrUX 数据里持续飘红。排查发现主线程在页面加载后被三个脚本的初始化和事件绑定占满了,用户的点击事件排在主线程队列里等了很久才被处理。
修复方式:将非首屏必需的第三方脚本改为异步或延迟加载,用 requestIdleCallback 把非关键初始化推到主线程空闲时执行。
<!-- ❌ 同步加载三个第三方脚本,全部阻塞主线程 -->
<head>
<script src="https://analytics.example/ga.js"></script>
<script src="https://hotjar.example/hj.js"></script>
<script src="https://chat.example/widget.js"></script>
</head>
<!-- ✅ 非关键脚本延迟加载,减少主线程阻塞 -->
<head>
<!-- Google Analytics 用 defer,不阻塞解析 -->
<script defer src="https://analytics.example/ga.js"></script>
</head>
<body>
<!-- Hotjar 和客服组件延迟到空闲时初始化 -->
<script>
// 用 requestIdleCallback 推迟非关键脚本
function loadNonCritical() {
const s1 = document.createElement('script')
s1.src = 'https://hotjar.example/hj.js'
s1.async = true
document.head.appendChild(s1)
const s2 = document.createElement('script')
s2.src = 'https://chat.example/widget.js'
s2.async = true
document.head.appendChild(s2)
}
if ('requestIdleCallback' in window) {
requestIdleCallback(loadNonCritical, { timeout: 3000 })
} else {
// 降级:3 秒后加载
setTimeout(loadNonCritical, 3000)
}
</script>
</body>修复后 INP 从 620ms 降到 180ms,主线程在页面加载后的前 2 秒基本不被第三方脚本占用。
性能预算的 CI 集成流程
预算写出来如果不自动检查,就只是一个愿望。下面是我在项目里用过的 CI 集成流程:
这个流程的关键设计是区分「阻塞」和「警告」。bundle 体积超预算直接阻塞合并,因为这是一个确定的、可复现的退化;Lighthouse CI 的某些 Vitals 指标可能受测试环境影响波动,所以用警告而不是阻塞,由人工判断。
webpack 自带的 performance 配置也可以作为第一道防线:
// webpack.config.js
module.exports = {
// ...
performance: {
maxAssetSize: 100 * 1024, // 单个资源不超过 100KB
maxEntrypointSize: 200 * 1024, // 入口点不超过 200KB
hints: 'error' // 'warning' 或 'error'
}
}webpack 的 performance 配置简单直接,但粒度不够细——它只检查文件大小,不检查运行时性能。更完整的方案需要配合 Lighthouse CI 或 size-limit。
工具选型对比
不同工具覆盖的阶段和能力不同,选型时需要根据团队实际情况决定。
| 工具 | 检查阶段 | 覆盖范围 | 阻塞能力 | 配置复杂度 | 适用场景 |
|---|---|---|---|---|---|
| webpack performance | 构建时 | 资源和入口体积 | 可配置为 error | 低 | 快速接入,基础防线 |
| size-limit | 构建时 | JS 打包体积 + 运行时成本 | 默认阻塞 | 低 | 库和中大型应用 |
| Lighthouse CI | 构建后 | 完整页面性能审计 | 可配置断言 | 中 | 全量 Vitals 检查 |
| bundlesize | 构建时 | 单文件体积 | 默认阻塞 | 低 | 轻量级体积检查 |
| SpeedCurve / Calibre | 运行时 | RUM + 合成监控 | 告警 | 低 | 线上持续监控 |
| 检查方式 | 优点 | 缺点 | 推荐频率 |
|---|---|---|---|
| 构建时检查 | 快速、确定性高、可在 PR 合并前拦截 | 无法测量运行时性能 | 每次 PR |
| 合成监控(Lab) | 覆盖完整页面指标 | 环境差异可能导致波动 | 每小时/每天 |
| 真实用户监控(RUM) | 反映真实用户体验 | 无法阻止退化,只能事后发现 | 持续 |
构建时检查是第一道门,合成监控是第二道巡检,RUM 是最终的事实来源。三层防线缺一不可,但投入应该从构建时检查开始。
好/坏做法对比
// ❌ 路由级组件全量打包,首屏加载大量非必需代码
const App = () => (
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
)
// ✅ 路由级代码分割,按需加载
import { lazy, Suspense } from 'react'
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings = lazy(() => import('./pages/Settings'))
const Analytics = lazy(() => import('./pages/Analytics'))
const App = () => (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
)差异在于首屏只加载当前路由的代码,其他路由的 JS 在用户导航时才下载。对于管理后台这类多页面应用,路由级分割通常可以把首屏 JS 减少 40-60%。
// ❌ 字体直接引用,不预加载,可能触发 FOIT/FOUT
// 在 CSS 中声明 @font-face,但没有 preload
// @font-face {
// font-family: 'CustomFont';
// src: url('/fonts/custom.woff2') format('woff2');
// }
// ✅ 预加载关键字体,消除字体加载延迟
// 在 HTML <head> 中添加
// <link rel="preload" href="/fonts/custom.woff2" as="font" type="font/woff2" crossorigin />
// CSS 中使用 font-display: swap 避免不可见文本
const fontStyles = `
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* 先用系统字体,自定义字体就绪后替换 */
}
`字体未预加载时,浏览器可能在渲染文本时才发现需要下载字体,导致 200-500ms 的文本不可见或布局抖动。preload 让字体在关键资源下载的同时就开始加载。
// ❌ 大列表一次性渲染所有项目
const UserList = ({ users }) => (
<ul>
{users.map(user => (
<UserItem key={user.id} user={user} />
))}
</ul>
)
// 10000 条数据直接渲染,首次绘制耗时 800ms+
// ✅ 虚拟滚动,只渲染可见区域
import { useVirtualizer } from '@tanstack/react-virtual'
const UserList = ({ users }) => {
const parentRef = useRef(null)
const virtualizer = useVirtualizer({
count: users.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60, // 每行预估高度
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<UserItem
key={users[virtualRow.index].id}
user={users[virtualRow.index]}
style={{ transform: `translateY(${virtualRow.start}px)` }}
/>
))}
</div>
</div>
)
}
// 10000 条数据只渲染可见的 10-20 条,首次绘制 < 50ms渲染成本也是一种性能预算。当列表数据超过几百条时,虚拟滚动可以将首次渲染时间从秒级降到毫秒级,同时减少内存占用和布局计算开销。
性能预算检查清单
以下清单按项目阶段分组,可以在团队协作中直接使用。
项目启动阶段
- 根据业务目标和竞品基准,确定 LCP、INP、CLS 的内部预算值
- 确定首屏 JS bundle 体积上限(建议 gzip 后 ≤ 170KB)
- 确定单个 chunk 的体积上限(建议 ≤ 50KB)
- 确定首屏图片的格式和大小规范(首屏用 AVIF/WebP,单张 ≤ 100KB)
- 确定首屏关键请求数量上限(建议 ≤ 15 个)
- 确定字体加载策略(preload + font-display: swap)
开发阶段
- 新增依赖前检查包体积,优先选择体积更小的替代方案
- 路由级组件使用
React.lazy做代码分割 - 首屏图片设置明确的
width/height属性,防止 CLS - 非关键第三方脚本使用
defer或async加载 - 大列表(> 100 条)使用虚拟滚动
CI/CD 阶段
- 配置 size-limit 或 bundlesize,每次 PR 自动检查 bundle 体积
- 配置 Lighthouse CI,对关键页面跑性能审计
- 将 bundle 体积检查设为阻塞(error),Vitals 检查设为警告(warning)
- CI 配置中指定性能预算阈值,不允许 PR 自行修改预算文件
上线后监控
- 接入 RUM(如 web-vitals 库 + 后端上报),收集真实用户的 LCP、INP、CLS 数据
- 设置性能指标告警,当 P75 值连续 3 天超出预算时触发
- 每周或每个迭代查看 CrUX 数据趋势,关注退化趋势
- 建立性能退化回滚机制,当关键指标突然恶化时可快速回滚
定期维护
- 每季度审查依赖体积,清理不再使用的依赖
- 每季度检查图片资源,确认是否可以使用更高效的格式
- 根据 RUM 数据调整预算阈值,随业务增长适度放宽或收紧
预算不是目的,是协作方式
性能预算的最终价值不是数字本身,而是让性能成为团队可以讨论的约束。当一个 PR 引入了 50KB 的新依赖,预算机制让这个话题从「要不要优化」变成「这个功能是否值得 50KB 的成本」。讨论的焦点从技术问题变成产品决策,效率会高很多。
我在项目里见过最有效的做法是把性能预算写进 CONTRIBUTING.md,让每个新成员在第一次提 PR 时就知道这些约束。预算不是某个人上线前兜底的事,而是每次变更都要守住的工程边界。
参考资料
Footnotes
-
AlterSquare. How Teams Incrementally Modernize Large Frontend Codebases. 2026. https://altersquare.io/how-teams-incrementally-modernize-large-frontend-codebases/ ↩
-
SARC Publisher. Engineering Lightning-Fast React Apps at Scale. 2025. https://sarcouncil.com/download-article/SJECS-82-2025-337-348.pdf ↩
-
MDN Web Docs. Performance Budgets. https://developer.mozilla.org/en-US/docs/Web/Performance/Guides/Performance_budgets ↩
-
Google. Web Vitals. web.dev. https://web.dev/articles/vitals ↩
-
size-limit. GitHub. https://github.com/ai/size-limit ↩