JavaScript中的模块

本文将主要介绍一下CommonJS、AMD、CMD,以及ES6 Module,每个部分的讲解足够开始上手使用相应的模块化工具,所以本文会略长,可以直接定位到需要的小节看~

模块系统的演进

本部分内容摘自:https://zhaoda.net/webpack-handbook/module-system.html

模块系统主要解决模块的定义、依赖和导出,先来看看已经存在的模块系统。

script标签

1
2
3
4
<script src="module1.js"></script>
<script src="module2.js"></script>
<script src="libraryA.js"></script>
<script src="module3.js"></script>

这是最原始的 JavaScript 文件加载方式,如果把每一个文件看做是一个模块,那么他们的接口通常是暴露在全局作用域下,也就是定义在 window 对象中,不同模块的接口调用都是一个作用域中,一些复杂的框架,会使用命名空间的概念来组织这些模块的接口,典型的例子如 YUI 库。

这种原始的加载方式暴露了一些显而易见的弊端:

  • 全局作用域下容易造成变量冲突
  • 文件只能按照 <script> 的书写顺序进行加载
  • 开发人员必须主观解决模块和代码库的依赖关系
  • 在大型项目中各种资源难以管理,长期积累的问题导致代码库混乱不堪

CommonJS

服务器端的 Node.js 遵循 CommonJS规范,该规范的核心思想是允许模块通过 require 方法来同步加载所要依赖的其他模块,然后通过 exportsmodule.exports 来导出需要暴露的接口。

1
2
3
4
require("module");
require("../file.js");
exports.doStuff = function() {};
module.exports = someValue;

优点:

  • 服务器端模块便于重用
  • NPM 中已经有将近20万个可以使用模块包
  • 简单并容易使用

缺点:

  • 同步的模块加载方式不适合在浏览器环境中,同步意味着阻塞加载,浏览器资源是异步加载的
  • 不能非阻塞的并行加载多个模块

实现:

  • 服务器端的 Node.js
  • Browserify浏览器端的 CommonJS 实现,可以使用 NPM 的模块,但是编译打包后的文件体积可能很大
  • modules-webmake,类似Browserify,还不如 Browserify 灵活
  • wreq,Browserify 的前身

AMD

Asynchronous Module Definition 规范其实只有一个主要接口 define(id?, dependencies?, factory),它要在声明模块的时候指定所有的依赖 dependencies,并且还要当做形参传到 factory 中,对于依赖的模块提前执行,依赖前置。

1
2
3
4
define("module", ["dep1", "dep2"], function(d1, d2) {
return someExportedValue;
});
require(["module", "../file"], function(module, file) { /* ... */ });

优点:

  • 适合在浏览器环境中异步加载模块
  • 可以并行加载多个模块

缺点:

  • 提高了开发成本,代码的阅读和书写比较困难,模块定义方式的语义不顺畅
  • 不符合通用的模块化思维方式,是一种妥协的实现

实现:

CMD

Common Module Definition 规范和 AMD 很相似,尽量保持简单,并与 CommonJS 和 Node.js 的 Modules 规范保持了很大的兼容性。

1
2
3
4
5
6
define(function(require, exports, module) {
var $ = require('jquery');
var Spinning = require('./spinning');
exports.doSomething = ...
module.exports = ...
})

优点:

  • 依赖就近,延迟执行
  • 可以很容易在 Node.js 中运行

缺点:

  • 依赖 SPM 打包,模块的加载逻辑偏重

实现:

UMD

Universal Module Definition 规范类似于兼容 CommonJS 和 AMD 的语法糖,是模块定义的跨平台解决方案。

ES6 模块

ECMAScript6 标准增加了 JavaScript 语言层面的模块体系定义。ES6 模块的设计思想,是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。

1
2
3
4
5
6
7
8
// 没有用原文中的代码
// 导出
export function shout(string) {
return `${string.toUpperCase()}!`
}

// 导入
import { shout } from './lib.js'

优点:

  • 容易进行静态分析
  • 面向未来的 ECMAScript 标准

