随着前端工程化的持续推进,打包工具已经成为开发标配,像 Sea.js 这样的经典模块加载器也逐步退出了主流舞台。然而,正因如此,重新审视其源码反而能更深刻地理解早年间开发者是如何在浏览器环境中实现模块化思维的。本文将从配置到加载,再到模块执行,逐步拆解 Sea.js 的核心流程,帮你厘清整个链路。
快速上手:Sea.js 的基本用法
在深入源码之前,先熟悉一下 Sea.js 的基础用法——毕竟许多较晚入行的同学一开始接触的是 Browserify、Webpack,未必亲自写过 Sea.js 的代码。下面展示一个最常见的使用场景:
// main.js
define(function (require, exports, module) {
// require('jquery')
// var $ = window.$
module.exports = 'main-module'
});
配置参数:如何设置 Sea.js
首先通过 script 标签引入 sea.js,然后调用 seajs.config 进行全局配置。所有配置项都会存入一个私有对象 data 中。如果某个属性之前已经设置过,并且该属性是数组或对象,新值会与旧值合并。
(function (global, undefined) {
if (global.seajs) {
return;
}
var data = seajs.data = {};
seajs.config = function (configData) {
for (var key in configData) {
var curr = configData[key]; // 获取当前配置
var prev = data[key]; // 获取之前的配置
if (prev && isObject(prev)) {
// 如果之前已经设置过且为一个对象,用新值覆盖旧值,旧值保留不变
for (var k in curr) {
prev[k] = curr[k];
}
} else {
// 如果之前的值为数组,进行 concat
if (isArray(prev)) {
curr = prev.concat(curr);
}
// 确保 base 为一个路径
else if (key === "base") {
if (curr.slice(-1) !== "/") {
curr += "/";
}
curr = addBase(curr); // 转换为绝对路径
}
// Set config
data[key] = curr;
}
}
};
})(this);
配置中有一个特殊项——base,它定义了所有模块加载的基础路径,必须是一个目录路径,且最终会被转换为绝对路径。例如,当前页面地址为 https://qq.com/web/index.html,配置 base: './js' 后,base 将变为 https://qq.com/web/js/。后续所有依赖模块的 ID 都会基于这个路径转换为 URI,除非另有覆盖。配置相关细节先点到为止,使用时会再详细说明。
模块的加载与执行
接下来调用 seajs.use,这是模块加载的入口,类似于 RequireJS 中的 require 方法。不同之处在于 Sea.js 允许直接传入字符串(RequireJS 必须传数组),内部会自动将字符串转换为数组。实际上,seajs.use 的内部直接调用了 Module.use。
// RequireJS 的写法
require(['main'], function (main) {
console.log(main);
});
Module 是一个构造函数,它挂载了所有与模块加载相关的方法和大量静态方法——实例化、ID 转 URI、定义模块等。直接来看 Module.use 的核心代码:
seajs.use = function(ids, callback) {
Module.use(ids, callback, data.cwd + "_use_" + cid());
return seajs;
};
// 该方法用来加载一个匿名模块
Module.use = function (ids, callback, uri) {
// 如果是通过 seajs.use 调用,uri 是自动生成的
var mod = Module.get(
uri,
isArray(ids) ? ids : [ids] // 这里会将依赖模块转成数组
);
mod._entry.push(mod); // 表示当前模块的入口为本身,后面还会把这个值传入他的依赖模块
mod.history = {};
mod.remain = 1; // 这个值后面会用来标识依赖模块是否已经全部加载完毕
mod.callback = function() {
// 设置模块加载完毕的回调,这一部分很重要,尤其是 exec 方法
var exports = [];
var uris = mod.resolve();
for (var i = 0, len = uris.length; i < len; i++) {
exports[i] = cachedMods[uris[i]].exec();
}
if (callback) {
callback.apply(global, exports); // 执行回调
}
};
mod.load();
};
Module.use 主要做了三件事:
- 调用
Module.get进行模块实例化; - 为模块绑定回调函数;
- 调用
load方法开始加载依赖模块。
实例化模块:一切的起点
Module.get 是一个静态方法,负责实例化 Module 对象,并将实例存入全局缓存对象 cachedMods 中,以 URI 作为唯一标识。这样,后续其他模块再加载同一个模块时,可以直接从缓存中获取。
var cachedMods = seajs.cache = {}; // 模块的缓存对象
Module.get = function(uri, deps) {
return cachedMods[uri] || (cachedMods[uri] = new Module(uri, deps));
};
function Module(uri, deps) {
this.uri = uri;
this.dependencies = deps || [];
this.deps = {}; // Ref the dependence modules
this.status = 0;
this._entry = [];
}
绑定的回调函数会在所有依赖模块加载完毕后触发。下面来看 load 方法:它首先将所有依赖的模块 ID 转换为 URI,然后实例化这些依赖模块,最后调用 fetch 方法并绑定加载成功/失败的回调,启动真正的加载过程。
// 所有依赖加载完毕后执行 onload
Module.prototype.load = function() {
var mod = this;
mod.status = STATUS.LOADING; // 状态置为模块加载中
// 调用 resolve 方法,将模块 id 转为 uri
// 比如之前的 "main",会在前面加上 base,再拼上 js 后缀
// 最后变成:https://qq.com/web/js/main.js
var uris = mod.resolve();
// 遍历所有依赖项的 uri,然后进行依赖模块的实例化
for (var i = 0, len = uris.length; i < len; i++) {
mod.deps[mod.dependencies[i]] = Module.get(uris[i]);
}
// 将 entry 传入到所有的依赖模块,这个 entry 是在 use 方法时设置的
mod.pass();
if (mod._entry.length) {
mod.onload();
return;
}
// 开始进行并行加载
var requestCache = {};
var m;
for (i = 0; i < len; i++) {
m = cachedMods[uris[i]]; // 获取之前实例化的模块对象
m.fetch(requestCache); // 进行 fetch
}
// 发送请求进行模块的加载
for (var requestUri in requestCache) {
if (requestCache.hasOwnProperty(requestUri)) {
requestCache[requestUri](); // 调用 seajs.request
}
}
};
模块 ID 转 URI
resolve 方法的工作方式很直观:从 config 中获取参数,拼接 URI。
Module.prototype.resolve = function() {
var mod = this;
var ids = mod.dependencies; // 取出所有依赖模块的 id
var uris = [];
for (var i = 0, len = ids.length; i < len; i++) {
uris[i] = Module.resolve(ids[i], mod.uri); // 将模块 id 转为 uri
}
return uris;
};
Module.resolve = function(id, refUri) {
var emitData = { id: id, refUri: refUri };
return seajs.resolve(emitData.id, refUri); // 调用 id2Uri
};
seajs.resolve = id2Uri;
function id2Uri(id, refUri) {
if (!id) return "";
id = parseAlias(id);
id = parsePaths(id);
id = parseAlias(id);
id = parseVars(id);
id = parseAlias(id);
id = normalize(id);
id = parseAlias(id);
var uri = addBase(id, refUri);
uri = parseAlias(uri);
uri = parseMap(uri);
return uri;
}
最终由 id2Uri 完成转换,其中调用了多个 parseXxx 方法,核心思路大同小异。以 parseAlias 为例:如果 ID 在 alias 中已定义,则替换为对应的地址。例如之前配置了 jquery: 'https://cdn.bootcss.com/jquery/3.2.1/jquery',那么传入 ID "jquery" 时,就会返回完整的 URL。
function parseAlias(id) {
var alias = data.alias;
return alias && isString(alias[id]) ? alias[id] : id;
}
为依赖添加入口,便于回溯
拿到 URI 后,Module.get 实例化依赖模块,接着执行 pass 方法。该方法的核心是记录入口模块还有多少个未加载的依赖项,存入 remain 中,同时将 entry 存入每个依赖模块的 _entry 属性里,方便后续回溯。当 onload 触发的次数等于 remain 时,入口模块的回调就会被激活。
Module.prototype.pass = function() {
var mod = this;
var len = mod.dependencies.length;
// 遍历入口模块的 _entry 属性,通常只有一个值——它本身
// 可以回去看 use 方法:mod._entry.push(mod)
for (var i = 0; i < mod._entry.length; i++) {
var entry = mod._entry[i];
var count = 0; // 计数器,用于统计未进行加载的模块
for (var j = 0; j < len; j++) {
var m = mod.deps[mod.dependencies[j]];
// 如果模块未加载且在 entry 中未使用,将 entry 传递给依赖
if (m.status < STATUS.LOADED && !entry.history.hasOwnProperty(m.uri)) {
entry.history[m.uri] = true;
count++;
m._entry.push(entry);
}
}
if (count > 0) {
// 这里 count - 1 的原因:回去看 use 方法 -> mod.remain = 1
// remain 初始值为 1,表示默认就有一个未加载的模块,所以需要减 1
entry.remain += count - 1;
// 如果有未加载的依赖项,则移除掉入口模块的 entry
mod._entry.shift();
i--;
}
}
};
发起请求:如何下载其他依赖模块?
pass 记录好 remain 后,就进入关键环节——遍历所有依赖模块并调用其 fetch 方法。调用 fetch 时会传入 requestCache 对象,用来缓存所有依赖模块的 request 方法。
var requestCache = {};
for (i = 0; i < len; i++) {
m = cachedMods[uris[i]]; // 获取之前实例化的模块对象
m.fetch(requestCache); // 进行 fetch
}
Module.prototype.fetch = function(requestCache) {
var mod = this;
var uri = mod.uri;
mod.status = STATUS.FETCHING;
callbackList[requestUri] = [mod];
emit("request", emitData = {
uri: uri,
requestUri: requestUri,
onRequest: onRequest,
charset: isFunction(data.charset) ? data.charset(requestUri) : data.charset,
crossorigin: isFunction(data.crossorigin) ? data.crossorigin(requestUri) : data.crossorigin
});
if (!emitData.requested) {
requestCache[emitData.requestUri] = sendRequest;
}
function sendRequest() {
seajs.request(emitData.requestUri, emitData.onRequest, emitData.charset, emitData.crossorigin);
}
function onRequest(error) {
var m, mods = callbackList[requestUri];
delete callbackList[requestUri];
if (anonymousMeta) {
Module.sa ve(uri, anonymousMeta);
anonymousMeta = null;
}
while ((m = mods.shift())) {
if (error === true) {
m.error();
} else {
m.load();
}
}
}
};
经过 fetch 处理后,requestCache 中缓存了每个模块的加载方法,最终都通过 seajs.request 执行,并传入 onRequest 回调。
for (var requestUri in requestCache) {
requestCache[requestUri](); // 调用 seajs.request
}
// 用来加载 js 脚本的方法
seajs.request = request;
function request(url, callback, charset, crossorigin) {
var node = doc.createElement("script");
addOnload(node, callback, url);
node.async = true;
node.src = url;
head.appendChild(node);
}
function addOnload(node, callback, url) {
node.onload = onload;
node.onerror = function() {
emit("error", { uri: url, node: node });
onload(true);
};
function onload(error) {
node.onload = node.onerror = node.onreadystatechange = null;
callback(error);
}
}
原理与 RequireJS 一致:创建 script 标签,绑定 onload 事件,插入 head。脚本加载完成后,onRequest 被调用,最后又回到 load 方法。
通知入口模块
依赖模块再次调用 load 时,分支有所不同:
if (mod._entry.length) {
mod.onload();
return;
}
如果该依赖模块没有其他依赖(即 _entry 非空),就直接调用 onload。但如果它内部还有 define 嵌套的其他依赖,就会走上述循环——遍历依赖项、转 URI、调用 fetch…… 那么 onload 具体做了什么?
Module.prototype.onload = function() {
var mod = this;
mod.status = STATUS.LOADED;
for (var i = 0, len = (mod._entry || []).length; i < len; i++) {
var entry = mod._entry[i];
// 每次加载完毕一个依赖模块,remain 就 -1
// 直到 remain 为 0,就表示所有依赖模块加载完毕
if (--entry.remain === 0) {
entry.callback();
}
}
delete mod._entry;
};
依赖模块执行:完成整条链路
回到最初 Module.use 中设置的 callback:
mod.callback = function() {
var exports = [];
var uris = mod.resolve();
for (var i = 0, len = uris.length; i < len; i++) {
exports[i] = cachedMods[uris[i]].exec();
}
if (callback) {
callback.apply(global, exports);
}
delete mod.callback;
delete mod.history;
delete mod.remain;
delete mod._entry;
};
exec 方法如下:
Module.prototype.exec = function () {
var mod = this;
mod.status = STATUS.EXECUTING;
if (mod._entry && !mod._entry.length) {
delete mod._entry;
}
function require(id) {
var m = mod.deps[id];
return m.exec();
}
var factory = mod.factory;
// 调用 define 定义的回调,传入 CommonJS 三个参数
var exports = factory.call(mod.exports = {}, require, mod.exports, mod);
if (exports === undefined) {
exports = mod.exports;
}
mod.exports = exports;
mod.status = STATUS.EXECUTED;
return mod.exports;
};
factory 就是依赖模块 define 中定义的回调。例如 main.js:
define(function (require, exports, module) {
module.exports = 'main-module';
});
执行 factory 后,exports 等于 module.exports,即字符串 "main-module"。最终 callback 接收到的就是这个值,页面会弹出 main-module。

