4.2 模块规范大杂烩

本节将对几种常见的JavaScript模块化方案进行简单的对比和讨论。

4.2.1 概述

初级开发者在理解非标准的前端模块化时之所以会感到非常吃力,并不是因为模块化规范本身有多复杂,所谓规范不过是一种约定,对如何定义模块、如何加载模块和如何管理模块的一种约定,即使你的开发经验还不足以理解规范中的每一条要求,仅仅遵从规范要求的格式来编写代码也并不难做到,况且模块化规范中定义的API非常少。事实上,前端模块化的困难在于规范和实现的分离所带来的一系列工程层面的兼容性问题。

我们先来直观感受一下几种主流的模块化管理方案对应的代码:

// 在AMD规范下引用模块
require(['axios'],function(axios){})
// 在CMD规范下引用模块
define(function(require){
   const axios = require('axios');
})
// 在CommonJS规范下引用模块
const axios = require('axios');
// 在ES Module规范下引用模块
import axios from faxios'x

我们在谈及AMD、CMD和UMD这几种模块定义规范时,事实上都只是在描述工程实践层面的约定,浏览器并没有对它们进行原生支持,也就是说,当你把一个AMD模块或CMD模块直接引入浏览器环境时,浏览器就会报错(UMD模块因为可以兼容无模块化的工程,所以不会报错),因此你需要事先引入一个实现了某种模块化规范的库(AMD标准使用Require.js,CMD标准使用Sea.js),之后所引入的模块才能够被识别,相当于在运行时预制了模块化管理的代码,它并不受运行环境原生支持。这里有必要提一下UMD模块化规范,它并不是一种具体的规范,而是一种代码模式,遵循UMD规范的模块在加载时,会根据适用的API来推断当前工程所遵循的模块化规范,并以恰当的方式把封装在模块中的内容提供给引用者。

提起CommonJS规范(它是一个模块化规范,并不是外部类库),就不得不提起大名鼎鼎的Node.js,它是一个JavaScript语言的服务端运行环境,Node.js对CommonJS模块化规范提供了原生支持,这就意味着使用JavaScript进行服务端开发时,不需要借助任何外部类库,就可以实现模块化管理。遗憾的是,要想让浏览器识别CommonJS模块,通常还需要依赖于构建工具注入的模块加载器代码来实现。

随着ES6标准的出现,JavaScript终于有了自己的模块化规范——ES Module规范,这就意味着将来无论是在浏览器端还是在服务端进行开发,都可以遵从同样的模块化规范,然而这仅仅是一件看起来很美好的事情。随着前端自动化工具的日渐成熟,“构建”逐渐成为前端开发工作流中的标配,我们可以在源代码中编写符合ES Module规范的模块管理语法,或者是一些还未被正式发布的规范(例如懒加载语法的规范),甚至是自创的语句(例如TypeScript中独有的“import...require”模块引用语句),新的语法通常会更精简,然后通过各种构建工具编译来得到符合生产环境需求或是能够兼容指定浏览器版本的软件包。自动化工具带来的便利是可想而知的,随着浏览器支持度的升级,只需要在构建工具中调整一些参数,就可以直接从源代码中编译出符合新需求的生产环境代码,而不必担心由此引发的重构负担,等到ES Module的运行时方案稳定后,很容易实现技术方案的迁移。

4.2.2 几个重要的差异

1. AMD规范和CMD规范

首先,强烈建议不要使用网络上流传的“异步”和“同步”的概念来记忆或是理解这两种规范,这只会带来更多的困扰。这两种规范使用的API非常相似,只是推崇的书写风格有差异而已。AMD规范推崇模块依赖前置,也就是定义模块时,需要在依赖列表中列举出该模块依赖的其他外部模块。当模块被加载时,加载器会先确保所依赖的模块已被下载和执行,然后在执行当前模块时将这些解析后的模块注入进来(表现上就是当前模块执行时可以通过形参来访问注入的模块)。CMD规范推崇在代码中就近编写依赖,它通过参数注入的方式为开发者提供了一个加载方法require,开发者可以用它来引用其他模块,所实现的效果就是,被依赖的模块只有在被需要时才会去解析和执行。这里需要注意的是,无论是在何时执行所依赖模块的代码,依赖的模块文件都需要提前下载到本地,不同的只是执行的时机,这种差异在大多数运行场景中带来的差别几乎微乎其微,因为模块解析的耗时其实非常少。对比各种开发语言的包管理规范,显然开发者更容易接受AMD规范所提倡的前置依赖声明方式。另一方面,规范是比较抽象和严谨的,但代码实现上却可以相对灵活,例如,早期使用Require.js进行模块化管理时,既可以采用依赖前置的写法,也可以采用依赖就近的写法。现代化前端开发中几乎已经很少提及这两种模块化规范了,对开发者而言只需要稍作了解即可。

