随着前端项目的越来越庞大,组件化的前端框架,前端路由等技术的发展,模块化已经成为现代前端工程师的一项必备技能。无论是什么语言一旦发展到一定地步,其工程化能力和可维护性势必得到相应的发展。
模块化这件事,无论在哪个编程领域都是相当常见的事情,模块化存在的意义就是为了增加可复用性,以尽可能少的代码是实现个性化的需求。同为前端三剑客之一的 CSS 早在 2.1 的版本就提出了 @import
来实现模块化,但是 JavaScript 直到 ES6 才出现官方的模块化方案: ES Module (import
、export
)。尽管早期 JavaScript 语言规范上不支持模块化,但这并没有阻止 JavaScript 的发展,官方没有模块化标准开发者们就开始自己创建规范,自己实现规范。
CommonJS 的出现
十年前的前端没有像现在这么火热,模块化也只是使用闭包简单的实现一个命名空间。2009 年对 JavaScript 无疑是重要的一年,新的 JavaScript 引擎 (v8) ,并且有成熟的库 (jQuery、YUI、Dojo),ES5 也在提案中,然而 JavaScript 依然只能出现在浏览器当中。早在2007年,AppJet 就提供了一项服务,创建和托管服务端的 JavaScript 应用。后来 Aptana 也提供了一个能够在服务端运行 Javascript 的环境,叫做 Jaxer。网上还能搜到关于 AppJet、Jaxer 的博客,甚至 Jaxer 项目还在github上。
但是这些东西都没有发展起来,Javascript 并不能替代传统的服务端脚本语言 (PHP、Python、Ruby) 。尽管它有很多的缺点,但是不妨碍有很多人使用它。后来就有人开始思考 JavaScript 要在服务端运行还需要些什么?于是在 2009 年 1 月,Mozilla 的工程师 Kevin Dangoor 发起了 CommonJS 的提案,呼吁 JavaScript 爱好者联合起来,编写 JavaScript 运行在服务端的相关规范,一周之后,就有了 224 个参与者。
"[This] is not a technical problem,It's a matter of people getting together and making a decision to step forward and start building up something bigger and cooler together."
CommonJS 标准囊括了 JavaScript 需要在服务端运行所必备的基础能力,比如:模块化、IO 操作、二进制字符串、进程管理、Web网关接口 (JSGI) 。但是影响最深远的还是 CommonJS 的模块化方案,CommonJS 的模块化方案是JavaScript社区第一次在模块系统上取得的成果,不仅支持依赖管理,而且还支持作用域隔离和模块标识。再后来 node.js 出世,他直接采用了 CommonJS
的模块化规范,同时还带来了npm (Node Package Manager,现在已经是全球最大模块仓库了) 。
CommonJS 在服务端表现良好,很多人就想将 CommonJS 移植到客户端 (也就是我们说的浏览器) 进行实现。由于CommonJS 的模块加载是同步的,而服务端直接从磁盘或内存中读取,耗时基本可忽略,但是在浏览器端如果还是同步加载,对用户体验极其不友好,模块加载过程中势必会向服务器请求其他模块代码,网络请求过程中会造成长时间白屏。所以从 CommonJS 中逐渐分裂出来了一些派别,在这些派别的发展过程中,出现了一些业界较为熟悉方案 AMD、CMD、打包工具(Component/Browserify/Webpack)。
AMD规范:RequireJS
RequireJS 是 AMD 规范的代表之作,它之所以能代表 AMD 规范,是因为 RequireJS 的作者 (James Burke) 就是 AMD 规范的提出者。同时作者还开发了 amdefine
,一个让你在 node 中也可以使用 AMD 规范的库。
AMD 规范由 CommonJS 的 Modules/Transport/C 提案发展而来,毫无疑问,Modules/Transport/C 提案的发起者就是 James Burke。
James Burke 指出了 CommonJS 规范在浏览器上的一些不足:
- 缺少模块封装的能力:CommonJS 规范中的每个模块都是一个文件。这意味着每个文件只有一个模块。这在服务器上是可行的,但是在浏览器中就不是很友好,浏览器中需要做到尽可能少的发起请求。
- 使用同步的方式加载依赖:虽然同步的方法进行加载可以让代码更容易理解,但是在浏览器中使用同步加载会导致长时间白屏,影响用户体验。
- CommonJS 规范使用一个名为
export
的对象来暴露模块,将需要导出变量附加到export
上,但是不能直接给该对象进行赋值。如果需要导出一个构造函数,则需要使用module.export
,这会让人感到很疑惑。
AMD 规范定义了一个 define
全局方法用来定义和加载模块,当然 RequireJS 后期也扩展了 require
全局方法用来加载模块 。通过该方法解决了在浏览器使用 CommonJS 规范的不足。
define(id?, dependencies?, factory);
-
使用匿名函数来封装模块,并通过函数返回值来定义模块,这更加符合 JavaScript 的语法,这样做既避免了对
exports
变量的依赖,又避免了一个文件只能暴露一个模块的问题。 -
提前列出依赖项并进行异步加载,这在浏览器中,这能让模块开箱即用。
define("foo", ["logger"], function (logger) { logger.debug("starting foo's definition") return { name: "foo" } })
-
为模块指定一个模块 ID (名称) 用来唯一标识定义中模块。此外,AMD的模块名规范是 CommonJS 模块名规范的超集。
define("foo", function () { return { name: 'foo' } })
RequireJS 原理
在讨论原理之前,我们可以先看下 RequireJS 的基本使用方式。
-
模块信息配置:
require.config({ paths: { jquery: 'https://code.jquery.com/jquery-3.4.1.js' } })
-
依赖模块加载与调用:
require(['jquery'], function ($){ $('#app').html('loaded') })
-
模块定义:
if ( typeof define === "function" && define.amd ) { define( "jquery", [], function() { return jQuery; } ); }
我们首先使用 config
方法进行了 jquery 模块的路径配置,然后调用 require
方法加载 jquery 模块,之后在回调中调用已加载完成的 $
对象。在这个过程中,jquery 会使用 define
方法暴露出我们所需要的 $
对象。
在了解了基本的使用过程后,我们就继续深入 RequireJS 的原理。
模块信息配置
模块信息的配置,其实很简单,只用几行代码就能实现。定义一个全局对象,然后使用 Object.assign
进行对象扩展。
// 配置信息
const cfg = { paths: {} }
// 全局 require 方法
req = require = () => {}
// 扩展配置
req.config = config => {
Object.assign(cfg, config)
}
依赖模块加载与调用
require
方法的逻辑很简单,进行简单的参数校验后,调用 getModule
方法对 Module
进行了实例化,getModule 会对已经实例化的模块进行缓存。因为 require 方法进行模块实例的时候,并没有模块名,所以这里产生的是一个匿名模块。Module 类,我们可以理解为一个模块加载器,主要作用是进行依赖的加载,并在依赖加载完毕后,调用回调函数,同时将依赖的模块逐一作为参数回传到回调函数中。
// 全局 require 方法
req = require = (deps, callback) => {
if (!deps && !callback) {
return
}
if (!deps) {
deps = []
}
if (typeof deps === 'function') {
callback = deps
deps = []
}
const mod = getModule()
mod.init(deps, callback)
}
let reqCounter = 0
const registry = {} // 已注册的模块
// 模块加载器的工厂方法
const getModule = name => {
if (!name) {
// 如果模块名不存在,表示为匿名模块,自动构造模块名
name = `@mod_${++reqCounter}`
}
let mod = registry[name]
if (!mod) {
mod = registry[name] = new Module(name)
}
return mod
}
模块加载器是是整个模块加载的核心,主要包括 enable
方法和 check
方法。
模块加载器在完成实例化之后,会首先调用 init
方法进行初始化,初始化的时候传入模块的依赖以及回调。
// 模块加载器
class Module {
constructor(name) {
this.name = name
this.depCount = 0
this.depMaps = []
this.depExports = []
this.definedFn = () => {}
}
init(deps, callback) {
this.deps = deps
this.callback = callback
// 判断是否存在依赖
if (deps.length === 0) {
this.check()
} else {
this.enable()
}
}
}
enable
方法主要用于模块的依赖加载,该方法的主要逻辑如下:
-
遍历所有的依赖模块;
-
记录已加载模块数 (
this.depCount++
),该变量用于判断依赖模块是否全部加载完毕; -
实例化依赖模块的模块加载器,并绑定
definedFn
方法;definedFn
方法会在依赖模块加载完毕后调用,主要作用是获取依赖模块的内容,并将depCount
减 1,最后调用check
方法 (该方法会判断depCount
是否已经小于 1,以此来界定依赖全部加载完毕); -
最后通过依赖模块名,在配置中获取依赖模块的路径,进行模块加载。
class Module {
...
// 启用模块,进行依赖加载
enable() {
// 遍历依赖
this.deps.forEach((name, i) => {
// 记录已加载的模块数
this.depCount++
// 实例化依赖模块的模块加载器,绑定模块加载完毕的回调
const mod = getModule(name)
mod.definedFn = exports => {
this.depCount--
this.depExports[i] = exports
this.check()
}
// 在配置中获取依赖模块的路径,进行模块加载
const url = cfg.paths[name]
loadModule(name, url)
});
}
...
}
loadModule
的主要作用就是通过 url 去加载一个 js 文件,并绑定一个 onload 事件。onload 会重新获取依赖模块已经实例化的模块加载器,并调用 init
方法。
// 缓存加载的模块
const defMap = {}
// 依赖的加载
const loadModule = (name, url) => {
const head = document.getElementsByTagName('head')[0]
const node = document.createElement('script')
node.type = 'text/javascript'
node.async = true
// 设置一个 data 属性,便于依赖加载完毕后拿到模块名
node.setAttribute('data-module', name)
node.addEventListener('load', onScriptLoad, false)
node.src = url
head.appendChild(node)
return node
}
// 节点绑定的 onload 事件函数
const onScriptLoad = evt => {
const node = evt.currentTarget
node.removeEventListener('load', onScriptLoad, false)
// 获取模块名
const name = node.getAttribute('data-module')
const mod = getModule(name)
const def = defMap[name]
mod.init(def.deps, def.callback)
}
看到之前的案例,因为只有一个依赖 (jQuery),并且 jQuery 模块并没有其他依赖,所以 init
方法会直接调用 check
方法。这里也可以思考一下,如果是一个有依赖项的模块后续的流程是怎么样的呢?
define( "jquery", [] /* 无其他依赖 */, function() {
return jQuery;
} );
check
方法主要用于依赖检测,以及调用依赖加载完毕后的回调。
// 模块加载器
class Module {
...
// 检查依赖是否加载完毕
check() {
let exports = this.exports
//如果依赖数小于1,表示依赖已经全部加载完毕
if (this.depCount < 1) {
// 调用回调,并获取该模块的内容
exports = this.callback.apply(null, this.depExports)
this.exports = exports
//激活 defined 回调
this.definedFn(exports)
}
}
...
}
最终通过 definedFn
重新回到被依赖模块,也就是最初调用 require
方法实例化的匿名模块加载器中,将依赖模块暴露的内容存入 depExports
中,然后调用匿名模块加载器的 check
方法,调用回调。
mod.definedFn = exports => {
this.depCount--
this.depExports[i] = exports
this.check()
}
模块定义
还有一个疑问就是,在依赖模块加载完毕的回调中,怎么拿到的依赖模块的依赖和回调呢?
const def = defMap[name]
mod.init(def.deps, def.callback)
答案就是通过全局定义的 define
方法,该方法会将模块的依赖项还有回调存储到一个全局变量,后面只要按需获取即可。
const defMap = {} // 缓存加载的模块
define = (name, deps, callback) => {
defMap[name] = { name, deps, callback }
}
RequireJS 原理总结
最后可以发现,RequireJS 的核心就在于模块加载器的实现,不管是通过 require
进行依赖加载,还是使用 define
定义模块,都离不开模块加载器。
感兴趣的可以在我的github上查看关于简化版 RequrieJS 的完整代码 。
CMD规范:sea.js
CMD 规范由国内的开发者玉伯提出,尽管在国际上的知名度远不如 AMD ,但是在国内也算和 AMD 齐头并进。相比于 AMD 的异步加载,CMD 更加倾向于懒加载,而且 CMD 的规范与 CommonJS 更贴近,只需要在 CommonJS 外增加一个函数调用的包装即可。
define(function(require, exports, module) {
require("./a").doSomething()
require("./b").doSomething()
})
作为 CMD 规范的实现 sea.js 也实现了类似于 RequireJS 的 api:
seajs.use('main', function (main) {
main.doSomething()
})
sea.js 在模块加载的方式上与 RequireJS 一致,都是通过在 head 标签插入 script 标签进行加载的,但是在加载顺序上有一定的区别。要讲清楚这两者之间的差别,我们还是直接来看一段代码:
RequireJS :
// RequireJS
define('a', function () {
console.log('a load')
return {
run: function () { console.log('a run') }
}
})
define('b', function () {
console.log('b load')
return {
run: function () { console.log('b run') }
}
})
require(['a', 'b'], function (a, b) {
console.log('main run')
a.run()
b.run()
})
sea.js :
// sea.js
define('a', function (require, exports, module) {
console.log('a load')
exports.run = function () { console.log('a run') }
})
define('b', function (require, exports, module) {
console.log('b load')
exports.run = function () { console.log('b run') }
})
define('main', function (require, exports, module) {
console.log('main run')
var a = require('a')
a.run()
var b = require('b')
b.run()
})
seajs.use('main')
可以看到 sea.js 的模块属于懒加载,只有在 require 的地方,才会真正运行模块。而 RequireJS,会先运行所有的依赖,得到所有依赖暴露的结果后再执行回调。
正是因为懒加载的机制,所以 sea.js 提供了 seajs.use
的方法,来运行已经定义的模块。所有 define 的回调函数都不会立即执行,而是将所有的回调函数进行缓存,只有 use 之后,以及被 require 的模块回调才会进行执行。
sea.js 原理
下面简单讲解一下 sea.js 的懒加载逻辑。在调用 define 方法的时候,只是将 模块放入到一个全局对象进行缓存。
const seajs = {}
const cache = seajs.cache = {}
define = (id, factory) => {
const uri = id2uri(id)
const deps = parseDependencies(factory.toString())
const mod = cache[uri] || (cache[uri] = new Module(uri))
mod.deps = deps
mod.factory = factory
}
class Module {
constructor(uri, deps) {
this.status = 0
this.uri = uri
this.deps = deps
}
}
这里的 Module,是一个与 RequireJS 类似的模块加载器。后面运行的 seajs.use 就会从缓存取出对应的模块进行加载。
注意:这一部分代码只是简单介绍 use 方法的逻辑,并不能直接运行。
let cid = 0
seajs.use = (ids, callback) => {
const deps = isArray(ids) ? ids : [ids]
deps.forEach(async (dep, i) => {
const mod = cache[dep]
mod.load()
})
}
另外 sea.js 的依赖都是在 factory 中声明的,在模块被调用的时候,sea.js 会将 factory 转成字符串,然后匹配出所有的 require('xxx')
中的 xxx
,来进行依赖的存储。前面代码中的 parseDependencies
方法就是做这件事情的。
早期 sea.js 是直接通过正则的方式进行匹配的:
const parseDependencies = (code) => {
const REQUIRE_RE = /"(?:\\"|[^"])*"|'(?:\\'|[^'])*'|\/\*[\S\s]*?\*\/|\/(?:\\\/|[^/\r\n])+\/(?=[^\/])|\/\/.*|\.\s*require|(?:^|[^$])\brequire\s*\(\s*(["'])(.+?)\1\s*\)/g
const SLASH_RE = /\\\\/g
const ret = []
code
.replace(SLASH_RE, '')
.replace(REQUIRE_RE, function(_, __, id) {
if (id) {
ret.push(id)
}
})
return ret
}
但是后来发现正则有各种各样的 bug,并且过长的正则也不利于维护,所以 sea.js 后期舍弃了这种方式,转而使用状态机进行词法分析的方式获取 require 依赖。
详细代码可以查看 sea.js 相关的子项目:crequire。
sea.js 原理总结
其实 sea.js 的代码逻辑大体上与 RequireJS 类似,都是通过创建 script 标签进行模块加载,并且都有实现一个模块记载器,用于管理依赖。
主要差异在于,sea.js 的懒加载机制,并且在使用方式上,sea.js 的所有依赖都不是提前声明的,而是 sea.js 内部通过正则或词法分析的方式将依赖手动进行提取的。
感兴趣的可以在我的github上查看关于简化版 sea.js 的完整代码 。
总结
ES6 的模块化规范已经日趋完善,其静态化思想也为后来的打包工具提供了便利,并且能友好的支持 tree shaking。了解这些已经过时的模块化方案看起来似乎有些无趣,但是历史不能被遗忘,我们应该多了解这些东西出现的背景,以及前人们的解决思路,而不是一直抱怨新东西更迭的速度太快。
不说鸡汤了,挖个坑,敬请期待下一期的《前端模块化的今生》。