没被 import 的代码,为什么还在 bundle 里:Tree Shaking 的证明边界
Tree shaking 是打包器在构建期沿着 ES 模块的静态依赖图做可达性分析,把入口走不到的导出和能证明无副作用的语句从产物里删掉。它常被说成"自动删除没用的代码",但实际能否删掉,取决于模块格式是否静态、调用是否有副作用、以及 package.json 里的 sideEffects 承诺是否准确。最常见的翻车是以为换成 ESM 就生效,或把带 CSS、polyfill 的包标成 sideEffects 为 false,生产构建静默丢功能。读完你能分清"没被引用"和"删了安全",看懂 Rollup 与 webpack 的判定路径,并知道何时该改导入方式、何时该放弃摇树去拆包。
是什么
Tree shaking(摇树优化)是打包器在构建期做的一种死代码消除:它沿着 ES 模块的 import/export 关系,从入口出发标记真正会被执行的绑定,把走不到的导出、以及能够证明"删掉也不改变程序行为"的顶层语句从产物里去掉。名字来自 Rollup 社区的比喻——把应用看成一棵树,活代码是绿叶,死代码是枯叶,用力一摇,枯叶自己掉下来。
一句话直觉:它不是"猜"哪段代码没用,而是"证明"哪段代码可以安全消失。
它出现的场景很集中:Vite/Rollup 的生产构建、webpack 的 usedExports 与 sideEffects、组件库发布 ESM 产物、Serverless 函数压缩包体积。只要产物要过网络、要进冷启动,摇树就值得做;但它的效果高度依赖代码写法与包作者的声明质量。
核心原理
前提:模块图必须是静态的
ESM 的 import/export 只能写在顶层,导入名和导出名都是字面量,打包器不需要运行代码就能把"谁依赖谁、谁用了哪个名字"列成一张图。CJS 做不到这一点:require 可以写在 if 里,module.exports 可以在运行时被替换、被循环赋值,打包器无法在不执行的情况下确定最终导出是什么,只能把整个模块包进来,再交给压缩器做局部优化。这也是为什么"整条链路都用 ESM"是摇树的第一前提。
两步:先标记可达,再按副作用剪枝
Rollup 的做法可以拆成两个阶段。构建阶段,它解析每个模块的 AST,记录每个顶层语句的副作用,并把 import/export 连成图;生成阶段,它从入口开始 include:入口的语句要保留,遇到 import 就递归标记被用到的绑定,没被任何地方引用的导出声明就可以不进产物。webpack 的 usedExports 思路类似:先给未使用的导出打上标记,再由 Terser 在压缩时判断这些声明能不能删。
这里的关键是"副作用"三个字。模块被 import 时,它的顶层代码会被执行。所以哪怕某个导出没人用,只要模块顶层有 console.log、写全局变量、改原型、注册自定义元素,这段语句就必须保留——删了会改变程序行为。打包器的判断非常保守:一次函数调用默认可能有副作用;读一个属性可能触发 getter 或抛错(Rollup 的 propertyReadSideEffects 默认开启);访问未声明的全局变量可能抛 ReferenceError;try 块里的代码还可能被反优化。于是很多"看起来没用"的代码留在产物里,不是打包器偷懒,而是它无法证明删掉安全。
sideEffects:模块级的承诺书
webpack 4 引入了 package.json 的 sideEffects 字段。它表达的是:"如果没有从这个模块导入任何会被使用的绑定,你可以连这个模块带它的整棵子树一起跳过。" 设为 false 等于声明所有文件都干净;写成数组则是"除了这些文件,其余都干净"。它比 usedExports 更彻底,因为 usedExports 只能删模块内的语句,而 sideEffects 能直接不进入整个模块,子树里的依赖也一并不看。
但这是一份承诺,不是开关。CSS 的 import、polyfill、全局事件注册、原型扩展、Web Components 的 customElements.define,全都是副作用。包作者把 sideEffects 写成 false,消费者在生产构建里就可能得到"组件在、样式没了"或"polyfill 没执行"的结果,而且开发模式往往看不出来。还需要补充一个容易忽略的事实:Rollup 核心本身并不读这个字段,是 node-resolve 这类解析插件在解析时把它转成 moduleSideEffects;换一个打包器或换一条解析链,同一份声明未必被同样对待。
最后一段:压缩器接力
摇树之后还有 Terser、esbuild 等压缩器做 DCE:删不可达分支、折叠常量、去掉没被使用的局部变量。/#__PURE__/ 这类注释就是给它们看的——它标记"这次调用没有副作用,返回值没人用就可以整句删掉"。Rollup 与 webpack 都认这个注解,较新的 webpack 还支持用 /#__NO_SIDE_EFFECTS/ 标记函数声明本身,这样每个调用点就不必重复标注。
// math.js
export function square(x) { return x * x; }
export function cube(x) { return x * x * x; }
console.log("math evaluated"); // 顶层副作用:导入即执行
// app.js
import { square } from "./math.js";
const discarded = /*#__PURE__*/ square(2); // 返回值没人用,可整句删除
console.log(square(3));
这段代码里,cube 的导出没有被任何地方引用,可以从产物消失;但 "math evaluated" 必须留下,因为导入 math.js 就会执行它。只有当整个模块没有任何导出被使用、同时它又被标记为无副作用时,打包器才敢连这行日志一起跳过——sideEffects 的威力和风险,其实是同一件事。
一个容易忽略的边界:动态访问
import as utils from "./utils.js"; utils[name]() 这种写法,name 是变量,打包器不知道会取哪个属性,只能把 utils 的所有导出都留下。export from "./a" 组成的 barrel 文件也会把整棵子树拉进图里;把函数当参数传来传去、挂到对象上再调用,同样会让静态分析失效。这不是 bug,而是静态分析在动态语言里的天然边界:能证明的才删,证不了的就保守保留。
常见考点与误区
- "用了 ESM 就会自动 tree shaking":不一定。还要生产模式开启优化、中间链路没有把 ESM 转成 CJS、压缩器真的在跑。如果 Babel 配置把模块语法转成了 require,静态结构在半路就没了;不确定时显式设
modules: false。Vite 开发态用 esbuild 预打包依赖、优先保证启动速度,完整摇树要看生产构建,两者不能混着比较。 - "tree shaking 会删掉模块里没被调用的函数":粒度没这么细。它主要处理的是"没有被引用的导出绑定"和"能证明无副作用的顶层语句"。模块内部被调用过的函数、对象方法、通过字符串或动态属性访问的导出,都删不掉。把"没被外部 import"等同于"没被执行",是这道题最常见的错。
- "sideEffects: false 只有好处":它是承诺,写错就是事故。CSS、polyfill、全局注册代码被静默跳过,往往只在生产构建暴露,开发环境一切正常。正确做法是
sideEffects: ["**/*.css", "./src/polyfill.js"]这样列例外,并对发布产物做一次冒烟构建。 - "换成 lodash-es 就万事大吉":ESM 版本只是让分析成为可能,命名空间访问、barrel 重导出、被判定为有副作用的模块仍会拖进大量代码。Rollup 官方就提醒过,lodash 这类库容易被保守判定,子路径导入
lodash-es/map通常比import { map } from "lodash-es"更稳。 - "开发环境没删,说明配置坏了":开发模式要的是快速重建、可读堆栈和 HMR,通常会保留模块边界,压缩器也不跑。拿 dev 产物判断摇树效果,本身就看错了对象。
- "摇树能代替拆包":摇树只删死代码,动不了首屏真正要执行的活代码。路由级 dynamic import、把大依赖挪到独立 chunk、用好缓存与压缩,才是首屏体积的大头;摇树是必要项,不是万能项。
实践建议
- 库作者先把出口设计对:package.json 用
exports提供条件导出,同时给 ESM 与 CJS;sideEffects精确到文件 glob,别图省事写 false;构建保留 ESM,不要只发 UMD 或 CJS。少用export *的 barrel,改为显式的具名重导出,消费者打包时更容易只取所需。 - 应用侧先量再改。用 rollup-plugin-visualizer 或 webpack-bundle-analyzer 看 treemap,确认到底是"死代码没删"还是"活代码本来就大";顺手检查同一依赖是否装了多个版本、图标库是否全量导入、日期库是否把全部 locale 都带进来。没有测量的优化,很容易只改到边角。
- 配置上守住三件事:构建跑在 production、
NODE_ENV正确替换、Babel 不把 ESM 转 CJS。webpack 的usedExports与sideEffects在生产模式默认开启,不要为了"看起来干净"随手把依赖 external 掉,那只是把体积转移给运行时。 - 把副作用配置纳入验证:每次改
sideEffects或升级大依赖,都跑一次生产构建,检查样式、polyfill、全局注册是否还在;再把各入口的 gzip 体积设成 CI 预算,超过阈值就失败,防止某次提交悄悄把整棵子树带回来。 - 别为了摇树把代码拆得不可维护。优先级应该是:去掉模块顶层副作用、提供子路径导入、再考虑加纯函数注解。注解是补丁,不是设计;一个到处需要
/#__PURE__/的模块,往往说明它本来就不该在导入时做那么多事。
一句话总结:Tree shaking 删掉的不是"没被写的代码",而是"能被证明删了也不改变行为的代码"。