news 2026/9/8 20:32:29

webpack Scope Hoisting 与 Code Splitting 组合实战:Partial Scope Hoisting(模块拼接)的取舍原理与产物剖析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
webpack Scope Hoisting 与 Code Splitting 组合实战:Partial Scope Hoisting(模块拼接)的取舍原理与产物剖析

webpack Scope Hoisting 与 Code Splitting 组合实战:Partial Scope Hoisting(模块拼接)的取舍原理与产物剖析

【免费下载链接】webpackA bundler for javascript and friends. Packs many modules into a few bundled assets. Code Splitting allows for loading parts of the application on demand. Through "loaders", modules can be CommonJs, AMD, ES6 modules, CSS, Images, JSON, Coffeescript, LESS, ... and your custom stuff.项目地址: https://gitcode.com/GitHub_Trending/web/webpack

本指南以仓库 examples/scope-hoisting 示例为核心,剖析 webpack 在"同时开启模块拼接(Module Concatenation / Scope Hoisting)与代码分割(Code Splitting)"时的真实行为。通过一个同时包含 ES module、CommonJS、懒加载与跨 chunk 共享模块的微型应用,你将掌握optimization.concatenateModules的生效边界、bailout(放弃拼接)的判定逻辑,以及如何阅读拼接后的产物注释来定位优化盲区。

示例背景:一个混合模块类型的依赖图

scope-hoisting示例构建了 9 个相互依赖的模块,刻意制造了三个"无法把所有模块塞进同一个作用域"的障碍,用于展示 webpack 如何在约束下尽可能多地拼接 ES module。

各模块相对位置与真实文件如下:

模块文件(仓库相对路径)模块系统角色
exampleexamples/scope-hoisting/example.jsESM入口,触发懒加载
lazyexamples/scope-hoisting/lazy.jsESM异步 chunk 入口
aexamples/scope-hoisting/node_modules/a.jsESM重导出shared
bexamples/scope-hoisting/node_modules/b.jsESM具名导出函数
cexamples/scope-hoisting/node_modules/c.jsESM依赖 CommonJS,再导出shared
dexamples/scope-hoisting/node_modules/d.jsESM纯变量导出
cjsexamples/scope-hoisting/node_modules/cjs.jsCommonJS非严格模式输出
sharedexamples/scope-hoisting/node_modules/shared.jsESM被两个 chunk 引用
shared2examples/scope-hoisting/node_modules/shared2.jsESMshared重导出

注意:示例把业务模块放在node_modules目录内仅是演示技巧(以便模拟"外部依赖"形态),它们实际是示例自带文件,见 examples/scope-hoisting/node_modules。

入口example.js同步导入ab,再通过import()异步加载lazy

import { a, x, y } from "a"; import * as b from "b"; import("./lazy").then(function(lazy) { console.log(a, b.a(), x, y, lazy.c, lazy.d.a, lazy.x, lazy.y); });

lazy.jscdshared系模块一并拉入懒加载分支:

export * from "c"; import * as d from "d"; export { d };

为什么"全量拼接成单一作用域"行不通

普通场景下 Scope Hoisting 把一组 ES module 内联到一个函数作用域,去掉每个模块的包裹函数(wrapper)。但本例在 README 中明确指出:把所有模块放进单一作用域必然失败,原因是三条硬约束:

  1. lazycdcjs必须属于独立的 chunk——import()异步加载决定了它们与入口不在同一份初始脚本里;
  2. shared被两个 chunk(主 chunk 与异步 chunk)同时访问——两个 chunk 处于不同作用域,无法共享同一份局部变量;
  3. cjs是 CommonJS 模块——其非严格模式、运行时修改exports的语义无法被静态内联。

下图展示"理想全拼接"(无分块时把所有模块放入单一作用域)的依赖视图(实线为同步导入、虚线为异步导入):

当引入 Code Splitting 后,模块被物理划分为主 chunk 与异步 chunk:

核心机制:Partial Scope Hoisting(模块拼接)

