AMD异步模块配合webpack代码拆分实现业务模块按需加载

发布于 | 分类于 前端/前端工程|本文包含AIGC内容

最近做了一个业务模块库的按需加载改造。这个库聚合了十几个业务子模块,原来宿主应用一上来就把所有模块全量加载,随着模块越来越多,首屏时间肉眼可见地变长。

目标是:宿主侧零改造,消费方无感知,用到哪个模块才去加载哪个模块对应的 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,而不是真正的模块。

所以核心问题有两个:

  1. AMD loader 需要识别 thenable factory,等待 Promise resolve 后再触发消费方的 callback
  2. 理解 webpack import() 编译出来的 AMD 产物是什么样的,才能知道 loader 应该如何配合

webpack 的 import() 编译产物

先搞清楚 webpack 在 AMD 模式下编译 import() 会产出什么。

假设源码是这样:

typescript
// 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 函数里的核心逻辑大致是这样的:

js
// 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.exportsret 是个 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 → EXECUTED
  • FETCHED:模块脚本已加载,define 被调用,factory 和 deps 已注册
  • LOADED:所有依赖模块均已 LOADED
  • EXECUTED:factory 已执行,mod.exports 已就绪

原有逻辑里,EXECUTED 意味着 mod.exports 立即可用。但异步模块要到 Promise resolve 之后 exports 才真正就绪,这个状态定义需要重新理解:EXECUTED 表示 factory 已启动,但 exports 不一定就绪

exec() 改造:识别 thenable

原来的 exec 核心是:

js
var ret = mod.factory.apply(null, args);
mod.exports = ret || mod.exports;
mod.state = STATUS.EXECUTED;

改造后,增加对 thenable 的判断(duck typing,不依赖 instanceof Promise,兼容各种 thenable 实现):

js
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 后触发的回调):

js
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(),执行路径与改动前一字不差。存量同步代码不受影响。

各场景验证

场景一:同步模块

js
define('sync-mod', function() { return { x: 1 } })
require(['sync-mod'], function(mod) {
    // promises 为空,直接 mod.exec(),callback 同步触发
    console.log(mod) // { x: 1 }
})

场景二:单个异步模块(factory 返回 Promise)

js
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 }
})

场景三:多个异步依赖并行

js
require(['async-a', 'async-b'], function(a, b) {
    // Promise.all([a._resolvePromise, b._resolvePromise])
    // 两个依赖并行加载,取最慢那个的时间
})

场景四:链式异步依赖(A 依赖 B,B 是异步)

js
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 }_resolvePromisenull。A 的 exec() 在等 B 的 Promise 时,收集的是 B 的 _resolvePromise(还没 resolve 的那个 Promise),等它完成后,b 参数通过 getDepsExport()B.exports 取到的就是已经更新的 { val: 10 }

webpack import() 拆分的产物结构

搞懂了 loader 怎么等待 Promise,再来看业务库的主包入口是怎么利用这个机制的。

require.context lazy 模式

入口文件的核心代码:

typescript
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,保证主包先执行:

js
// 构建后产物
require(["my-lib"], function() {
    // 主包 factory 执行完毕,所有子模块已注册
    require(["dep-a", "my-lib/foo", ...], function(depA, foo) {
        // app factory
    });
});

这个思路通过一个 webpack 插件在构建产物层面实现,不改运行时代码,对开发环境无影响。

小结

整个方案的核心链路:

  1. AMD loader 扩展exec() 识别 thenable factory,用 _resolvePromise 追踪异步状态;mod.callbackPromise.all 等待所有异步依赖,全同步时执行路径与改动前完全等价
  2. webpack 编译层import() 在 AMD 模式下编译为 factory 内的 __webpack_require__.e(...).then(...),factory 返回这个 Promise——正好对接改造后的 loader;require.context lazy 让每个 expose 文件自动拆出独立 chunk
  3. 主包入口entry.ts 批量注册异步 AMD 声明,factory 是一个 () => import(chunk) 的薄包装,模块声明注册后 chunk 还未加载,真正 require 时才拉取

宿主消费方代码完全不需要改,require(['my-lib/foo'], callback) 和用同步模块的写法一模一样,异步加载的细节对上层透明。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。