原稿:语雀 · 模块化开发 · 原目录:Webpack › 模块化开发

在模块化编程中,开发者将程序分解为功能离散的 chunk,并称之为模块。

每个模块都拥有小于完整程序的体积,使得验证、调试及测试变得轻而易举。 精心编写的模块提供了可靠的抽象和封装界限,使得应用程序中每个模块都具备了条理清晰的设计和明确的目的。

—— webpack doc

在其他高级语言中,Java 有类文件,Python 有 import 机制,Ruby 有 require,PHP 有 include 和 require。而 JavaScript 通过 <script> 引入代码显得杂乱无章,在 ES6 之前,JS 自身缺乏对模块的支持。但是社区为 JS 制定了相应的规范,尽可能支持模块化开发,其中最重要的就是 CommonJS 规范,其规范了同步声明依赖的模块定义。这个规范主要用于在服务器端实现模块化代码组,不能在浏览器直接运行。

CommonJS

模块定义

CJS 规范下,每个文件就是一个模块,有自己的作用域,当中的变量、函数、类,都是私有的,对其他文件不可见。每个模块内部存在 module 对象即模块自身,该对象具有 exports 属性,是指向一个对外接口对象的引用,module.exports 对象上暴露出的才是模块提供给外界的方法和变量等。当加载某个模块时,其实是加载该模块的 module.exports 对象

var counter = 1;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};

Node:Node 为每个模块额外提供一个 exports 变量,它是指向 module.exports 对象的引用,等同在每个模块头部有一行:var exports = module.exports,因为 exports 实际上是一个引用变量,因此不能直接对 exports 重新赋值,这样会断开它对 module.exports 对象的引用(参考 Node API:exports 快捷方式);如果觉得 exports 与 module.exports 之间的区别很难分清,一个简单的处理方法就是弃用 exports,只使用 module.exports

// 正确写法
exports.add = function () {};
// 错误写法 1:
exports = function(x) {console.log(x)};
// 错误写法 2:
module.exports = 'Hello world';

以下代码中,实际上是先整体加载模块 fs 的 module.exports 对象,在本地拷贝出一个 _fs 对象后,从上面获取对象的属性,可以将 module.exports 对象作为一个类

// CommonJS模块
let { stat, exists, readfile } = require('fs');

// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;

模块导入