缺点:

  • 原生浏览器端还没有实现该标准 (笔者注:目前主流的浏览器都已经支持了JavaScript的模块)
  • 全新的命令字,新版的 Node.js才支持

实现:

CommonJS 与 Node.js

Node.js的模块化实现,采用的是CommonJS规范,我们可以在Node环境中进行调试,观察CommonJS规范中的一些细节。

关于CommonJS的使用和介绍,可以阅读阮一峰老师的这篇文章:CommonJS规范,讲的各方面都比较详细易懂。

现在准备3个.js文件,引用关系如下:

1
main.js -----> increment.js -----> math.js

math.js

1
2
3
4
var inc = require('./increment').increment

var a = 1
inc(a) // 2

increment.js

1
2
3
4
5
var add = require('./math').add

module.exports.increment = function(val) {
return add(val, 1)
}

math.js

1
2
3
4
5
6
7
module.exports.add = function() {
var sum = 0, i = 0, args = arguments, l = args.length
while (i < l) {
sum += args[i++]
}
return sum
}

打开终端,进行调试:

1
$ node debug main.js

当前的变量如下图所示:

可以看到,这里面多了3个变量,并不是我们手动创建的:exportsmodulerequire。搞懂了这几个变量,基本可以应对平时在Node中使用导入导出时涉及到的问题,以及对Node是如何实现CommonJS规范的,有一个大致的理解。

exports

exports中的内容,就是要暴露给别人使用的接口,因为main.js没有导出东西,所以这里exports是空的。这个exports变量实际上是Node为了方便提供的,指向module.exports。这相当于在每个模块头部,有这样一行语句:

1
var exports = module.exports

所以当要导出接口时,下面两种写法都是可以的:

1
2
3
4
5
6
7
exports.repeat = function(string) {
return `${string} ${string}`
}
// 或
module.exports.repeat = function(string) {
return `${string} ${string}`
}

注意,不能直接让exports变量直接指向一个值,这相当于切断了exportsmodule.exports之间的联系。

1
2
3
exports = function(string) {
return `${string} ${string}`
}

上面的写法是无效的,因为exports不再指向module.exports了。如果一个模块对外只有一个接口,只能使用module.exports输出。

1
2
3
module.exports = function(string) {
return `${string} ${string}`
}

module

moduleModule的实例对象,将它展开可以看到具有如下属性:

  • module.id — 模块的标识符,通常是带有绝对路径的文件名。可见CommonJS规范中一个文件就是一个模块,不管它又没有对外输出值。

  • module.filename - 模块的文件名,带有绝对路径。

  • module.loaded — 布尔值,表示模块是否被加载过。可以看到main.js模块没有被加载过,loadedfalse,而它的children模块,loadedtrue

  • module.childrenModule对象数组,在当前模块中require的其他模块。

  • module.parent — 一个Module对象或null,如果当前模块是入口模块,它的parent即为null,否则它的parent是在代码执行顺序上第一个加载它的模块。

  • module.exports — 当前模块对外输出的值。

require

Module的原型方法中有一个require方法,我们看到的这个require实际内部就调用了module.require。可以做个实验,用module.require去加载一个模块,会发现一样能正常加载。

1
var inc = module.require('./increment').increment

此外,Node又给当前的这个require新增加了一些属性:

  • require.cache - 一个文件被编译成了模块后,这个模块就会被缓存起来,当下次再需要加载这个模块时,就会拷贝一份缓存中的模块副本返回。所以当我们想让Node重新执行一遍文件中的代码时,可以从require.cache中将它删除。

  • require.main - 入口模块,也是直接执行的模块,其他的是被调用执行的。

  • require.resolve - 传入模块名称,返回该模块文件所在的位置,它并不会去加载模块。

1
2
var path = require.resolve('./increment')
// 返回 /Users/Shinancao/Documents/workplace/js-modules/increment.js

在调试的过程中,会发现,当执行一条require('xxx')时,require.cache中就会新增一条记录,这也说明了CommonJS中的模块是按照代码执行顺序生成的。并且当require执行完,后面的代码从会执行,说明模块是同步加载的,会产生阻塞。所以CommonJS规范比较适合服务器端的环境,.js文件大部分都已经在硬盘上了,耗时的就是I/O部分。

