ARTICLE DETAIL

资讯详情

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

Tree-shaking 从原理到实践:Webpack/Vite 打包优化与死代码消除全解析

Tree-shaking 从原理到实践:Webpack/Vite 打包优化与死代码消除全解析 Tree-shaking 这个词前端圈这两年基本就是面试高频词加打包优化必选项。但说实话我在社区里看过太多人把它当“配置项”来用——生产环境开一下体积小了就完事了。至于它到底是怎么把没用的代码摇掉的、为什么摇不干净、哪些写法会让它直接罢工很多人是懵的。这篇东西我不打算写成官方文档的翻译就按我实际在 Webpack、Vite 项目里折腾的经验来聊把这玩意儿的原理、配置、坑和排查方法一次性讲透。1. 先搞明白Tree-shaking 到底在摇什么1.1 一次打包体积翻车的现场去年我接手一个中后台项目首屏 JS 体积一口气冲到了 4.2MBgzip 之后还有 1.1MB。查了半天发现罪魁祸首是一段很离谱的代码——项目里有人为了图省事直接import * as _ from lodash引入了整个库。点开webpack-bundle-analyzer一看整个 lodash 的 chunk 躺在里面占了小 500KB。我把这段代码改成按需引入体积降了 200 多 KB。但真正的问题在于同一个项目里还有别的不起眼的文件——一个工具函数库utils.js暴露了 30 个函数实际只用到了 5 个。由于那个工具库是用 CommonJS 写的Webpack 根本没法判断哪些导出没被使用30 个函数全部被保留下来了。这才是 Tree-shaking 存在的意义它解决的就是“模块被引入后没被用到的部分依然被打包”的问题。如果你编译出来的 bundle 里躺着一堆dead code死代码那不管压缩器怎么优化这些代码都还是会被算进加载体积里。1.2 摇树的名字是怎么来的Tree-shaking 这个术语最早是 Rollup 的作者 Rich Harris 在 2015 年提出的灵感来自“摇动一棵树枯枝落叶就会掉下来”的画面。它的本质是Dead Code EliminationDCE死代码消除——把模块里定义了但没被引用的导出从最终的产物里剔除掉。注意它跟“代码压缩”不是一回事。压缩器Terser、esbuild、SWC做的是把变量名缩短、去掉空格、合并表达式Tree-shaking 做的是在语义层面判断“这段代码根本没被使用”然后把它从模块图里整个摘掉。两个机制配合使用效果才最好——先摇树把不需要的模块摘出去再压缩把留下的代码尽可能压扁。2. 为什么只有 ES Module 才能被摇动2.1 静态结构是前提Tree-shaking 能生效的最底层原因是 ES Module 的静态结构。import和export语句在代码解析阶段就能确定——不需要真正运行代码构建工具就能知道“这个模块导出了 a、b、c那个模块只引用了 b”。拿一段最简单的代码举例// math.js export function add(a, b) { return a b } export function subtract(a, b) { return a - b }// index.js import { add } from ./math console.log(add(1, 2))当打包器处理index.js时它能看到math.js导出了add和subtract但只有add被引用了。结合usedExports分析subtract会被标记为“未使用的导出”在压缩阶段被安全删除。这个逻辑只对 ESM 成立是因为 CommonJS 的require是运行时的——require(./math)返回的是一个对象里面的属性是动态的打包器不知道调用的require(./math).subtract在代码运行那一刻是否存在所以不敢动。2.2 CommonJS 为什么摇不动看一个典型的 CommonJS 写法// utils.js exports.foo function () { return foo } exports.bar function () { return bar }// index.js const { foo } require(./utils) console.log(foo())理论上这里bar也没被用到。但打包器只能静态分析出utils.js是一个函数require编译后就是一个函数调用它没法确定exports.bar ...和exports.foo ...的赋值关系。别说是摇掉bar了它连utils.js整体是否安全都要打个问号——说不定require(./utils)这个调用本身在模块内部有副作用。RSBuildRspack在这类场景下比 Webpack 激进一些能分析部分 CJS但官方并不承诺 CJS 能被完整摇树。所以“Tree-shaking 依赖 ESM”这句话不是教条是底层机制决定的。2.3 副作用判断sideEffects 字段ESM 的静态分析解决的是“导出的哪些东西被用了”但还有一个问题模块的顶层代码有没有副作用。比如// polyfill.js import ./polyfill这个文件没有任何导出你import ./polyfill只是为了执行它的副作用——比如往原型上挂方法。这种模块是绝对不能被摇掉的。但有些模块顶层只有函数和导出没什么副作用构建工具能不能判断呢不能因为它不确定这段代码会不会有别的行为。所以有了package.json里的sideEffects字段。这是 Webpack 4 之后引入的关键配置false明确告诉打包器“本包所有文件都没有副作用没被引用的模块可以直接丢弃”。[*.css, *.scss]列出有副作用的文件允许这些文件被import后保留其余的一律可摇。不配置默认所有模块都按“有副作用”处理摇树效果大打折扣。这个字段写错了非常容易引发线上事故——最经典的就是sideEffects: false把全局样式给摇没了。2.4 编译链把 ESM 降级是最大的隐性杀手这里要提一个非常容易踩的坑Babel 默认会把 ESM 编译成 CommonJS。很多人项目里babel/preset-env用的还是默认配置什么modules: autoBabel 看到你的import就直接降级成require。等代码到了 Webpack 手里模块已经变成 CJS 了Tree-shaking 直接废掉一半。正确的做法是在 Babel 配置里明确设置modules: false让 Babel 保留 ESM 语法把模块转换的活儿留给 Webpack或者 Vite 背后的 Rollup/esbuild。{ presets: [ [babel/preset-env, { modules: false }] ] }如果用的是 Vite它底层是 esbuild Rollupesbuild 只负责转译语法TypeScript、JSX不做 ESM 到 CJS 的转换所以 Vite 项目天然避开了这个坑。这算是 Vite 在开发体验上的一个隐形加分项。3. 实操在你的项目里把 Tree-shaking 用好3.1 Webpack 4/5 的默认行为与手动配置Webpack 4 之后只要你把mode设为productionTree-shaking 是自动开启的背后做三件事optimization.usedExports: true—— 标记未使用的导出。optimization.minimize: true—— 用 TerserPlugin 做压缩和 DCE。optimization.sideEffects: true—— 依据sideEffects字段跳过无副作用模块。如果你想在development模式下提前验证摇树效果可以手动打开usedExports但注意开发模式默认不压缩所以未使用的导出只会被标记而不会被删除体积并不会真正变小。如果你用webpack.config.js自定义了optimization.minimizer有个坑手动覆盖minimizer数组之后TerserPlugin 的默认配置就没了必须自己显式加回来否则压缩和 Tree-shaking 同时失效。// webpack.config.js const TerserPlugin require(terser-webpack-plugin) module.exports { mode: production, optimization: { minimize: true, minimizer: [ new TerserPlugin({ terserOptions: { compress: { // 纯函数标记相关默认 true pure_funcs: [console.log] } } }) ] } }3.2 Vite / Rollup 里的表现Vite 生产构建用的是 Rollup。Rollup 从第一版开始就把 Tree-shaking 作为核心卖点它的摇树能力比 Webpack 更激进一些——在分析模块依赖图的时候会尽量做“模块级别的消除”。在 Vite 项目里想优化摇树主要注意几点优先用带 ESM 的 npm 包lodash-es、rxjs、date-fns都提供 ESM 版本。如果用的是 CJS 包Vite 会用rollup/plugin-commonjs做转换但转换出来的代码虽然变成了 ESM 格式摇树效果非常有限——它不会做深度的usedExports分析。某些老包没有module字段、只有main指向 CJS 的不要硬靠 Tree-shaking老老实实按路径引入或者找替代方案。还有一个跟 Vite 强相关的点依赖预构建。Vite 在开发环境会把依赖预打包成 esbuild 能快速处理的格式但生产构建时依赖会交给 Rollup 重新处理。如果你发现某些依赖摇不干净可以看看是不是被预构建缓存了node_modules/.vite必要时找个重启命令清理一下。3.3 代码层面的六个“助力”习惯Tree-shaking 不是靠配置一劳永逸还得在写代码时养成几个习惯。我自己梳理了六个最实际的操作用具名导出不要一味用默认导出默认导出是一个完整对象打包器很难拆解。具名导出可以让打包器精确识别每个导出是否被使用// 不推荐 export default { add, subtract } // 推荐 export function add() {} export function subtract() {}避免动态导入动态变量import()里的路径如果是拼接出来的打包器只能把它当成运行时行为处理可能直接给你保留整个目录的代码// 摇摆不定的写法 const name button import(./components/${name})谨慎使用import * as namespaceimport * as utils from ./utils看起来很爽但它会让打包器默认你“可能用到全部导出”很多实现里会导致整个模块的代码全被保留。实际测试下来Webpack 5 能识别这种情况并优化但 Rollup 在老版本上表现不稳定。能枚举就枚举。避免顶层代码有副作用模块顶部写console.log、直接调用函数、解析配置这些会被当成副作用影响模块级别的摇树。工具库尤其要注意顶层只放导入和导出。使用/*#__PURE__*/注解这是给压缩器看的纯标记。如果你确定一个函数调用没有副作用可以加上这个注释让压缩器放心大胆地删const result /*#__PURE__*/ createSomeObject()很多 React 项目里组件定义和枚举转换的地方都习惯加这个标记。按需引入 UI 库时要走 ESM 路径Ant Design、Element Plus 这类组件库的lib目录通常是 CJSes目录是 ESM。如果你用import { Button } from antd依赖的是package.json的main字段大概率走 CJS 路径摇树失效。线上的做法是配置module字段指向es目录或者用babel-plugin-import之类的插件做按需转换。3.4 装个“体脂秤”验证效果不量化就没法优化。推荐三个验证工具webpack-bundle-analyzerWebpack 老牌工具生成一个交互式的体积分析页面。重点看有没有某个 chunk 里躺着你没用的库。rollup-plugin-visualizerVite 项目里用的跟上面思路一样生成看板。source-map-explorer比你想象中好用直接分析已有的 minified bundle配合 sourcemap 展示每个源文件在最终产物里占多大体积。运行方式很简单npx source-map-explorer dist/assets/*.js4. 常见问题与排查实录4.1 Tree-shaking 失效的十大原因我把实际工作中遇到过的失效场景整理成了速查表排查的时候一条条对照症状原因解决办法依赖的整个包被打进去了包的入口是 CJS没有module字段换 ESM 版本或按路径引入模块内未使用导出仍被保留Babel 把 ESM 降级成 CJS设置modules: false全局样式被摇没了sideEffects: false误伤改为sideEffects: [*.css]未使用导出保留但能被删除压缩器配置被覆盖显式配置 TerserPlugin动态 import 变量粒度太粗字符串拼接导致目录级保留用完整静态路径默认导出对象太大所有默认导出被整体保留改成具名导出import * as把所有导出标记为用命名空间导入不能精确追踪改为按需导入顶层代码有副作用模块被标记为不可摇清理副作用老版库依赖了fs、window构建工具认为 CJS 有副作用寻找可替代的开源方案项目里混用require()CJS 调用链阻断 ESM 分析统一 ESM 风格4.2 sideEffects 误伤样式最经典的“线上事故”我的一个项目曾经出现过npm run build之后页面样式只剩一张背景图的情况。排查了半天发现是根目录的package.json里写了这样一行{ sideEffects: false }这个配置的意思相当于告诉 Webpack“本项目所有文件都没有副作用所以只要某个模块没被引用到直接扔。”问题是 CSS 文件本身是全局作用的——你在index.js里import ./app.css就是靠这个 import 的副作用生效。sideEffects: false会让 Webpack 认为app.css也没副作用于是整个样式文件在构建时被删掉了。正确写法是要把样式文件列出来{ sideEffects: [*.css, *.scss, *.less] }注意一个细节sideEffects默认只对“真的没有引用”的文件生效。Webpack 5 的算法是先看你package.json的sideEffects如果为false说明整个包都安全可以直接摇如果是一个数组只有数组里匹配到的文件被当作有副作用、会被保留。还有个大坑有的库会在自己的package.json里声明sideEffects: false但库本身其实有 CSS 副作用。遇到这种情况你没法改库的源码但可以在 Webpack 的module.rules里写规则手动覆盖module.exports { module: { rules: [ { test: /\.css$/, use: [style-loader, css-loader], sideEffects: true } ] } }4.3 压缩器眼里的“纯函数”Terser 在压缩时遇到函数调用会判断这个调用是否有副作用。它能识别一部分内置函数但自定义函数它没法猜。这也是为什么写库和写业务代码的人都该养成一个习惯给纯函数加/*#__PURE__*/标记。尤其那种只在初始化阶段用一次、返回值不被引用的场景加上这个注释Terser 会毫不犹豫地删除整行调用。举一个实际例子。我维护的一个工具包里有一段枚举映射const typeMap /*#__PURE__*/ Object.freeze({ success: 0, failure: 1, pending: 2 })没有/*#__PURE__*/的时候Object.freeze调用并不知道是纯的——万一这个Object.freeze后面藏了什么小动作呢加上标记后压缩器如果发现typeMap没被引用整行直接删。4.4 排查思路从“可能失效”到“确定失效”如果怀疑 Tree-shaking 没生效有一个非常直接的验证思路在源码的某个“未使用导出”函数里写一个明显的console.log比如console.log(I should be shaken)然后看最终产物里头有没有这句话。如果你用 Webpack最直接的方式是开 development 模式并打开usedExports然后在dist产物里搜这个字符串grep -r I should be shaken dist/搜不到说明摇成功了搜到了说明这段代码的导出被标记为使用了或者模块整体被认为有副作用。再配合source-map-explorer能直接看到每个模块在最终 bundle 里占的百分比。如果一个模块只导出 3 个函数实际用 1 个体积却占比很高基本就可以判断 Tree-shaking 没吃到这个模块。5. 写在最后的经验之谈我实际体验下来Tree-shaking 的最佳实践不是“配置好就再也不管”而是“把它当成一把从头用到尾的刀”。做技术方案阶段先想清楚依赖形态——哪些库有 ESM 版本、哪些库的module字段指向的是 CJS 目录、哪些老库需要换方案。写代码阶段注意默认导出和import * as的使用频率把副作用尽量限制在小范围。构建打包阶段再配合sideEffects字段和压缩器配置最后用体积分析工具验证。有一点想表达的是Webpack 的production模式让我们习惯了“开箱即用”但 Tree-shaking 不是魔法它只是工具分析能力始终受限于你给的信息。你给它 ESM给它sideEffects标记给它纯函数注解它才能发挥出该有的效果。给它 CJS 和默认导出它就只能装看不见。如果你最近恰好被某个依赖体积困扰建议别急着换构建工具。先跑一遍体积分析确认是不是 Tree-shaking 没生效分清到底是“模块没摇”还是“配置没开”再去找对应的解决路径。这样无论最后是换用 esbuild、SWC 还是坚守 Webpack思路都不乱。
返回列表