2. 懒执行和懒加载

当代码中需要引用一个体积较大的外部依赖时,无论是采用AMD规范还是CMD规范的方式来书写,对应模块的下载都是提前进行的,区别只是解析这个模块的时机,CMD的机制通常称为“懒执行”,但由于模块解析相较于网络请求而言耗时非常短,因此这样的设计并没有表现出显著的差异。在另一种场景中,我们更希望代码首次加载时能够先忽略某个体积较大的库,等用户真正进入某个页面时再下载这个依赖文件,也就是开发者常说的“懒加载”技术,有时也称为“分包加载”。“分包加载”是一种宏观的异步行为,它不像AMD或CMD规范中要求的那样需要提前下载依赖,然后按需解析,而是当代码执行到需要外部依赖的时候才会下载该依赖文件,这种处理方式在组件化开发的性能优化中很常见,因为它可以有效减小首屏依赖代码的体积。

3. CommonJS和ES Module

CommonJS规范是Node.js原生支持的模块化管理方案,这个规范并不是JavaScript官方提出的标准,所以浏览器并没有对它提供支持,在JavaScript语言有了自己的模块系统标准后,Node.js势必会跟进并实现这个标准。在CommonJS规范下,既支持具名模块导出,也支持默认模块导出:

// 具名模块导出
exports.a = 1;
// 默认模块导出
module.exports = {
    b:2
}

但开发者不能同时使用这两种导出方式,因为exports和module.exports会指向内存中的同一个地址,且最终导出的模块会以module.exports为准,例如上面示例代码中的书写方式在模块导出后,a属性及其对应的值将会丢失。CommonJS中加载模块使用的是require关键字,它是同步执行的,并且只能全量加载模块的导出。尽管开发者可以像下面这样用类似于ES Module中引用具名模块的语法来编写代码,但实际上它只是将require引用语句和解构赋值语句联合在一起简写罢了,b模块中导出的其他未被使用的模块实际上也会被解析和加载。

const { b } = require('b');

下面再来看看ES Module规范,它同样支持具名模块导出和默认模块导出这两种形式,这两种模块的导出方式可以共存但不能混用,在使用import关键字引用模块时使用的语法也不同,示例如下:

// 具名模块导出
export { a }
// 默认模块导出
export default b;
// 引用具名模块
import { a } from 'a';
// 引用默认模块
import c from 'a';
// 在浏览器中引用ES Module模块
<script src="……" type="module"></script>

默认模块的具体名称由引用者自己提供。这样的语法看起来与CommonJS类似,但其运作机制却存在着非常大的差异。图4-1和图4-2所示的两段代码分别遵循CommonJS规范和ES Module规范,可以看到,代码运行后两者的表现结果完全不同。

058-1

图4-1 遵循CommonJS规范的模块导出导入结果

058-2

图4-2 遵循ES Module规范的模块导出导入结果

