标准解决的问题

在标准出现前,无论我们是以哪种方式实现的模块化,它最后总是由开发通过使用<script> 标签将不同模块的代码手动加入 HTML 文件中。当后续需求出现变更时,又要由开发者对 <script> 标签进行手动的增删改——这种开发体验毫无疑问是灾难性的。而没有标准产生的另一个问题是:开发者总是在重复的造轮子。由于不同开发者、不同项目之间的风格与内部实现上的差异,各类模块的复用性并不如想得这么好。

对于开发者而言,一个体验更好的图景应该是这样的:首先,所有的模块引入都可以通过 JavaScript 直接控制,不需要自行去维护 <script> 标签。其次,模块的代码可以按需进行加载,最后背后还附带着一堆开箱即用的生态。为了实现这一点,各类模块化的标准就应运而生了,并且不论是哪一种标准,它总是离不开两个要素:

  1. 一个可以自动加载模块的基础库。
  2. 一个与内部实现无关的模块化规范。

CommonJS

CommonJS 是为了服务于 Node.js 等服务端而产生的规范,其主要的实践者也是 Node.js ,它无疑是第一个影响力深远的模块化规范。对于 Node.js 来说,这一规范的深入依旧是逃不开的。在规范中,它规定每一个文件是一个模块,除非是以 global 定义的变量,或是以 exports 暴露的属性,否则其内的一切都是私有的。

用法

  1. // 导出
  2. const foo = () => alert('foo')
  3. module.exports.foo = foo;
  4. // 引用
  5. const foo = require('./foo.js');
  6. foo.foo()

加载模式与机制

CommonJS 规范中所有的模块加载都是同步的,也可以说是运行时才加载的,任何后续的操作都必须等待其加载结束后再进行。它会生成一个基于输出值的拷贝的对象,也就是说,在当前的值进行了 exports 之后,对该值的后续改变并不会影响到外部。而在当多次加载同一模块时,模块的内部代码并不会被多次执行,而会直接使用第一次运行结果的缓存。

由于它的模块是以同步的方式进行加载的, CommonJs 在浏览器环境中具有先天上的不足。不同于服务端直接在硬盘中读写,由于浏览器环境天生的异步性, CommonJS 并不能在浏览器环境中运行,这也就引出了对 AMD 等规范的需求。

AMD

AMD( Asynchronous Module Definition, 异步模块定义)规范是为了能够在浏览器环境中使用模块化而产生的规范,它所对应的模块加载器是 Require.js 。在规范中,模块的加载并不会影响其之后的代码执行,它是通过在模块中使用回调函数来处理的。该规范仍有至今仍有大量的第三方库使用该规范,这些某MD规范的真正原理在于通过 define 函数来创建出一个可以支持 exports / require 的运行环境。

用法

  1. <!-- 通过 data-main 属性指定入口文件 -->
  2. <script src="./require.js" data-main="./main.js"></script>
// 声明一个 foo 模块,该模块依赖于 A 依赖
define('foo', ['dependanceA'], function(dependanceA) {
    const data = dependanceA.method();
  // 通过return暴露外部需要的内容
  return { data }
})

// 引入a模块
require(['foo'],function(foo){
  console.log(foo.data)
})

可以看出,在依赖定义时, AMD 规范就要求对可能用到的依赖进行声明,而这些依赖又作为回调函数的参数被使用,这种写法被称为依赖前置。

加载模式与机制

AMD 规范中,依赖的模块总是异步加载的,并且默认是提前执行的。尽管在2.0版本中可以通过 require.async 进行延迟加载,但并不是官方推荐的写法。除此之外,由于 AMD 规范本身不推崇懒加载的做法,这就有可能出现循环依赖的情况——A依赖于B,B又依赖于A。面对这种情况,一种不太优雅的做法是破坏这种依赖前置的写法,使得整体变为一个活环,而规范本身对此的做法则是:当A依赖B时,执行B,而B此时的A处于未定义状态,因此,B最后总是被最先执行完。这种处理本身只是一种强制忽略的权宜之计。

