前端模块化机制

前端模块化规范有 AMD、CMD、CommonJS、ES2015 规范:

对于 AMD、CMD,本文尝试解读 requirejs、seajs 的实现。对于 CommonJS、ES6,本文更多聚焦于尝试解读其规范。介于笔者认知的不足,本文对 ES6 规范中 Cyclic Module Record、Source Text Module Record 并未作过多的投入(因为对 babel 理解的有限,本文也未深入 babel-helper-module-transforms)。实际上,这两个模型的规划设计恰恰是前端模块系统的精髓所在。相形之下,本文通过解读 requirejs、seajs 源码反演其设计并未臻于妙境。

AMD

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 直接导出对象
define(module);

// 定义没有依赖的模块
define(function(){
return module;
});

// 定义有依赖的模块
define(['module1', 'module2'], function(module1, module2){
return module;
});

// callback 内部通过 require 加载模块
define(function(require, exports, module) {
const module1 = require('module1');
return module;
});

require(['module1', 'module2'], function(m1, m2){
// 使用 module1、module2
})

requirejs

  • 支持浏览器、web worker、nodejs 环境。
  • 插件机制,’plugin!resource’ 形式指定资源。
  • 通过在模块导出中使用 require 实现循环依赖。
  • 通过 config.shim 支持将全局变量转换成依赖模块。
  • 基于不同的上下文对模块进行分区。

requirejs 中有两类模块:一类为使用 define 语法书写的模块;另一类为使用 require 语法加载的模块。requirejs 会在全局上下文中定义 define、require 函数;这两个函数都会基于不同的上下文实现分区定义和加载模块,默认在顶层上下文中定义和加载模块。

  • define(name?, deps?, callback): 定义模块名、依赖、函数体(返回导出)。callback 有两种形式,一种显式指定依赖,另一种显式以 require, exports, module 为参数。
  • require(config?, deps, callback, errback, optional): 加载模块,通过 config.context 定义所使用的上下文,deps 依赖,callback 用于导出模块的句柄。

context 上下文主要包含如下属性或方法:

  • registry:已注册、尚未启动加载流程的模块,对象形式,属性值为 Module 实例。
  • enabledRegistry:通过将 enabled = true 启动加载流程的模块,对象形式。
  • defined:已创建导出的模块,对象形式。
  • defQueue:define 模块已加载,它并未转化成 requirejs 内部的 Module 形式,依赖也未曾加载,
  • require:在当前上下文环境中加载模块。由 context.makeRequire 创建。

模块的内部表示为 Module 实例,主要包含以下属性或方法:

  • map:moduleMap 对象,通过 makeModuleMap 获得。其下包含 prefix 属性为插件,即 ‘i18n!my/nls/colors’ 中 i18n 插件;isDefine 是否命名模块。
  • pluginMaps:插件,用于转换加载的模块。
  • depMaps:依赖,数组形式;depCount:未加载的依赖数;depMatched:标识依赖已加载;depExports:依赖的导出内容,作为 callback 的参数。
  • load 方法:通过创建 script 节点或调用 importScripts 形式加载模块。
  • callPlguin 方法:通过插件加载模块。
  • fetch 方法:加载模块总接口,内部会以 load 或 callPlguin 方法加载模块。
  • init 方法:初始化,按条件执行 enable 或 check 方法。特别的,当模块作为依赖时,它会未经 init 方法处理,即使用 enable 方法加载。enable 方法尾部调用的 check 会依据 !inited 状态 fetch 模块。
  • enable 方法:将当前模块及其依赖、所使用的插件置为 enabled = true。若依赖已加载,则更新 mod.depExports 当前模块的依赖;若依赖未加载,调用依赖的 enable 方法加载依赖。尾部调用 check 方法,尝试创建模块的导出、或作错误处理、或加载模块。
  • check 方法:若模块尚未 inited 且不在 context.defQueue 队列中,调用 fetch 方法加载它;若报错,错误处理;若模块的依赖已加载完成,创建模块的导出,并触发 defined 事件,以向上创建 parentMod 的导出。其多态性需要通过 checkLoaded 函数反复触发不同情境的处理机制,如加载模块、错误处理、创建模块导出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
