JavaScript 模块基础

avatar
前端团队 @晓教育集团

在我们的日常开发中,基本都在使用着模块,无论是 Node.js 的 CommonJS,还是 ES6 的 ES Modules,甚至是更古老的 AMD、UMD 等,模块的引入给我们的开发带来了许多的方便,为我们解决了很多问题。但是具体解决的是什么问题呢?

下面我们从 JavaScript 模块的发展史开始讲起,到最新的 ES Modules 的,重新认识一下模块。

1、JavaScript 模块发展史

1.1 Vanilla JS(1995~2009)

JavaScript 被开发出来的时候,是没有模块标准的,因为 JavaScript 的设计初衷就是作为一个 toy script,在浏览器中做一些简单的交互。但是随着互联网的高速发展,人们已经不再满足于简单的交互,而代码的复杂度也日益增长,维护难度也越来越高。

那么维护指的是维护什么呢?指的是维护变量。因为随着项目不断迭代,多人协同开发是不可避免的。在 JS 初期所有变量都写在全局作用域上,那么很可能出现的问题是什么呢?变量的覆盖、篡改和删除,这是一个很头疼的问题。很可能突然有一天你的功能报错了,就是因为你的某个变量被另一位开发者所删除了。

所以对于模块的引入初衷是为了解对变量的控制。当然还有其他的好处,例如对代码的封装、复用等等。 image.png 那么初期在没有模块标准的支持下,开发者们是如何实现类似模块的效果呢?有 2 种方式。

1.1.1 Object Literal Pattern(对象字面量)

使用 JS 内置的对象对变量进行控制:

function Person(name) {
  this.name = name;
}

Person.prototype.talk = function () {
  console.log("my name is", this.name);
};

const p = new Person("anson");
p.talk();

这样就可以通过 new Person 的方式把变量都控制在对象内部。

1.1.2 IIFE(Immediately Invoked Function Expression)

我们知道在 JavaScript 中有作用域(Scope)的概念,在作用域内的变量,只在作用域内可见。在 ES6 之前,作用域只有 2 种,分别是:

上面提到了对变量的控制,那么肯定是把变量的作用范围控制的越小越好,所以毫无疑问把变量写在函数内是最好的办法。但是,这又引发了另一个问题,函数中的变量要如何提供给外部使用呢? image.png 这个问题在初期并没有很好的解决方法,你必须把变量暴露到全局作用域中,例如经典的 jQuery。 image.png 而开发者们通常会使用 IIFE 去实现:

// lib.js
(function() {
  const base = 10;
  this.sumDOM = function(id) {
    // 依赖 jQuery
    return base + +$(id).text();
  }
})();

在 HTML 中引入 lib.js

// index.html
<html>
  <head>
    <script src="/path/to/jquery.js"></script>
    <script src="/path/to/lib.js"></script>
  </head>
  <body>
    <script>
      window.sumDOM(20);
    </script>
  </body>
</html>

但是 IIFE 有几个问题:

  • 至少一个变量污染全局作用域;
  • 模块之间的依赖关系模糊,不明确(lib.js 不能直观看出依赖 jquery.js);
  • 加载顺序无法保证,不好维护(必须确保 jquery.js 必须在 lib.js 前加载完成,否则会报错)。

image.png 所以,JavaScript 非常需要一个模块标准来解决上述问题。

1.2 Non-Native Module Format & Module Loader(2009~2015)

由于模块能为我们解决上述问题,所以开发者尝试着自己去设计一些非原生模块标准如 CommonJSAMD (Asynchronous Module Definition)UMD (Universal Module Definition),然后搭配对应的 Module Loader 如 cjs-loader、RequireJSSystemJS 可以实现模块的效果,我们下面过一下几个流行的非原生模块标准。

1.2.1 CommonJS (CJS)

2009 年,来自 Mozilla 的工程师 Kevin 提出了为运行在浏览器以外的 JavaScript 建立一个模块标准 CommonJS,主要应用在服务端如 Node.js。因为使用效果不错,随后也被用在浏览器的模块开发中,但由于浏览器并不支持 CommonJS,所以代码需要通过 Babel 等 transpiler 转换为 ES5 才能在浏览器上运行。

CommonJS 的特征是使用 require 来导入依赖,exports 来导出接口。

// lib.js
module.exports.add = function add() {};

// main.js
const { add } = require("./lib.js");
add();

