带你了解前端模块化的今生

背景

成都创新互联公司是一家朝气蓬勃的网站建设公司。公司专注于为企业提供信息化建设解决方案。从事网站开发,网站制作,网站设计,网站模板,微信公众号开发,软件开发,微信平台小程序开发,10余年建站对成都履带搅拌车等多个行业,拥有多年的网站运维经验。

众所周知,早期 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. }  
  4. export class Logger {  
  5.     log(...args) {  
  6.     console.log(...args)  
  7.   }  

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

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

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

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

模块导入

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

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

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

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

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

 
 
 
 
  1. // main.js  
  2. import name, { getName } from './module.js'  
  3. //module.js  
  4. const name = 'Shenfq'  
  5. export const getName = () => name  
  6. 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. // module.js  
  7. export const name = 'Shenfq'  
  8. 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. setTimeout(() => {  
  4.   console.log(mod)  
  5. }, 1000)  
  6. // b.js  
  7. let mod = 'first value'  
  8. setTimeout(() => {  
  9.   mod = 'second value'  
  10. }, 500)  
  11. modmodule.exports = mod  
 
 
 
 
  1. $ node a.js  
  2. first value 

ESM 

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

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

模块构造

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

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

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

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

  •   
  •     import $ from 'jQuery'  
  •   $(function () {  
  •     $('#app').html('init')  
  •   })  
  •  
  • 下载好的模块,都会被转化为模块记录然后缓存到 module map 中,遇到不同文件获取的相同依赖,都会直接在 module map 缓存中获取。 

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

    模块实例

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

    模块连接

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

    模块求值

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

    ESM的进展

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

    客户端

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

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

     
     
     
     
    1.  

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

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

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

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

     
     
     
     
    1.   
    2.  

    预加载

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

     
     
     
     
    1.  

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

     
     
     
     
    1.  

    现状

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