require内部的处理流程

通过调试和试验,我们可以大概猜一下Node是怎样来实现CommonJS规范的。

  • 将一个文件中的代码都放入到一个函数中,这个函数返回exports中输出的内容。

  • 当第一次要加载模块时,就将这个函数执行一次,然后将返回结果放入缓存中。

  • 下次再要加载该模块时,就从缓存中取出,然后拷贝一份返回。

Node中具体的执行流程,可以看这篇文章的讲解:The Node.js Way - How require() Actually Works

每个文件中的代码最后都注入进了一个函数中,所有的变量都在这个函数作用域中,因此就不会对Node环境中的其他部分操作污染:

1
2
3
(function (exports, require, module, __filename, __dirname) {
// YOUR CODE INJECTED HERE!
});

AMD 与 RequireJS

CommonJS虽好,但是不适合用于浏览器环境中,所以就有了AMD和CMD。AMD(异步模块定义,Asynchronous Module Definition)采用异步的方式加载模块,这样就不会阻塞后面代码的执行了,所以适用于浏览器环境中。require.js是实现AMD规范比较有名的库,本节中测试使用的就是require.js

先来看一个使用require.js最简单的例子,新建index.htmlapp.jsshirt.js,它们之间的调用关系如下:

1
index.html -----> app.js ------> shirt.js

shirt.js

1
2
3
4
5
6
define(function() {
return {
color: "black",
size: "unisize"
}
})

app.js

1
2
3
4
require(['shirt'], function(shirt) {
console.log(shirt.color)
console.log(shirt.size)
})

index.html

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
<head>
<script data-main="app" src="lib/require.js"></script>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>

可以直接在本地用浏览器打开index.html,即可观察到结果,不会受跨域的限制。

index.html中,下面这句话的意思是加载完require.js后,去执行app.js,所以app.js相当于一个调用的入口。

1
<script data-main="app" src="lib/require.js"></script>

如果在浏览器中查看页面元素会发现,被依赖的文件都生成了一个<script>标签,放在了页面的<head>中。

定义模块:define()

define()的完整定义如下:

1
define(id?, dependencies?, factory)
  • id - 模块的名称,可以省略。通常是省略的,require.js提供的优化代码的工具会自动给加上,这使得我们的代码可以容易地移动到别的文件中。
  • dependencies — 模块依赖的其他模块,如果没有,也可以省略。
  • factory - 可以是对象、字符串、函数等任意值,当是函数时,前面依赖的其他模块就是该函数的参数,函数的返回就是模块对外的输出。

放上一个来自官方文档的🌰:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
define(
// 模块的名称
"types/Manager",

//依赖项数组
["types/Employee"],

// 当所有的依赖项都加载完后执行该函数
// 这个函数的参数就是前面的依赖项
function (Employee) {
function Manager () {
this.reports = [];
}

//This will now work
Manager.prototype = new Employee();

//return the Manager constructor function so it can be used by
//other modules.
return Manager;
}
);

笔者尝试了一下,在同一个文件中,用不同的名字定义了两个模块,结果在运行时会报错,看来还是一个文件中只能定义一个模块。

Node是隐式地把文件中的代码放在函数中,而AMD规范的做法是显示地让开发者自己来写,其实这样更有利于debug,但就是给阅读代码时增加了一些障碍。

为了能兼容CommonJS规范,也可以像下面这样定义一个模块:

1
2
3
4
5
6
7
8
define(function(require, exports, module) {
var a = require('a'),
b = require('b');

//返回该模块的值
return function () {};
}
);

这里的exports就是当前这个模块的输出值,如果将输出放在exports中,那么函数不返回也可以,其他引用当前模块的地方也可以正确拿到值。module指的就是当前模块,封装了当前模块的一些信息。

引入模块:require()

require()的完整定义如下:

1
require([module], callback)

举个🌰:

