ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Tree-shaking原理与配置:让构建产物真正瘦身

Tree-shaking原理与配置:让构建产物真正瘦身 前两天一位读者发来生产环境包体积截图跟我说他已经开了mode: production也装了压缩插件为什么包体积还是比预期大了一倍。我扫了一眼构建产物发现他项目里那个工具函数模块几乎整体进了 bundle里面大半函数一次都没被调用过。我告诉他你缺的不是插件是 Tree-shaking 这块拼图。Tree-shaking 直译过来是“摇树”是前端工程化里专门用来清除“死代码”的构建优化手段。它解决的问题很具体你写了一个好好的模块里面导出了 10 个函数但业务代码只用到了其中 2 个剩下的 8 个如果在构建时没有被正确剔除就会被原封不动地打进最终产物变成用户需要下载的“纯垃圾”。这篇内容不打算只给你概念我会把原理、配置、坑和排查思路一次讲透尤其适合那些已经会写 webpack 配置但始终没搞懂“为什么摇不动”的前端开发者。1. “没用的代码”是怎么混进产物的先别急着看配置我们得先搞清楚一个问题死代码到底从哪来。很多人以为只有自己手滑写了永远不会调用的函数才会产生死代码实际上在现代前端项目里死代码的主要来源是“导出但未被引用”的模块成员以及“只因为入口被连带引入”的整个文件。1.1 一个最常见的污染场景假设你的业务代码长这样// utils.js export function formatDate(date) { return date.toLocaleDateString(zh-CN); } export function formatPhone(phone) { return phone.replace(/(\d{3})\d{4}(\d{4})/, $1****$2); } export function randomId() { return Math.random().toString(36).slice(2); } export function debounce(fn, delay 300) { let timer null; return function (...args) { clearTimeout(timer); timer setTimeout(() fn.apply(this, args), delay); }; }// main.js import { formatDate, debounce } from ./utils.js;如果构建器不做任何优化formatPhone和randomId这两个函数虽然没人调用但因为它们是utils.js的导出成员构建器为了确保“到时候真的能拿到”默认会把整个模块塞进产物。结果用户多下载了几 KB 甚至几十 KB 的代码浏览器解析器也要多花时间处理这些永远不会执行的内容。1.2 模块入口的“连带效应”更隐蔽的情况是模块入口文件。很多项目习惯在目录里放一个index.js然后用export * from ./a这种方式把所有子模块聚合出去。这时候只要业务代码import { a } from ./api/index构建器为了保守地保留所有导出很可能会把整个目录下的代码都打包进来。这种场景不是摇树机制失效而是“聚合导出”这种写法天然会给静态分析制造麻烦。1.3 Tree-shaking 和压缩、按需加载不是一回事很多新手会把“压缩代码”和 Tree-shaking 混为一谈甚至觉得开了压缩就等于做了摇树这是误区。代码压缩Minify解决的是“代码写得太啰嗦”的问题比如删掉空格、缩短变量名、合并声明。Tree-shaking 解决的是“代码压根不该存在”的问题它把未被引用的导出和模块直接移除。按需加载Code Splitting解决的是“代码其实可以晚点再下载”的问题它把包拆成多个 chunk让用户访问首屏时不加载其他路由的代码。这三者经常配合出现但语义完全不同。你压缩做得再好死代码依然躺在那里只是体积从 10KB 变成了 8KB而 Tree-shaking 是让这 8KB 直接消失。2. 摇树背后的核心原理为什么只有 ES Module 能做到Tree-shaking 不是靠“猜”来删除代码的它依赖一整套可静态分析的语言机制。这里有个重要的硬性前提只有 ES Module即import/export语法才能被可靠地摇树CommonJS 的require/module.exports不行。2.1 静态分析的本质ES Module 的设计决定了import和export必须出现在模块顶层不能放在if分支里也不能用变量动态拼接导入名。这就让构建器在读取源码的时候不需要真正执行代码就能画出一张清晰完整的“引用关系图”。构建器会把每个文件解析成抽象语法树找出所有import和export然后记录每个导出被哪些模块引用了。如果一个导出没有被任何入口引用它就会被标记为“死代码候选”等待压缩器在最后一步把它从产物里剔除。我习惯用一个类比来帮助理解一套家具流水线图纸上标注了哪个零件装在哪件家具上。Tree-shaking 就是拿着订单清单去仓库领料订单里没用到的那批零件直接不领而不是先全部搬上车再回头扔掉。ES Module 的价值在于图纸足够精确机械臂可以读懂CommonJS 的图纸是模糊的机械臂不敢乱动。2.2 为什么 CommonJS 摇不动看这段代码const utils require(./utils); if (process.env.NODE_ENV production) { module.exports utils.buildRelease; } else { module.exports utils.buildDebug; }require是运行时才执行的函数module.exports的赋值对象也可以是动态计算的。构建器在静态分析阶段根本无法确定“这个模块最终暴露了什么”更没法判断某个导出是否被真正用到。为了不破坏运行逻辑它只能把整个模块原样保留。这也就是为什么你在把第三方 CJS 库换成 ESM 版本后包体积经常能明显下降的原因。lodash全家桶走 CommonJS 入口时很难摇但换成lodash-es之后单个函数级别的 Tree-shaking 立刻就能生效。2.3 副作用的“安全边界”不过静态分析再精确也有一个必须遵守的安全红线副作用。所谓副作用是指一个模块在加载那一刻对外部世界产生的影响比如// polyfill.js if (!window.fetch) { window.fetch myFetch; }这个模块没有任何 export但它绝对不能因为“没被引用”就被删掉否则window.fetch不会被补上整个项目都会崩。即使有 export 的模块也可能在顶层执行console.log、读取环境变量、修改全局对象等操作构建器不能贸然删除。所以在 webpack 里光靠“有没有被引用”不够还需要一个显式声明sideEffects: false意思是“我们这个模块除了导出成员之外加载过程没有任何副作用你可以放心删”。这个声明能大幅提升摇树效果但也可能带来灾难——我后面会专门讲怎么排查。2.4 压缩器负责最后补刀现在业界主流的构建链路里Tree-shaking 通常是“标记 删除”两段式完成。以 webpack 为例usedExports负责标记出每个模块里哪些导出被使用了。sideEffects负责决定一个模块能否被整体跳过。minimize会把前面标记出来的“未使用导出”真正从产物里删掉。在 webpack 5 里这三个开关在production模式下默认都是打开的但默认打开不意味着所有第三方依赖都具备摇树条件。Rollup 和 Vite 的机制类似只不过 Rollup 把“tree-shaking 主体逻辑”内置得更加激进压缩步骤由 esbuild、terser 或 SWC 完成。3. 在你的工程里把 Tree-shaking 真正开启Webpack、Vite 与 Babel 的配合理论说完了肯定有人要问那我的项目要不要额外配置答案取决于你用的构建器。我给不同场景分别写一下实操配置和最容易忽略的细节。3.1 Webpack 5 的标准配置如果你正在用 webpack 5下面这份配置可以当成项目里的默认基线// webpack.config.js module.exports { mode: production, optimization: { // 标记未使用的导出配合压缩器删除 usedExports: true, // 允许跳过“无副作用”且“未被引用”的模块 sideEffects: true, // 开启压缩真正删除被标记的代码 minimize: true, // 将可组合的模块合并减少函数调用开销 concatenateModules: true } };sideEffects: true这个选项表示“开启副作用处理机制”它并不会自动把项目里所有文件都当成无副作用真正判断依据来自package.json里的sideEffects字段以及 loader 对模块的声明。如果你在根目录package.json里写了{ sideEffects: false }那就是告诉 webpack我的业务代码里所有模块加载过程都没有副作用你可以尽情删。但这个声明非常危险一旦某个文件确实有副作用比如import ./global.css、import ./registerServiceWorker.js它就会被误删。更稳的写法是数组白名单{ sideEffects: [*.css, *.scss, ./src/polyfills.js] }这样只有 CSS 和 polyfill 文件有副作用豁免权其他纯 JS 模块都可以正常参与摇树。3.2 Babel 转换带来的“隐形杀手”这是我对所有 webpack 用户强调最多的一点如果你用了babel/preset-env并且没有显式关闭模块转换Babel 会把 ES Module 转换成 CommonJS。一旦转换发生webpack 看到的代码就变成了require和exportsTree-shaking 直接失效。而且这种失效不会报错构建日志干干净净只有产物体积在无声抗议。正确做法是在 Babel 配置里把模块转换成 ES Module// babel.config.js module.exports { presets: [ [ babel/preset-env, { modules: false } ] ] };设置modules: false后Babel 只负责语法降级不碰模块系统import/export会原样留给 webpack 处理。这里要记住一个顺序模块语法交给打包器语法降级交给 Babel两者各管一段不要越权。如果用 TypeScript对应配置是{ compilerOptions: { module: ESNext, moduleResolution: Bundler } }不要让 TypeScript 把代码编译成commonjs否则同样会把 ESM 的静态结构破坏掉。3.3 Vite 和 Rollup 的天然优势Vite 在开发环境用 esbuild 做依赖预构建在生产环境用 Rollup 打包。Rollup 从设计之初就把 Tree-shaking 当成头等大事所以默认情况下Vite 项目里纯 ESM 库的摇树效果往往比 webpack 项目更明显。Rollup 的摇树行为可以通过treeshake配置调整// rollup.config.js export default { treeshake: { moduleSideEffects: false, propertyReadSideEffects: false, tryCatchDeoptimization: false } };moduleSideEffects: false对应 webpack 的“全模块可删”声明propertyReadSideEffects是用来判断“读取对象属性是否可能产生副作用”的开关把它设为false会让摇树更激进。不过 Vite 项目里最常见的坑不是 Rollup而是第三方依赖本身不是 ESM 格式。Vite 在预构建阶段会把 CJS 依赖转成 ESM但转换出来的代码往往带有“运行时导出代理”摇树效果会大打折扣。遇到这种情况要么找这个库的 ESM 构建版本要么在optimizeDeps.include里强制预构建再不行就只能接受它的体积。3.4 库代码和业务代码的摇树差异很多人会忽略一个区别业务代码是“被构建的工程”第三方库是“被消费的产物”。业务代码里你写export function打包器可以直接分析源码但第三方库里你拿到的往往已经是编译过、压缩过、甚至打包成一整块的文件。如果它没有提供 ESM 入口也没有在package.json里声明sideEffects: false下游项目再怎么做 Tree-shaking 都作用不到它内部。这也是为什么现在越来越多前端库提倡“源码或近源码产物直接发布”。至少要让产物保持 ES Module 格式并且用exports字段把import和require条件区分开。4. “摇不动”的典型现场与一步步排查思路配置都对了树还是摇不动这是最让人崩溃的。我梳理了几个高频翻车场景每个都是真实项目里见过的问题。4.1 样式文件莫名消失副作用标记的锅现象项目升级构建配置后页面样式全部丢失控制台没有报错构建也成功。查到最后往往就是某个package.json写了一句sideEffects: false然后恰好 CSS 是通过 JS 引入的import ./global.css;webpack 认为global.css这个模块没有被任何业务代码“使用”而且sideEffects: false表示它没有副作用于是整个文件被跳过。修复方式很简单把sideEffects改成数组或者把 CSS 文件明确加入豁免名单{ sideEffects: [*.css] }这里我想多说一句sideEffects字段的“副作用”不是指“这个文件有没有用”而是指“加载这个文件时会不会对运行环境产生必要影响”。CSS 文件不导出任何 JS 变量但它通过浏览器的样式系统影响页面这就是一种必须保留的副作用。所有静态资源、polyfill、全局状态初始化文件都该进白名单。4.2 第三方库全量打包入口格式不对现象import { debounce } from lodash构建后整个 lodash 主文件都进了 bundle。原因是 lodash 的main入口是 CommonJS 格式webpack 对 CJS 整个模块做静态分析时很难拆到函数粒度。你可以把import { debounce } from lodash换成import debounce from lodash-es/debounce;或者直接安装lodash-es它提供纯 ESM 的逐函数子路径Tree-shaking 立刻生效。同样的道理也适用于很多老牌 npm 包不是你的配置有问题是它根本没给你摇树的机会。判断一个包能不能摇树最快的方法是看node_modules/包名/package.json里有没有module或exports的import条件有没有sideEffects: false。如果只有main且指向.cjs或没有type: module的.js基本可以放弃对该包的函数级摇树。4.3 入口文件用了export *导致分析器保守保留现象自己项目里的工具库明明只用了a函数但b、c、d全都还在产物里。打开代码一看// utils/index.js export * from ./a; export * from ./b; export * from ./c;export *会引入“命名冲突遮蔽”的问题。两个模块如果导出了同名变量最终入口里暴露哪个是不确定的构建器为了安全会保守地把所有可能都用上的名字都保留。解决方法是把聚合导出改成显式命名// utils/index.js export { a } from ./a; export { b } from ./b; export { c } from ./c;这样分析器就能精确知道每个导出名的来源未引用的模块可以被放心剔除。这个改动看起来微不足道但对大型项目的摇树效果影响很明显。4.4 排查链路从产物反推问题如果以上场景都排除后树还是摇不动我建议按下面这条链路一步步走打开打包产物搜索那些“你确定没引用”的函数名。如果还在说明标记阶段没生效如果函数名不在了但代码体量还在说明是模块整体副作用导致的保守保留。检查mode是否真的是production。有人会在配置文件里强行设置mode: development此时 Tree-shaking 默认关闭。检查 Babel 是否把 ESM 转成了 CJS。在构建结果里搜exports.xxx 或Object.defineProperty(exports,这类特征搜到就是转换了。确认第三方依赖的模块格式。看它的package.json字段。用包体积分析器定位最终体积来源。webpack 项目可以生成stats.json然后丢进webpack-bundle-analyzer里可视化查看Vite 项目直接看build的产物目录再用基于 Rollup 的分析插件看各模块大小。排查这种事情最忌讳“凭感觉改配置”。每次改动只动一个变量然后对比产物大小才能定位真实原因。5. 库开发者的必修课你的 npm 包能不能被下游摇树如果你不只是写业务代码还会发布一些工具库、组件库那么 Tree-shaking 对你来说不是“可选项”而是“必须项”。下游用户的构建体验直接取决于你的包怎么发布。5.1 三个关键字段决定命运一个对 Tree-shaking 友好的package.json至少要包含三个信息{ name: my-utils, main: ./dist/index.cjs, module: ./dist/index.esm.js, exports: { .: { types: ./dist/index.d.ts, import: ./dist/index.esm.js, require: ./dist/index.cjs }, ./package.json: ./package.json }, sideEffects: false }module是给打包器看的 ESM 入口虽然现在很多打包器已经不完全依赖它但写上是兼容老版本 webpack 的礼貌。exports是 Node.js 和打包器共同遵循的现代条件导出映射import条件优先让打包器选择 ESM 产物。sideEffects是告诉下游“我这个包加载过程没有副作用你可以随便删”。千万不要只写main指向一个 CJS 文件那样下游用户做得再好也只能对你整个包“望洋兴叹”。5.2 组件库的样式副作用如何处理组件库和纯工具库有一个显著区别组件通常附带样式。如果你在 JS 里直接import ./style.css而package.json又写了sideEffects: false下游项目很可能会先遇到我们前面说的“样式消失”。所以组件库必须在sideEffects里显式放行样式文件{ sideEffects: [*.css, *.scss] }还有一类组件库选择把样式从 JS 中分离出去用一个单独的styles.css文件让人手动引入。这种做法摇树更干净但使用体验差一些。现在的组件库通常采用“ESM 产物 CSS 副作用声明”的组合既保证组件代码能被按需摇树又保证样式不丢。5.3 类库发布前的摇树自测我自己发布库之前会做一个特别简单的验证建一个最小测试工程只import库里的一个 API然后打包看产物里另外一个明显不会被用到的 API 是否还在。比如说我发布一个my-format库里面有formatDate和formatMoney测试工程只引formatDate。如果构建产物体积大于几 KB或者搜formatMoney能搜到我就知道这个库的 ESM 产物有问题。这个自测方法比任何文档说明都有说服力。6. 最后聊聊我对 Tree-shaking 的边界理解这一节不是总结而是我踩过不少坑之后的一些个人体会。Tree-shaking 是构建层的优秀机制但它不是优化包体积的银弹也无法替你做出好的代码设计。6.1 Tree-shaking 做不到的事首先它对“动态访问”无能为力。如果你写import * as utils from ./utils; const name getUserInput(); const fn utils[name]; fn();打包器根本不知道运行时用户会输入什么函数名它没法判断哪个导出“确实没被用”只能全部保留。所以业务代码里要尽量避免import *后用动态属性访问这会直接瓦解静态分析。其次它对“模块副作用”的判断只能依赖声明。如果你把sideEffects: false写错位置或者第三方库声明错误Tree-shaking 就会在“删错”和“不敢删”之间走极端。要么用户样式丢失要么体积依然庞大。这里没有中间态。6.2 好的代码规范比优化配置更重要我做过一个性能优化项目一开始疯狂调 webpack 配置产出却没什么变化。后来把工具函数按领域拆成小文件去掉export *聚合把明显有副作用的初始化逻辑单独隔离并给package.json补上精确的sideEffects白名单包体积才真正掉下来。从那以后我形成了一个习惯写模块时先问自己三个问题。第一这个文件有没有顶层副作用有就单独放。第二这个文件导出是不是命名精确尽量少用export *也不要在一个文件里塞几十个无关函数。第三如果我发布这个包下游能不能只拿一个函数就走如果答案是否定的说明我的导出设计还不够细粒度。Tree-shaking 本质上是一条“信任链”语言保证静态可分析打包器负责标记压缩器负责删除开发者负责给副作用划线。任何一环掉链子树都摇不干净。理解了这一点你再去看那些花里胡哨的优化配置就会发现真正要做的往往是几件特别朴素的事写纯的模块、给准的声明、看最终产物。
返回列表