define 定义模块
到目前为止,我们只讨论了依赖模块的 define 方法中没有其他依赖的情况。如果 define 内部还依赖了其他模块呢?先看 define 做了些什么:
global.define = Module.define;
Module.define = function (id, deps, factory) {
var argsLen = arguments.length;
// 参数校准
if (argsLen === 1) {
factory = id;
id = undefined;
} else if (argsLen === 2) {
factory = deps;
if (isArray(id)) {
deps = id;
id = undefined;
} else {
deps = undefined;
}
}
// 如果没有直接传入依赖数组,则从 factory 中提取所有依赖模块
if (!isArray(deps) && isFunction(factory)) {
deps = typeof parseDependencies === "undefined" ? [] : parseDependencies(factory.toString());
}
var meta = {
id: id,
uri: Module.resolve(id),
deps: deps,
factory: factory
};
emit("define", meta);
meta.uri ? Module.sa ve(meta.uri, meta) : (anonymousMeta = meta);
};
先进行参数修正,然后判断是否已提供依赖数组。如果没有提供,就需要通过 parseDependencies 从 factory 的函数字符串中解析出 require 的参数。这个方法是一个状态机,逐个字符解析,遇到 require 就提取其中的模块名,最后组装成数组。早期 Sea.js 和 RequireJS 一样使用正则,后来改为状态机,可能是出于性能考虑(Sea.js 的仓库有专门文章介绍这个状态机)。
接着组装 meta 对象(模块的元数据)。如果定义时没有传 id(匿名模块),则先存入全局变量 anonymousMeta,等脚本加载完毕再取出。回顾模块加载时 onRequest 回调中的一段代码:
function onRequest(error) {
// ...
if (anonymousMeta) {
Module.sa ve(uri, anonymousMeta);
anonymousMeta = null;
}
// ...
}
无论是否匿名,最终都通过 sa ve 方法把元数据存入对应的 mod 中。
Module.sa ve = function(uri, meta) {
var mod = Module.get(uri);
if (mod.status < STATUS.SA VED) {
mod.id = meta.id || uri;
mod.dependencies = meta.deps || [];
mod.factory = meta.factory;
mod.status = STATUS.SA VED;
}
};
保存之后,逻辑又回到前面——检查当前模块是否还有依赖项,如果有就继续加载(与 use 流程一致),所有依赖加载完毕后通知入口模块的 remain 减 1,直到为 0 时执行入口模块的回调。至此,Sea.js 的整个加载和执行链路就完整贯通了。
结语
如果你有阅读 RequireJS 源码的经验,再看 Sea.js 会顺畅许多,对模块化的理解也会更加深刻。读源码之前最好先对框架的基本用法有一定了解,否则容易在某些地方卡住。这也提示我们,以后应该优先阅读工作中经常使用的框架或类库的源码,目标明确,效果会更好。
下面用一张流程图总结 Sea.js 的加载过程:

