前端模块化的今生

背景

众所周知,早期 JavaScript 原生并不支持模块化,直到 2015 年,TC39 发布 ES6,其中有一个规范就是 ES modules(为了方便表述,后面统一简称 ESM)。但是在 ES6 规范提出前,就已经存在了一些模块化方案,比如 CommonJS(in Node.js)、AMD。ESM 与这些规范的共同点就是都支持导入(import)和导出(export)语法,只是其行为的关键词也一些差异。

CommonJS

 
 
 
 
  1. // add.js 
  2. const add = (a, b) => a + b 
  3. module.exports = add 
  4. // index.js 
  5. const add = require('./add') 
  6. add(1, 5) 

AMD

 
 
 
 
  1. // add.js 
  2. define(function() { 
  3.   const add = (a, b) => a + b 
  4.   return add 
  5. }) 
  6. // index.js 
  7. require(['./add'], function (add) { 
  8.   add(1, 5) 
  9. }) 

ESM

 
 
 
 
  1. // add.js 
  2. const add = (a, b) => a + b 
  3. export default add 
  4. //index.js 
  5. import add from './add' 
  6. add(1, 5) 

关于 JavaScript 模块化出现的背景在上一章(《前端模块化的前世》)已经有所介绍,这里不再赘述。但是 ESM 的出现不同于其他的规范,因为这是 JavaScript 官方推出的模块化方案,相比于 CommonJS 和 AMD 方案,ESM采用了完全静态化的方式进行模块的加载。

ESM规范

模块导出

模块导出只有一个关键词:export,最简单的方法就是在声明的变量前面直接加上 export 关键词。

 
 
 
 
  1. export const name = 'Shenfq' 

