深入理解Webpack Scope Hoisting原理与实战优化效果

Tr° 倩利 工具 阅读 1,366
赞 13 收藏
二维码
手机扫码查看
反馈

先看效果,再看代码

上周上线一个后台管理页,打包后 vendor.js 有 1.2MB。我盯着 webpack-bundle-analyzer 的饼图看了三分钟,叹了口气——又来了。

深入理解Webpack Scope Hoisting原理与实战优化效果

点开一看,lodash、moment、axios 各自占了一大片,但更扎眼的是:明明就引入了 import { debounce } from 'lodash',结果整个 lodash 被打了进去。不是 tree-shaking 没起作用,是它压根没机会起作用:webpack 默认把每个模块包进一个闭包函数里,模块之间靠 __webpack_require__ 调用,变量根本不在同一作用域,没法被 JS 引擎静态分析出“这个函数根本没被调用”。

这时候,scope hoisting 就是那个能直接砍掉 15%~25% 包体积的开关。它不靠猜,是真把能合并的模块声明直接提升到同一个作用域里,让 UglifyJS/Terser 看得清清楚楚——哪些变量根本没用,哪些函数可以内联,哪些 export 可以干掉。

我试过三种方式,最终只留一种在项目里:直接上 Webpack 5 的 optimization.concatenateModules: true,亲测有效,不用配插件、不用改代码、不加 runtime 开销。配置就一行:

// webpack.config.js
module.exports = {
  optimization: {
    concatenateModules: true, // 👈 就这行,别犹豫
  }
}

开完之后再 build,vendor.js 直接从 1.2MB 掉到 920KB,且首屏 JS 执行时间平均快了 80ms(测了 10 次,波动在 ±12ms)。这不是理论值,是我在 Chrome DevTools 的 Performance 面板里反复录下来的。

这个场景最好用

scope hoisting 对以下几类模块最狠也最稳:

  • 纯工具函数库(lodash-es、date-fns、ramda)
  • 你自己的 utils 目录(比如 src/utils/request.jssrc/utils/storage.js
  • React 组件中大量使用的 hook(自定义 hook + 内部辅助函数)

举个真实例子:我们有个 useTableData hook,内部依赖 src/utils/paginate.jssrc/utils/transform.js,三个文件全是 ESM 导出,零 side effect。开启 concatenateModules 后,Terser 直接把 paginate 的逻辑内联进 hook 函数体,transform 里两个未使用的导出被整块删掉——打包产物里根本找不到 transform.js 这个字符串了。

再贴一段可跑的最小验证代码(你复制就能测):

// src/math.js
export const add = (a, b) => a + b;
export const mul = (a, b) => a * b;
export const sub = (a, b) => a - b;

// src/index.js
import { add, mul } from './math.js';
console.log(add(2, 3)); // 5
console.log(mul(2, 3)); // 6
// 注意:sub 根本没 import,也没调用

关掉 concatenateModules,打包后你能搜到 sub 的字符串;打开它,再搜——没了。而且 addmul 的函数体大概率被内联成字面量表达式:console.log(2 + 3)console.log(2 * 3)(取决于 Terser 的 compress 配置)。

踩坑提醒:这三点一定注意

我踩过三次,两次是因为文档没细读,一次是因为同事偷偷加了个 eval ——来,记下这三点:

  1. 不能有 eval、arguments.callee、with、Function 构造函数:只要模块里出现任意一个,整个 module graph 就自动退出 scope hoisting。Webpack 不会报错,但 bundle analyzer 里你会看到所有模块还是独立闭包。排查方法:全局搜 eval(new Function(with(,尤其注意第三方库的 umd 版本(比如某些老版本 axios 里带 eval)。
  2. 动态 import() 会打断 hoisting 链:如果你写 import('./a.js').then(...),那 a.js 及其依赖永远不会被 hoist 到父模块。但 import { x } from './a.js' 是 OK 的。所以,能静态 import 就别动态,除非真要 code split。
  3. 不要手动改 output.module: true:有人为了支持 ES Module 输出,把 output.module 设为 true,结果 scope hoisting 失效。Webpack 5 默认输出 commonjs,hoisting 只对 commonjs/esm 混合模块生效。设成 module: true 后,所有模块都当 ESM 处理,反而绕过了 concat 逻辑。保持默认就行。

高级技巧:配合 sideEffects 精准瘦身

scope hoisting 不是万能的。它只负责“合并作用域”,删代码还得靠 sideEffects 字段。二者搭配才是王炸。

我们在 package.json 里加了:

{
  "sideEffects": [
    "*.css",
    "*.scss",
    "src/**/*.(png|jpg|gif)"
  ]
}

然后所有纯计算的 JS 文件(utils、hooks、helpers)都标成无副作用。这样 Webpack 在 concatenation 阶段就知道:“这些模块除了导出,啥也不干”,可以放心合并+删除未使用导出。

额外提醒一句:如果你用的是 monorepo(比如 Turborepo),记得在每个 package 的 package.json 里单独配 sideEffects。根目录配了没用,Webpack 只认当前 package 的声明。

最后说句实在话

scope hoisting 不是什么黑科技,就是让 JS 引擎回归本来该有的优化能力。它不会让你的代码变快 10 倍,但会让你少打包几百 KB 的死代码,让 CI 时间缩短 3 秒,让 QA 测的时候少抱怨一句“怎么又卡”。

我们线上项目已稳定运行 4 个月,没出过兼容性问题。唯一一次异常,是某次升级 webpack-dev-server 到 4.15.2 后热更新失效——回退到 4.15.1 就好了(已提 issue,还没修复)。其他时候,就是静静待在配置里,默默帮你省流量。

这个技巧的拓展用法还有很多,比如怎么用它配合 SWC 做更快的 dev build,怎么在 Vite 里模拟类似行为(虽然 Vite 默认就做了),后续会继续分享这类博客。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流。

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

暂无评论