Webpack usedExports配置详解与实际项目中的优化效果
usedExports 没生效?打包后代码还是全量导出
今天上线前做体积分析,发现一个模块明明只用了 getUserId,结果整个工具函数文件 300 行全被打进了 bundle —— 而且 webpack-bundle-analyzer 里显示它被标记为 “exported as side-effect free”,但实际没删。我第一反应是:wtf,usedExports 又骗我?
这已经是我第三次被它坑了。第一次是在去年搞一个 UI 组件库的按需加载,第二次是上个月优化一个后台系统的权限模块,这次是给一个老项目加 tree-shaking。每次都是看着文档配好了,build 一看,size 毫无变化,心里直犯嘀咕。
先说结论吧,省得你跟我一样浪费两小时:usedExports 不是开关,它是“配合型选手”;光开它没用,必须同时满足三个条件:
- webpack 配置里
optimization.usedExports: true - 对应模块必须是 ES Module(
export/import),不是 CommonJS(module.exports) - 所有调用链上的模块,都不能有副作用(side effects)—— 尤其是那些带
console.log、localStorage.setItem或直接执行语句的顶层代码
我踩的第一个坑就是:这个工具函数文件虽然用了 export const getUserId = () => ...,但开头写了这么一行:
console.warn('utils/user.js loaded')
就这一行,让整个文件被 webpack 判定为 “有副作用”,直接放弃对它的 usedExports 分析。后来试了下发现,哪怕只是 const DEBUG = true 这种声明,只要没被任何 export 引用,它也会被保留(因为 webpack 默认认为顶层语句可能有副作用)。所以,这里注意我踩过好几次坑:不是 export 写得不对,而是“顺手写的调试语句”在默默阻止 tree-shaking。
第二个坑更隐蔽:我们有个 utils 目录,里面混着 .js 和 .ts 文件,而 tsconfig.json 里 module 是 commonjs。tsc 编译完输出的 js 是 exports.xxx = xxx,webpack 根本不会把它当 ESM 处理 —— 所以 usedExports 对它完全无效。折腾了半天发现,得把 ts 的 module 改成 esnext,或者干脆让 webpack 直接处理 ts(用 ts-loader + transpileOnly: false),确保最终进打包流程的是真·ESM。
第三个坑是 loader 配置。我们用了 babel-loader,但 .babelrc 里有 presets: ['@babel/preset-env'],而默认情况下 preset-env 会把 export 转成 module.exports(尤其在 target 没配全时)。查 babel 官网才发现,得显式加上 modules: false:
// babel.config.js
module.exports = {
presets: [
['@babel/preset-env', {
modules: false, // 关键!不转成 commonjs
targets: { chrome: '87' }
}]
]
}
到这里,三个条件齐了,但 build 后还是有残留。我扒了 dist 下的 chunk,发现某个工具函数被两个地方 import:一个是主业务逻辑,另一个是 mock 数据的测试代码(写在同一个文件里,但用 if (process.env.NODE_ENV === 'test') 包着)。Webpack 默认不会基于环境变量做静态分析,所以它看到 import { formatTime } from './utils' 就认为这个 formatTime 是“被用了”,哪怕实际 runtime 根本走不到那条分支。
解决方法很简单:把测试代码挪到单独的 mock/ 目录,或者用 /*#__PURE__*/ 注释标一下(虽然不推荐,但临时救急):
// utils/index.js
export const formatTime = (t) => new Date(t).toLocaleString()
// 下面这行会被 webpack 当作“未使用”处理(前提是没其他地方引用)
/*#__PURE__*/ console.log('dev-only debug')
最终,我的 webpack.config.js 关键配置长这样(只贴相关部分):
module.exports = {
mode: 'production',
optimization: {
usedExports: true,
minimize: true,
minimizer: [
new TerserPlugin({
terserOptions: {
compress: { drop_console: true },
mangle: true
}
})
]
},
module: {
rules: [
{
test: /.(js|ts)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { modules: false }]
]
}
}
}
]
},
resolve: {
extensions: ['.ts', '.js']
}
}
再补一个真实 case:我们有个 api/client.js,里面 export 了几十个请求函数,但页面只用了一个 fetchUser。改完之后,bundle 分析里它从 12KB 缩到 1.8KB —— 其中 10KB 是 axios 实例和拦截器的副作用代码(全局 request header 设置、错误弹窗等),这部分没法删,但至少把没用的 fetchOrderList、updateProfile 全干掉了。
顺便提一句原理:usedExports 不是靠 AST 静态扫描“哪些 export 没被 import”,而是让 webpack 在编译阶段给每个 export 打标记,比如 /* unused export getUserName */,然后在后续的压缩阶段(Terser)根据这些标记真正删除。所以如果你关了 minimize 或者用了不识别该注释的压缩器,usedExports 就等于白配。
还有个小问题没彻底解决:某些工具函数里用了 new URL(),在低版本 Safari 上报错。本来想用 /*#__NO_SIDE_EFFECTS__*/ 告诉 webpack 这个函数纯计算,但它只对整个模块有效,对单个函数没用。目前折中方案是加 try/catch 包一层,或者把兼容性代码抽到独立 polyfill 文件(不参与 tree-shaking)。暂时能接受 —— 毕竟体积降了 60%,这点小瑕疵无大碍。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案(比如怎么优雅地隔离测试代码、或者在不改 tsconfig 的前提下让 tsc 输出 ESM),欢迎评论区交流。

暂无评论