// 加载模块及其依赖
const enable = function () {
this.enabled = true;

each(this.depMaps, bind(this, function (depMap, i) {
var id, mod, handler;

if (typeof depMap === 'string') {
depMap = makeModuleMap(depMap,
(this.map.isDefine ? this.map : this.map.parentMap),
false,
!this.skipMap);
this.depMaps[i] = depMap;

// 依赖已加载
handler = getOwn(handlers, depMap.id);
if (handler) {
this.depExports[i] = handler(this);
return;
}

this.depCount += 1;

// 绑定事件,依赖加载完成后,调用 this.check 加载当前模块
// on 方法同时为依赖创建 Module 实例,注册到 registry 中,但未执行 init 方法
on(depMap, 'defined', bind(this, function (depExports) {
if (this.undefed) {
return;
}
this.defineDep(i, depExports);
this.check();
}));
}

// 将依赖的 enabled 置为 true,尝试加载依赖
id = depMap.id;
mod = registry[id];
if (!hasProp(handlers, id) && mod && !mod.enabled) {
// 因依赖未执行 init 方法,通过 enable 调用 check 时将加载该依赖
context.enable(depMap, this);
}
}));

// 将插件的 enabled 置为 true
eachProp(this.pluginMaps, bind(this, function (pluginMap) {
var mod = getOwn(registry, pluginMap.id);
if (mod && !mod.enabled) {
context.enable(pluginMap, this);
}
}));

// 创建模块的导出
this.check();
}

// 多态,加载模块、或错误处理、或创建导出
// mod.check 的多态操作既能由 enable 唤起,也能由 script 节点加载完成后通过 checkLoaded 函数唤起
// 以创建模块的导出或作错误处理
const check = function () {
if (!this.enabled) return;

var err, cjsModule,
id = this.map.id,
depExports = this.depExports,
exports = this.exports,
factory = this.factory;

// 模块未经初始化,通常是依赖,加载该模块
if (!this.inited) {
if (!hasProp(context.defQueueMap, id)) {
this.fetch();
}
// 错误处理
} else if (this.error) {
this.emit('error', this.error);
// 依赖已加载完成,创建导出,并触发 defined 事件
} else if (!this.defining) {
this.defining = true;

// depCount < 1 表示依赖已加载完成
if (this.depCount < 1 && !this.defined) {
if (isFunction(factory)) {
// 执行 factory 句柄,生成刀块的导出
exports = context.execCb(id, factory, depExports, exports);

// factory 句柄如 CommonJS 一样使用 module、module.exports 进行赋值导出
if (this.map.isDefine && exports === undefined) {
cjsModule = this.module;
if (cjsModule) {
exports = cjsModule.exports;
} else if (this.usingExports) {
exports = this.exports;
}
}
} else {
exports = factory;
}

this.exports = exports;

// 清理 context.registry,设置 context.defined,表示模块已 defined
if (this.map.isDefine && !this.ignore) {
defined[id] = exports;
}
cleanRegistry(id);

this.defined = true;
}

this.defining = false;

if (this.defined && !this.defineEmitted) {
this.defineEmitted = true;
// 触发 defined 事件,促使加载已当前模块为依赖的模块
this.emit('defined', this.exports);
}
}
},

requirejs 主要是在浏览器环境实现 js 脚本的模块化加载,其基本逻辑流程为:

  1. 首先在 html 中通过 script 节点加载 requirejs 模块,该 script 节点的 data-main 属性指定了 entryMod 入口模块。
  2. requirejs 模块执行期间,会通过 req({}) 创建顶层上下文并加载 entryMod 入口模块。
  3. 入口模块通常为 require 模块,因此会执行 require 函数,创建内部表示 Module 实例,并执行 entryMod.enable 方法。
    • 当入口模块没有依赖,会直接调用 entryMod.check 执行入口模块的句柄。
    • 当入口模块有依赖,调用依赖的 enable 方法,此时依赖没有经过 init 方法处理,它会在 check 方法执行期间使用 fetch 方法加载依赖。
      • 当依赖为 require 模块时,重复 entryMod 的处理机制。
      • 当依赖为 define 模块,加载该 define 模块,通过 script 节点加载事件加载其子依赖,内部依旧使用子依赖的 check 方法获取模块。
  4. 完成入口模块的句柄。

依赖模块加载机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// define 定义模块,只更新 context.defQueue 或 globalDefQueue,并不会加载依赖、执行 callback
define = function (name, deps, callback) {
// ...参数处理,会将 require, [exports], [module] 注入 deps 依赖中

// 如 define() 函数指定了上下文(通过 script 节点属性指定),直接更新 context.defQueue
// 如未指定,等待 takeGlobalQueue 函数更新 context.defQueue
// https://github.com/requirejs/requirejs/blob/master/require.js#L558
if (context) {
context.defQueue.push([name, deps, callback]);
context.defQueueMap[name] = true;
} else {
globalDefQueue.push([name, deps, callback]);
}
};

