
一句话:体积优化的顺序是「先量(parsed size,不是 gzip)、再砍(tree-shaking / 依赖替换 / 去重)、然后拆(代码分割)、最后压(terser + brotli)」,并用「体积预算 + CI 门禁」把结论固化下来,防止悄悄回涨。
一、先量:看清「谁在占地方」
⭐⭐⭐ 第一条原则:看 parsed size(解析后大小),而不是 gzip size
原因:gzip 的压缩率**高度依赖内容重复度**。
· 一个大 JSON 常量表 → gzip 后可能只有 5%(看起来"很小",但解析它要花 CPU 与内存)
· 一段重复度低的代码 → gzip 后 40%
⭐ 结论:「gzip 小」不等于「开销小」——
· **下载体积**看 gzip/brotli(这是网络成本)
· **解析与执行成本**看 parsed size(这是 CPU 与内存成本)
→ 两个都要看;只看 gzip 会漏掉「常量表/大对象」这类问题
| 工具 | 适用 | 特点 |
|---|---|---|
| webpack-bundle-analyzer | webpack | 树图(treemap),能看「初始/异步 chunk」与「重复包」 |
| rollup-plugin-visualizer / vite-bundle-visualizer | Vite / Rollup | 同样输出 treemap,支持 gzipSize/brotliSize |
| source-map-explorer | 有 source map 的产物 | ⭐ 能归因到「源文件」(不是模块),回答「哪个源文件占了 300KB」 |
| statoscope | webpack / rspack | ⭐ 面向 CI 的体积分析与差异对比(PR 级) |
du / 自写脚本 |
任意 | 快速看产物总览(见本篇实现) |
# ✅ 四个最常用的命令
# ① Vite:可视化(生成 stats.html)
npx vite-bundle-visualizer
# ② Rollup:作为插件配置
# visualizer({ filename: 'stats.html', gzipSize: true, brotliSize: true })
# ③ source-map-explorer:归因到源文件(需要保留 source map)
npx source-map-explorer dist/assets/*.js --gzip
# ④ 纯看大小排序(不需要任何工具)
du -sh dist/assets/* | sort -h | tail -20
⭐⭐ 读分析报告的四个要点:
① 【区分 initial 与 async】
初始 chunk 影响「首屏」(LCP/TTI);异步 chunk 影响「交互时的等待」
→ ⭐ 优先砍【初始 chunk】,它才是首次加载的关键路径
② 【找「重复依赖」(duplicate)】
同一个包出现两份(如 `lodash@3` 与 `lodash@4`、`react@18` 与 `react@17`)
→ 这是最容易被忽略、收益又很大的问题
③ 【找「不该在初始 chunk 里的大包」】
典型:`moment`(含全部 locale)、`chart.js`、`monaco-editor`、
`@ant-design/icons`(整个图标集)、`lodash`(整个)
→ ⭐ 它们往往「只在某个页面用」却被打进了入口
④ 【找「同一个库被多个 chunk 各打一份」】
原因:没有配置 `manualChunks`(webpack)或 `manualChunks`(rollup 的 `output.manualChunks`)
→ 配置后可以让「共享库」只出现一份并独立缓存
二、tree-shaking:让它真正生效的五个条件
⭐⭐⭐ tree-shaking 不是「配置项」,而是「一组前提」:
① 【必须是 ESM】
`import { a } from 'pkg'` 能被静态分析;`require('pkg').a` 不能。
→ 检查包的 `package.json`:有没有 `"module"` 字段 / `"exports"` 里的 `import` 条件
② 【包必须声明「无副作用」】
`"sideEffects": false`
⚠️ 写错会误删 CSS(见下方「特有的坑」)
→ 正确写法是把「有副作用的文件」列出来:
"sideEffects": ["*.css", "./src/polyfill.ts", "./src/register.ts"]
③ 【消费方要用「具名导入」】
✅ `import { debounce } from 'lodash-es'`
❌ `import * as _ from 'lodash'` 后再「动态取属性」(`_[name]()` 无法分析)
④ 【没有「顶层副作用」】
如果包在顶层做了 `window.x = ...`、注册全局、polyfill 等,
打包器不敢删(因为「删了会改变行为」)→ 无法 shake
⑤ 【构建器开启了优化】
Vite/Rollup 默认开;webpack 需要 `mode: 'production'` + `optimization.usedExports: true`
⚠️ 但如果是 CJS 包(如 `lodash`),webpack 仍然 shake 不掉
// ✅ 一个「检查依赖是否可 tree-shake」的小脚本(用 node 跑)
// 思路:看 package.json 的 module/exports/sideEffects 字段
import { readFileSync, existsSync } from 'node:fs';
function inspectPackage(pkgDir: string) {
const pkgPath = `${pkgDir}/package.json`;
if (!existsSync(pkgPath)) return null;
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
const hasEsmEntry = Boolean(pkg.module) || Boolean(pkg.exports && JSON.stringify(pkg.exports).includes('import'));
const sideEffects = pkg.sideEffects;
const verdict =
!hasEsmEntry ? '❌ 无 ESM 入口 → 基本无法 tree-shake'
: sideEffects === false ? '✅ ESM + sideEffects:false → 可 tree-shake'
: Array.isArray(sideEffects) ? `⚠️ ESM + sideEffects 数组(${sideEffects.length} 项)→ 可 tree-shake,但被列出的文件会保留`
: '⚠️ ESM 但未声明 sideEffects → 打包器保守处理,可能保留更多';
return { name: pkg.name, version: pkg.version, hasEsmEntry, sideEffects, verdict };
}
// 用法:列出项目里「可能 shake 不掉」的依赖
// ✅ 正确的 sideEffects 写法(保护 CSS 与 polyfill)
{
"name": "my-lib",
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfill.ts", // 有全局副作用
"./src/auto-init.ts"
]
}
// ⚠️ 常见错误:直接写 false,但代码里有「导入即生效」的文件
// "sideEffects": false
// → 打包器会把 `import './polyfill'` 删掉(因为它"看起来"没有用)⭐ 这是经典事故
三、依赖替换:收益最大的一件事
⭐⭐ 六个「高频大包」与替代方案:
① 【lodash(完整 ≈ 70KB+ min)】
❌ import { debounce } from 'lodash' // CJS → shake 不掉
✅ import debounce from 'lodash/debounce' // 单文件导入
✅ import { debounce } from 'lodash-es' // ESM,可 shake
✅ 更好:自己写(debounce/throttle 只有 20 行,见 3.13 的类似实现)
② 【moment(≈ 230KB,含全部 locale)】
✅ 换 dayjs(≈ 7KB)或 date-fns(可 tree-shake)
⚠️ 迁移注意:dayjs 默认也是「全局 locale」,要用按需引入:
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn'; // 只引需要的 locale
dayjs.locale('zh-cn');
⚠️ 时区要用 `dayjs/plugin/timezone` 与 `utc` 插件
③ 【图标库(如 @ant-design/icons / react-icons)】
❌ import { AllIcons } from '@ant-design/icons'(部分版本会把整包打进)
✅ import { SearchOutlined } from '@ant-design/icons'(具名导入,可 shake)
✅ react-icons 必须按「子包」导入:import { FaSearch } from 'react-icons/fa'
(从 'react-icons' 根导入会把所有图标集打进)
④ 【UI 库的按需】
现代版 antd / element-plus 已是 ESM 且支持 tree-shaking,
⚠️ 但「全局样式导入」(import 'antd/dist/reset.css')是整包的
→ 改用「按需样式」(或用 CSS-in-JS 方案)
⑤ 【polyfill(core-js)】
❌ 全量引入(core-js 全量 ≈ 250KB)
✅ babel 的 `useBuiltIns: 'usage'`(按实际用到的 API 引入)
⚠️ 但要「明确 target 浏览器」,否则会引入太多
⭐ 更现代的做法:用 `browserslist` + 让构建器自己决定(Vite 默认基于 target 转换)
⑥ 【图表/编辑器/地图等重库】
✅ 改为「动态 import」(只在打开时加载)
✅ 或用更轻的替代(echarts 的按需构建、chart.js 替代 heavy 库)
// ✅ «按需 + 懒加载» 的完整写法(图表库为例)
import { lazy, Suspense } from 'react';
// ⭐ 只在「需要时」才下载整个图表库(把重库移出初始 bundle)
const HeavyChart = lazy(() => import('./HeavyChart'));
export function Dashboard({ showChart }: { showChart: boolean }) {
if (!showChart) return <Placeholder />;
return (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
);
}
// ✅ Vite 的 manualChunks:把「重且稳定」的库拆成独立 chunk(利于缓存)
export default {
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return;
// ⭐ 把「体积大且很少变」的库单独拆(它们的 hash 长期不变,能被长效缓存命中)
if (id.includes('echarts')) return 'vendor-echarts';
if (id.includes('monaco')) return 'vendor-monaco';
if (id.includes('react-dom') || id.includes('/react/')) return 'vendor-react';
if (id.includes('lodash')) return 'vendor-lodash';
return 'vendor'; // 其余第三方统一一个 chunk
},
},
},
},
};
四、重复依赖:最容易被忽略的收益
# ✅ 检测「同一个包出现多个版本」
npm ls lodash # 看树里有没有多个版本
npx npm-dedupe-check # 或直接
npm dedupe # 尝试去重(同主版本会被提升)
# pnpm
pnpm why lodash # 谁依赖了 lodash
pnpm list --depth=Infinity | grep -c "lodash@" # 数版本
# yarn
yarn why lodash
// ✅ 强制统一版本(npm 8+ 用 overrides;yarn 用 resolutions;pnpm 用 pnpm.overrides)
{
"overrides": {
"lodash": "^4.17.21",
"moment": "$dayjs" // pnpm 支持「用另一个包替换」
}
}
⭐⭐ 「重复依赖」为什么收益大?
假设产物里同时有 `lodash@4.17.20` 与 `lodash@4.17.21`:
· 两份代码都会被下载与解析(即使 gzip 能压缩掉重复部分,
**parsed size 与执行时的代码量都是双份**)
· 而且「同一个函数的两份实现」可能带来「instanceof 不一致」这类诡异 bug
⭐ 更重要的一类:`react` 被装了两份(如某个库依赖 react@17)
→ 直接导致「Hooks 报错」("Invalid hook call")——这属于「体积问题」之外的结构性事故
五、压缩与产物:最后一道省
⭐⭐ 四个压缩层面的选择:
① 【minifier】
· **terser**:压缩率最好(默认、成熟),但慢
· **esbuild**:快 10~100 倍,体积略大 2~5%(Vite 默认用它压 minify)
· **swc**:与 esbuild 类似(Rust 实现)
→ ⭐ 结论:CI 里追求极致体积用 terser;追求构建速度用 esbuild/swc
② 【传输编码】
· gzip:兼容性最好
· **brotli(br)**:文本通常比 gzip 再小 15~20%(⚠️ 压缩耗时更长,适合构建期预压缩)
→ ⭐ 做法:「构建期生成 .br / .gz」+ CDN 按 `Accept-Encoding` 选择
→ ⚠️ 动态 brotli(服务端实时压)要设 quality(如 5~6),否则 CPU 会很高
③ 【source map】
· 生产环境**不要**把 `.map` 部署到公开目录(体积 + 源码泄露)
· 做法:构建时生成,**上传到监控平台**(Sentry 等),部署产物里删掉
· 或者用 `hidden-source-map`(生成但不加 `sourceMappingURL` 注释)
④ 【删掉无用代码】
· `drop_console` / `drop_debugger`(terser 选项)
· `dead_code`(消除 `if (false)` 分支)
· 环境变量替换(`process.env.NODE_ENV` → `"production"`)
⭐ 这一条极其重要:不替换的话,`react` 的 dev 分支、各种 `if (DEV)` 都会被打进产物
// ✅ Vite 的压缩配置(追求极致体积)
export default {
build: {
// esbuild 快;要更小就用 terser
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.info'], // ⭐ 只删日志,保留 console.error
passes: 3, // ⭐ 多轮压缩(体积更小,更慢)
},
format: { comments: false },
},
sourcemap: 'hidden', // 生成但不注入注释(上传监控后删除产物里的 map)
reportCompressedSize: true, // 输出 gzip 后大小
},
define: {
// ⭐ 让构建器能「死代码消除」
__DEV__: 'false',
},
};
六、完整实现:一个 240 行的体积报告与预算门禁
// scripts/analyze-bundle.mjs —— 体积报告 + 预算门禁(零依赖,用 Node 内置模块)
// 用法:node scripts/analyze-bundle.mjs [--dist dist] [--budget scripts/bundle-budget.json]
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
import { join, relative, extname } from 'node:path';
import { gzipSync, brotliCompressSync, constants } from 'node:zlib';
/** ============ 1. 遍历产物目录 ============ */
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(p));
else out.push(p);
}
return out;
}
/** ============ 2. 计算三种体积 ============ */
function measure(file) {
const buf = readFileSync(file);
const raw = buf.length;
const gzip = gzipSync(buf, { level: 9 }).length;
const brotli = brotliCompressSync(buf, {
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
}).length;
return { raw, gzip, brotli };
}
/** ============ 3. 判定「初始加载」资源(从 index.html 里解析) ============ */
function findInitialAssets(distDir) {
const htmlPath = join(distDir, 'index.html');
if (!existsSync(htmlPath)) return { scripts: new Set(), styles: new Set() };
const html = readFileSync(htmlPath, 'utf8');
const scripts = new Set();
const styles = new Set();
// ⭐ 关键判据:只把「没有 crossorigin/没有 modulepreload 标记为 async 的」算初始
// 这里简化处理:所有 <script src> 与 <link rel="stylesheet"> 都算初始
const scriptRe = /<script[^>]+src="([^"]+)"/g;
const styleRe = /<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"/g;
const preloadRe = /<link[^>]+rel="modulepreload"[^>]+href="([^"]+)"/g;
let m;
while ((m = scriptRe.exec(html))) scripts.add(normalize(m[1]));
while ((m = styleRe.exec(html))) styles.add(normalize(m[1]));
while ((m = preloadRe.exec(html))) scripts.add(normalize(m[1]));
// ⚠️ 注意:标记为 `async` / `defer` 且在「动态 import」里的 chunk 不算初始
return { scripts, styles };
}
/** 把 HTML 里的 URL 规范成产物相对路径(去掉 base、前导斜杠、query) */
function normalize(url) {
const noQuery = url.split('?')[0];
const noBase = noQuery.replace(/^\/+/, '').replace(/^\.\//, '');
// 只取 assets 之后的部分(兼容 base: '/sub/' 这类配置)
const idx = noBase.indexOf('assets/');
return idx >= 0 ? noBase.slice(idx) : noBase;
}
/** ============ 4. 预算:初始加载的 gzip 总量不能超标 ============ */
function loadBudget(path) {
if (!existsSync(path)) return null;
return JSON.parse(readFileSync(path, 'utf8'));
}
/** ============ 5. 主流程 ============ */
const args = process.argv.slice(2);
const distDir = getArg('--dist') ?? 'dist';
const budgetPath = getArg('--budget') ?? 'scripts/bundle-budget.json';
function getArg(name) {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
if (!existsSync(distDir)) {
console.error(`❌ 找不到产物目录:${distDir}(先执行构建)`);
process.exit(2);
}
const initial = findInitialAssets(distDir);
const files = walk(distDir).filter((f) => /\.(js|css)$/.test(f) && !f.endsWith('.map'));
const rows = files.map((file) => {
const rel = relative(distDir, file).split('\\').join('/');
const size = measure(file);
const isInitial = initial.scripts.has(rel) || initial.styles.has(rel);
return { file: rel, kind: isInitial ? 'initial' : 'async', size };
});
// ============ 6. 输出报告 ============
const fmt = (n) => `${(n / 1024).toFixed(1)}KB`;
const initialRows = rows.filter((r) => r.kind === 'initial').sort((a, b) => b.size.gzip - a.size.gzip);
const asyncRows = rows.filter((r) => r.kind === 'async').sort((a, b) => b.size.gzip - a.size.gzip);
const sum = (list, key) => list.reduce((acc, r) => acc + r.size[key], 0);
console.log('\n📦 初始加载(影响首屏)');
console.table(
initialRows.slice(0, 15).map((r) => ({
file: r.file,
raw: fmt(r.size.raw),
gzip: fmt(r.size.gzip),
brotli: fmt(r.size.brotli),
}))
);
console.log('\n🗂 异步 chunk(按需加载)Top 15');
console.table(
asyncRows.slice(0, 15).map((r) => ({
file: r.file,
raw: fmt(r.size.raw),
gzip: fmt(r.size.gzip),
brotli: fmt(r.size.brotli),
}))
);
const totals = {
initialRaw: sum(initialRows, 'raw'),
initialGzip: sum(initialRows, 'gzip'),
initialBrotli: sum(initialRows, 'brotli'),
asyncRaw: sum(asyncRows, 'raw'),
asyncGzip: sum(asyncRows, 'gzip'),
};
console.log('\n📊 汇总');
console.log(` 初始:raw ${fmt(totals.initialRaw)} | gzip ${fmt(totals.initialGzip)} | brotli ${fmt(totals.initialBrotli)}`);
console.log(` 异步:raw ${fmt(totals.asyncRaw)} | gzip ${fmt(totals.asyncGzip)}`);
console.log(` 产物总文件数:${rows.length}(初始 ${initialRows.length} / 异步 ${asyncRows.length})`);
// ============ 7. 预算门禁 ============
const budget = loadBudget(budgetPath);
if (!budget) {
console.log(`\nℹ️ 未找到预算文件 ${budgetPath},跳过门禁检查`);
process.exit(0);
}
const violations = [];
const checks = [
{ name: '初始 gzip 总量', actual: totals.initialGzip, limit: budget.initialGzipTotal },
{ name: '初始 brotli 总量', actual: totals.initialBrotli, limit: budget.initialBrotliTotal },
{ name: '单个 chunk 上限', actual: Math.max(0, ...rows.map((r) => r.size.gzip)), limit: budget.maxSingleChunkGzip },
];
for (const c of checks) {
if (typeof c.limit !== 'number') continue;
if (c.actual > c.limit) {
violations.push(`${c.name} 超标:${fmt(c.actual)} > ${fmt(c.limit)}(超出 ${fmt(c.actual - c.limit)})`);
} else {
console.log(` ✅ ${c.name}:${fmt(c.actual)} / ${fmt(c.limit)}`);
}
}
// 单文件规则(如「不允许某个 chunk 超过 X」)
for (const [pattern, limit] of Object.entries(budget.perFile ?? {})) {
const re = new RegExp(pattern);
for (const r of rows) {
if (re.test(r.file) && r.size.gzip > limit) {
violations.push(`文件 ${r.file} 超标:${fmt(r.size.gzip)} > ${fmt(limit)}`);
}
}
}
console.log('');
if (violations.length) {
console.error('❌ 体积门禁未通过:');
violations.forEach((v) => console.error(` · ${v}`));
process.exit(1);
}
console.log('✅ 体积门禁通过\n');
// scripts/bundle-budget.json
{
"initialGzipTotal": 204800,
"initialBrotliTotal": 174080,
"maxSingleChunkGzip": 153600,
"perFile": {
"vendor-echarts.*\\.js$": 184320
}
}
# ✅ CI 里的用法(.github/workflows/bundle.yml 片段)
- run: npm run build
- run: node scripts/analyze-bundle.mjs --dist dist
- name: 上传体积报告(便于 PR 对比)
uses: actions/upload-artifact@v4
with:
name: bundle-report
path: dist/**/*.js
// ✅ 也可以直接用成熟方案:size-limit(它内置「与上次对比」)
// package.json
{
"size-limit": [
{ "name": "初始加载", "path": "dist/assets/index-*.js", "limit": "200 KB", "gzip": true },
{ "name": "CSS", "path": "dist/assets/*.css", "limit": "20 KB" }
]
}
// npx size-limit → 报告每个入口的大小与是否超标
// npx size-limit --why → ⭐ 直接告诉你「哪个包占了最多」(这是它最实用的功能)
七、本篇特有的坑
// ① 只看 gzip 大小(忽略 parsed size)
// ⚠️ 一个「大常量表」gzip 后可能很小,但解析/遍历成本很高
// ✅ 两个都看,重要页面用「Performance 面板」看真实解析时间
// ② 「sideEffects: false」写成 false,导致 CSS / polyfill 被删掉
{
"sideEffects": false // ❌ 如果你的包里有 import './style.css' 或 global polyfill
}
// ✅ "sideEffects": ["*.css", "./src/polyfill.ts"]
// ③ 以为「配了 tree-shaking 就一定生效」
// ⚠️ 前提是「ESM + sideEffects + 具名导入 + 无顶层副作用」四条都成立
// ✅ 检查依赖的 package.json(module/exports/sideEffects)
// ④ 用「CJS 的 lodash」还想 tree-shake
import { debounce } from 'lodash'; // ❌ CJS → shake 不掉
import debounce from 'lodash/debounce'; // ✅
import { debounce } from 'lodash-es'; // ✅
// ⑤ moment 的 locale 全打进去
import moment from 'moment'; // ⚠️ 默认包含所有 locale(≈ 230KB)
// ✅ 换 dayjs,或用 IgnorePlugin 排除 locale(webpack)
// import 'dayjs/locale/zh-cn'; // ⭐ 只引需要的(默认 dayjs 只带 en)
// ⑥ 从根导入图标集(react-icons)
import { FaSearch } from 'react-icons'; // ❌ 会把所有图标集打进去
import { FaSearch } from 'react-icons/fa'; // ✅ 只引 fa
// ⑦ 「全部图标都注册成组件」(自研图标库的常见错误)
// ⚠️ 如果 icon 组件在「字典对象」里被引用,打包器无法判定哪些没用
// → 用「显式具名导出」或「按需 import」
// ⑧ 忽略「重复依赖」
// ✅ `npm ls <pkg>`、`overrides`/`resolutions` 统一版本、`npm dedupe`
// ⭐ 尤其检查 react / react-dom 是否只有一份(两份会导致 Hooks 报错)
// ⑨ 把「只在某个页面用的大库」放进初始 chunk
// ✅ 改动态 import(lazy),或配置 manualChunks
// ⑩ manualChunks 拆得太碎(上百个 chunk)
// ⚠️ HTTP 请求数增加 + 每个 chunk 的框架开销(Rollup 的 wrapper)
// ✅ 只拆「大且稳定」的库(react、图表、编辑器),其余合成 vendor
// ⑪ 生产环境部署 source map(体积 + 源码泄露)
// ✅ `sourcemap: 'hidden'` + 只上传到监控,产物里删除 .map
// ⑫ 忘记「依赖目标浏览器」
// ⚠️ target 定得太低 → 引入大量 polyfill;太高 → 老设备报错
// ✅ 用 `browserslist`(或 Vite 的 `build.target`)明确支持范围
// ⑬ 用 gzip 的「动态压缩」而不预压缩
// ⚠️ 服务端实时压 brotli(quality 11)CPU 极高
// ✅ 构建期预压缩(生成 .br/.gz),CDN 直接发
// ⑭ 只优化一次(没有门禁),下个迭代又涨回来
// ✅ 加「体积预算 + CI 检查」(见本篇实现)
// ⑮ 优化「异步 chunk」而放过「初始 chunk」
// ⚠️ 初始 chunk 才是首屏关键路径 → 优先砍它
// ⑯ 用「体积」作为唯一指标(忽略「缓存命中率」)
// ⚠️ 把稳定的第三方库拆成独立 chunk,能让「业务代码更新后」用户的 vendor 缓存仍然命中
// → 这比「总体积小 5KB」更有价值(见 5.16 的资源提示与缓存)
八、面试延伸
- 「新接手一个项目,怎么把包体积降下来?」
分五步:① 先量——用webpack-bundle-analyzer/vite-bundle-visualizer/source-map-explorer看「初始 chunk 里谁最大」(⭐ 优先砍初始 chunk,它才是首屏关键路径);② 换/按需——moment → dayjs、lodash → lodash-es 或单文件导入、图标按需、UI 库按需、core-js用useBuiltIns: 'usage';③ 拆——把「只在某页面用的大库」(图表、编辑器、地图)改成 dynamic import,并用manualChunks把「大且稳定」的库拆成独立 chunk(利于长效缓存);④ 去重——npm ls查「同包多版本」,用overrides/resolutions统一(⚠️ 尤其确认react只有一份);⑤ 压——terser(passes/drop_console)+ 构建期 brotli 预压缩 + 删 source map,最后加 体积预算 + CI 门禁。 -
「为什么不能只看 gzip 大小?」
因为 gzip 的压缩率高度依赖内容的重复度:一个大常量表(如城市列表、locale 数据)gzip 后可能只剩 5%,看起来「很小」,但它依然需要下载、解析、常驻内存,并且会被遍历。所以要看两个数:gzip/brotli 反映「网络传输成本」,parsed size 反映「解析与执行成本」。只看 gzip 会漏掉「常量表/大对象」这类问题(它们往往应该被「移到服务端」或「按需加载」)。 -
「tree-shaking 生效需要什么条件?」
四个前提:① 代码是 ESM(import/export,不是 CJS);② 包声明了sideEffects(false或「只列出有副作用的文件」);③ 消费方用具名导入(而不是「导入命名空间后动态取属性」);④ 包没有顶层副作用(没在模块顶层改全局/注册东西)。最容易踩的坑是「CJS 包」(如lodash主包)——即使你写了具名导入,webpack 也 shake 不掉,必须改成lodash/debounce这样的单文件导入,或换lodash-es。 -
「
sideEffects写错会怎样?」
经典事故:把一个「有副作用的包」声明成"sideEffects": false,打包器就会把import './polyfill'或import './style.css'当作「没有用的导入」删掉 → 结果是「样式丢失」或「polyfill 没生效」。正确写法是把它写成数组,只把「确实有副作用的文件」列出来:"sideEffects": ["*.css", "*.scss", "./src/polyfill.ts"]。判断标准是「这个文件被 import 后,即使不取用它的任何导出,也会产生效果吗」。 -
「代码分割怎么切比较合理?」
三层切法:① 路由级——每个路由一个 async chunk(收益最大);② 重型组件级——「打开才需要」的大库(图表、编辑器、地图)用lazy+Suspense;③ chunk 级共享——用manualChunks把「大且稳定」的库(react/react-dom、图表库)拆成长期不变的 vendor chunk,这样「业务代码更新」时用户只需重新下载业务 chunk,vendor 缓存仍然命中(这比「总体积小 5KB」价值更高)。⚠️ 不要拆得太碎——每个 chunk 都有请求开销与 wrapper 开销,通常「1 个入口 + 3~8 个 vendor + 每路由 1 个」是合理区间。 -
「重复依赖是怎么产生的?有什么危害?」
产生原因:① 不同依赖对同一个包要求了不兼容的版本范围(如 A 要lodash@^3、B 要lodash@^4);② 包管理器无法把两个版本「提升到顶层同一位置」;③ 有 peerDependencies 未被满足(最常见的是 React 装了两份)。危害:① 体积翻倍(且 parsed size 与执行代码量都是双份);②instanceof判断失效(跨副本的对象不是同一个类);③ ⭐ React 装两份会直接导致 “Invalid hook call”(不是性能问题,是结构性故障)。检测与修复:npm ls <pkg>/pnpm why/ analyzer 的 duplicate 视图 → 用overrides(npm)、resolutions(yarn)、pnpm.overrides强制统一。 -
「terser 和 esbuild 压缩怎么选?gzip 和 brotli 呢?」
minifier:terser压缩率最好(成熟、可通过passes多轮压缩),但慢;esbuild/swc快 10~100 倍,体积略大 2~5%。→ CI/生产追求极致体积用 terser,本地开发或追求构建速度用 esbuild(Vite 默认)。传输编码:brotli对文本通常比 gzip 再小 15~20%,但压缩更耗时 → ⭐ 正确做法是「构建期预压缩(生成.br/.gz)+ CDN 按Accept-Encoding直接发」,而不是「服务端实时压 brotli quality 11」(CPU 会爆炸)。 -
「怎么做体积门禁?」
分三层:① 总量预算——「初始加载 gzip 不超过 200KB」(写进bundle-budget.json或size-limit配置);② 单文件规则——「任何单个 chunk 不超过 150KB」「某个 vendor chunk 不超过 180KB」;③ 趋势对比——在 PR 里自动评论「本 PR 体积变化 +X KB / −Y KB」(size-limit --why、statoscope都支持)。实现方式是「在 CI 里跑构建 → 跑分析脚本 → 超标则exit 1」(见本篇的 240 行脚本)。⭐ 门禁的价值在于「防止悄悄回涨」——一次性优化很容易,难的是「半年后它还是这么大」。
一句话速记
顺序是「先量 → 再砍 → 然后拆 → 最后压」;量的时候「parsed size 与 gzip/brotli 都要看」(gzip 小 ≠ 开销小,常量表就是典型),并区分「初始 chunk(首屏关键路径,优先砍)」与「异步 chunk」;tree-shaking 的四个前提是「ESM +
sideEffects声明 + 具名导入 + 无顶层副作用」,⚠️sideEffects: false写错会删掉 CSS/polyfill(应写数组列白名单),CJS 的lodash主包 shake 不掉(用lodash-es或单文件导入);收益最大的是「依赖替换」(moment→dayjs、图标按需、core-js按需、重库改lazy)与「去重」(npm ls+overrides,尤其确认 react 只有一份);分割用「路由级 + 重组件级 +manualChunks把大而稳定的库拆成独立 vendor」(利于长效缓存,别拆太碎);压缩用「terser(passes+drop_console)+ 构建期 brotli 预压缩 + 删 source map」;最后用「体积预算 + CI 门禁 + PR 趋势评论」把结论固化,防止悄悄回涨。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!