实现一个 JavaScript 的异步模块定义(AMD)加载器是一个非常好的编程练习,它能让你深入理解模块化、依赖管理和异步编程的核心。
我们将从第一性原理出发,逐步构建一个简单但功能完备的 AMD 加载器。整个过程会分为几个核心步骤:
-
定义核心数据结构:我们需要一个地方来存储和管理所有模块的状态。
-
实现
define函数:模块的“定义”者,用于声明一个模块及其依赖。 -
实现
require函数:模块的“使用者”,用于启动模块的加载和执行。 -
实现依赖加载与执行:这是加载器的核心,负责下载脚本、解决依赖关系并执行模块代码。
核心思想
AMD 的核心是解决两个问题:
-
异步加载:在浏览器中,加载 JavaScript 文件是异步的,我们不能阻塞页面渲染。
-
依赖管理:一个模块可能依赖其他多个模块,必须确保所有依赖都加载完成后,才执行当前模块。
解决方案是通过一个全局的 define 函数,让每个模块文件都能“注册”自己,包括它的依赖项和工厂函数。加载器则负责解析这些注册信息,构建依赖图,并按正确的顺序加载和执行它们。
第一步:基础架构和数据管理
我们需要一个闭包来封装加载器的所有内部状态和方法,避免污染全局作用域。同时,需要一个对象来缓存所有模块的信息。
一个模块通常有以下几种状态:
-
LOADING:正在从服务器加载脚本文件。
-
LOADED:脚本已加载,但依赖项可能还未就绪。
-
DEFINED:模块的工厂函数已执行,模块已完全可用。
JavaScript
// amd-loader.js
(function() {
// 模块缓存。key 是模块ID (通常是文件路径), value 是模块对象
const modules = {};
// 模块状态常量
const Status = {
LOADING: 'loading',
LOADED: 'loaded',
DEFINED: 'defined',
};
// 模块类,用于规范化模块信息
class Module {
constructor(id) {
this.id = id;
this.status = Status.LOADING;
this.dependencies = []; // 依赖的模块ID列表
this.factory = null; // 工厂函数
this.exports = {}; // 最终导出的对象
this.callbacks = []; // 当此模块被定义后,需要调用的回调函数
}
// 当模块的依赖加载完成,执行此方法来定义模块本身
define() {
// 将依赖模块的exports作为参数传入factory
const args = this.dependencies.map(depId => modules[depId].exports);
this.exports = this.factory.apply(null, args);
this.status = Status.DEFINED;
// 执行所有等待此模块的回调
this.callbacks.forEach(callback => callback());
this.callbacks = []; // 清空回调
}
}
// 获取或创建一个模块实例
function getModule(id) {
if (!modules[id]) {
modules[id] = new Module(id);
}
return modules[id];
}
// 接下来我们将在这里实现 define 和 require
// ...
// 将 define 和 require 暴露到全局
window.define = define;
window.require = require;
})();
第二步:实现 define 函数
define 函数是给模块文件使用的。当一个 JS 文件被加载执行时,它应该调用全局的 define 函数来注册自己。
define(id?, dependencies?, factory) 有三种常见的调用形式:
-
define(factory): 匿名模块,无依赖。 -
define(dependencies, factory): 匿名模块,有依赖。 -
define(id, dependencies, factory): 命名模块,有依赖。(我们的简化版加载器主要处理匿名模块,这是最常见的情况)。
JavaScript
// (继续在闭包内)
// 全局变量,用于追踪当前正在加载的脚本对应的模块
let currentLoadingModule = null;
function define(dependencies, factory) {
// 参数修正:允许 define(factory) 的形式
if (typeof dependencies === 'function') {
factory = dependencies;
dependencies = [];
}
// define调用时,我们认为它对应的是当前正在加载的那个脚本文件
// 所以 currentLoadingModule 就是我们要定义的模块
const mod = currentLoadingModule;
mod.dependencies = dependencies;
mod.factory = factory;
mod.status = Status.LOADED; // 脚本已加载,但依赖未解决
// 检查此模块的依赖是否已经全部加载完成
checkDependencies(mod);
}
// AMD规范要求 define 函数有一个 amod 属性
define.amd = {};
function checkDependencies(mod) {
// 如果所有依赖都已定义,那么就定义当前模块
const allDefined = mod.dependencies.every(depId => {
const depModule = modules[depId];
return depModule && depModule.status === Status.DEFINED;
});
if (allDefined) {
mod.define();
}
}
第三步:实现 require 函数和脚本加载
require 是用户启动加载过程的入口。它和 define 非常相似,但不定义模块,而是直接在依赖加载后执行一个回调。
JavaScript
// (继续在闭包内)
function require(dependencies, callback) {
// require可以看作是一个临时的、匿名的模块
const tempModule = new Module('require_module_' + Date.now());
tempModule.dependencies = dependencies;
tempModule.factory = function() {
// 当所有依赖都准备好后,执行回调
callback.apply(null, arguments);
};
tempModule.status = Status.LOADED; // 假装它已加载
// 立即开始处理它的依赖
loadDependencies(tempModule);
}
function loadDependencies(mod) {
mod.dependencies.forEach(depId => {
const depModule = getModule(depId);
if (depModule.status === Status.DEFINED) {
return; // 依赖已就绪,跳过
}
// 如果依赖正在加载或已加载但未定义,
// 添加一个回调,当依赖准备好后,回来检查当前模块
depModule.callbacks.push(() => {
checkDependencies(mod);
});
// 如果依赖是第一次遇到,则加载它
if (depModule.status === Status.LOADING) {
// 避免重复加载脚本
if (!depModule.isLoadingScript) {
depModule.isLoadingScript = true;
loadScript(depId);
}
}
});
// 在分发完加载任务后,立即检查一次
checkDependencies(mod);
}
function loadScript(id) {
const script = document.createElement('script');
script.src = id + '.js'; // 简单约定:模块ID就是文件名
script.async = true;
script.onload = () => {
// 当脚本加载完成时,全局的 define 函数应该刚刚被执行
// onScriptLoad 函数负责处理后续逻辑
onScriptLoad(id);
};
script.onerror = () => {
console.error(`Error loading module: ${id}`);
// 实际的加载器需要更完善的错误处理
};
// 在加载脚本前,设置当前正在加载的模块
// 这样 define 函数才知道自己属于哪个模块
currentLoadingModule = getModule(id);
document.head.appendChild(script);
}
function onScriptLoad(id) {
const mod = getModule(id);
// 脚本加载后,define 已经被调用,模块状态变为 LOADED
// 现在需要为这个新加载的模块处理它的依赖
loadDependencies(mod);
}
完整代码与示例
将以上所有部分组合起来,我们就得到了一个基础的 AMD 加载器。
amd-loader.js (完整版)
JavaScript
(function() {
const modules = {};
const Status = {
LOADING: 'loading',
LOADED: 'loaded',
DEFINED: 'defined',
};
let currentLoadingModule = null;
class Module {
constructor(id) {
this.id = id;
this.status = Status.LOADING;
this.dependencies = [];
this.factory = null;
this.exports = {};
this.callbacks = [];
this.isLoadingScript = false; // 防止重复请求脚本
}
define() {
const args = this.dependencies.map(depId => modules[depId].exports);
// 如果factory返回undefined,则exports为空对象,符合AMD规范
this.exports = this.factory.apply(null, args) || this.exports;
this.status = Status.DEFINED;
this.callbacks.forEach(callback => callback());
this.callbacks = [];
}
}
function getModule(id) {
if (!modules[id]) {
modules[id] = new Module(id);
}
return modules[id];
}
function checkDependencies(mod) {
const allDefined = mod.dependencies.every(depId => {
const depModule = modules[depId];
return depModule && depModule.status === Status.DEFINED;
});
if (allDefined) {
mod.define();
}
}
function loadDependencies(mod) {
mod.dependencies.forEach(depId => {
const depModule = getModule(depId);
if (depModule.status === Status.DEFINED) {
return;
}
depModule.callbacks.push(() => {
checkDependencies(mod);
});
if (!depModule.isLoadingScript) {
depModule.isLoadingScript = true;
loadScript(depId);
}
});
checkDependencies(mod);
}
function loadScript(id) {
const script = document.createElement('script');
script.src = id + '.js';
script.async = true;
script.onload = () => {
onScriptLoad(id);
};
script.onerror = () => {
console.error(`Error loading module: ${id}`);
};
currentLoadingModule = getModule(id);
document.head.appendChild(script);
}
function onScriptLoad(id) {
const mod = getModule(id);
// 此刻,该脚本中的 define() 已经执行完毕
// 模块的 factory 和 dependencies 已经记录在 mod 对象上
loadDependencies(mod);
}
function define(dependencies, factory) {
if (typeof dependencies === 'function') {
factory = dependencies;
dependencies = [];
}
const mod = currentLoadingModule;
if (mod) {
mod.dependencies = dependencies;
mod.factory = factory;
mod.status = Status.LOADED;
} else {
// 这种情况理论上不应发生,除非define被不规范地调用
console.error("define() called without a loading script context.");
}
}
define.amd = {};
function require(dependencies, callback) {
const tempModule = new Module('require_module_' + Date.now());
tempModule.dependencies = dependencies;
tempModule.factory = function() {
callback.apply(null, arguments);
};
tempModule.status = Status.LOADED;
loadDependencies(tempModule);
}
window.define = define;
window.require = require;
})();
使用示例
假设我们有以下文件结构:
.
├── index.html
├── amd-loader.js
├── modules
│ ├── math.js
│ └── main.js
modules/math.js
JavaScript
// 定义一个名为 'modules/math' 的模块,它没有依赖
define(function() {
console.log('math.js executed');
return {
add: function(a, b) {
return a + b;
}
};
});
modules/main.js
JavaScript
// 定义一个名为 'modules/main' 的模块,它依赖 'modules/math'
define(['modules/math'], function(math) {
console.log('main.js executed');
const sum = math.add(5, 7);
console.log('Result of math.add(5, 7) is:', sum);
return {
result: sum
};
});
index.html
HTML
<!DOCTYPE html>
<html>
<head>
<title>AMD Loader Test</title>
</head>
<body>
<h1>Check the console</h1>
<script src="amd-loader.js"></script>
<script>
require(['modules/main'], function(main) {
console.log('All modules loaded. Final result from main.js:', main.result);
});
</script>
</body>
</html>
当你用浏览器打开 index.html,控制台会按以下顺序输出:
-
math.js executed -
main.js executed -
Result of math.add(5, 7) is: 12 -
All modules loaded. Final result from main.js: 12
这证明了我们的加载器成功地按依赖顺序加载并执行了模块。
局限性与改进方向
这个实现为了清晰地展示核心原理,省略了很多生产级加载器必备的功能:
-
错误处理:脚本加载超时、404错误、模块定义错误等都需要完善的处理机制。
-
路径解析:目前我们用
id + '.js'的简单方式。真正的加载器需要能处理相对路径 (./util)、根路径 (/lib) 和复杂的路径映射配置。 -
循环依赖:如果 A 依赖 B,B又依赖 A,我们的简单实现可能会导致死锁或栈溢出。生产级加载器需要能检测并优雅地处理循环依赖。
-
插件系统:像 RequireJS 支持
text!或json!这样的插件来加载非 JS 资源。 -
性能优化:对于打包后的文件(多个
define在一个文件里),加载逻辑需要调整。
结论与现代前端的视角
亲手实现一个 AMD 加载器是理解前端模块化历史和异步依赖管理绝佳的方式。它所蕴含的思想,如依赖图、回调、状态管理,在现代前端工具中依然以各种形式存在。
然而,对于当前和未来的新项目,我的建议是直接使用原生的 ES Modules (ESM)。
ESM (import/export 语法) 是现代 JavaScript 的标准,并已在所有主流浏览器和 Node.js 中得到原生支持。它有几个显著优势:
-
静态分析:
import和export必须在顶层,这使得打包工具(如 Webpack, Rollup, Vite)可以在编译时进行高效的摇树优化(Tree Shaking),移除未使用的代码。 -
语法简洁:相比
define([...], function(...) {})的回调地狱风格,import语法更直观、更易读写。 -
标准:作为语言标准,它拥有最好的生态支持和未来保障。
总而言之,将实现 AMD 加载器视为一次宝贵的学习经历,但在实际开发中,拥抱 ES Modules 是更明智、更高效的选择。
你好!你的问题非常棒,正中这个 AMD 加载器实现的核心与精妙之处。define 函数和 currentLoadingModule 的交互确实是理解整个异步流程的关键。
让我从第一性原理出发,一步步为你拆解这个流程,解答你的困惑。
核心解惑:define 并不“主动”工作,而是“被动”响应
首先要建立一个核心观念:define 函数本身不负责创建模块对象,也不负责加载任何文件。
define的角色:它像一个“登记员”。当一个模块JS文件被浏览器加载并执行时,这个文件里的define(...)代码就会运行。define函数的作用就是接收这个模块的信息(依赖、工厂函数),然后把它登记到一个已经存在、并且正在等待被登记的模块对象上。
那么,这个“已经存在、正在等待登记的模块对象”是谁创建的?currentLoadingModule又是在哪里被赋值的呢?
答案是:这一切都始于 require,并通过 loadScript 函数串联起来。
完整流程追踪:从 require 到 define
我们以 require(['modules/main'], ...) 这个入口为例,看看代码的执行顺序:
第 1 步:require 启动
-
你调用
require(['modules/main'], callback). -
require函数创建一个临时的、匿名的模块来管理这次调用。 -
最关键的是,它调用
loadDependencies(['modules/main']),开始加载依赖。
第 2 步:loadDependencies 和 getModule 首次登场
-
loadDependencies遍历依赖数组,拿到第一个依赖ID:'modules/main'。 -
它调用
getModule('modules/main')。 -
因为这是第一次遇到
'modules/main',缓存modules中没有它,所以getModule会执行new Module('modules/main')。- 回答你的问题:
new Module在这里发生! 一个代表'modules/main'的模块对象被创建了,它的初始状态是LOADING。
- 回答你的问题:
-
loadDependencies发现这个新模块的状态是LOADING,并且还没有开始加载脚本 (isLoadingScript是false)。于是,它决定去加载脚本。
第 3 步:loadScript —— 赋值 currentLoadingModule 的关键时刻
-
loadDependencies调用loadScript('modules/main')。 -
loadScript创建一个<script>标签,src指向modules/main.js。 -
在把这个 script 标签插入到 HTML 之前,执行了最核心的一行代码:
JavaScript
currentLoadingModule = getModule(id); // id 是 'modules/main'- 回答你的问题:
currentLoadingModule在这里被赋值了! 它现在指向了我们在第 2 步创建的那个'modules/main'模块对象。此时,这个全局(闭包内)变量就像一个标记,告诉整个加载器:“我接下来要处理的define,就是为'modules/main'服务的。”
- 回答你的问题:
-
loadScript随后将<script>标签添加到<head>中。浏览器开始异步下载modules/main.js。
第 4 步:define 被动执行
-
浏览器下载并执行
modules/main.js文件。 -
该文件中的代码
define(['modules/math'], function(math) { ... });被执行。 -
此时,我们的全局
define函数被调用了。 -
在
define函数内部,它读取currentLoadingModule。因为第 3 步的赋值,currentLoadingModule不再是 null,它就是'modules/main'的模块对象。 -
define函数将依赖['modules/math']和工厂函数function(math) {...}存入currentLoadingModule这个对象中,并将其状态更新为LOADED(表示脚本加载完成,信息已登记,但依赖尚未解决)。
第 5 步:onload 与递归加载
-
modules/main.js文件执行完毕后,script标签的onload事件触发,调用onScriptLoad('modules/main')。 -
onScriptLoad拿到'modules/main'模块对象,发现它的依赖['modules/math']还没处理,于是再次调用loadDependencies(['modules/math'])。 -
流程回到第 2 步,但这次是为
'modules/math'创建模块对象、在loadScript中设置currentLoadingModule、加载math.js、执行math.js中的define... 如此循环往复,直到所有依赖链都加载并定义完毕。
总结与图解
简单来说,这是一个“安排与响应”的机制:
-
安排 (
require->loadDependencies->loadScript):-
“我需要一个叫
main的模块。” -
创建
main的模块对象(状态:LOADING)。 -
在加载
main.js脚本前,用currentLoadingModule全局“记住”main这个模块对象。 -
命令浏览器去加载
main.js。
-
-
响应 (
define):-
main.js加载并执行。 -
它里面的
define函数被调用。 -
define说:“我知道了,我这些信息(依赖和工厂函数)是属于currentLoadingModule(也就是main模块)的。” -
它把信息填入
main模块对象,并更新其状态。
-
这个设计巧妙地利用了 JavaScript 的单线程和事件循环特性。在 loadScript 设置 currentLoadingModule 和浏览器执行完脚本并调用 define 之间,不会有其他的 loadScript 操作来覆盖 currentLoadingModule(因为脚本加载是异步的,但 define 的执行是同步的,会发生在 onload 事件之前)。
希望这个分步解析能让你彻底明白 define 和 currentLoadingModule 之间精巧的协作关系!