前端模块化入门基础教程_模块化菜鸟教程网

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

前端模块化入门基础教程

背景

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

前端模块化入门基础教程_模块化菜鸟教程网

CommonJS

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

AMD

// add.js
define(function() {
  const add = (a, b) => a + b
  return add
})
// index.js
require(['./add'], function (add) {
  add(1, 5)
})

ESM

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

ESM 的出现不同于其他的规范,因为这是 JavaScript 官方推出的模块化方案,相比于 CommonJS 和 AMD 方案,ESM采用了完全静态化的方式进行模块的加载。

ESM规范

模块导出

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

export const name = 'Shenfq'

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

export function getName() {
  return name
}
export class Logger {
    log(...args) {
    console.log(...args)
  }
}

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

const name = 'Shenfq'
function getName() {
  return name
}
class Logger {
    log(...args) {
    console.log(...args)
  }
}

export { name, getName, Logger }

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

const name = 'Shenfq'
export default name

模块导入

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

// main.js
import name from './module.js'

// module.js
const name = 'Shenfq'
export default name

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

// main.js
import { name, getName } from './module.js'

// module.js
export const name = 'Shenfq'
export const getName = () => name

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

// main.js
import name, { getName } from './module.js'

//module.js
const name = 'Shenfq'
export const getName = () => name
export default name

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

// main.js
import * as mod from './module.js'
let name = ''
name = mod.name
name = mod.getName()

// module.js
export const name = 'Shenfq'
export const getName = () => name

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

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

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

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

导入同时进行导出

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

前端模块化入门基础教程_模块化菜鸟教程网

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

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

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

上面是 ESM 规范的一些基本语法

ESM 与 CommonJS 的差异

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

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

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

CommonJS

// a.js
const mod = require('./b')

setTimeout(() => {
  console.log(mod)
}, 1000)

// b.js
let mod = 'first value'

setTimeout(() => {
  mod = 'second value'
}, 500)

module.exports = mod
$ node a.js
first value

ESM

// a.mjs
import { mod } from './b.mjs'

setTimeout(() => {
  console.log(mod)
}, 1000)

// b.mjs
export let mod = 'first value'

setTimeout(() => {
  mod = 'second value'
}, 500)
$ node --experimental-modules a.mjs
# (node:99615) ExperimentalWarning: The ESM module loader is experimental.
second value

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

// a.js
(function(exports, require, module, __filename, __dirname) {
    const mod = require('./b')
  setTimeout(() => {
    console.log(mod)
  }, 1000)
});

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

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

加载的原理

步骤:

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

模块记录

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

// Node.js: main.mjs
import Log from './log.mjs'
<!-- chrome、firefox -->
<script type="module" src="./log.js"></script>

值得注意的是,刚开始拿到入口文件,我们并不知道它依赖了哪些模块,所以必须先通过 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 进行模块导入。

<script type="importmap">
{
  "imports": {
      "jQuery": "/node_modules/jquery/dist/jquery.js"
  }
}
</script>
<script type="module">
    import $ from 'jQuery'
  $(function () {
    $('#app').html('init')
  })
</script>

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

// log.js
const log = console.log
export default log

// file.js
export { 
  readFileSync as read,
  writeFileSync as write
} from 'fs'

前端模块化入门基础教程_模块化菜鸟教程网

模块实例

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

前端模块化入门基础教程_模块化菜鸟教程网

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

模块求值

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

ESM的进展

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

客户端

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

前端模块化入门基础教程_模块化菜鸟教程网

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

<script type="module" src="./main.js"></script>

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

前端模块化入门基础教程_模块化菜鸟教程网

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

<script type="module" src="./main.js"></script>
<script nomodule>
  alert('当前浏览器不支持 ESM !!!')
</script>

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

<script type="module" src="./src/main.js"></script>
<script nomodule src="./dist/app.[hash].js"></script>

预加载

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

<link rel="preload" href="./main.js"></link>

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

<link rel="modulepreload" href="./main.js"></link>

现状

虽然主流浏览器都已经支持了 ESM,但是根据 chrome 的统计,有用到 <script type=”module”> 的页面只有 1%。截图时间为 2019/11。

前端模块化入门基础教程_模块化菜鸟教程网

服务端

浏览器能够通过 script 标签指定当前脚本是否作为模块处理,但是在 Node.js 中没有很明确的方式来表示是否需要使用 ESM,而且 Node.js 中本身就已经有了 CommonJS 的标准模块化方案。就算开启了 ESM,又通过何种方式来判断当前入口文件导入的模块到底是使用的 ESM 还是 CommonJS 呢?为了解决上述问题,node 社区开始出现了 ESM 的相关草案,具体可以在 github 上查阅。

2017年发布的 Node.js 8.5.0 开启了 ESM 的实验性支持,在启动程序时,加上 –experimental-modules来开启对 ESM 的支持,并将 .mjs 后缀的文件当做 ESM 来解析。早期的期望是在 Node.js 12 达到 LTS 状态正式发布,然后期望并没有实现,直到最近的 13.2.0 版本才正式支持 ESM,也就是取消了 –experimental-modules 启动参数。具体细节可以查看 Node.js 13.2.0 的官方文档。

关于 .mjs 后缀社区有两种完全不同的态度。支持的一方认为通过文件后缀区分类型是最简单也是最明确的方式,且社区早已有类似案例,例如,.jsx 用于 React 组件、.ts 用于 ts 文件;而支持的一方认为,.js 作为 js 后缀已经存在这么多年,视觉上很难接受一个 .mjs 也是 js 文件,而且现有的很多工具都是以 .js 后缀来识别 js 文件,如果引入了 .mjs 方案,就有大批量的工具需要修改来有效的适配 ESM。

所以除了 .mjs 后缀指定 ESM 外,还可以使用 pkg.json 文件的 type 属性。如果 type 属性为 module,则表示当前模块应使用 ESM 来解析模块,否则使用 CommonJS 解析模块。

{
  "type": "module" // module | commonjs(default)
}

当然有些本地文件是没有 pkg.json 的,但是你又不想使用 .mjs 后缀,这时候只需要在命令行加上一个启动参数 –input-type=module。同时 input-type 也支持 commonjs 参数来指定使用 CommonJS(-—input-type=commonjs)。

总结一下,Node.js 中,以下三种情况会启用 ESM 的模块加载方式:

  1. 文件后缀为.mjs;
  2. pkg.json 中 type 字段指定为 module;
  3. 启动参数添加 –input-type=module。

同样,也有三种情况会启用 CommonJS 的模块加载方式:

  1. 文件后缀为.cjs;
  2. pkg.json 中 type 字段指定为 commonjs;
  3. 启动参数添加 –input-type=commonjs。

虽然 13.2 版本去除了 –experimental-modules 的启动参数,但是按照文档的说法,在 Node.js 中使用 ESM 依旧是实验特性。

不过,相信等到 Node.js 14 LTS 版本发布时,ESM 的支持应该就能进入稳定阶段了,这里还有一个 Node.js 关于 ESM 的整个计划列表可以查阅。

海计划公众号
(0)
上一篇 2020/03/23 08:57
下一篇 2020/03/23 08:57

您可能感兴趣的内容