Webpack usedExports配置详解与实际项目中的优化效果

Mr-世暄 优化 阅读 2,581
赞 22 收藏
二维码
手机扫码查看
反馈

usedExports 没生效?打包后代码还是全量导出

今天上线前做体积分析,发现一个模块明明只用了 getUserId,结果整个工具函数文件 300 行全被打进了 bundle —— 而且 webpack-bundle-analyzer 里显示它被标记为 “exported as side-effect free”,但实际没删。我第一反应是:wtf,usedExports 又骗我?

Webpack usedExports配置详解与实际项目中的优化效果

这已经是我第三次被它坑了。第一次是在去年搞一个 UI 组件库的按需加载,第二次是上个月优化一个后台系统的权限模块,这次是给一个老项目加 tree-shaking。每次都是看着文档配好了,build 一看,size 毫无变化,心里直犯嘀咕。

先说结论吧,省得你跟我一样浪费两小时:usedExports 不是开关,它是“配合型选手”;光开它没用,必须同时满足三个条件

  • webpack 配置里 optimization.usedExports: true
  • 对应模块必须是 ES Module(export/import),不是 CommonJS(module.exports
  • 所有调用链上的模块,都不能有副作用(side effects)—— 尤其是那些带 console.loglocalStorage.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 里 modulecommonjs。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 设置、错误弹窗等),这部分没法删,但至少把没用的 fetchOrderListupdateProfile 全干掉了。

顺便提一句原理: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),欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论