1
2
3
4
require(['foo', 'bar'], function ( foo, bar ) {
// 这里写其余的代码
foo.doSomething();
})

也可以在模块中动态加载依赖的模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
define(function ( require ) {
var isReady = false, foobar;

// 请注意在模块定义内部内联的 require 语句
require(['foo', 'bar'], function (foo, bar) {
isReady = true;
foobar = foo() + bar();
});

// 我们仍可以返回一个模块
return {
isReady: isReady,
foobar: foobar
};
});

很显然,这里的require()是去同步加载模块的。

配置选项

下面是配置项比较常用的几个,关于配置选项的介绍,可以到require.js官方文档详细了解一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
requirejs.config({
baseUrl: '/another/path',
paths: {
"some": "some/v1.0"
},
shim: {
backbone: {
deps: ['jquery', 'underscore'],
exports: 'Backbone'
},
underscore: {
exports: '_'
}
}
})

require( ["some/module", "my/module", "a.js", "b.js"],
function(someModule, myModule) {

}
);
  • baseUrl - 指定查找模块的路径,上例中 “my/module”将会生成一个script标签是src = "/another/path/my/module.js"
  • path - 映射不能直接在baseUrl下找到的模块路径,这里的路径在书写上,是相对于baseUrl的位置,上例中”some/module”将会生成一个script标签是src = "/another/path/some/v1.0/module.js"

对于具有以下特征之一的模块ID,不会按照baseUrl+path的规则查找,就是普通正常的文件路径:

  1. .js结尾的
  2. /开头的
  3. 包含URL协议的,如:http:https:
  • shim - 主要用来配置像jquery.jsunderscore.jsbackbone.js这样很早就流行的库,但是没有采用AMD规范。官方这里给了一个解释很详细的例子,像上面配置的,之后在其他文件中可以这样使用:
1
2
3
define(['backbone'], function (Backbone) {
return Backbone.Model.extend({})
})

关于require.config还是requirejs.config,在stack overflow找了答案。requirejsrequire的别名,为了防止有其他库也使用了require

参考资料

【1】 JavaScript 模块化方案总结

【2】 使用 AMD、CommonJS 及 ES Harmony 编写模块化的 JavaScript

【3】 requirejs.org

CMD 与 SeaJS

这里是sea.js的文档:Sea.js 手册与文档
可以看到使用上和require.js十分相近,定义一个模块也是如下格式:

1
define(id?, dependencies?, factory)

这里的factory可以是函数,也可以是对象、字符串等任意值,这时module.exports会设置为factory的值。如果是函数时,第一个参数必须是require。与require.js的区别是,这里并不会把前面传入的依赖项,作为factory的参数。并且sea.js更推荐省略iddependencies,像下面这样定义一个模块:

1
2
3
4
5
define(function(require, exports, module) {

// The module code goes here

})

在需要其他模块的地方时再去调用require

1
2
3
4
define(function(require) {
var a = require('./a')
a.doSomething()
})

这里的require是同步的,所以sea.js提供了require.async可以异步加载模块:

1
2
3
4
5
6
7
8
9
10
11
define(function(require, exports, module) {
// 加载一个模块
require.async('./b', function(b) {
b.doSomething()
});

// 加载多个模块
require.async(['./c', './d'], function(c, d) {
// do something
});
})

搞懂了require.js的使用,上手sea.js还是挺轻松的,这里不再赘述了。

所以AMD与CMD的主要区别是:

AMD推崇的是依赖前置,等依赖的模块都加载完毕,才执行模块中的代码。

CMD推崇的是就近依赖,在模块的执行过程中,遇到依赖其他的模块时再去加载。

ES6 Module

目前主流的浏览器已经支持原生JavaScript的模块化,可以在JavaScript modules via script tag查看原生JavaScript模块的支持情况。

基础用法

JavaScript自己的模块使用的依然是exportimport的方式,为了演示如果使用JavaScript modules,我们先创建3个文件:index.htmlmain.jslib.js,之间的调用关系如下:

1
index.html -----> main.js ------> lib.js

