AMD异步模块配合webpack代码拆分实现业务模块按需加载
最近做了一个业务模块库的按需加载改造。这个库聚合了十几个业务子模块,原来宿主应用一上来就把所有模块全量加载,随着模块越来越多,首屏时间肉眼可见地变长。
目标是:宿主侧零改造,消费方无感知,用到哪个模块才去加载哪个模块对应的 chunk。这篇文章整理了这个过程中的核心技术细节,重点在两块:AMD loader 怎么改造才能支持异步模块,以及 webpack 的 import() 到底做了什么让 factory 变成异步的。
背景:为什么原来不行?
我们的项目运行时基于 AMD 模块方案,宿主通过 AMD require 加载这个业务库,库以 AMD 模块的形式注册并对外暴露子模块。
按需加载的思路很自然:把每个子模块的代码通过 webpack import() 拆分成独立 chunk,只在真正用到时才拉取。但立刻遇到了问题:
webpack 把 import() 编译进 AMD factory 时,factory 里有异步操作,factory 返回的是一个 Promise。而原来的 AMD loader 根本不认识 Promise,直接把这个 Promise 对象当成 exports 赋出去了,消费方拿到的是一个尚未 resolve 的 Promise,而不是真正的模块。
所以核心问题有两个:
- AMD loader 需要识别 thenable factory,等待 Promise resolve 后再触发消费方的 callback
- 理解 webpack
import()编译出来的 AMD 产物是什么样的,才能知道 loader 应该如何配合
webpack 的 import() 编译产物
先搞清楚 webpack 在 AMD 模式下编译 import() 会产出什么。
假设源码是这样:
// entry.ts
const exposeContext = require.context('./expose', false, /\.ts$/, 'lazy');
defineModule('my-lib/foo', () => exposeContext('./foo.ts'));webpack 在构建时会把 require.context 的 lazy 模式展开,每个匹配文件生成一个对应的 async chunk。运行时调用 exposeContext('./foo.ts') 时,实际上等价于执行了一个动态 import()。
webpack 编译后,factory 函数里的核心逻辑大致是这样的:
// webpack 编译后的 AMD 产物(简化)
define('my-lib/foo', [], function() {
// __webpack_require__.e 加载对应 chunk,返回 Promise
return __webpack_require__.e(/* chunkId */ "foo")
.then(__webpack_require__.bind(__webpack_require__, "./src/expose/foo.ts"))
.then(function(module) { return module; });
});关键点在于:factory 函数 return 了一个 Promise。原来的 AMD loader 不识别这个 Promise,exec() 里直接 mod.exports = ret || mod.exports,ret 是个 Promise 对象,于是 mod.exports 就成了这个 Promise,而不是 resolve 后的模块内容。
__webpack_require__.e 的原理(动态创建 script 加载 chunk,返回 Promise,chunk 加载完后 resolve)在 在webpack中基于chunk加载external外部依赖 这篇里有详细分析,这里就不展开了。
改造 AMD loader:支持异步 factory
AMD 模块状态机回顾
AMD 的模块有一个状态机,从加载到可用经历这几个状态:
UNFETCH → FETCHING → FETCHED → LOADING → LOADED → EXECUTEDFETCHED:模块脚本已加载,define被调用,factory 和 deps 已注册LOADED:所有依赖模块均已 LOADEDEXECUTED:factory 已执行,mod.exports已就绪
原有逻辑里,EXECUTED 意味着 mod.exports 立即可用。但异步模块要到 Promise resolve 之后 exports 才真正就绪,这个状态定义需要重新理解:EXECUTED 表示 factory 已启动,但 exports 不一定就绪。
exec() 改造:识别 thenable
原来的 exec 核心是:
var ret = mod.factory.apply(null, args);
mod.exports = ret || mod.exports;
mod.state = STATUS.EXECUTED;改造后,增加对 thenable 的判断(duck typing,不依赖 instanceof Promise,兼容各种 thenable 实现):
Module.prototype.exec = function() {
var mod = this;
if (mod.state >= STATUS.EXECUTED) {
return mod.exports; // 防重入,已执行过直接返回
}
// 先收集依赖的 _resolvePromise(依赖也可能是异步模块)
var depPromises = [];
if (mod.isDepsDec) {
each(mod.deps || [], function(dep) {
if (isBuiltinModule(dep) || dep.indexOf('!') > -1) return;
var depMod = getModule(resolveId(dep, mod.id));
depMod.exec();
if (depMod._error) mod._error = depMod._error;
else if (depMod._resolvePromise) depPromises.push(depMod._resolvePromise);
});
}
// 先设为 EXECUTED,防止重复 exec
mod.state = STATUS.EXECUTED;
if (depPromises.length > 0) {
// 有异步依赖,等它们全部 resolve 后再执行 factory
mod._resolvePromise = Promise.all(depPromises).then(
function() {
if (mod._error) { /* 触发 errback ... */ return; }
return runFactory(); // factory 执行结果可能还是个 Promise
},
function(err) { mod._error = err; /* ... */ }
);
return mod.exports; // 此时 exports 还是 {},后续异步更新
}
runFactory(); // 无异步依赖,直接执行
return mod.exports;
};
function runFactory() {
var args = mod.getDepsExport();
if (!isFunction(mod.factory)) {
mod.exports = mod.factory;
return null;
}
var ret = mod.factory.apply(null, args);
if (ret != null && typeof ret.then === 'function') {
// factory 返回了 thenable:异步模块
mod._resolvePromise = ret.then(
function(value) {
if (value !== undefined) mod.exports = value; // resolve 后更新 exports
mod._resolvePromise = null;
},
function(err) {
mod._error = err;
mod._resolvePromise = null;
if (mod.errorListeners.length > 0) mod.onerror(err);
}
);
return mod._resolvePromise;
} else {
mod.exports = ret || mod.exports; // 同步模块,原有路径
return null;
}
}这里有几个设计细节值得关注:
mod._resolvePromise 的作用:它是一个标志,非 null 表示模块还没完成异步 resolve。上层(require([...], callback) 的调度逻辑)可以通过检查这个字段来决定是否需要等待。resolve 完成后置 null,避免被重复收集。
state 提前设为 EXECUTED:factory 一旦「启动」就算执行过了,避免并发场景下的重复执行。这和同步模块的语义稍有不同——同步模块 EXECUTED 时 exports 就绪,异步模块 EXECUTED 时 exports 可能还在路上。
getDepsExport() 的时机:对于有异步依赖的模块(depPromises.length > 0),factory 被推迟到 Promise.all 之后执行。此时依赖们的 exports 已经是 resolve 后的真实值,factory 收到的参数是正确的,不是初始的 {}。
require() 改造:Promise.all 等待所有异步依赖
exec() 的改造让单个模块能处理异步 factory,但 require(['a', 'b'], callback) 的场景还不够——a 和 b 都可能是异步模块,需要等它们全部 resolve 后才能触发 callback。
这个逻辑在 requireFactory 里创建的匿名模块的 mod.callback 中处理(依赖图 LOADED 后触发的回调):
mod.callback = function() {
var errors = [], promises = [];
each(mod.deps, function(dep) {
if (dep.indexOf('!') > -1 || isBuiltinModule(dep)) return;
var absId = resolveId(dep, mod.id);
var depMod = getModule(absId);
depMod.exec(); // 触发依赖的 factory 执行
if (depMod._error)
errors.push(depMod._error);
else if (depMod._resolvePromise)
promises.push(depMod._resolvePromise); // 收集待完成的 Promise
});
// 有同步错误,直接 errback
if (errors.length > 0) {
errback && errback(errors[0]);
return;
}
if (promises.length > 0) {
// 有异步依赖,等 Promise.all
Promise.all(promises).then(function() {
// Promise.all 之后再次检查 reject(异步 reject 可能比第一次 exec 晚)
var asyncErrors = [];
each(mod.deps, function(dep) {
if (dep.indexOf('!') > -1 || isBuiltinModule(dep)) return;
var depMod2 = getModule(resolveId(dep, mod.id));
if (depMod2._error) asyncErrors.push(depMod2._error);
});
asyncErrors.length > 0 ? errback && errback(asyncErrors[0]) : mod.exec();
});
} else {
mod.exec(); // 全同步,直接走,与改动前完全等价
}
};Promise.all 里传的是所有依赖的 _resolvePromise,它们是依赖模块 factory 返回的 Promise(经过 .then 链更新 exports 的那个 Promise)。等 Promise.all resolve 时,所有依赖的 exports 已经被更新为真实值,此时调用 mod.exec() 执行用户 callback,callback 的参数通过 getDepsExport() 收集,拿到的自然是 resolve 后的内容。
兼容性保证:当所有依赖都是同步模块时,promises 为空,直接走 mod.exec(),执行路径与改动前一字不差。存量同步代码不受影响。
各场景验证
场景一:同步模块
define('sync-mod', function() { return { x: 1 } })
require(['sync-mod'], function(mod) {
// promises 为空,直接 mod.exec(),callback 同步触发
console.log(mod) // { x: 1 }
})场景二:单个异步模块(factory 返回 Promise)
define('async-mod', function() {
return new Promise(resolve => setTimeout(() => resolve({ x: 2 }), 500))
})
require(['async-mod'], function(mod) {
// depMod._resolvePromise 非 null,进入 Promise.all
// 500ms 后 Promise resolve,mod.exports 更新,触发 callback
console.log(mod) // { x: 2 }
})场景三:多个异步依赖并行
require(['async-a', 'async-b'], function(a, b) {
// Promise.all([a._resolvePromise, b._resolvePromise])
// 两个依赖并行加载,取最慢那个的时间
})场景四:链式异步依赖(A 依赖 B,B 是异步)
define('B', function() {
return new Promise(resolve => setTimeout(() => resolve({ val: 10 }), 1000))
})
define('A', ['B'], function(b) {
// 这里的 b 是 B resolve 后的 { val: 10 },不是 {}
// 原因:A 的 exec() 检测到 depPromises 非空,等 B 的 Promise resolve 后才执行 factory
return new Promise(resolve => setTimeout(() => resolve({ val: b.val + 1 }), 500))
})
require(['A'], function(a) {
console.log(a) // { val: 11 },总耗时 1500ms
})这里值得说一下 B 的 _resolvePromise 生命周期:B 的 factory 执行返回 Promise,_resolvePromise 被赋值;1000ms 后 Promise resolve,mod.exports 更新为 { val: 10 },_resolvePromise 置 null。A 的 exec() 在等 B 的 Promise 时,收集的是 B 的 _resolvePromise(还没 resolve 的那个 Promise),等它完成后,b 参数通过 getDepsExport() 读 B.exports 取到的就是已经更新的 { val: 10 }。
webpack import() 拆分的产物结构
搞懂了 loader 怎么等待 Promise,再来看业务库的主包入口是怎么利用这个机制的。
require.context lazy 模式
入口文件的核心代码:
const defineModule = (window as any).define;
// lazy 模式:webpack 扫描 expose 目录,每个文件单独拆 chunk,调用时才触发加载
const exposeContext = require.context('./expose', false, /\.ts$/, 'lazy');
exposeContext.keys().forEach(key => {
const moduleName = key.replace(/^\.\//, '').replace(/\.ts$/, '');
// 为每个子模块注册 AMD 异步声明
defineModule(`my-lib/${moduleName}`, () => exposeContext(key));
});require.context 的第四个参数 'lazy' 是关键。普通的 require.context 会把所有匹配文件全部打进当前 chunk,而 'lazy' 模式会让 webpack 为每个文件生成独立的 async chunk,exposeContext(key) 调用时才触发对应 chunk 的加载,返回一个 Promise。
所以每个 defineModule 注册的 factory 实质上是 () => Promise<module>,完全符合改造后的 AMD loader 对异步 factory 的预期。
构建产物
my-lib/
index.js ← 主包(极小,仅含声明注册逻辑)
chunks/
foo.abc123.js ← expose/foo.ts 的内容
bar.def456.js
baz.ghi789.js
...主包 index.js 加载后执行 entry.ts,为所有子模块调用 window.define,注册异步 AMD 声明。此时 chunk 还没有被拉取,模块只是进入了 FETCHED 状态(factory 已注册,但未执行)。
当宿主代码 require(['my-lib/foo'], callback) 时,AMD loader 找到已注册的模块,执行 factory(即触发 import('./expose/foo.ts')),发出 chunk 请求,等 chunk 加载完毕 Promise resolve,mod.exports 更新为真实的子模块内容,最后触发 callback。
webpack 模块缓存保证单例
按需拆分后,一些内部共享工具模块可能同时被多个 chunk 引用,物理上存在于多个 chunk 文件里。但 webpack 的模块缓存(__webpack_module_cache__)是跨所有 async chunk 共享的,某个模块一旦执行并写入缓存,后续 chunk 再次引用时直接命中缓存,不会重复执行。
所以重复打包是体积问题(浪费带宽),不是逻辑问题(不会出现多实例、副作用重复执行)。想解决体积问题可以配 splitChunks,但那是可选优化。
这个 chunk 加载机制的细节在 在webpack中基于chunk加载external外部依赖 里有详细分析,这里不重复了。
加载顺序:一个需要处理的坑
宿主应用的 app.js 依赖列表里同时包含了 my-lib(主包)和 my-lib/foo(子模块,来自某个静态 import 被 externalize 的结果)。AMD loader 会并行解析所有依赖——此时主包 factory 还没执行,window.define('my-lib/foo', ...) 还没调用,loader 找不到 my-lib/foo,就会尝试从 URL 加载 my-lib/foo.js,结果 404。
解决方案是在 webpack emit 阶段,把 app.js 的 AMD require 调用包裹一层前置 require,保证主包先执行:
// 构建后产物
require(["my-lib"], function() {
// 主包 factory 执行完毕,所有子模块已注册
require(["dep-a", "my-lib/foo", ...], function(depA, foo) {
// app factory
});
});这个思路通过一个 webpack 插件在构建产物层面实现,不改运行时代码,对开发环境无影响。
小结
整个方案的核心链路:
- AMD loader 扩展:
exec()识别 thenable factory,用_resolvePromise追踪异步状态;mod.callback用Promise.all等待所有异步依赖,全同步时执行路径与改动前完全等价 - webpack 编译层:
import()在 AMD 模式下编译为 factory 内的__webpack_require__.e(...).then(...),factory 返回这个 Promise——正好对接改造后的 loader;require.context lazy让每个 expose 文件自动拆出独立 chunk - 主包入口:
entry.ts批量注册异步 AMD 声明,factory 是一个() => import(chunk)的薄包装,模块声明注册后 chunk 还未加载,真正require时才拉取
宿主消费方代码完全不需要改,require(['my-lib/foo'], callback) 和用同步模块的写法一模一样,异步加载的细节对上层透明。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。