1.2.2 AMD

因为 CommonJS 设计初衷是应用在服务端的,所以模块的加载执行也都是同步的(因为本地文件的 IO 很快)。但是同步的方式运用到浏览器就不友好了,因为在浏览器中模块文件都是通过网络加载的,单线程阻塞在模块加载上,这是不可接受的。所以在 2011 年有人提出了 AMD,对 CommonJS 兼容的同时支持异步加载。 image.png AMD 的特征是使用 define(deps, callback) 来异步加载模块。

// Calling define with a dependency array and a factory function
define(['dep1', 'dep2'], function (dep1, dep2) {
    //Define the module value by returning a value.
    return function () {};
});

1.2.3 UMD

因为 CommonJS 和 AMD 的流行,随后又有人提出了 UMD 的模块标准,UMD 通过对不同的环境特性进行检测,对 AMD、CommonJS 和 Global Variable 三种格式兼容。

// UMD
(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD
    define(['jquery', 'underscore'], factory);
  } else if (typeof exports === 'object') {
    // Node, CommonJS-like
    module.exports = factory(require('jquery'), require('underscore'));
  } else {
    // Browser globals (root is window)
    root.returnExports = factory(root.jQuery, root._);
  }
}(this, function ($, _) {
  //    methods
  function a(){};    //    private because it's not returned (see below)
  function b(){};    //    public because it's returned
  function c(){};    //    public because it's returned
  //    exposed public methods
  return {
    b: b,
    c: c
  }
}));

因为 UMD 的兼容性好,不少库都会提供 UMD 的版本。

1.3 ESM(2015~now)

随着 ECMAScript 的逐渐规范化、标准化,终于在 2015 年发布了 ES6(ES 2015),在这次版本更新中,制定了 JS 模块标准即 ES Modules,ES Modules 使用 import 声明依赖,export 声明接口。

// lib.mjs
const lib = function() {};
export default lib;

// main.js
import lib from './lib.mjs';

截止到 2018 年,大部分主流浏览器都已经支持 ES Modules,在 HTML 中通过为 <script> 中添加 type="module" 属性来声明 ESM 类型。

在 HTML 中使用 ES Modules 有几个注意点:

  • 默认启用严格模式即 "use strict"
  • 默认 defer 加载执行;
  • 默认启用 CORS 跨域;
  • 在同一个文档中,相同的模块只会加载、执行一次;

随着 ES Modules 模块标准的发布,JS 的周边生态系统也在慢慢向 ES Modules 靠拢。Node.js 在 14.x 添加了对 ES Modules 的支持;Module Bundler 如 Rollup 均以 ES Modules 作为默认模块标准;还有 DenoTypeScript 等等。

现在仍然在使用的模块标准主要就是 CJS 和 ESM,CJS 的存在主要是 Node 的历史原因。下面我们对 ESM 工作原理进行介绍,并结合 CJS 进行对比。

2、ESM 工作原理

在介绍 ES Modules 工作原理前,先理解几个概念。

Module Scope(模块作用域)

我们都知道 ES6 引入了一个新的作用域:块作用域(Block Scope),但是还有一个模块作用域(Module Scope),用于管理模块内部的变量。与函数作用域不同的是,在模块作用域中,你需要显式地指定导出的变量,这也叫作一个 export;同时你需要显式地指定导入的变量,这也叫作一个 import。所以你不再需要污染全局作用域了。 image.png 正因为模块之间的依赖关系是显示的、明确的,所以你不用再担心你的模块是否会因为 jquery 没有前置加载而报错了,因为这在编译阶段就会提示你了。

Module Record(模块记录)

当我们使用模块的时候,实则是在构建一个模块依赖图。你传递一个模块文件作为入口(Entry Point),JS 引擎根据模块中的 import 声明语句递归查询、下载、解析子模块。

在这里 main.js 作为入口,然后依赖另外两个子模块 counter.js 和 display.js。 image.png 解析指的是把模块文件解析为一种数据结构 Module Record,Module Record 记录了模块中的 importexportcode 等信息,用于后续的 Linking、Evaluation。 image.png

Module Environment Record(模块环境记录)

当 JS 引擎执行到一个作用域时,会创建一个 Environment Record(环境记录)绑定到该作用域,用于存储作用域内的变量和函数。Module Environment Record 除了保存着模块内的 top-level 的变量声明,还保存着模块内的 import 绑定变量。