可以在 const、let、var 前直接加上 export,也可以在 function 或者 class 前面直接加上 export。

 
 
 
 
  1. export function getName() { 
  2.   return name 
  3. export class Logger { 
  4.  log(...args) { 
  5.     console.log(...args) 
  6.   } 

上面的导出方法也可以使用大括号的方式进行简写。

 
 
 
 
  1. const name = 'Shenfq' 
  2. function getName() { 
  3.   return name 
  4. class Logger { 
  5.  log(...args) { 
  6.     console.log(...args) 
  7.   } 
  8.  
  9. export { name, getName, Logger } 

最后一种语法,也是我们经常使用的,导出默认模块。

 
 
 
 
  1. const name = 'Shenfq' 
  2. export default name 

模块导入

模块的导入使用import,并配合 from 关键词。

 
 
 
 
  1. // main.js 
  2. import name from './module.js' 
  3.  
  4. // module.js 
  5. const name = 'Shenfq' 
  6. export default name 

这样直接导入的方式,module.js 中必须使用 export default,也就是说 import 语法,默认导入的是default模块。如果想要导入其他模块,就必须使用对象展开的语法。

 
 
 
 
  1. // main.js 
  2. import { name, getName } from './module.js' 
  3.  
  4. // module.js 
  5. export const name = 'Shenfq' 
  6. export const getName = () => name 

如果模块文件同时导出了默认模块,和其他模块,在导入时,也可以同时将两者导入。

 
 
 
 
  1. // main.js 
  2. import name, { getName } from './module.js' 
  3.  
  4. //module.js 
  5. const name = 'Shenfq' 
  6. export const getName = () => name 
  7. export default name 

当然,ESM 也提供了重命名的语法,将导入的模块进行重新命名。

 
 
 
 
  1. // main.js 
  2. import * as mod from './module.js' 
  3. let name = '' 
  4. name = mod.name 
  5. name = mod.getName() 
  6.  
  7. // module.js 
  8. export const name = 'Shenfq' 
  9. export const getName = () => name 

上述写法就相当于于将模块导出的对象进行重新赋值:

 
 
 
 
  1. // main.js 
  2. import { name, getName } from './module.js' 
  3. const mod = { name, getName } 

同时也可以对单独的变量进行重命名:

 
 
 
 
  1. // main.js 
  2. import { name, getName as getModName } 

导入同时进行导出

如果有两个模块 a 和 b ,同时引入了模块 c,但是这两个模块还需要导入模块 d,如果模块 a、b 在导入 c 之后,再导入 d 也是可以的,但是有些繁琐,我们可以直接在模块 c 里面导入模块 d,再把模块 d 暴露出去。

模块关系

 
 
 
 
  1. // module_c.js 
  2. import { name, getName } from './module_d.js' 
  3. export { name, getName } 

这么写看起来还是有些麻烦,这里 ESM 提供了一种将 import 和 export 进行结合的语法。

 
 
 
 
  1. export { name, getName } from './module_d.js' 

上面是 ESM 规范的一些基本语法,如果想了解更多,可以翻阅阮老师的 《ES6 入门》。

ESM 与 CommonJS 的差异

首先肯定是语法上的差异,前面也已经简单介绍过了,一个使用 import/export 语法,一个使用 require/module 语法。

另一个 ESM 与 CommonJS 显著的差异在于,ESM 导入模块的变量都是强绑定,导出模块的变量一旦发生变化,对应导入模块的变量也会跟随变化,而 CommonJS 中导入的模块都是值传递与引用传递,类似于函数传参(基本类型进行值传递,相当于拷贝变量,非基础类型【对象、数组】,进行引用传递)。

下面我们看下详细的案例:

CommonJS

 
 
 
 
  1. // a.js 
  2. const mod = require('./b') 
  3.  
  4. setTimeout(() => { 
  5.   console.log(mod) 
  6. }, 1000) 
  7.  
  8. // b.js 
  9. let mod = 'first value' 
  10.  
  11. setTimeout(() => { 
  12.   mod = 'second value' 
  13. }, 500) 
  14.  
  15. module.exports = mod 
  16. $ node a.js 
  17. first value 

ESM

 
 
 
 
  1. // a.mjs 
  2. import { mod } from './b.mjs' 
  3.  
  4. setTimeout(() => { 
  5.   console.log(mod) 
  6. }, 1000) 
  7.  
  8. // b.mjs 
  9. export let mod = 'first value' 
  10.  
  11. setTimeout(() => { 
  12.   mod = 'second value' 
  13. }, 500) 
  14. $ node --experimental-modules a.mjs 
  15. # (node:99615) ExperimentalWarning: The ESM module loader is experimental. 
  16. second value 

另外,CommonJS 的模块实现,实际是给每个模块文件做了一层函数包裹,从而使得每个模块获取 require/module、__filename/__dirname 变量。那上面的 a.js 来举例,实际执行过程中 a.js 运行代码如下:

 
 
 
 
  1. // a.js 
  2. (function(exports, require, module, __filename, __dirname) { 
  3.  const mod = require('./b') 
  4.   setTimeout(() => { 
  5.     console.log(mod) 
  6.   }, 1000) 
  7. }); 

而 ESM 的模块是通过 import/export 关键词来实现,没有对应的函数包裹,所以在 ESM 模块中,需要使用 import.meta 变量来获取 __filename/__dirname。import.meta 是 ECMAScript 实现的一个包含模块元数据的特定对象,主要用于存放模块的 url,而 node 中只支持加载本地模块,所以 url 都是使用 file: 协议。

 
 
 
 
  1. import url from 'url' 
  2. import path from 'path' 
  3. // import.meta: { url: file:///Users/dev/mjs/a.mjs } 
  4. const __filename = url.fileURLToPath(import.meta.url) 
  5. const __dirname = path.dirname(__filename) 

加载的原理

步骤:

  1. Construction(构造):下载所有的文件并且解析为module records。
  2. Instantiation(实例):把所有导出的变量入内存指定位置(但是暂时还不求值)。然后,让导出和导入都指向内存指定位置。这叫做『linking(链接)』。
  3. Evaluation(求值):执行代码,得到变量的值然后放到内存对应位置。

模块记录

所有的模块化开发,都是从一个入口文件开始,无论是 Node.js 还是浏览器,都会根据这个入口文件进行检索,一步一步找到其他所有的依赖文件。

 
 
 
 
  1. // Node.js: main.mjs 
  2. import Log from './log.mjs' 
  3.  
  4.  

值得注意的是,刚开始拿到入口文件,我们并不知道它依赖了哪些模块,所以必须先通过 js 引擎静态分析,得到一个模块记录,该记录包含了该文件的依赖项。所以,一开始拿到的 js 文件并不会执行,只是会将文件转换得到一个模块记录(module records)。所有的 import 模块都在模块记录的 importEntries 字段中记录,更多模块记录相关的字段可以查阅tc39.es。

模块记录

模块构造

得到模块记录后,会下载所有依赖,并再次将依赖文件转换为模块记录,一直持续到没有依赖文件为止,这个过程被称为『构造』(construction)。

模块构造包括如下三个步骤:

  1. 模块识别(解析依赖模块 url,找到真实的下载路径);
  2. 文件下载(从指定的 url 进行下载,或从文件系统进行加载);
  3. 转化为模块记录(module records)。

对于如何将模块文件转化为模块记录,ESM 规范有详细的说明,但是在构造这个步骤中,要怎么下载得到这些依赖的模块文件,在 ESM 规范中并没有对应的说明。因为如何下载文件,在服务端和客户端都有不同的实现规范。比如,在浏览器中,如何下载文件是属于 HTML 规范(浏览器的模块加载都是使用的 script 标签)。

虽然下载完全不属于 ESM 的现有规范,但在 import 语句中还有一个引用模块的 url 地址,关于这个地址需要如何转化,在 Node 和浏览器之间有会出现一些差异。简单来说,在 Node 中可以直接 import 在 node_modules 中的模块,而在浏览器中并不能直接这么做,因为浏览器无法正确的找到服务器上的 node_modules 目录在哪里。好在有一个叫做 import-maps 的提案,该提案主要就是用来解决浏览器无法直接导入模块标识符的问题。但是,在该提案未被完全实现之前,浏览器中依然只能使用 url 进行模块导入。

 
 
 
 
  1.  
  2.   "imports": { 
  3.    "jQuery": "/node_modules/jquery/dist/jquery.js" 
  4.   } 
  5.  
  6.  
  7.  import $ from 'jQuery' 
  8.   $(function () { 
  9.     $('#app').html('init') 
  10.   }) 
  11.  

下载好的模块,都会被转化为模块记录然后缓存到 module map 中,遇到不同文件获取的相同依赖,都会直接在 module map 缓存中获取。

 
 
 
 
  1. // log.js 
  2. const log = console.log 
  3. export default log 
  4.  
  5. // file.js 
  6. export {  
  7.   readFileSync as read, 
  8.   writeFileSync as write 
  9. } from 'fs' 

模块实例

获取到所有依赖文件并建立好 module map 后,就会找到所有模块记录,并取出其中的所有导出的变量,然后,将所有变量一一对应到内存中,将对应关系存储到『模块环境记录』(module environment record)中。当然当前内存中的变量并没有值,只是初始化了对应关系。初始化导出变量和内存的对应关系后,紧接着会设置模块导入和内存的对应关系,确保相同变量的导入和导出都指向了同一个内存区域,并保证所有的导入都能找到对应的导出。

模块连接

由于导入和导出指向同一内存区域,所以导出值一旦发生变化,导入值也会变化,不同于 CommonJS,CommonJS 的所有值都是基于拷贝的。连接到导入导出变量后,我们就需要将对应的值放入到内存中,下面就要进入到求值的步骤了。

模块求值

求值步骤相对简单,只要运行代码把计算出来的值填入之前记录的内存地址就可以了。到这里就已经能够愉快的使用 ESM 模块化了。

ESM的进展

因为 ESM 出现较晚,服务端已有 CommonJS 方案,客户端又有 webpack 打包工具,所以 ESM 的推广不得不说还是十分艰难的。

客户端

我们先看看客户端的支持情况,这里推荐大家到 Can I Use 直接查看,下图是 2019/11的截图。

Can I use

目前为止,主流浏览器都已经支持 ESM 了,只需在 script 标签传入指定的 type="module" 即可。

 
 
 
 
  1.  

另外,我们知道在 Node.js 中,要使用 ESM 有时候需要用到 .mjs 后缀,但是浏览器并不关心文件后缀,只需要 http 响应头的MIME类型正确即可(Content-Type: text/javascript)。同时,当 type="module" 时,默认启用 defer 来加载脚本。这里补充一张 defer、async 差异图。

img

我们知道浏览器不支持 script 的时候,提供了 noscript 标签用于降级处理,模块化也提供了类似的标签。

 
 
 
 
  1.  
  2.  
  3.   alert('当前浏览器不支持 ESM !!!') 
  4.  

这样我们就能针对支持 ESM 的浏览器直接使用模块化方案加载文件,不支持的浏览器还是使用 webpack 打包的版本。

 
 
 
 
  1.  
  2.  

预加载

我们知道浏览器的 link 标签可以用作资源的预加载,比如我需要预先加载 main.js 文件:

 
 
 
 
  1.  

如果这个 main.js 文件是一个模块化文件,浏览器仅仅预先加载单独这一个文件是没有意义的,前面我们也说过,一个模块化文件下载后还需要转化得到模块记录,进行模块实例、模块求值这些操作,所以我们得想办法告诉浏览器,这个文件是一个模块化的文件,所以浏览器提供了一种新的 rel 类型,专门用于模块化文件的预加载。

 
 
 
 
  1.  

现状

虽然主流浏览器都已经支持了 ESM,但是根据 chrome 的统计,有用到