Advanced (更新: 2026/6/2)

用 Claude Code 改善 Tree Shaking:可测量的实战指南

用 Claude Code 改善 tree shaking:ESM、sideEffects、体积测量、失败案例和可运行示例。

用 Claude Code 改善 Tree Shaking:可测量的实战指南

先用普通话讲清楚 Tree Shaking

Tree shaking 是生产构建时删除未使用 JavaScript/TypeScript export 的优化。 它的目标不是让源码变少,而是让浏览器少下载当前页面不需要的代码。 如果一个公共工具文件里有金额格式化、CSV 导出、Markdown 渲染和调试函数,而页面只用了金额格式化,理想结果是其他函数不进入最终 bundle。

问题在于,打包器不能猜开发者的意图。 它会根据 importexportpackage.json 里的 sideEffects、CommonJS 转换,以及模块顶层会不会执行代码来判断能不能删除。 所以真实项目里经常出现两类问题:明明没用的函数仍然被打进去,或者一加 sideEffects: false,CSS、polyfill、全局注册就被误删。

Claude Code 最适合做的是“有证据的瘦身”,而不是一句“把包变小”。 让它先测量当前体积,再找 CommonJS、barrel file、default object export 和副作用文件,然后小范围修改,最后用生产构建验证。 下面的流程是 Masa 在清理 Vite、React、Astro 项目时常用的最小做法。

flowchart LR
  A["source files"] --> B["ESM import/export graph"]
  B --> C["bundler tree shaking"]
  C --> D["minified production bundle"]
  B --> E["side effects kept"]
  E --> D
  D --> F["measure bytes and gzip"]

先对齐官方文档

不同打包器的保守程度不同。 改生产项目之前,至少把这些官方页面作为依据。

主题官方链接实务检查点
webpackTree ShakingsideEffects、ESM、production build
webpack 配置optimization.sideEffectswebpack 如何读取 package 的副作用标记
Rollup/ViteRollup treeshake不要粗暴关闭全部模块副作用
Rollup 细节treeshake.moduleSideEffects保留需要执行初始化的模块
esbuildTree shakingESM 静态分析和 metafile 测量

核心原则是:tree shaking 不是按字符串删除代码。 它依赖 ESM 的静态依赖图,并且只在不会改变运行时行为时删除代码。 CommonJS、命名空间 import、把很多函数塞进 default object、在模块顶层导入 CSS 或 polyfill,都会让结果变得保守。

给 Claude Code 的提示词模板

第一步不要直接修改。 先让 Claude Code 输出证据,否则很容易为了追求体积,把样式或初始化逻辑删掉。

请调查这个仓库生产 bundle 中 tree shaking 不理想的原因。
先用表格列出当前构建体积、主要 chunk、重依赖、CommonJS 依赖和 barrel export。
每个修改建议都要附上风险、预期体积影响和验证命令。
CSS、polyfill、analytics、global setup 文件必须保留,不要误删。

开始修改时,把范围限制住。

本轮只处理 src/utils 和 src/components/index.ts。
把 default object export 改成 named export,并同步修改使用方 import。
改完后运行 npm run build 和 bundle 体积测量。
如果会影响公开 API,请保留兼容 re-export。

这样 Claude Code 会围绕“少发代码但不破坏行为”工作,而不是做大范围重构。

可复制运行的最小测量示例

先建立一个小项目。

mkdir tree-shaking-lab
cd tree-shaking-lab
npm init -y
npm install --save-dev esbuild
mkdir src scripts

package.json 改成下面这样。

{
  "name": "tree-shaking-lab",
  "version": "1.0.0",
  "type": "module",
  "private": true,
  "sideEffects": false,
  "scripts": {
    "measure": "node scripts/measure-tree-shaking.mjs"
  },
  "devDependencies": {
    "esbuild": "^0.25.0"
  }
}

弱一点的写法是把所有工具函数放进一个对象。

// src/bad-utils.ts
const utils = {
  formatCny(amount: number): string {
    return new Intl.NumberFormat("zh-CN", {
      style: "currency",
      currency: "CNY"
    }).format(amount);
  },
  heavyReport(rows: number[]): string {
    const body = rows.map((row) => `row:${row}`).join("\n");
    return `report\n${body}\n${"=".repeat(4000)}`;
  },
  debugOnly(): string {
    return "debug:" + "x".repeat(4000);
  }
};

export default utils;