Environment Record 有一个很重要的字段 [[OuterEnv]] 用于指向外部的 Environment Record,这与原型链十分相似,末端为 nullimage.png 如上图所示 lib-a.jslib-b.js 是两个独立的模块,环境记录分别为 ModuleEnvironmentRecord-lib-a 与 ModuleEnvironmentRecord-lib-b,两者的 [[OuterEnv]] 都指向 GlobalEnvironmentRecord,这样做实现了模块之间的变量分离。

ES Modules 工作过程主要可以划分 3 个阶段:

  1. Construction - 查询、下载、解析模块为 Module Record;
  2. Linking - 创建 Environment Record 并关联模块之间的 importexport 关系;
  3. Evaluation - 执行 top-level 代码并填充 Environment Record。

大家都说 ESM 是异步执行的,是因为这 3 个阶段是独立的、可分离的,但是这并不表示一定需要使用异步去实现,它也是可以通过同步去执行的,例如在 CJS 中就是同步去执行的。

因为在 ESM spec 里面只说到如何解析 Module Record;如何做模块之间的 Linking;如何执行模块的 Evaluation。但是并没有提到如何获取到模块文件,这在不同的运行环境中由不同的 loader 去负责加载完成。对于浏览器而言,在 HTML spec 中使用的是异步加载的方式。 image.png loader 不仅仅负责模块的加载,同时它负责调用 ESM 的方法如 ParseModuleModule.LinkModule.Evaluate。loader 控制着这些方法的执行顺序。 image.png

2.1 Construction

Construction 阶段主要分为 3 个步骤:

  1. 找到模块路径,也叫 module resolution;
  2. 获取模块文件(从网络下载或从文件系统加载);
  3. 解析模块文件为 Module Record;