// require 加载模块,对外接口
req = requirejs = function (deps, callback, errback, optional) {
// 参数处理,deps 可能以 config 形式传入

if (config && config.context) {
contextName = config.context;
}

context = getOwn(contexts, contextName);
if (!context) {
context = contexts[contextName] = req.s.newContext(contextName);
}

// 配置 context,并加载 data-main 入口模块
if (config) context.configure(config);

return context.require(deps, callback, errback);
};

req({});// 加载入口模块

// context.require 函数内部实现,通过 context.makeRequire(relMap, options) 制作
function localRequire(deps, callback, errback) {
var map, requireMod;

// require('module') 形式,直接导出模块
if (typeof deps === 'string') {
if (relMap && hasProp(handlers, deps)) {
return handlers[deps](registry[relMap.id]);
}

// nodejs 等环境使用 req.get 获取模块
if (req.get) {
return req.get(context, deps, relMap, localRequire);
}

map = makeModuleMap(deps, relMap, false, true);
return defined[map.id];
}

// define 模块已加载,将其转换成 Module 实例并执行该实例的 check 方法
intakeDefines();

context.nextTick(function () {
intakeDefines();

// 获得 moduleMap 并创建 context.Module 实例,存入 context.registry[moduleMap.id] 中
requireMod = getModule(makeModuleMap(null, relMap));

// 执行 requireMod 的 enable 方法,将当前模块及其依赖、插件的 enabled 属性置为 true
// 若依赖已加载,更新 requireMod.depExports
requireMod.init(deps, callback, errback, {
enabled: true
});

checkLoaded();
});

return localRequire;
}

CMD

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 直接导出对象
define([moduleName], [deps], [moduleFactoryOrObject]);

define(module);

define(function(require, exports, module) {
// ...
});

define(['module1', 'module2'], function(require, exports, module){
// module.dependencies 依赖
// ...
});

require('module');

seajs.use('js/main');// 入口模块

seajs

  • 支持插件。插件通过事件接口与 seajs 主流程进行交互,仅需导入插件脚本,即可加载如 css 模块等。
  • define 句柄中的 require 会通过正则表达式提前收集为依赖。

与 requirejs 相比较,seajs 的编码结构更为清晰。seajs 处理流程为:

  1. html 中导入 seajs 脚本。
  2. 通过 seajs.use 加载首层依赖及处理句柄。内部会创建 Module 实例,并调用 mod.load 加载依赖。
  3. 所有依赖加载完成后,会执行 mod.onload 创建 Module 实例的 mod.callback 创建模块的导出。
  4. mod.callback 内,调用依赖的 exec 创建依赖的导出,然后执行当前模块的 callback 句柄创建导出。

在以上步骤中,被依赖模块会通过 pass 方法将自身及父模块灌入到依赖的 _entry 属性中,已使加载完成后的依赖能确切地预知到哪些被依赖模块需要创建导出。当然,被依赖模块可能有多个依赖模块,只有当这些依赖模块都加载完成后,被依赖模块才能执行其句柄,这通过被依赖模块的 remain 属性判断。有多少未加载的依赖,被依赖模块的 remain 属性即为多少;在每个依赖加载完成后,remain 属性也相应减 1;当所有依赖加载完成后,remain 属性即为 0。

以下是 Module 实例包含的属性:

  • uri:模块的地址。
  • dependencies:模块的依赖,数组形式。
  • deps:模块依赖的导出,数组形式。
  • status:状态。FETCHING 加载中;SAVED 元数据已存入模块实例;LOADING 依赖加载中;LOADED 依赖加载完成,可以创建模块的导出;EXECUTING 模块的句柄执行中;EXECUTED 模块的句柄执行完成,即导出创建成功;ERROR 模块加载失败。
  • _entry:被依赖模块,作为当前模块加载的入口,数组形式。当前模块加载完成,将通过 module.onload 方法唤起所有被依赖模块的 callback 句柄,这一行为等价于 requirejs 中的 emit(‘defined’)。
  • remain:标识未加载的依赖数。值为 0 时,所有依赖均已加载。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
Module.use = function (ids, callback, uri) {
var mod = Module.get(uri, isArray(ids) ? ids : [ids])

mod._entry.push(mod)// 用于构成依赖的 _entry 属性,当前模块的 _entry 将被清空

// 通过 mod.callback 创建入口模块的导出
mod.callback = function() {
var exports = []
var uris = mod.resolve()// 获取依赖路径

for (var i = 0, len = uris.length; i < len; i++) {
// 执行依赖模块的句柄,创建依赖模块的导出
// exec 会为句柄注入 require, exports, module
exports[i] = cachedMods[uris[i]].exec()
}

if (callback) {
callback.apply(global, exports)
}
}

// 加载入口模块及其依赖
mod.load()
}