更利于分析的写法是单独导出每个函数。

// src/good-utils.ts
export function formatCny(amount: number): string {
  return new Intl.NumberFormat("zh-CN", {
    style: "currency",
    currency: "CNY"
  }).format(amount);
}

export function heavyReport(rows: number[]): string {
  const body = rows.map((row) => `row:${row}`).join("\n");
  return `report\n${body}\n${"=".repeat(4000)}`;
}

export function debugOnly(): string {
  return "debug:" + "x".repeat(4000);
}

建立两个入口。

// src/bad-entry.ts
import utils from "./bad-utils";

console.log(utils.formatCny(1200));
// src/good-entry.ts
import { formatCny } from "./good-utils";

console.log(formatCny(1200));

测量脚本如下。

// scripts/measure-tree-shaking.mjs
import { gzipSync } from "node:zlib";
import { build } from "esbuild";

async function bundle(entryPoint) {
  const result = await build({
    entryPoints: [entryPoint],
    bundle: true,
    minify: true,
    format: "esm",
    treeShaking: true,
    write: false,
    metafile: true
  });

  const code = result.outputFiles[0].text;
  return {
    entryPoint,
    bytes: Buffer.byteLength(code),
    gzipBytes: gzipSync(code).byteLength,
    inputs: Object.keys(result.metafile.inputs)
  };
}

const rows = await Promise.all([
  bundle("src/bad-entry.ts"),
  bundle("src/good-entry.ts")
]);

console.table(rows);

运行:

npm run measure

真实项目里,不要只看 raw bytes。 还要记录 chunk 名称、gzip、Brotli,以及 Lighthouse 的 Total Blocking Time。 如果需要追踪是哪一个依赖留在图里,可以配合阅读包体积分析

用例1:拆分工具函数

最常见的问题是 utils/index.tshelpers.ts 太大。 日期、金额、字符串、CSV、Markdown、调试工具混在一起,页面只用一个函数也会让分析变困难。

可以让 Claude Code 这样处理:

按用途拆分 src/utils。
把使用方改成 named import,只在 index.ts 里 re-export 真正需要公开的函数。
如果发现顶层 Date.now、console、localStorage、fetch,请移动到函数内部。

目标结构可以是这样:

// src/utils/formatDate.ts
export function formatDate(date: Date, locale = "zh-CN"): string {
  return new Intl.DateTimeFormat(locale).format(date);
}
// src/utils/index.ts
export { formatDate } from "./formatDate";
export { formatCny } from "./formatCny";
// src/pages/invoice.ts
import { formatCny } from "../utils/formatCny";

export function invoiceLabel(total: number): string {
  return `合计: ${formatCny(total)}`;
}

barrel file 本身不是坏东西。 危险的是它做了初始化、链式 export * from 太多,或者把无关模块都拉进来。 应用内部优先直接 import;公开库为了兼容,可以保留很薄的 barrel。

用例2:社内 UI 组件库

很多团队的 UI 包都提供 import { Button } from "@acme/ui"。 但这个入口背后可能同时评估 Modal、DatePicker、Chart、图标集合、CSS 和主题初始化。 如果所有组件共享一个大入口,named export 也未必能解决问题。

更好的方式是提供 subpath entry。

{
  "name": "@acme/ui",
  "type": "module",
  "sideEffects": [
    "**/*.css",
    "./src/setup-theme.ts"
  ],
  "exports": {
    ".": "./dist/index.js",
    "./button": "./dist/button.js",
    "./modal": "./dist/modal.js"
  }
}

使用方只导入需要的入口。

import { Button } from "@acme/ui/button";

这里最容易犯错的是 sideEffects: false。 它表示“导入这个模块本身不会产生必须保留的外部影响”。 CSS、custom element 注册、polyfill、主题初始化如果必须执行,就要写进 sideEffects 数组。

用例3:只在管理画面加载重依赖

Markdown、PDF、图表、富文本编辑器通常不需要出现在公开首页的初始 chunk 里。 先用 tree shaking 删除未使用 export,再用代码分割把管理功能延后加载。

// src/features/admin/loadMarkdownPreview.ts
export async function renderMarkdown(markdown: string): Promise<string> {
  const [{ unified }, remarkParse, remarkHtml] = await Promise.all([
    import("unified"),
    import("remark-parse"),
    import("remark-html")
  ]);

  const file = await unified()
    .use(remarkParse.default)
    .use(remarkHtml.default)
    .process(markdown);

  return String(file);
}