loader 负责对模块进行寻址以及下载。首先我们需要一个入口文件,这在 HTML 中通常是一个 <script type="module"> 的标签来表示一个模块文件(在 Node 中通常使用 *.mjs 来表示一个模块文件或修改 package.json 中的 "type": "module"image.png 那模块是怎么找到下一个子模块的呢?这就需要通过 import 声明语句了,在 import 声明语句中有一部分被称为 module specifier,这告诉 loader 要如何找到下一个子模块的地址。 image.png 注意 module specifier 在不同环境(浏览器、Node)中有不同的解释方法,解释的过程也叫作 module resolution。例如在浏览器中只支持 URL 作为 module specifier;而 Node 除此以外还支持 bare module specifier,也就是我们平常写的 import moment from "moment";。W3C 也在推进 import maps 特性来支持 bare module specifier

你只有在解析完当前模块为 Module Record 之后,才知道当前模块依赖的是哪些子模块,然后你需要 resolve 子模块、fetch 子模块、parse 子模块,不断的循环这套流程 resolving -> fetching -> parsing,如下图所示: image.png 如果整个过程,主线程都在等待每个模块文件的下载,那么整个任务队列都会挂起。因为你在浏览器中下载是很慢的,这也是为什么在 ESM spec 中把模块加载拆分为 3 个阶段的原因。

阶段的拆分也是 CJS 与 ESM 主要的一个不同点,因为 CJS 加载的都是本地文件,自然不需要考虑 IO 的问题。这意味着 Node 会阻塞主线程去做这个模块的加载动作,接着同步执行 Linking、Evaluation。 image.png 上图代码执行到 require,然后需要加载子模块了,马上切换到 fetch 子模块,然后继续执行 evaluate 子模块,这一切都是同步发生的。这也是为什么在 Node 中,你可以在 module specifier 中使用变量。

但是对于 ESM 来说就不同了,因为 ESM 在执行 Evaluation 之前,就需要构建好整个模块依赖图,这包括所有模块的resolving、fetching、parsing。所以 ESM 在 module specifier 中是无法使用变量的。

但是这也有一个好处,那就是 Rollup、Webpack 等 Module Bundler 可以在编译时对 ESM 进行静态分析,做 Tree Shaking 移除 dead code。 image.png 如果实在想在 ESM 中使用变量作为 module specifier,那么可以使用 dynamic import import(${path}/foo.js) 来导入新的模块,新的模块入口会自动创建一个新的模块依赖图。 image.png 虽然是新的模块依赖图,但是并不会创建新的 Module Record,loader 使用 Module Map 对全局的 Module Record 进行追踪、缓存。这样可以保证模块文件只被 fetch 一次。每个全局作用域中会有一个独立的 Module Map,也就说每个 iframe 会有独立的 Module Map。

可以把 Module Map 想象为一个简单的 key/value 映射对象。例如初次加载的模块会标记状态为 fetching,然后发起请求,接着继续 fetch 下一个模块文件。 image.png 我们可以通过查看下图来理解 Document 与 Module Map 之间的关系: module-map.png Document 与 Module Map 是一对一的关系,main.js 有自己的 Module Map;底下的 iframe-a、iframe-b 也会有自己的 Module Map。所以尽管它们内部依赖模块的地址是一样的,仍然会重复去请求下载。 image.png 好,那么下载完文件后,JS 引擎会把模块文件解析为 Module Record,保存着模块中的 import、export、code 等信息。 image.png Module Record 会放置到 Module Map 中缓存。 image.png

2.2 Linking

在所有 Module Record 被解析完后,接下来 JS 引擎需要把所有模块进行链接。JS 引擎以入口文件 main.js 的 Module Record 作为起点,以深度优先的顺序去递归链接模块,为每个 Module Record 创建一个 Module Environment Record,用于管理 Module Record 中的变量。

具体是如何进行链接的呢?JS 引擎会对当前模块 main.js 下的所有子模块 counter.js、display.js 创建 Module Environment Record,对子模块中的 export 变量进行绑定,为其分配内存空间。 image.png 然后控制权回到上一级,也就是当前模块 main.js,对 main.js 中 import 的变量进行关联,注意这里 main.js 中 import 指向的内存位置与 count.js、display.js 中 export 变量指向的内存位置是一致的,这样就把父子模块之间的关系链接起来了。 image.png 但是在 CJS 在这一点上面不同。在 CJS 里面,会对整个 module.exports 对象进行复制。 image.png 这意味着 exporting module 在后面修改变量值,importing module 并不会自动更新。

相反,ESM 用的一种技术叫作 live bindings。父子模块指向相同的内存位置,所以 exporting module 修改变量值,importing module 会马上得到更新。

需要注意的是,只有 exporting module 才可以对 export 变量值进行改变,importing module 是无法改变。可以说 exporting 模块有读写权限,而 importing 模块只有读权限。 image.png 使用 live bindings 的一个原因是,它可以帮助把所有模块关联起来,而不需要跑任何代码。这在当我们 Evaluation 遇到循环依赖(cyclic dependencies)的时候很有帮助。

下面我们要开始执行代码,并填充上面的内存了。

2.3 Evaluation

在模块彼此链接之后,JS 引擎通过执行模块中的 top-level 代码来实现,所以你的 importexport 语句是不能写在函数里面的。 image.png 但是执行 top-level 代码是可能会产生副作用,例如发送网络请求,所以你肯定不希望同一个模块执行多次。这也是为什么会使用 Module Map 来做全局缓存 Module Record 的原因,如果一个 Module Record 的状态为 evaluated,那么下次执行会自动跳过,从而保证一个模块只会执行一次。与 Linking 阶段一样的是,同样是对 Module Record 执行深度优先遍历的操作。

在 Linking 结尾提到的依赖循环问题,通常是错综复杂的依赖循环,这里以简单的例子说明下: image.png main.js 与 counter.js 之间循环依赖彼此。 我们先来看看 CommonJS 中的依赖循环问题: image.png 首先 main.js 执行到 require("./counter.js"),然后进入 counter.js 执行获取 main.js 中的 message,而这时是 undefined 的,所以 counter.js 复制了 undefinedimage.png 在 counter.js 执行完成后(注意最后我们设置了一个 setTimeout 来查看 message 是否会自动更新),在控制权返回到 main.js 继续执行代码,最后对 message 赋值为 "Eval complete"image.png 但因为在 CommonJS 中 import 的变量值是对 export 变量值的复制,所以 counter.js 中的 message 并不会更新。 image.png 而 ES Modules 使用的是 live bindings,所以在 counter.js 中会自动更新 message 的值。

3、CJS 与 ESM 之间的混用

因为历史原因,npm 上大多数的包都使用 CJS 编写,但是随着 ESM 的出现,开发者们开始使用 ESM 去编写模块。而为了最大程度复用 npm 上的包,在 ESM 中难免会需要导入 CJS。而因为模块加载方式的差异性,CJS 无法导入 ESM,而 ESM 可以导入 CJS。

虽然 ESM 可以导入 CJS,但是使用上仍然有些限制。

3.1 ESM 只支持 default import CJS

ESM 支持 CJS 的 default import,但是不支持 named import,即:

import pkg from 'lib.cjs'; // work
import { fn1, fn2 } from 'lib.cjs'; // error

为什么呢?结合上面的 ESM 工作原理,ESM 是对模块变量进行静态分析的,而 CJS 的模块变量是动态计算的。所以 ESM 还在还没执行代码的第一阶段 Construction,又如何能计算出 CJS 的模块变量呢?

但是在 Node 14.13.0 版本中,Node 添加了对 CJS named export 的支持,可以支持大部分的 CJS 模块。 image.png 为什么说大部分呢?Node 官网做出了说明

The detection of named exports is based on common syntax patterns but does not always correctly detect named exports. In these cases, using the default import form described above can be a better option.

注意 detect 这一关键字,它是基于的 CJS 模块语法对文本分析得到 named exports,所以并不能保证正确。在这种情况,使用 default import 是更好的选择。

Node 使用了一个叫做的 cjs-module-lexer 的语法分析库,对 CJS 模块内容进行静态语法分析,只支持简单的 exports 写法,如 exports.name = ...module.exports = require('...'),这里举个可被分析的例子:

// correct.cjs
exports.a = 1;
exports.b = 2;
if (Math.random() > 0.5) {
  exports.c = 3;
}

// main.mjs
import { a, b, c } from './correct.cjs';
// 执行 main.mjs 无异常

无法分析的例子:

// wrong.cjs
// 使用 tmp 来设置 exports
const tmp = exports;
tmp.a = 1;
tmp.b = 2;
if (Math.random() > 0.5) {
  tmp.c = 3;
}

// main.mjs
import { a, b, c } from './wrong.cjs';
// 执行 main.mjs 报错

执行上面的例子会报以下错误:

file:///E:/javascript-modules/esm-app/dual/index.mjs:1
import { a, b, c } from "./lib.cjs";
         ^
SyntaxError: Named export 'a' not found. The requested module './lib.cjs' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:

import pkg from './lib.cjs';
const { a, b, c } = pkg;

你可能会想,谁会这么写阿,不巧,蛮多有名的库是这么写的,例如 lodashchalkimage.png image.png 对于无法分析 named exports 的模块,Node 会在错误里面给我们提出建议使用 default import,然后再进行解构,也就多一行代码:

CommonJS modules can always be imported via the default export, for example using:

import pkg from './lib.cjs'; const { a, b, c } = pkg;

3.2 使用 ESM Wrapper 为 CJS 实现 named exports

如果我们实在希望在 ESM 中使用 named exports CJS,那么我们可以为 CJS 提供一个 ESM Wrapper,其实就是根据 Node 的错误提示去封装一层代码,对 CJS 采用 default import,然后对里面指定的变量 re-export 一次:

// lib.cjs
const tmp = exports;
tmp.a = 1;
tmp.b = 2;
if (Math.random() > 0.5) {
  tmp.c = 3;
}

// lib-esm-wrapper.mjs
import lib from "./lib.cjs";
export const { a, b, c } = lib;

// main.mjs
import { a, b, c } from "./lib-esm-wrapper.mjs";
console.log(a);
console.log(b);
console.log(c);

所以当用户需要 ESM 模块,而当前只有 CJS 模块时,可以考虑编写一个简单的 ESM Wrapper 进行包装。

4、编写支持多种模块格式的库

有时候我们在编写库的时候,希望我们的库支持 CJS 和 ESM 两种格式,大家可能对 package.json 的 module 字段比较熟悉,它是一个约定俗成的字段,主要用在 Module Bundler 如 Webpack、Rollup 对包是否支持 ESM 的检查,然而 Node 并不会对该字段识别

在 Node 12+ 我们可以使用 package.json 的 exports 字段来为包配置支持不同的模块文件,Node 会根据你使用 import 还是 require 来加载,返回相应的模块文件:

// package.json
{
  "exports": {
   	"import": "./lib.mjs",
    "require": "./lib.cjs" 
  }
}

// app.mjs
import { value } from "lib";
console.log("value from mjs", value);

// app.cjs
const value = require("lib").value;
console.log("value from cjs", value);

参考文章