← 返回首页

SIGNAL · POST

前端模块化之ESModule

约 8 分钟阅读 bajiu 前端

如何在浏览器和 Node.js 之中加载 ES6 模块,以及实际开发中经常遇到的一些问题(比如循环加载)

加载规则

浏览器加载 ES6 模块,也使用 <script> 标签,但是要加入 type="module" 属性

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

由于 type 属性设为 module ,所以浏览器知道这是一个 ES6 模块

浏览器对于带有 type="module"<script> ,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了 <script> 标签的defer属性

<script type="module" src="./foo.js"></script>
<!-- 等同于 -->
<script type="module" src="./foo.js" defer></script>

如果网页有多个<script type="module">, 它们会按照在页面出现的顺序依次执行

<script> 标签的 async 属性也可以打开, 这时只要加载完成, 渲染引擎就会中断渲染立即执行. 执行完成后, 再恢复渲染

<script type="module" src="./foo.js" async></script>
  • 对于外部的模块脚本(上例是foo.js),有几点需要注意:
    代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。
  • 模块脚本自动采用严格模式,不管有没有声明use strict
  • 模块之中, 可以使用 import 命令加载其他模块 (.js后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用 export 命令输出对外接口
  • 模块之中,顶层的 this 关键字返回 undefined, 而不是指向 window. 也就是说, 在模块顶层使用this关键字,是无意义的
  • 同一个模块如果加载多次,将只执行一次

ES6 模块与 CommonJS 模块的差异

讨论 Node.js 加载 ES6 模块之前, 必须了解 ES6 模块与 CommonJS 模块完全不同

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口
  • CommonJS 模块的 require() 是同步加载模块,ES6 模块的 import 命令是异步加载,有一个独立的模块依赖的解析阶段。

第二个差异是因为 CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

CommonJS 模块输出的是值的拷贝, 也就是说, 一旦输出一个值, 模块内部的变化就影响不到这个值.

ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令 import, 就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import 有点像 Unix 系统的 **”符号连接”**,原始值变了, import加载的值也会跟着变. 因此, ES6 模块是动态引用, 并且不会缓存值, 模块里面的变量绑定其所在的模块

Node.js 的模块加载方法

概述

JavaScript 现在有两种模块。一种是 ES6 模块,简称 ESM;另一种是 CommonJS 模块,简称 CJS。

CommonJS 模块是 Node.js 专用的,与 ES6 模块不兼容。语法上面,两者最明显的差异是, CommonJS 模块使用require()module.exports, ES6 模块使用 importexport

它们采用不同的加载方案。从 Node.js v13.2 版本开始,Node.js 已经默认打开了 ES6 模块支持。

Node.js 要求 ES6 模块采用 .mjs 后缀文件名。也就是说,只要脚本文件里面使用 import 或者 export 命令,那么就必须采用 .mjs 后缀名. Node.js 遇到 .mjs 文件,就认为它是 ES6 模块,默认启用严格模式,不必在每个模块文件顶部指定**”use strict”**

如果不希望将后缀名改成 .mjs, 可以在项目的package.json 文件中, 指定 type 字段为 module

{
   "type": "module"
}

一旦设置了以后, 该目录里面的 JS 脚本, 就被解释用 ES6 模块

# 解释成 ES6 模块
$ node my-app.js

如果这时还要使用 CommonJS 模块, 那么需要将 CommonJS 脚本的后缀名都改成.cjs. 如果没有type字段, 或者type字段为commonjs, 则.js脚本会被解释成 CommonJS 模块

总结为一句话: .mjs文件总是以 ES6 模块加载, .cjs 文件总是以 CommonJS 模块加载, .js文件的加载取决于 package.json 里面 type 字段的设置

package.json 的 main 字段

package.json 文件有两个字段可以指定模块的入口文件: mainexports. 比较简单的模块, 可以只使用 main 字段, 指定模块加载的入口文件

// ./node_modules/es-module-package/package.json
{
  "type": "module",
  "main": "./src/index.js"
}

上面代码指定项目的入口脚本为./src/index.js, 它的格式为 ES6 模块。如果没有 type 字段, index.js就会被解释为 CommonJS 模块。

然后, import s命令就可以加载这个模块。

package.json 的 exports 字段

exports 字段的优先级高于 main 字段. 它有多种用法

(1) 子目录别名

package.json 文件的 exports 字段可以指定脚本或子目录的别名

// ./node_modules/es-module-package/package.json
{
  "exports": {
    "./submodule": "./src/submodule.js"
  }
}

上面的代码指定 src/submodule.js 别名为submodule ,然后就可以从别名加载这个文件.

import submodule from 'es-module-package/submodule';
// 加载 ./node_modules/es-module-package/src/submodule.js

CommonJS 模块加载 ES6 模块

CommonJS 的 require() 命令不能加载 ES6 模块, 会报错, 只能使用 import() 这个方法加载

(async () => {
  await import('./my-app.mjs');
})();

上面代码可以在 CommonJS 模块中运行

require() 不支持 ES6 模块的一个原因是,它是同步加载,而 ES6 模块内部可以使用顶层 await 命令,导致无法被同步加载

ES6 模块加载 CommonJS 模块

ES6 模块的 import 命令可以加载 CommonJS 模块, 但是只能整体加载, 不能只加载单一的输出项

// 正确
import packageMain from 'commonjs-package';

// 报错
import { method } from 'commonjs-package';

这是因为 ES6 模块需要支持静态代码分析,而 CommonJS 模块的输出接口是 module.exports, 是一个对象,无法被静态分析,所以只能整体加载。

循环加载

“循环加载” (circular dependency)指的是, a脚本的执行依赖b脚本, 而b脚本的执行又依赖a脚本

通常,“循环加载”表示存在强耦合,如果处理不好,还可能导致递归加载,使得程序无法执行,因此应该避免出现