注意:动态 import 不是 tree shaking 的替代品。 它只是把代码移动到后续 chunk;如果那个 chunk 里仍然是 CommonJS 或大对象导出,体积还是会大。

用例4:发布 npm 包

如果你发布库,就要给使用者的打包器一个容易分析的 ESM 入口。 只暴露 CommonJS main,会让前端使用者更难获得理想的 tree shaking。

{
  "name": "@masa/formatters",
  "type": "module",
  "sideEffects": false,
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./currency": {
      "types": "./dist/currency.d.ts",
      "import": "./dist/currency.js"
    }
  }
}

只有整个包真的没有导入时副作用,才写 sideEffects: false。 如果有 CSS、polyfill、全局注册或 analytics 初始化,要把那些文件列出来。

失败案例和坑

症状处理
Babel 或 TypeScript 太早转 CommonJS未使用 export 留在 bundle到打包步骤前保留 ESM
sideEffects: false 范围太大CSS 或 polyfill 消失显式列出副作用文件
default object export没用的函数跟着对象留下拆成 named export
barrel file 有顶层初始化导入一个组件也很重barrel 只做 re-export
用 dev build 测量体积结果不可信用 production、minify、gzip 比较
全局设置 moduleSideEffects: false初始化逻辑被删除按依赖或文件验证
namespace import分析更保守改成具体 named import

最危险的是轻微的视觉回归。 测试只检查 DOM 是否存在时,CSS 被删掉也可能通过。 像做性能优化一样,把构建输出、关键页面和用户可见行为一起检查。

把体积预算放进 CI

一次瘦身不够,下一次依赖升级可能又变大。 至少加入一个 gzip 预算脚本。

// scripts/check-bundle-budget.mjs
import { statSync } from "node:fs";
import { gzipSync } from "node:zlib";
import { readFileSync } from "node:fs";

const file = "dist/assets/index.js";
const maxGzipBytes = 160 * 1024;
const raw = readFileSync(file);
const gzipBytes = gzipSync(raw).byteLength;

if (gzipBytes > maxGzipBytes) {
  console.error(`Bundle budget exceeded: ${gzipBytes} > ${maxGzipBytes}`);
  process.exit(1);
}

console.log({
  file,
  bytes: statSync(file).size,
  gzipBytes
});

构建后执行:

npm run build
node scripts/check-bundle-budget.mjs

第一次预算不要设成理想值。 以当前 gzip 体积为基准,留一点余量;PR 增加体积时要求说明原因。 如果体积降了但页面仍慢,再结合速度改善检查图片、字体、API 和 hydration。

Claude Code 复核清单

请复核这个 tree-shaking PR。
1. 未使用 export 是否真的从 production bundle 中消失?
2. CSS、polyfill、注册文件是否被保留?
3. ESM 是否保留到打包器可以分析的阶段?
4. 直接 import 是否破坏公开 API 兼容性?
5. build、测试、关键页面、bundle budget 的结果是什么?
每一点都请附文件名和命令证据。

这个清单能把重构变成发布前质量检查。 Masa 的项目里,sideEffects 修改后一定会打开登录、计费、管理页面,看样式和初始化逻辑是否仍然存在。

变现和咨询视角

Tree shaking 不只是工程洁癖。 初次加载越轻,用户越容易继续阅读文章、打开产品页、提交注册表单或咨询表单。 对 ClaudeCodeLab 这类技术内容站来说,代码示例页和服务咨询页太重,会直接削弱广告和咨询转化路径。

ClaudeCodeLab 可以帮你审计 Vite、Next.js、Astro 和内部 UI 包:从 bundle 分析、tree shaking、代码分割到 CI 预算化一起处理。 咨询时准备 package.json、构建配置、关键路由和最近的 bundle report,通常能很快找出削减候选。

总结

Tree shaking 要靠 ESM、准确的 sideEffects、可控的副作用和持续测量一起发挥作用。 让 Claude Code 做小范围、可验证的修改,比让它“把项目变轻”安全得多。

我已经在本地运行了本文的最小示例,并通过 npm run measure 确认 bad entry 与 good entry 会产生不同输出体积。 真实项目的数字取决于依赖和构建配置,请始终在自己的 production build 中测量,并先明确哪些副作用必须保留。

#Claude Code #tree shaking #bundle size #ES Modules #前端优化
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。