Module.define = function (id, deps, factory) {
// parseDependencies 通过正则表达式从 factory 句柄中解析显式调用 require 语句的依赖
if (!isArray(deps) && isFunction(factory)) {
deps = typeof parseDependencies === "undefined" ? [] : parseDependencies(factory.toString())
}

var meta = {
id: id,
uri: Module.resolve(id),// Module.resolve 解析出绝对路径
deps: deps,
factory: factory
}

// Module.save 通过 Module.get 创建 Module 实例,并将 meta 存入 Module 实例,状态置为 SAVED
meta.uri ? Module.save(meta.uri, meta) :
// Save information for "saving" work in the script onload event
anonymousMeta = meta
}

Module.prototype.load = function() {
var mod = this

mod.status = STATUS.LOADING

var uris = mod.resolve()// 解析依赖路径

for (var i = 0, len = uris.length; i < len; i++) {
mod.deps[mod.dependencies[i]] = Module.get(uris[i])// 依赖转变成 Module 实例
}

// 将当前模块及其祖先传入依赖中,作为依赖的 _entry 属性
// 以便依赖创建导出后,能唤起当前模块及其祖先件的 onload 方法
mod.pass()

// 当依赖已加载,mod._entry 将不被清空,这时只需执行 mod.onload
// 通过 mod.onload 执行所有被依赖模块的 callback 方法
if (mod._entry.length) {
mod.onload()
return
}

var requestCache = {}
var m

for (i = 0; i < len; i++) {
m = cachedMods[uris[i]]// cachedMods 缓存所有 Module 实例

if (m.status < STATUS.FETCHING) {
// 构建依赖加载函数,存入 requestCache 中
// 该函数会通过创建 script 节点或调用 importScripts 加载依赖
// 依赖加载完成后,递归调用 m.load 创建依赖的导出
m.fetch(requestCache)
} else if (m.status === STATUS.SAVED) {
// 旨在调用 m.onload 创建依赖的导出
m.load()
}
}

// 逐个调用依赖加载函数
for (var requestUri in requestCache) {
if (requestCache.hasOwnProperty(requestUri)) {
requestCache[requestUri]()
}
}
}

CommonJS

CommonJS 规范指出:es2015 等官方规范定义了适用于客户端的标准 api,在服务端等领域却留下了巨大的真空。CommonJS 规范旨在于填补这个真空,像 Python、Rudy、Java 那样提供标准库,以期开发者使用 CommonJS 标准接口就能编写跨 js 解释器、跨宿主环境的应用。这些应用可以是服务端应用、命令行工具、桌面 GUI 应用、Adobe AIR 等混合应用。

Modules/1.1.1 规范定义了实现模块系统的最小特性:

  • 在模块上下文中提供 require、exports、module 变量。每个模块以 module.id 作为唯一标识;module.uri 作为除沙箱环境外可访问的资源位。模块标识以小驼峰式设定,可以指定为相对路径。
  • require 函数以模块唯一标识为入参,模块导出 api 为出参。对于循环依赖,依赖模块须导出被依赖模块执行时所需的 api。require.main 或者为模块上下文中的 module 变量,或者为 undefined。require.pathes 用于指定 loader 加载模块的目录列表,优先级从高到低(loader 会对其进行解析);但是 loader 不只会在 require.pathes 范围内查找模块。
  • exports 初始时持有 module.exports 相同的指针。exports 可用于增量改写导出方法或属性;module.exports 可用于全量改写导出对象。

CommonJS 的典型实现有 nodejs、browserify

示例

1
2
3
4
5
6
7
// 导入
const mod = require('moduleName');
const x = require('moduleName').x;

// 导出
exports.x = mod;// 增量导出
module.exports = mod;// 全量导出

nodejs

nodejs 实现 require 函数的源码见于 cjs/loader.js。nodejs v13.12.0 也实现了 ES2015 模块的加载方式,参见 esm/loader.js

