构建体积与性能治理
从产物分析、依赖治理、代码分割、Tree Shaking、CSS/图片压缩到预算和 CI 回归,建立可持续的构建性能闭环。
构建体积与性能治理
1. 先把“包大”拆成三个问题
- 首屏传输大:入口 chunk、关键 CSS、字体和 LCP 图片字节数高。
- 运行时执行重:下载不大,但解析、编译、执行或初始化耗时长。
- 构建/发布慢:loader、插件、压缩、source map、依赖预构建或 CI 缓存效率低。
Bundle 大小只是线索。应同时查看传输大小(gzip/Brotli)、未压缩大小、解析/执行时间、缓存命中率和真实页面路径。
2. 建立可重复的产物基线
2.1 产物可视化
Webpack 可用 webpack-bundle-analyzer,Rollup/Vite 可用 rollup-plugin-visualizer:
// vite.config.ts(只在分析命令中开启)
import { visualizer } from 'rollup-plugin-visualizer'
export default {
plugins: [
process.env.ANALYZE
? visualizer({ filename: 'tmp/stats.html', gzipSize: true, brotliSize: true })
: undefined,
].filter(Boolean),
}
固定 Node、包管理器、锁文件、环境变量和构建命令;否则不同机器的压缩、依赖解析或 source map 会让比较失真。每次报告至少记录:入口/路由 chunk、重复依赖、最大模块、传输与压缩大小、构建耗时。
2.2 源文件体积扫描
源文件扫描适合发现异常图片、字体或生成文件,但不能替代构建分析:
import fs from 'node:fs'
import path from 'node:path'
function walk(dir, result = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const file = path.join(dir, entry.name)
if (entry.isDirectory() && !['node_modules', 'dist', '.git'].includes(entry.name)) walk(file, result)
else if (entry.isFile()) result.push({ file, bytes: fs.statSync(file).size })
}
return result
}
const files = walk('src').sort((a, b) => b.bytes - a.bytes)
console.table(files.slice(0, 20))
不要把 Markdown、测试 fixture、source map 或临时分析目录算进线上预算;扫描脚本应明确 include/exclude,并把异常结果作为待调查项而不是自动删除。
3. 代码分割与长缓存
3.1 路由边界优先
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
function AppRoutes() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Suspense>
)
}
先按用户访问路径动态导入,再按公共依赖和更新频率设计共享 chunk。分包过细会增加请求、调度和解析开销;过粗则首屏和缓存收益差。用真实导航瀑布和缓存命中率验证,而不是以 chunk 数量为目标。
3.2 Webpack SplitChunks
optimization: {
splitChunks: {
chunks: 'all',
minSize: 20 * 1024,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: -10,
},
},
},
runtimeChunk: 'single',
}
runtimeChunk 抽离运行时代码可减少业务模块变化导致的缓存失效;具体 minSize、请求数和 cache group 应根据页面类型和协议调优。Vite/Rollup 的 manualChunks 同样应按访问边界与更新频率,而不是简单按库名切割。
3.3 文件指纹与部署顺序
output: {
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
}
静态资源使用内容 hash 才能安全长缓存;HTML 不应引用不存在的 chunk。发布顺序应为“上传新资源 -> 校验可访问 -> 切换 HTML -> 保留旧资源兼容窗口”,并记录构建 release 以支持回滚和 Source Map 定位。
4. Tree Shaking 与副作用
Tree Shaking 依赖 ESM 的静态导入导出、构建模式和副作用声明:
{
"sideEffects": [
"**/*.css",
"src/polyfills.ts"
]
}
常见失效原因:
- 使用 CommonJS 或动态字符串导入,静态分析信息不足;
sideEffects: false错误地删除了 polyfill、全局注册或 CSS;- 从聚合入口导入整个库,或库本身没有可摇树的 ESM 产物;
- 代码在模块顶层执行副作用,且未在包元数据中声明。
sideEffects 是契约,不是“越严格越好”的体积开关。修改前后用产物分析和运行时回归确认样式、注册器和 polyfill 仍然生效。
5. 依赖治理
5.1 未使用依赖分析的边界
静态扫描可以收集 ESM、CommonJS、动态导入和配置文件中的包名,但以下情况可能误报:
- CLI 命令、插件自动加载、框架约定目录;
package.json的 exports、side effects、types 或 peer dependency;- 字符串拼接动态导入、运行时按平台加载;
- 仅在 SSR、测试、脚本或特定构建模式使用的包。
安全流程:
- 先列候选,不自动卸载;
- 搜索源码、配置、脚本、锁文件和发布包入口;
- 在最小分支移除一个依赖,执行类型检查、构建、测试和关键页面冒烟;
- 观察生产错误、样式和按需加载,再合并变更。
5.2 依赖替换和 peerDependencies
moment换成dayjs可能减小体积,但要核对时区、locale、插件和 API 语义;lodash换成lodash-es只有在构建链能处理 ESM、且使用方式支持 Tree Shaking 时才有收益;- 组件库/公共库应把宿主已提供的 React/Vue 等放入
peerDependencies,避免打进两份运行时; - peer dependency 的版本范围、消费者安装方式和单例要求必须写清,不能只移动字段。
5.3 重复代码与无引用文件
重复代码工具(如 jscpd)适合发现可抽取的业务逻辑,但相似片段不必强行合并:先确认抽象边界、发布影响和可读性。无引用文件扫描要考虑路由约定、动态导入、组件自动注册、样式入口和 public 资源,扫描结果只能作为人工清理候选。
6. CSS、图片和字体构建优化
// PostCSS 生产配置示意
import cssnano from 'cssnano'
export default {
plugins: [
cssnano({ preset: 'default' }),
],
}
- 删除无用 CSS 前先覆盖动态 class、主题、CSS Modules 和 SSR 输出;
- 用 Brotli/gzip 压缩文本,图片在源头按格式、尺寸和质量优化;
- 对字体做字符集子集化,避免把全部语言字形放进首屏;
- CSS 提取和按路由加载要兼顾 FOUC、关键 CSS 和缓存粒度。
7. 构建速度优化
7.1 先找耗时来源
使用 Webpack profile、构建日志、插件 timing 或 Vite debug 输出,把耗时拆成依赖预构建、loader/transform、插件、source map、压缩和 I/O。没有 profile 时,不要直接添加并行插件。
7.2 常见手段及代价
| 手段 | 适用 | 代价/风险 |
|---|---|---|
缩小 include/exclude |
loader 处理范围过大 | 漏处理文件,需覆盖扩展名 |
| filesystem cache | 重复构建和 CI | 缓存键失效或占磁盘 |
| 并行 transform/压缩 | CPU 资源充足 | 进程开销、内存峰值、日志复杂 |
| 减少插件/关闭非必要 source map | 构建瓶颈明确 | 调试和错误定位能力下降 |
| 依赖预构建缓存 | Vite 开发冷启动 | 软链包、条件导出兼容问题 |
| 固定 Node/锁文件 | 可复现构建 | 升级需要维护兼容矩阵 |
Webpack 的 loader/plugin 生命周期和 Vite/esbuild 的边界见 Webpack 构建流程、Webpack 面试进阶、Vite 面试进阶 和 esbuild 面试进阶。
8. 性能预算与 CI 门禁
预算应按页面和设备类别设置,而非只设一个全局 MB 数:
{
"routes": {
"/": { "initialJsGzipKb": 220, "lcpP75Ms": 2500 },
"/editor": { "initialJsGzipKb": 420, "inpP75Ms": 200 }
}
}
CI 可以做三类门禁:
- 产物门禁:入口/路由 chunk、CSS、字体和图片超过预算时失败或要求评审;
- 实验室回归:固定 Lighthouse 条件,对 LCP/TBT/CLS 等设置容差;
- 现场回归:发布后按 release 观察 RUM P75/P95、错误率和 chunk 加载失败率,超阈值自动暂停灰度或回滚。
预算不是越低越好:过严会阻止必要功能,过松则失去反馈。每次豁免记录原因、影响路径、到期时间和后续偿还计划。
9. 面试追问速答
Q: 你如何证明做过包体积优化?
A: 先给构建版本、页面和设备条件,再展示分析报告中最大模块/重复依赖,说明采用路由懒加载、公共依赖拆分、Tree Shaking 或依赖替换的具体改动,最后给出传输字节、解析执行、LCP 和缓存命中率的前后对比。没有现场数据时明确标为实验室结果或待验证假设。
Q: 为什么 sideEffects: false 可能出问题?
A: 它告诉打包器未引用模块可整体删除;若模块注册全局能力、注入 polyfill 或导入 CSS,删除会改变运行时行为。应把真实副作用列入白名单,并通过样式/功能回归验证。
Q: 分包是不是越细越好?
A: 不是。分包要平衡首屏字节、请求/调度、解析执行和缓存复用;优先按路由和更新频率切,再通过瀑布图与现场数据调整粒度。
Q: 如何判断一个依赖真的没用?
A: 静态扫描只给候选。还要检查配置、脚本、自动注册、动态导入、SSR/测试和 peer dependency,在隔离分支跑类型检查、构建、测试和关键页面,再观察发布后的错误和资源变化。
10. 资料来源与现有专题
本篇综合 前端性能优化详解.pdf 关于 Rollup visualizer、源文件体积、未使用依赖、重复代码、无引用文件、peerDependencies、依赖替换和 CSS 压缩的内容,以及三份性能 PDF 关于代码分割、Tree Shaking、缓存和构建优化的内容。相关专题:前端性能优化知识地图、前端项目架构设计、组件库工程化。