面对上述约束,webpack 采用的策略被 README 称为"Partial Scope Hoisting",即Module Concatenation(模块拼接):它选出可被作用域提升的 ES module 的尽可能大的子集,将其拼接,其余无法拼接的部分仍退回默认的 webpack 运行时原语(模块数组 +__webpack_require__)组合。

拼接后,模块内的标识符会被重命名以避免冲突、模块内部导入被简化;而从拼接根模块出发的对外导入与导出,则沿用既有 ESM 构造(import/export语义)。最终优化形态如下(同一拼接作用域内的模块共享一个函数作用域,跨 chunk 的依赖以外部模块形式保留):

这一机制在仓库源码中的实现位于 lib/optimize/ModuleConcatenationPlugin.js(其插件类定义见第 106 行)。它是内置的optimization.concatenateModules背后的插件(production模式默认启用),且与FlagDependencyUsagePluginModuleConcatenationPlugin等的执行顺序紧密相关(见 WebpackOptionsApply.js 中对optimization.usedExportsconcatenateModules的处理)。

示例依赖模块逐个拆解

a.js —— 重导出shared

// module a export var a = "a"; export * from "shared";

b.js —— 导出一个与a同名冲突的函数

// module b export function a() { return "b"; };

a.js导出变量ab.js导出函数a,同名冲突正好演示拼接时必须重命名标识符(见后文产物中b_a的产生)。

c.js —— 桥接 CommonJS 与 ESM

// module c import { c as e } from "cjs"; export var c = String.fromCharCode(e.charCodeAt(0) - 2); export { x, y } from "shared";

ccjs取到字符串"e",经String.fromCharCode换算得到"c";同时把sharedx/y原样转发。

d.js、cjs.js、shared.js、shared2.js

// module d export var a = "d";
// module cjs (commonjs) exports.c = "e";
// shared module export var x = "x"; export * from "shared2";
// shared2 module export var y = "y";

整个依赖链形成两条"导出走廊":example → a → shared → shared2同步可达;example →(async) lazy → c → shared / cjs。这正是shared被两个 chunk 共享、cjs无法拼接的根源。

配置文件:如何开启模块拼接

示例配置位于 examples/scope-hoisting/webpack.config.js:

