ARTICLE DETAIL

资讯详情

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

深度拆解Tree-shaking:原理、配置与打包体积优化实战

深度拆解Tree-shaking:原理、配置与打包体积优化实战 1. 先说结论Tree-shaking 到底在解决什么问题如果你写过前端项目大概率遇到过这种场景明明只import { debounce } from lodash结果打包出来发现体积多了好几十 KB明明import * as echarts from echarts结果一个折线图页面打包出了 1MB 的 JS。你第一反应是“这库真大”第二反应是“能不能只打包我用到的那一部分”。Tree-shaking 就是干这件事的。Tree-shaking 这个词直译是“摇树”指的是构建工具在打包阶段把模块中没被引用到的代码“摇掉”。它的名字很形象你的项目代码像一棵树每个模块是树枝每个导出是树叶和果实构建工具抓住模块依赖关系的根部使劲摇一下没被用到的“枯枝败叶”就掉下来了剩下的是真正会运行的代码。这个机制解决的核心痛点就是打包体积。体积直接影响页面加载速度尤其在移动端、弱网环境多出来的几十 KB 可能就意味着白屏时间多几百毫秒。对于工具库开发者来说tree-shaking 更是决定库能不能被现代构建链优雅使用的基础能力。这篇文章我会从原理、配置、实操验证到踩坑排查把 Tree-shaking 完整拆一遍。适合刚接触前端工程化、对“为啥我打包体积这么大”有困惑的同学也适合想优化自己工具库体积的包作者。2. 原理解析为什么能“摇”得动代码2.1 ES Module 的静态结构是前提Tree-shaking 能成立前提是代码必须使用 ES Moduleimport/export语法。为什么因为 ES Module 的导入导出是静态的——import和export必须写在模块顶层不能嵌套在if或者循环里导入路径也必须是确定的字符串不能是变量。这意味着构建工具在做编译时分析的时候不需要真正运行你的代码就能把整个模块依赖关系画成一张静态的图。在这张图里每个模块导出了哪些东西、每个文件从哪里导入了什么都是一目了然的。既然能够在编译阶段拿到导出的全集和导入的使用情况自然就能做匹配哪些导出被引用到了哪些导出从头到尾没人碰然后打上“没用”的标记。这里的判断是基于“顶层语句的引用分析”并不要求去执行你的业务逻辑。编译器做的是静态判断不是动态分析。2.2 CommonJS 为什么做不到那为什么require不行因为 CommonJS 的require是运行时加载的。路径可以是require(path)这种变量拼出来的模块本身也可以在代码运行过程中被反复加载、修改不会有一个固定的导出集合。构建工具如果碰到const mod require(./utils)根本不知道mod上最终会有哪些属性也没法安全地判定“哪个属性没被用到、可以删掉”。所以一个实用判断标准是你的项目只要还在用 CommonJS 规范写业务代码tree-shaking 基本就废了。现代框架用 Vite 默认给的是 ESM 开发环境Webpack 也默认对import语法做处理但如果你自己装的老依赖是 CJS 格式或者 Babel 配置把import全转成了require那 tree-shaking 就空转了。2.3 摇树不是“删掉不用代码”这么简单实际工程中tree-shaking 通常是两部分协作完成的模块标记构建时分析出哪些 export 没有被引用在产物里保留一个“已使用/未使用”的标记信息。压缩器删除后续 Terser 这类压缩插件读取标记把未使用的导出语句从最终代码里物理移除。这也就是为什么有时候你觉得“我明明开了 production 模式tree-shaking 也生效了”但打包产物里还是能搜到一些未使用代码的碎片——标记做了但压缩器没删干净或者反过来标记没做到位压缩器不敢删。理解这个协作关系之后排查体积问题的思路会清晰很多。3. 工具侧的 Tree-shaking 配置Webpack、Rollup 与 Vite3.1 Webpackproduction 模式眼里的 tree-shakingWebpack 在mode: production下默认会开启一堆优化选项其中和 tree-shaking 相关的主要有两个usedExports分析每个模块的导出使用情况给未用导出打标记。minimize用 TerserPlugin 做代码压缩压缩时根据标记物理删除未用代码。也就是说用 Webpack 构建生产包的时候只要你的代码是 ESM、依赖是 ESMtree-shaking 是默认生效的。但注意Webpack 对“副作用”的处理非常谨慎后面我会专门讲sideEffects字段那是 Webpack 能否深度摇树的关键开关如果你不告诉它“这个模块是纯函数模块删掉也没关系”它宁可保守一点把整个模块保留。如果你用的是 Webpack 5还有个更激进的选项叫optimization.innerGraph默认开启。这个选项让 Webpack 能做更细粒度的推断比如函数内部的未用参数、未用分支能力上更强但也更依赖正确的sideEffects声明。3.2 Rollup 与 Vite天生为摇树而生Rollup 是把 tree-shaking 作为核心设计目标的打包器所以它对 ESM 的支持和摇树能力比早期 Webpack 激进得多。Rollup 会做真正的“依赖图级死代码消除”:它只把被引用到的导出打进产物并且分析得更透。Vite 的生产构建底层就是 Rollup所以你用 Vite 构建项目时只要在vite.config.ts里没有刻意关掉相关优化tree-shaking 天然是生效的。用量化一点的感受来说同样一个import { debounce } from lodash-es的 demoWebpack 打出来可能保留了一些模块壳子和辅助函数Rollup 产物里你只会看到一个干净的 debounce 实现——这就是两者摇树力度的差异。3.3 最容易废掉摇树的“元凶”Babel 的模块转换这是我在实际项目里踩过最多坑的地方值得重点标记。很多人项目里装了babel/preset-env配置里写着{ presets: [ [babel/preset-env, { modules: commonjs }] ] }modules: commonjs会把代码里的import全部转成require。如果这个配置作用于你的业务代码那么到了 Webpack 手里看到的已经是一堆 CommonJS 了——静态分析的目标消失tree-shaking 直接失效。现代前端构建链里Webpack 自己就能处理import根本不需要 Babel 先转成 CommonJS。所以 Babel 预设里务必要设置modules: false让 Babel 只做语法降级比如把可选链转成 ES5 语法不要动模块语法。这个配置我建议直接记死Babel 处理模块语法的正确姿势是modules: false把模块解析的活留给 Webpack / Rollup。3.4 关键配置速查表工具开启方式关键配置备注Webpack 4mode: productionoptimization.usedExports、optimization.sideEffectsWebpack 4 之前需要手动配4 之后默认开启Rollup默认开启treeshake选项可自定义推荐保持默认Vite默认开启底层 Rollup 传递生产构建vite build生效Babel不限presets 设置modules: false防止 ESM 被转换成 CommonJS4. 实操验证从零到一确认 Tree-shaking 真的生效4.1 构造一个测试用例动手验证远比看文档来得实在。我建议你建一个极小的 demo 工程自己感受一下“摇之前”和“摇之后”的体积差异。假设你有一个utils.jsexport function add(a, b) { return a b; } export function multiply(a, b) { return a * b; } export function mysteriousFunction() { // 这段代码很复杂但没被使用 return I am never called; } // 模块顶层的副作用语句 console.log(module loaded);入口index.jsimport { add } from ./utils; console.log(add(1, 2));这里有个关键console.log(module loaded)是模块顶层语句它跟add导入没有引用关系。Rollup 在摇树时碰见这种顶层副作用语句会默认认为“这个模块不能被整体删除”因为它执行的时候确实有外部可观察的副作用打印日志。这正是mysteriousFunction能被删掉、但console.log却保留的原因。4.2 用 Rollup 看真实产物跑一次 Rollup 的零配置构建产物的format用es你看编译出的文件你会发现multiply和mysteriousFunction完全消失了但console.log(module loaded)保留了。这段打印语句就是 Rollup 判断的“模块副作用”——它不确定你删掉这个模块会不会影响别的逻辑所以不敢动。这告诉我们一个重要信息想让 tree-shaking 摇得干净不是只靠配置更要在代码层面“让模块可被安全摇动”。如果你的工具函数模块顶部都放着console.log、全局事件绑定、window 属性修改那不管打包器多激进它都得把这个模块的副作用保留下来。4.3 用 Webpack 和 Bundle Analyzer 验证Webpack 项目建议装一个webpack-bundle-analyzer可以可视化看出每个模块打进了多大体积。配合 source-map-explorer 可以定位到具体代码位置。验证步骤生产构建开启mode: production。在 package.json 中给各个依赖模块配置正确的sideEffects。构建完成后用webpack-bundle-analyzer dist/*.js或者直接搜索产物代码找那个没被引用的函数名。如果你搜到了一个你没引用的工具函数名字说明要么这个模块的副作用声明有问题要么这个函数被真正引用了可能是间接引用。这个排查过程比任何文档都有说服力。4.4 “按需引入”的经典实战lodash 换成 lodash-es老生常谈但必须谈lodash的 CJS 版本是没法摇树的你要用 ESM 版本的lodash-es。实际对比import { debounce } from lodash安装后构建产物很大因为整个 lodash 被打入。import { debounce } from lodash-es安装后构建产物很小因为lodash-es每个模块是一个独立 ESM 文件tree-shaking 能精确定位到debounce这一个函数。我实测过一个小 demo只用debounce一个方法lodash打出来约 70KBlodash-es打出来不到 1KB。这个差距就是 tree-shaking 的价值。5. 常见问题与排查技巧实录5.1 问题一为什么 Tree-shaking 没生效最常见的原因按出现频率排序Babel 把 ESM 转成了 CJS查babel/preset-env配置确认modules: false。依赖包本身是 CJS/UMD 格式比如老版本 React 生态的部分库。这种情况只能换 ESM 替代品或者用 CDN 外链减少打包体量。package.json里没配置sideEffectsWebpack 默认对node_modules里的模块比较保守不确认“安全”就不删。需要库的作者在package.json中显式声明。代码里有顶层副作用如模块加载时执行console.log、修改全局对象、绑定事件。打包器看到副作用整个模块都不删。动态导入/动态引用import(variable)这种动态路径或者obj[methodName]()这种动态属性访问都会让静态分析失效。对于属性访问可以用/*#__PURE__*/注释辅助标记。5.2 问题二sideEffects到底怎么配配错了会怎么样sideEffects字段写在库的package.json里作用是告诉打包工具“我这个包里面的模块有没有副作用能不能安全摇掉。”sideEffects: false整个包中的所有模块都无副作用打包器可以放心删除未引用模块。sideEffects: [*.css, *.scss]除了样式文件其他模块都无副作用。样式文件是必须保留的因为它们通常会被直接import但内部没有导出任何变量属于典型“副作用模块”。不配置sideEffectsWebpack 默认认为所有模块都有副作用摇树能力大打折扣。这个字段配错的代价是真实的如果你维护的库在package.json里标了sideEffects: false但某个模块顶层其实修改了全局对象那使用方打包时会把这个模块整体删掉导致线上运行报错。这是库作者最容易埋的雷。我个人在维护组件库时的做法是组件主体 JS 模块统一声明无副作用CSS/LESS 文件单独列进副作用数组每个入口文件手动检查一遍有没有在顶层写全局逻辑。5.3 问题三样式文件丢了怎么办典型场景组件库使用了import ./index.css这种字面导入但库作者写了sideEffects: false。打包时 Webpack 发现这个导入没有使用任何导出判定“可以删除”于是你的 CSS 就没了。解决方式就是上文说的——在sideEffects数组里把*.css明确标记为副作用文件{ sideEffects: [*.css, *.scss, *.less] }所有工具库作者看到这段建议直接抄进你的package.json。5.4 问题四如何在代码层面“帮”打包器做得更好几个亲测有效的技巧技巧一用 PURE 注释标记纯函数。如果是为了保持链式调用风格而刻意写的表达式语句你可以给它们加/*#__PURE__*/注释明确告诉压缩器“这里没有副作用可以删”/*#__PURE__*/ configureStore({ reducer: rootReducer, });这个注释在 Webpack、Rollup、Terser 中都有一致的识别规则是官方推荐的优化辅助手段。技巧二避免直接导出整个对象。比如不要这样写export default { add, multiply, mysteriousFunction, };默认导出对象会让分析器认为“对象的所有属性都有可能被外部访问”很难精确摇掉。除非你能接受只按需导入命名的导出否则建议写成export { add, multiply };技巧三尽量避免顶层副作用。模块加载时做初始化、绑定全局变量、修改原型链这些都是阻断摇树的因子。把这些逻辑放进显式函数里让使用方按需调用反而促进了 tree-shaking。5.5 快速排查清单排查体积问题时我一般按下面的步骤走效率很高确认代码链路都是 ESMimport/export没被 Babel 转掉。确认目标包是 ESM 格式看node_modules/xxx/package.json的module字段如果没有module字段基本就是 CJS。确认sideEffects配置没有误伤尤其样式文件被删了先查这里。用webpack-bundle-analyzer可视化直接看哪块体积异常。在产物里搜未引用函数的独有字符串确认它到底还在不在。这套排查法我反复用了两年多碰到“打包体积突然变大”“样式丢失”“线上报某方法未定义”这三大类问题基本都是上面这几个原因。原理不复杂配置也就几个字段但联动了 Babel、Webpack、库作者的 metadata任何一个环节脱节结果就是白打包几十 KB。我自己在被sideEffects: false坑掉组件库样式那次之后所有发布的包都把副作用声明写成显式数组再也不图省事直接写false了。这个习惯建议每个包作者都养成——你在 package.json 里多写几行字可能就帮下游开发者省下一下午的排错时间。
返回列表