先看lib.js,这里我们放两个工具函数,然后使用export将其暴露出去:

1
2
3
4
5
6
7
// lib.js

export const repeat = (string) => `${string} ${string}`

export function shout(string) {
return `${string.toUpperCase()}!`
}

main.js通过importlib.js引入进来,之后就可以使用lib.js中暴露出的功能了:

1
2
3
4
5
6
7
// main.js

import {repeat, shout} from './moduleA.js'

console.log(repeat('hello'))

console.log(shout('Modules in action'))

index.html在用script标签引入main.js时要加上type="module"

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// index.html

<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<title>JavaScript module object example</title>

<script type="module" src="./main.js"></script>

</head>
<body>

</body>
</html>

如果在浏览器中打开本地的index.html,控制台会报错,因为不能跨域访问到.js文件。用open -n /Applications/Google\ Chrome.app/ --args --disable-web-security --user-data-dir=/Users/yourname/MyChromeDevUserData/(Mac上)打开允许跨域访问的Chrome,也还是不行,会报The server responded with a non-JavaScript MIME type。只能将这些文件都放到服务器上去测试,这里我用node.js写了一下server端,可以直接拿去用。

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
// server.js
// 运行:node server.js

const http = require('http')
const fs = require('fs')
const url = require('url')

const hostname = 'localhost'
const port = 3000

const server = http.createServer(function(req, res) {
const pathname = url.parse(req.url).pathname
console.log(`request ${pathname}`)

fs.readFile(`.${pathname}`, function(err, file) {
if (!err) {
if (/(\.js)$/.test(pathname)) {
res.writeHead(200, {"Content-Type": "text/javascript"})
} else {
res.writeHead(200, {"Content-Type": "text/html"})
}
res.write(file)
res.end()
} else {
res.writeHead(404, {"Content-Type": "text/plain"})
res.write('404 Not Found')
res.end()
}

})
})