"use strict"; /** @type {import("webpack").Configuration} */ const config = { // mode: "development" || "production", optimization: { usedExports: true, concatenateModules: true, chunkIds: "named" // To keep filename consistent between different modes (for example building only) } }; module.exports = config;

三个关键项的实战含义:

  • optimization.concatenateModules: true:核心开关,显式启用模块拼接。注意在production模式下它默认即为true,此处显式声明是让示例在development模式下也能复现拼接行为;而在development下拼接产物仍保留可读注释,便于教学观察。若需在production下关闭拼接可显式置为false(例如排查依赖 ESM 语法分析问题时)。
  • optimization.usedExports: true:启用导出使用情况分析(tree shaking 的前提之一)。产物注释中的[used in main][provided][usage prevents renaming]等标记即来自该分析,它为拼接决策提供"哪些导出真的被用了、可否重命名"的信息。
  • optimization.chunkIds: "named":让 chunk 使用可读名称(如lazy_js),保证 dev/prod 两种模式产物文件名一致。这也是输出文件lazy_js.output.js名称的来源。

拼接判定结果并不会"全有或全无",而是按模块逐一决定,判定逻辑封装在ModuleConcatenationPlugin内部,每放弃一个模块都会产出ModuleConcatenation bailout原因,源码中用前缀常量BAILOUT_PREFIX = "ModuleConcatenation bailout: "统一标记(见 lib/optimize/ModuleConcatenationPlugin.js)。

产物解剖:从注释读懂拼接决策

主入口产物dist/output.js

先看被拼接进主 chunk 的模块区。注意注释头:./example.js + 2 modules,以及./node_modules/shared.js + 1 modules表示把sharedshared2拼成一个"包裹":

/* 1 */ /*!********************************************!*\ !*** ./node_modules/shared.js + 1 modules ***! \********************************************/ /*! namespace exports */ /*! export x [provided] [used in main] [could be renamed] */ /*! export y [provided] [used in main] [could be renamed] -> ./node_modules/shared2.js .y */ /*! runtime requirements: __webpack_exports__, __webpack_require__.d, __webpack_require__.* */ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { // EXPORTS __webpack_require__.d(__webpack_exports__, { x: () => (/* binding */ x), y: () => (/* reexport */ y) }); ;// ./node_modules/shared2.js // shared2 module var y = "y"; ;// ./node_modules/shared.js // shared module var x = "x";

这里可以直观看到拼接的本质:shared.jsshared2.js的源码顺序内联进同一模块体,sharedshared2export *被改写为直接定义ygetter,模块间不再有嵌套__webpack_require__(shared2)调用。

随后是入口拼接区,注释头部直接给出了放弃拼接的明确原因

let __webpack_exports__ = {}; // This entry needs to be wrapped in an IIFE because it needs to be isolated against other modules in the chunk. (() => { /*!********************************!*\ !*** ./example.js + 2 modules ***! \********************************/ /*! namespace exports */ /*! runtime requirements: __webpack_require__, __webpack_require__.e, __webpack_require__.* */ /*! ModuleConcatenation bailout: Cannot concat with ./node_modules/shared.js: Module ./node_modules/shared.js is referenced from different chunks by these modules: ./node_modules/c.js */ // EXTERNAL MODULE: ./node_modules/shared.js + 1 modules var shared = __webpack_require__(1); ;// ./node_modules/a.js // module a var a = "a"; ;// ./node_modules/b.js // module b function b_a() { return "b"; };

观察要点:

  • example.js + 2 modulesexampleab)被成功拼接:a的变量ab的函数a同处一个作用域,为避冲突函数被重命名为b_a
  • shared.js"referenced from different chunks by these modules: ./node_modules/c.js"无法并入该拼接体——c.js在异步 chunk 中也要引用它,所以这里只能退化为外部模块调用__webpack_require__(1)
  • 运行时侧同样出现一条对应 bailout:ModuleConcatenation bailout: Cannot concat with ./node_modules/cjs.js: Module is not in strict mode(见dist/lazy_js.output.js)。

拼接后异步导入被翻译为:

__webpack_require__.e(/*! import() */ "lazy_js").then(() => (__webpack_require__(/*! ./lazy */ 2))).then(function(lazy) { console.log(a, b_a(), shared.x, shared.y, lazy.c, lazy.d.a, lazy.x, lazy.y); });

注意lazy.x/lazy.y实际命中shared.x/shared.y,而拼接前的__webpack_require__运行时、JSONP chunk 加载、script注入等引导代码均被折叠进<details>中的 webpack runtime 区块,正式构建日志展示这部分只占了 5.35 KiB。

异步 chunk 产物dist/lazy_js.output.js

(self["webpackChunk"] = self["webpackChunk"] || []).push([["lazy_js"],[ /* 2 */ /*!*****************************!*\ !*** ./lazy.js + 2 modules ***! \*****************************/ /*! namespace exports */ /*! export c [provided] [used in main] [usage prevents renaming] -> ./node_modules/c.js .c */ /*! export d [provided] [only properties used in main] [usage prevents renaming] -> ./node_modules/d.js */ /*! export a [provided] [used in main] [usage prevents renaming] */ /*! export x [provided] [used in main] [usage prevents renaming] -> ./node_modules/shared.js + 1 modules .x */ /*! export y [provided] [used in main] [usage prevents renaming] -> ./node_modules/shared2.js .y */ /*! ModuleConcatenation bailout: Cannot concat with ./node_modules/cjs.js: Module is not in strict mode */ /*! ModuleConcatenation bailout: Cannot concat with ./node_modules/shared.js: Module ./node_modules/shared.js is not in the same chunk(s) (expected in chunk(s) unnamed chunk(s), module is in chunk(s) ) */ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; // EXPORTS __webpack_require__.d(__webpack_exports__, { c: () => (/* reexport */ c), d: () => (/* reexport */ d_namespaceObject), x: () => (/* reexport */ shared.x), y: () => (/* reexport */ shared.y) }); // NAMESPACE OBJECT: ./node_modules/d.js var d_namespaceObject = {}; __webpack_require__.r(d_namespaceObject); __webpack_require__.d(d_namespaceObject, { a: () => (a) }); // EXTERNAL MODULE: ./node_modules/cjs.js var cjs = __webpack_require__(3); // EXTERNAL MODULE: ./node_modules/shared.js + 1 modules var shared = __webpack_require__(1); ;// ./node_modules/c.js // module c var c = String.fromCharCode(cjs.c.charCodeAt(0) - 2); ;// ./node_modules/d.js // module d var a = "d"; ;// ./lazy.js

可观察到异步 chunk 内的三层取舍:

  1. lazy.js与其直接依赖c.jsd.js被拼接(lazy.js + 2 modules),cjs.js因为非严格模式(not in strict mode)被拆成独立模块 3(exports.c = "e"保留运行时语义);
  2. dexport { d }import * as d需要保真 namespace 语义,因此额外生成d_namespaceObject并通过__webpack_require__.r标记为 ES namespace;
  3. shared(模块 1)因属于其他 chunk而无法进入本 chunk 拼接,只能以__webpack_require__(1)跨 chunk 引用——注意主 chunk 与异步 chunk 引用的是同一个模块 1,避免重复打包,这正是模块缓存__webpack_module_cache__的价值。

压缩(minimized)后的异步 chunk 几乎只剩语义骨架:

(self.webpackChunk=self.webpackChunk||[]).push([["lazy_js"],{207(a,e,r){"use strict";r.d(e,{c:()=>C,d:()=>c,x:()=>s.x,y:()=>h.y});var c={};r.r(c),r.d(c,{a:()=>k});var d=r(330),s=r(331),h=r(453),C=String.fromCharCode(d.c.charCodeAt(0)-2),k="d"},330(a,e){e.c="e"}}]);

拼接与非拼接的收益差异非常直观:未被拼接的cjs依旧保留独立模块函数与exports交互,而拼接后的cdlazy直接内联为扁平语句。

构建统计解读:Unoptimized 与 Production 对比

README 记录两种模式的统计输出(编译产物分别为dist/output.jsdist/lazy_js.output.js):

asset output.js 10.6 KiB [emitted] (name: main) asset lazy_js.output.js 2.35 KiB [emitted] chunk (runtime: main) lazy_js.output.js 263 bytes [rendered] > ./lazy ./example.js 4:0-16 ./lazy.js + 2 modules 221 bytes [built] [code generated] [exports: c, d, x, y] [all exports used] import() ./lazy ./example.js + 2 modules ./example.js 4:0-16 chunk (runtime: main) output.js (main) 367 bytes (javascript) 5.35 KiB (runtime) [entry] [rendered] > ./example.js main runtime modules 5.35 KiB 8 modules ./example.js + 2 modules 267 bytes [built] [code generated] [no exports] [no exports used] entry ./example.js main webpack X.X.X compiled successfully

Production(minimized)下:

asset output.js 2.08 KiB [emitted] [minimized] (name: main) asset lazy_js.output.js 265 bytes [emitted] [minimized] chunk (runtime: main) output.js (main) 367 bytes (javascript) 5.35 KiB (runtime) [entry] [rendered] ./example.js + 2 modules 267 bytes [built] [code generated] [no exports]

要点:./lazy.js + 2 modules./example.js + 2 modules两条记录即"拼接单元"的直接证据——stats 中"模块数+拼接模块数"的呈现方式(X + Y modules);[exports: c, d, x, y]/[all exports used]说明该示例所有导出均被消费,因而拼接后[usage prevents renaming]的标记才会出现。main chunk 的体积从 10.6 KiB 降到 2.08 KiB 主要由压缩承担,而拼接真正消除的是函数调用层级的运行时开销而非体积本身。

从源码理解拼接的判定规则

ModuleConcatenationPlugin(类定义见 lib/optimize/ModuleConcatenationPlugin.js)是拼接的核心执行者。在优化阶段它会遍历每个 chunk,检查模块是否满足可拼接条件(是否严格模式、是否为 ESM 语义完整模块、是否被多个 chunk 引用、是否被多个入口作用域共享等),不满足的模块通过setBailoutReason记录原因——源码中可检索到与之对应的两条产出字符串:

  • "Module is not in strict mode"(lib/optimize/ModuleConcatenationPlugin.js):对应cjs.js的放弃原因;
  • "... is referenced from different chunks by these modules: ..."(lib/optimize/ModuleConcatenationPlugin.js):对应shared.js在主 chunk 的放弃原因。

这些原因会以/*! ModuleConcatenation bailout: ... */注释形式直接写入产物,因此当你发现某模块没有被拼接、产物体积与预期不符时,第一步就是在产物中搜索ModuleConcatenation bailout,逐条阅读即可定位是跨 chunk 共享、非严格模式还是其他语义障碍所致。这也是学习模块拼接最有价值的调试习惯。

小结

scope-hoisting示例用最小依赖图讲清了 webpack 模块拼接的完整边界:

  • Scope Hoisting 不是全局拼接,而是 Partial Scope Hoisting:在每个 chunk 内选取可拼接的最大 ESM 子集;
  • 跨 chunk 共享模块、非严格模式模块、懒加载边界是三类最常见的拼接阻断因素,均以ModuleConcatenation bailout显式注释暴露;
  • 拼接通过重命名消解标识符冲突、简化内部导入,对外仍保持 ESM 语义(namespace 对象、具名 reexport);
  • 阅读产物注释与X + Y modules形式的构建统计,可以精确反推ModuleConcatenationPlugin的决策过程。

进一步延伸可对比阅读 examples/aggressive-merging(chunk 合并主题)与 examples/harmony(ES module 基础),以及优化器实现 lib/optimize/ModuleConcatenationPlugin.js,形成从语法到产物的完整认知链路。

【免费下载链接】webpackA bundler for javascript and friends. Packs many modules into a few bundled assets. Code Splitting allows for loading parts of the application on demand. Through "loaders", modules can be CommonJs, AMD, ES6 modules, CSS, Images, JSON, Coffeescript, LESS, ... and your custom stuff.项目地址: https://gitcode.com/GitHub_Trending/web/webpack

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 20:30:13

AI生成代码的Code Review实战框架:告别逐行审查

1. 先聊聊为什么你还在用上一代方法审AI代码我见过不少团队的状态是这样的&#xff1a;项目用了大量AI辅助生成代码&#xff0c;产出速度确实快了几倍&#xff0c;但到了code review环节&#xff0c;所有人又回到了老路子——对着diff面板&#xff0c;一个人工文件一个文件地过…

作者头像 李华
网站建设 2026/9/8 20:28:57

SEO交易方式全解析:从按词付费到效果分成,避坑指南

做SEO这行十几年&#xff0c;我见过太多人把“SEO交易”想得太简单&#xff0c;以为就是花钱买排名、按月付钱看数据。实际上SEO交易的玩法远比想象中复杂&#xff0c;从按词付费到按效果分成&#xff0c;从月度托管到项目整包&#xff0c;每一种交易方式的背后都是不同的风险分…

作者头像 李华
网站建设 2026/9/8 20:26:24

Ollama本地部署大模型:从安装到API集成与IDE接入

Ollama 是我目前用过的本地大模型部署方案里最省心的一个。它做的事情很简单&#xff1a;把开源大模型变成一条ollama run命令、一个 HTTP 服务&#xff0c;再把服务暴露给 IDE、Web 应用和 API 调用方。也就是说&#xff0c;从“下载模型”到“程序里真正用上模型”&#xff0…

作者头像 李华