遵循CommonJS规范的b.js文件,虽然没有导出具名模块A,但这并不影响其他代码的执行顺序,b.js文件中的输出内容出现在“before require”之后,这意味着a.js中的代码执行到require这一行时才运行b.js中的代码。再来看看遵循ES Module规范时模块引用的表现,当a.mjs需要从b.mjs中加载具名模块A时,代码还没有执行就先报错了,这说明错误抛出是在代码运行之前发生的(否则控制台会先输出“before import”,然后再报错),而且ES Module规范中导入的具名模块只能从导出的具名模块中获取,并不会从默认模块中获取,b.mjs文件中仅有一个默认导出,所以a.mjs文件在静态分析阶段就检查到依赖关系异常从而抛出了错误。读者可以尝试在b.mjs中导出一个具名模块A并输出一些信息,再运行程序时就可以看到控制台能够正常打印信息了,不过b.mjs输出的内容会在a.mjs输出的“before import”之前,这说明b.mjs文件是在a.mjs之前运行的。另一个显著的区别是,在CommonJS规范中,指向模块名的标识符是可以被重新赋值的,而在ES Module中是不允许这样做的。

前面说过,CommonJS中的require函数是同步执行的,它将根据Node.js原生提供的寻址策略来寻找模块的定义文件,找到后就会立即执行,require函数可以在代码中的任何地方调用,引用到某个模块时才会去执行相关的代码,这就意味着想要知道一个模块对外到底会导出哪些内容,需要等到运行时才行。而在ES Module规范中,import和export语句只能在顶层作用域中使用,加载器并不会直接运行脚本,它会先对代码进行静态类型检查,构建出完整的“依赖图谱”,获取并解析这些模块,然后才会从“依赖图”的末端开始执行模块代码,具名模块和默认模块互不干扰。ES Module规范[1]规定了将文件转换为模块记录(Parse)、进行实例化(Instantiate),以及对模块进行求值(evaluate)的过程,但它并没有规定在此之前应该如何获取模块定义文件。其对于文件的获取方式依赖于加载器的实现,在浏览器环境中它是依赖于HTML标准[2]的,而浏览器则需要按照ES Module规范中要求的ParseModule、Module.Instantiate和Module.Evaluate方法来实现加载逻辑,以便控制JavaScript引擎加载模块的过程。为了避免对主线程造成阻塞,加载器会先完成模块的远程下载和ParseModule部分,以便构建出模块的依赖关系图谱,等到所有的依赖模块都下载至本地并完成Parse环节后,再执行后续的步骤。

ES Module规范以及Node.js对新特性的支持都是在不断发展变化的,例如ES Module规范最初并不支持动态的模块路径标识,而在CommonJS规范下却可以像使用普通函数一样为require函数传入动态路径,示例如下:

// 在CommonJS规范下可以使用包含变量的拼接路径来加载模块
const mPath = `module-${lang}`;
const submodule = require(mPath);
// ES Module规范最初并不支持包含变量的路径
import submodule from `module-${lang}`;  // 这样的动态路径最初是不符合规范的

因为在ES Module规范中,代码在依赖分析阶段并未运行,变量也还没有被赋值,所以无法使用动态路径来寻找模块,这个特性在很长一段时间内也被用于面试题中,但在本书写作时,“动态加载”[3]已经处于ECMA标准提案的第4阶段,这表示它很快会被纳入正式的ECMA语言标准。

更多地关注技术背后的原理对我们的成长有很大帮助,比如,对于另一个与模块化规范高度相关的性能优化技术——“tree-shaking”(摇树优化),或许你听说过想要让它生效,就需要使用ES Module规范中的语法来管理模块而不能使用CommonJS规范中的语法;或许你也知道背后的原因与ES Module静态依赖分析的处理机制有关,那么当ES Module规范支持“动态导入”的特性后,依赖关系在静态分析阶段就会变得不再确定了,这时,“tree-shaking”的机制还能正常工作吗?如果不能,我们要如何在工程化方案中对其进行改进呢?顺着这条脉络探寻背后的原理,相信你会对许多前端工程化的方案有更深入的理解。对ES Module模块化规范感兴趣的读者可以自行阅读《通过动画深入理解ES Modules》[4]一文,里面非常详细地描述了加载器对于ES Module模块的处理机制,本节就不再赘述了。


[1]https://tc39.es/ecma262/#sec-modules,见EcmaScript规范中与Module相关的描述章节。

[2]https://html.spec.whatwg.org/#fetch-a-module-script-tree,见HTML标准中关于如何获取模块脚本的章节。

[3]https://github.com/tc39/proposal-dynamic-import,关于在ES Module模块化规范中实现动态加载的提案。

[4]https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/