server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`)
})

我们在使用JavaScript的模块来设计项目时,可以将某一类的功能封装成一个模块,然后再有一个模块进行整合实现最终的业务,然后在html中只要引入少量模块即可了。

关于export

导出任意合法的表达式

只要表达式在JavaScript中是合法的,都可以在其前面加上exportz将其导出。

举个🌰:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 导出变量
export const repeat = (string) => `${string} ${string}`

// 导出函数
export function shout(string) {
return `${string.toUpperCase()}!`
}

// 导出常量
export const LENGTH = 10

// 导出类
export class Square {
constructor(length) {
this.length = length
}

area() {
return this.length * this.length
}
}

上面的例子中是分别导出每个表达式,也可以先不在每个表达式前加export,最后再一次性导出:

1
export { repeat, shout, LENGTH, Square }

重命名导出

export后的大括号中,可以使用as关键字后面跟上新的名字来重命名即将导出的功能的标识名字,来让别的模块通过新的名字使用对应的功能。

1
2
3
4
5
6
export { 
repeat as repeatString,
shout as shoutString,
LENGTH as SQUARE_LENGTH,
Square as BigSquare
}

默认导出

每一个模块都可以有一个默认导出的功能(且只能有一个),在export后面再加上default关键字,就标识了该功能是默认导出的,如果是默认导出的functionclass,可以将名字省略。

1
2
3
export default function(string) {
return string.match(/\s+/g)
}
1
2
3
4
5
export default class {
constructor(name) {
this.name = name
}
}

在其他模块中导入的时候,可以将大括号省略,并且给起个名字,如下导入刚刚的函数:

1
import hasSpaces from './lib.js'

关于import

重命名导入

在导入时也可以重命名功能的标识名字,以避免命名冲突。原来的名字后面加上as关键字,再加上新名字即可。

比如我们在lib.js中导入的是下面这样:

1
export { repeat, shout, LENGTH, Square }

main.js中导入时重新命名:

1
2
3
4
import { repeat as repeatString, 
shout as shoutString,
LENGTH as SQUARE_LENGTH,
Square as BigSquare } from './moduleA.js'

创建模块对象

上面的导入方式,一旦东西变多就会显得混乱、冗长,一种更好的导入方式是将模块导入到一个模块上,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 导入
import * as Lib from './lib.js'

// 使用
console.log(Lib.repeat('hello'))

console.log(Lib.shout('Modules in action'))

console.log(Lib.LENGTH)

const square = new Lib.Square(10)
console.log(square.area())

动态导入 🌹

到目前为止我们用到的都还是静态导入,也就是在页面一加载时,import.js文件就会被下载到本地,有时我们可能不希望这样,我们想要在需要某个模块时再去下载它。嗯,目前的主流浏览器支持了这一功能,也就是可以让我们动态加载模块!

看个动态加载模块的🌰,现在给index.html加上个按钮,然后将main.js改为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const testBtn = document.querySelector('.testBtn')

testBtn.addEventListener('click', () => {
import('./lib.js').then(Lib => {

console.log(Lib.repeat('hello'))

console.log(Lib.shout('Modules in action'))

console.log(Lib.LENGTH)

const square = new Lib.Square(10)
console.log(square.area())
})
})

可以观察到,在点击了按钮时,lib.js才被下载下来。

因为import()返回的是一个Promise对象,所以也可以用async/await形式来写:

1
2
3
4
5
6
7
8
9
10
11
12
testBtn.addEventListener('click', async () => {
const Lib = await import('./moduleA.js')

console.log(Lib.repeat('hello'))

console.log(Lib.shout('Modules in action'))

console.log(Lib.LENGTH)

const square = new Lib.Square(10)
console.log(square.area())
}

也可以只拿模块中的一部分功能,像下面这样:

1
2
3
4
5
const { repeat, shout } = await import('./moduleA.js')

console.log(repeat('hello'))

console.log(shout('Modules in action'))

而且动态导入也可以用于标准的JavaScript中,也就是上面的代码可以直接写在<script></script>中。

标准JavaScript的区别

  • 模块默认就是开启了strict mode

  • 模块具有顶级的词法作用域,就是如果在模块中定义了var foo = 42,并不会在全局作用域中生成一个foo变量,但是在标准的JavaScript中,在浏览器环境中,会生成window.foo

  • 同样地,模块中的this也不会指向全局对象,而是undefined

  • Top-level await可用于模块中,但是在标准JavaScript中不支持,也就是在模块中await语句可以不在async function内部定义。

  • 模块默认支持defer,所以不需要在<script type="module">上再加defer关键字。各种情况下,HTML解析和.js文件下载和执行的顺序如下图所示(图片来源:[JavaScript modules](https://v8.dev/features/modules):

  • 如果一个.js文件被引入多次,在标准的JavaScript中该文件引入几次就会被执行几次,但如果是模块,只会执行一次。
1
2
3
4
5
6
7
8
<script src="classic.js"></script>
<script src="classic.js"></script>
<!-- classic.js executes multiple times. -->

<script type="module" src="module.mjs"></script>
<script type="module" src="module.mjs"></script>
<script type="module">import './module.mjs';</script>
<!-- module.mjs executes only once. -->

因为有这些区别在,所以JavaScript运行环境需要知道一段JavaScript代码是否是模块,所以需要加上type="module"标识。

关于性能的建议

  • 尽管JavaScirpt自身支持了模块,但是对于大型项目仍然需要打包工具(如:webpack)打包,因为打包工具会进行很多优化,比如去掉没有被引用的代码,减少包的大小等等。

  • 习惯于使用细粒度模块编写代码,这不仅让代码看起来易读简单,也有利于消除没有被用到的代码,如果一个模块没有被任何地方导入,浏览器不会去下载该模块。被使用过的代码也会被浏览器逐个缓存。

  • 如果确定一些模块是在页面初始化时就要用到的,可以使用<link rel="modulepreload">可以让浏览器预加载,甚至预解析和执行模块及它们的依赖。

参考资料

【1】 v8.dev — JavaScript modules
【2】 MDN — JavaScript modules

本文作者:意林
本文链接:http://shinancao.cn/2019/08/25/JS-Modules/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!