CommonJS 在 nodejs 环境应用时,可用于加载 js、C++ 模块。与 requirejs、seajs 主要在浏览器环境加载远程资源不同的是,nodejs 加载的都是本地资源。nodejs 文档详细描绘了模块寻址流程。以 require(x) 为例,即如下:

  1. x 为 fs 等核心模块,加载该核心模块。
  2. x 为文件模块
    • x 以 ‘/‘, ‘./‘, ‘../‘ 起始,首先查找 x 模块;其次查找 x.js、x.json、x.node 模块;其次查找 x/package.json 文件,取 main 属性加载模块,若 main 属性不存在,则加载 x/index.js、x/index.json、x/index.node 模块。
    • x 在 package.json 的 exports 属性中,按 exports 属性检索文件。
    • 向上查找各 node_modules 文件夹中的 x 模块。

加载模块的整体流程为:

  1. 基于寻址流程获取 filename
  2. 尝试从 Module._cache 中获取缓存。
  3. loadNativeModule 加载核心模块。
  4. 创建 Module 实例并调用 module.load 加载文件模块。在 module.load 执行过程中,会使用 Module._extensions[extension] 编译执行 js 文件、解析 json 文件或执行 C++ 模块。
    • js 文件:使用 module._compile 编译处理模块内容。正是在这一过程中,nodejs 会使用 模块封装器 包装模块体,为其注入 require、module、exports、filename、dirname。

另据死月在《Node.js 来一打 C++ 扩展》中提到,npm 2.x 嵌套式依赖管理方案适合 node 端开发,相同依赖可以有多个版本;npm 3.x 扁平化管理方案适合纯前端开发,打包体积会较小。

ES2015

比 AMD、CMD、CommonJS 后推出的 ECMAScript6 Module 规范旨在于整合前述模块化规范的优势,使不同的用户都满意。它有以下特点:

  1. 语法结构适合静态分析,编译期即可识别语法错误。
  2. 支持循环依赖、异步加载。

ES2015 规定以 Abstract Module Record 抽象模块记录封装单个模块的导入和导出。该规范还定义了 Cyclic Module Record 抽象子类用于处理循环依赖、Source Text Module Record 具体子类用于解析文件模块。其他规范或实现可据此实现自己的模块记录子类。Module Record 模块记录包含以下属性和方法(所有实现类均需包含):

  • [[Realm]]:创建模块的域。
  • [[Environment]]:模块对应的顶层词法环境。
  • [[Namespace]]:模块的命名空间。
  • [[HostDefined]]:宿主环境提供的附加信息。
  • GetExportedNames([exportStarSet]):获取导出列表。
  • ResolveExport(exportName [, resolveSet]):获取导出名称的绑定,{[[module]]: Module Record, [[bindingName]]: String} 形式。
  • Link():递归解析模块依赖,创建模块的 Environment Record。
  • Evaluate():创建模块的导出。首先会创建依赖的导出,然后再创建当前模块的导出;其次作错误处理。

我们可以发现,ES2015 规范中的模块导出构建过程与 seajs 有些相似,如 Link 方法譬如 seajs 中的 pass 方法,Evaluate 方法譬如 onLoad 方法。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 导入模块
import v from "mod";
import * as ns from "mod";
import { x } from "mod";
import { x as v } from "mod";
import "mod";

// 导出模块
export var v;
export default function f(){};
export default function(){};
export default 42;
export { x };
export { v as x };
export { x } from "mod";
export { v as x } from "mod";
export * from "mod";

其他

UMD

UMD 模块用于适配 AMD、CommonJs 模块化规范,它会用下述代码包裹模块体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
((root, factory) => {
if (typeof define === 'function' && define.amd) {
//AMD
define(['jquery'], factory);
} else if (typeof exports === 'object') {
//CommonJS
var $ = requie('jquery');
module.exports = factory($);
} else {
root.testModule = factory(root.jQuery);
}
})(this, ($) => {
//todo
});

打包

打包一个模块,可使用 webpackrollup

如使用 webpack 打包,有以下两个配置会影响打包策略。

  • output.library:指定导出模块名。
  • output.libraryTarget:指定打包后的模块系统。可选项 commonjs、commonjs2、amd、umd、var、global、window 等。commonjs2 用于改写 module.exports;commonjs 用于改写 exports 的指定属性。

与 webpack 会为打包后的模块添加如 webpackUniversalModuleDefinition 等内容不同,rollup 打包产物格外纯净,因此编写 library 推荐使用 rollup 进行打包。在 rollup 配置文件中,output.format 可用于打包后的模块系统,可选项包含 amd, cjs, esm, iife, umd。esm 即 ES2015 模块化规范;cjs 即 CommonJS;iife 将模块包装成立即执行的匿名函数(见于参考文档)。

参考

前端模块化详解
深入了解前端模块化