CJS 要求存在 require() 方法接收模块名称或路径,以此引入模块 API 到当前上下文

  • 对于本地模块,路径使用相对路径,模块系统会自动基于 __dirname 寻找(参考 Node API:require
  • 对于 node_modules 模块或 Node.js 内置模块可直接引入
const a = require('./modules/a.js');
const math = require('math');

模块加载原理

CJS 模块加载的特性:

  1. CJS 模块输出的是值的拷贝:原始值会创建新变量、对象则会创建指向同一对象的新引用
  2. CJS 模块是运行时加载
  3. CJS 的 require() 是同步加载的:当前脚本的解析阶段不会处理 require() 语句,当真正执行到 require() 所在行时,才会去模块位置进行解析加载,当前脚本的执行会被阻塞,直到模块执行完毕
  4. CJS 处理循环依赖:遇到循环加载时,返回的是当前已执行部分的值,而不是代码全部执行后的值

例一:有以下模块

var counter = 1;
function increment() {
  counter++;
}
function counterGetter() {
  return counter;
}
module.exports = {
  counter: counter,
  increment: increment,
  counterGetter: counterGetter,
};

在别的地方引入

let { counter, increment, counterGetter } = require("./exportDemo");

increment();
increment();

console.log(counter); // 1
console.log(counterGetter()); // 3

最终输出的结果是: 1 3,因为 counter 是一个原始值,其引入后就是一个独立的本地缓存;increment() 每次调用修改的是模块对象上的 counter,必须使用 getter 函数才能获取对象上的值

例二:模块使用时会出现 a 和 b 相互循环引用的情况

// a.jsexports.done = false;var b = require("./b.js");console.log("在 a.js 之中,b.done = %j", b.done);exports.done = true;console.log("a.js 执行完毕");// b.jsexports.done = false;var a = require("./a.js");console.log("在 b.js 之中,a.done = %j", a.done);exports.done = true;console.log("b.js 执行完毕");

当执行 node a.js 时,最后的输出结果:

在 b.js 之中,a.done = falseb.js 执行完毕在 a.js 之中,b.done = truea.js 执行完毕

分析:首先 a.done 被赋值为 false,之后遇到 require(),去解析执行 b.js 的代码,令 b.done 为 false,此时遇到循环 require(),系统会去 a.js 对应对象的 exports 取值,可是 a.js 还没有执行完,从 exports 只能取回已经执行的部分,而不是最后的值。因此输出 a.done = false,之后直到 b.js 执行完毕才返回继续执行 a.js


ES6 module

标签与定义

ES6 原生引入了模块规范,在 html 中可以设置 <script> 标签的 type=“module”,告知浏览器相关代码作为模块执行;模块可以嵌入 html 也可由外部引入,但是嵌入的模块定义代码不能使用 import 加载其他模块

<script type="module">/*模块代码*/</script>
<script type="module" src="path/to/myModule.js"></script>

异步/推迟加载模块 2. 所有模块都会和 <script defer> 效果相同,解析到 <script type=“module”> 标签后会立即下载模块文件,但执行会延迟到文档解析完成,修改标签位置只会影响下载时机,不影响加载时机(同时适用于嵌入和外部引入)

<!-- 第二个执行 -->
<script type="module"></script>
<!-- 第三个执行 -->
<script type="module"></script>
<!-- 第一个执行 -->
<script></script>
  1. 可以为模块使用 async 属性,此时模块的执行顺序与标签出现顺序不再相关,模块也不会等待文档解析完才执行,但是入口模块仍需要等待依赖加载完毕

ES6 模块入口

与 <script type=“module”> 标签关联的 ES6 模块都被认为是模块图中的入口模块。一个页面上对于入口模块数无限制,同一个模块被重复加载时,实际上只加载一次

命名导出/导入

与 CJS 相似,ES6 通过 export 导出模块对外可见的部分,有以下规则:顶级块 & 随意位置,export 导出语句不能嵌套在某个块中,但相对位置不限。ES6 模块支持两种导出:命名导出和默认导出

命名导出:命名导出提供了模块内值的对外接口

  • 行内写法:直接在 export 后接着变量、函数、类声明
  • 非行内写法:使用大括号指定所要输出的一组变量,大括号内变量名保持输出值本身名称,但可使用 as 设置别名
// 行内命名导出
export const foo = 'foo';
// 非行内命名导出
const foo = 'foo';
export { foo } ;
// 别名与多个导出
const bar = 'bar';
const baz = 'baz';
export { foo, bar as myBar, baz };

导入:模块的导入可以使用 * 指定一个对象,所有输出值都加载在这个对象上面,之后为对象设置别名即可,而无须列出每个标识符

// 导出
const foo = 'foo', bar = 'bar', baz = 'baz';
export { foo, bar, baz }
// 导入
import * as Foo from './foo.js';
console.log(Foo.foo); // foo
console.log(Foo.bar); // bar
console.log(Foo.baz); // baz

还可逐一导入,但需要和导出时的接口名一致

import { foo, bar, baz as myBaz } from './foo.js';
console.log(foo); // foo
console.log(bar); // bar
console.log(myBaz); // baz

默认导出/导入

默认导出:命名模块导入时需要知道所要加载的变量名或函数名,否则无法加载。但是,用户肯定希望快速上手,未必愿意阅读文档,去了解模块有哪些属性和方法。默认导出使用 default 将一个值声明为默认导出,每个模块只能有一个默认导出

const foo = 'foo';
export default foo;

导入:导入时可以用任意名称指向模块输出的值

import foo from './foo.js';

实际上默认的输入输出就是基于命名模块,导出一个叫做 default 的变量或方法,系统允许导入时取任意名字

// modules.js
function add(x, y) {
  return x * y;
}
export {add as default};
// 等同于
// export default add;

// app.js
import { default as foo } from 'modules';
// 等同于
// import foo from 'modules';

模块加载原理

ES6 模块加载的特性:

  1. ESM 输出的是值的引用
  2. ESM 是编译时输出接口
  3. ESM 的 import 命令是异步加载的,有独立的模块依赖解析阶段:浏览器会解析入口模块,确定依赖,发送对依赖模块的请求;之后浏览器会解析响应,再递归确定下一层依赖;异步递归加载过程会持续到整个程序的依赖图都解析完成,此时程序拿到的是模块的对外接口,并未执行模块
  4. ESM 处理循环依赖:遇到循环加载时,返回的是当前已执行部分的值,而不是代码全部执行后的值

例一:通过 ESM 方式改写 CJS 的例一

var counter = 1;
function increment() {
  counter++;
}
function counterGetter() {
  return counter;
}
export { counter, increment, counterGetter };