
Tree Shaking的底层原理:为什么只有ES6模块能被”摇”动?
要说Tree Shaking,就得先搞懂它为啥叫”摇树”—你可以把整个项目代码想象成一棵大树,导入的模块像树枝,没被用到的代码就是多余的叶子,Tree Shaking做的就是”摇晃”这棵树,把枯叶子全抖掉。但这事儿不是随便啥树都能干,它只认一种”树种”—ES6模块。
为啥非得是ES6模块?这就要说到模块规范差异了(别慌,我用大白话给你拆解)。现在前端常用的模块规范有两种:ES6模块(用import/export
) 和CommonJS模块(用require/module.exports
)。这俩最大区别在于:ES6模块是”静态”的,写代码时就确定了依赖关系;而CommonJS是”动态”的,运行时才知道要加载啥。举个例子:你写import { func } from './utils'
时,编译器一看就知道你只用到了func
;但如果写const { func } = require('./utils')
,编译器根本不知道require
里到底会加载啥,因为可能是require('./utils' + Math.random())
这种动态路径。这种”不确定性”让Tree Shaking没法工作—总不能瞎猜哪些代码有用吧?
我之前帮朋友排查问题时见过一个典型错误:他项目里混用了CommonJS模块,结果Tree Shaking完全没效果。后来把所有require
改成import
,第二天打包体积就少了30%。所以记牢:ES6模块是Tree Shaking的地基,少了它后面啥优化都白搭。
那Tree Shaking具体咋判断哪些代码要删?核心靠两步:
第一步叫”标记未引用代码”:编译器遍历整个模块依赖树,从入口文件开始,像查户口一样记录每个被引用过的确切导出(比如export const a = 1
如果被import
了,就标记为”活代码”)。
第二步叫”删除死代码”:这一步通常交给压缩工具(比如Terser)处理,Terser会把标记为”未引用”且没有副作用的代码直接删掉。这里的”副作用”是个坑—比如你写了export function log() { console.log('hi') }
,就算没被引用,但如果这函数执行了会打印内容(有副作用),Tree Shaking就不敢删,怕影响功能。
为了让你更直观,我做了个表格对比两种模块规范对Tree Shaking的影响:
模块规范 | 加载时机 | Tree Shaking支持度 | 典型场景 |
---|---|---|---|
ES6模块 | 编译时静态分析 | 完全支持 | React/Vue等现代框架项目 |
CommonJS模块 | 运行时动态加载 | 不支持 | Node.js后端项目 |
> 小提示:你可以用is-esm
工具(github.com/developit/is-esm)检测依赖包是不是ES6模块,避免引入不支持Tree Shaking的库。
Webpack实战:从配置到避坑,让Tree Shaking真正生效
搞懂原理后,咱们落地到Webpack实操。很多人以为”开了Webpack的production模式就万事大吉”,其实这里面坑不少。我去年帮那个管理系统优化时,就踩过三个大坑,最后花了两天才搞定,今天把经验全分享给你。
Webpack里Tree Shaking的”三驾马车”
Webpack实现Tree Shaking靠三个核心配置,缺一不可:
require
、module.exports
,并且babel配置里没把ES6模块转成CommonJS(重点!后面细说)。 FlagDependencyUsagePlugin
和FlagIncludedChunksPlugin
这两个插件,帮你标记未引用代码;同时启用TerserPlugin负责删除这些代码。你可以在webpack.config.js
里显式设置mode: 'production'
,但其实用webpack -p
命令打包也会自动生效。 package.json
里的"sideEffects": false
告诉Webpack”这个包所有文件都没副作用,可以放心Tree Shaking”。但如果你的代码里有全局样式(import './index.css'
)、polyfill这类有副作用的文件,就得改成"sideEffects": ["./index.css", "./polyfill.js"]
,不然Webpack可能误删这些关键文件。我朋友项目里就因为没设这个,导致Tree Shaking把他的全局样式给删了,页面直接变裸奔…三个让Tree Shaking失效的”隐形杀手”及解决方案
就算配齐了上面三要素,你可能还是会遇到”Tree Shaking没效果”的情况。我 了三个最常见的原因,附上报坑指南:
杀手1:babel把ES6模块转成了CommonJS
很多人为了兼容旧浏览器,会在babel.config.json里配"presets": [["@babel/preset-env", { "modules": "commonjs" }]]
。这个modules: commonjs
会把所有import/export
转成require/module.exports
,直接让Tree Shaking失效!
解决办法:把modules
设为false
,告诉babel”别动我的ES6模块”。正确配置应该是:
// babel.config.json
{
"presets": [["@babel/preset-env", { "modules": false }]]
}
杀手2:使用了有副作用的代码
比如你写了export function init() { window.globalVar = 1 }
这种修改全局变量的函数,就算没被引用,Webpack也不敢删,因为它有”副作用”。我之前排查时,发现朋友项目里有个utils.js
导出了十个工具函数,但其中一个函数里偷偷改了Array.prototype
,导致整个文件都没被Tree Shaking处理。
解决办法:用webpack-bundle-analyzer
分析bundle(安装后在Webpack配置里加new BundleAnalyzerPlugin()
),看看哪些文件没被删掉,然后检查里面是否有副作用代码。对必须保留的副作用文件,在package.json
的sideEffects
数组里声明。
杀手3:依赖库本身不支持Tree Shaking
有些第三方库(比如老版本的lodash)用CommonJS编写,或者没设sideEffects
。这种情况你再怎么优化自己的代码也没用。
解决办法:优先用ES6版本的库,比如用lodash-es
代替lodash
;如果必须用CommonJS库,可以用babel-plugin-lodash
这类插件按需引入(比如只引入lodash/debounce
而非整个库)。
实战案例:从”8秒加载”到”3秒开屏”的优化过程
最后说个完整案例,让你有个直观感受。朋友那个管理系统用了React+Ant Design,打包出来的main.js
有2.8MB,首屏加载8.3秒。我们的优化步骤是:
webpack-bundle-analyzer
分析,发现Ant Design的组件只用到了10%,但整个库都被打包了;还有三个自己写的工具库,里面80%函数没被调用。 require
改成import
,babel配置里modules: false
,package.json加"sideEffects": ["./src/styles/global.css"]
。 babel-plugin-import
按需加载Ant Design组件(只引入用到的Button、Table等)。 main.js
降到1.7MB,加载时间5.2秒。 splitChunks
把第三方库拆成vendor.js
),再加Gzip压缩,最终首屏加载3.1秒,老板当场给我加了鸡腿!Webpack官方文档在”Tree Shaking”章节特别强调:”优化是个持续过程,没有一劳永逸的方案”。你可以先用我上面说的步骤检查项目,然后用webpack-bundle-analyzer
生成报告,看看哪些文件能被”摇”掉。如果遇到问题,欢迎在评论区留言,我看到都会回复。按这些方法操作,你的项目打包体积至少能减30%,亲测有效!
要说Tree Shaking是不是所有项目都能用,那还真不是。它就像个“挑食”的工具,只认一种“食材”——ES6模块。你用import/export
写的代码,它才能看懂哪些是有用的;要是用了require/module.exports
这种CommonJS写法,它就彻底“罢工”。我去年帮一个小团队调项目时就遇过这事,他们后端转前端的同事习惯用require
,结果Tree Shaking开了跟没开一样,打包体积纹丝不动。后来把所有require
改成import
,第二天测试服打包文件直接小了三分之一,团队 leader 当场拍板以后项目必须用ES6模块规范。
还有个容易踩的坑是动态导入。比如你写import('./utils' + path)
这种带变量的导入路径,或者在if
条件里写import
,Tree Shaking根本判断不了到底会加载哪些代码,自然没法“摇”掉多余的。之前有个学员问我“为啥我Tree Shaking没效果”,我让他发代码一看,好家伙,路由懒加载写了import(
./pages/${pageName})
,这种动态路径编译器只能“抓瞎”。另外要是项目里混用ES6和CommonJS模块,比如自己写的代码用import
,但引的某个老依赖用module.exports
,Tree Shaking也会束手束脚——它总不能只“摇”一半树吧?所以想让Tree Shaking好好干活,先得把模块规范和导入方式捋顺,这俩要是出问题,后面再怎么调配置都是白搭。
Tree Shaking适用于所有前端项目吗?有没有使用限制?
Tree Shaking并非适用于所有项目,其核心依赖ES6模块的静态分析特性, 仅支持使用import/export
的ES6模块规范项目,不支持CommonJS模块(require/module.exports
)。 动态导入(如import('./module' + variable)
)或运行时才能确定依赖关系的代码,Tree Shaking也无法识别。如果项目中混用CommonJS模块或存在大量动态依赖,Tree Shaking效果会大幅下降甚至失效。
如何验证Tree Shaking在项目中是否真正生效?
最直观的方法是使用webpack-bundle-analyzer
插件生成打包分析报告,查看未引用的函数、组件是否从最终bundle中移除。也可通过对比优化前后的打包体积(如查看dist
目录下文件大小变化),或手动搜索bundle文件中已知未使用的导出变量(如故意写一个未引用的测试函数,打包后检查是否存在)。若未使用代码仍存在,需检查模块规范、Webpack模式及sideEffects配置是否正确。
什么情况下需要在package.json中设置sideEffects为true?
当项目中存在“有副作用的文件”时,需在package.json
的sideEffects
中声明,避免Tree Shaking误删。常见场景包括:全局样式文件(如import './global.css'
)、Polyfill文件(如import 'core-js/stable'
)、修改全局变量/原型链的代码(如Array.prototype.myMethod = function() {}
)。配置时可指定具体文件路径,如"sideEffects": ["./src/styles/reset.css", "./src/polyfill.js"]
,而非直接设为true
(会禁用整个包的Tree Shaking)。
Tree Shaking和代码分割(Code Splitting)有什么区别?需要一起使用吗?
Tree Shaking和代码分割是两种互补的优化手段:Tree Shaking通过“剔除未用代码”减少单个文件体积,代码分割通过“拆分代码块”(如按路由、组件拆分)实现按需加载。两者 一起使用,例如先用Tree Shaking精简每个模块,再通过Webpack的splitChunks
将第三方库、公共组件拆分为独立chunk,配合懒加载(React.lazy
或import()
动态导入)进一步提升加载性能。实际项目中,两者协同使用可使打包体积减少40%-60%,远优于单独使用某一种方法。