CMD

CMD (Common Module Definition,通用模块定义)是由阿里主导的模块化规范,它所对应的模块加载器是 Sea.js 。该规范在当下的影响力已经微乎其微,且 Sea.js 中的写法已经为 Require.js 所兼容,两者在真正应用上的区别非常小,因此专门深入掌握的意义不大。

注意要点

CMD 规范中推崇依赖就近的写法,只有在用到某个模块的时候才去加载这个模块,但这并不意味着它的加载是同步的,其与 AMD 规范的差别并不是加载机制上有不同,而是加载完模块后执行时机的不同。如前所述,在 AMD 中,模块由于依赖前置,其被执行的顺序并不一定与代码书写的顺序一致,而只是与哪个依赖先行下载完有关。而在 CMD 中,模块只是被加载了,只有等到当真正碰到 require 语句时, Sea.js 才会执行下载好了的模块。因此可以说, CMD 规范中的推荐写法相对于 AMD 规范会有一定的性能优势。

ES Module

ES Module (ESM) 是在 ECMAScript 2015 (ES6) 中提出的 JavaScript 官方模块化标准。由于这种支持是在语言层面的,因此其相较于 AMD 等社区提出的规范必然相对更加完备。而根据 Node.js 的提案,服务端在未来也将转向支持 ESM 。在浏览器中原生支持该规范产生了两个好处:

  1. 它可以被浏览器最优化加载。
  2. 它会远比使用库方便(不需要额外处理)。

用法

// 导出
export const data = 'foo'

// 使用
import { data } from './foo.js'
// 也可以使用内联脚本声明,只有在顶级的模块脚本中才可以使用 import/export 语句。
<script type="module" src="foo.mjs"></script>

值得注意的是,在 V8 推崇的写法里,为了区别模块与普通的文件,模块文件应当以 .mjs 的文件后缀结尾。但由于该文件后缀在 Content-Type 未设置的环境下会出现错误,因此实际规范中依旧建议以 .js 作为文件的后缀。另一个实际应用与规范不同的地方在于是否要写文件后缀。在实际开发中由于存在 WebpackBabel ,并不一定要写文件后缀,但没有文件后缀实际上无法在原生的模块系统中运行。

除此以外, ESM 还包含以下需要注意的点:

  1. 模块的脚本默认是严格模式的。
  2. 加载模块的 <script> 标签不需要使用 defer 属性。
  3. 模块功能导入到单独的脚本文件的范围,它们无法在全局中获得。

加载模式与机制

ESM 的语法是静态的,所有的 import 都会被提升至顶层。静态的语法意味着可以在编译时确定导入和导出,可以进行依赖检查以及类型检查。此外, ESM 在加载后提供是一个 Sysmbol 引用,因此对模块内部对象的修改会间接的影响到其它使用该模块的代码,这一点与所有原先的社区规范都不同。

此外,基于 Promise 的机制, ESM 也可以通过 import() 的方式实现动态的模块加载。该机制的出现版本为ECMAScript 2020(ES10):

fooBtn.addEventListener('click', () => {
  import('/foo.mjs').then((Module) => {
       Module.method()
  })
});

这一方式被称为创建模块对象(Creating a module object),它在使用上与规范出现前模块统一暴露一个全局变量的相当类似的,但这种懒加载却可以用来处理一定要有却不见得常见的功能加载。

各标准的差异总结

特性\标准 CommonJS AMD UMD CMD ES Module
浏览器使用
服务端使用
异步加载 ✅允许 ✅允许
传递方式 Object Object Object Object Sysmbol

尽管在标准出现之后,前端模块化已经相当有声有色,然而不论是以何种规范进行的模块化开发,在当下的开发环境中仍旧有一些无法避开的问题:

  • ESM 来说,用户当前的浏览器版本无法确保其在生产环境下畅通无阻。
  • 拆碎的模块会创造出频繁的网络请求,影响应用的整体性能。
  • 也许并不是只有 JavaScript 文件才需要模块化, CSS 与其它静态文件也需要对应的模块化。