EcmaScript modules 用法及原理

何谓模块化

所谓模块化,就是指将我们编写的程序分割为一个个小的单独模块,每个模块都有自己独立的作用域,有着自己的逻辑代码,而不会影响其它的模块。一个模块可以将自己想暴露给其它模块的变量或函数等导出,然后由另一个需要使用的模块进行导入。

ES6 之前的模块化方案

在 ES6(ES2015)推出 ES modules 之前,如果想用模块化开发,在 node 环境下,Node.js 本身支持基于 CommonJS (CJS) 的模块化方案;在浏览器环境下可以使用基于社区的模块化规范实现的库,如 AMD 的 require.js 或者 CMD 的 sea.js。又或者我们自己借助于函数拥有作用域这一特性来实现,想把某一部分代码封装成一个模块,就将它们写在一个单独的 js 文件,比如 a.js:

javascript 复制代码
// a.js
const moduleA = (function () {
  const flag = true
  return {
    flag
  }
})()

将代码逻辑写在一个自执行函数中,然后把想要暴露出去的变量放到一个对象中返回给变量 moduleA 接收。如此,我们在 index.html 引入 a.js 时,在全局作用域就有了个 moduleA 对象。即使我们想在另一个文件 b.js 里同样定义名为 flag 的变量,然后在 index.html 引入,也不会对在 a.js 定义的 flag 有影响,因为它们分别作为 moduleAmoduleB 对象的属性暴露在全局:

javascript 复制代码
// b.js
const moduleB = (function () {
  const flag = true
})()

ES modules

用法

自 ES6 起,js 终于有了被写入官方规范的依赖于 importexport(注意没有 's',区别 CommonJS 的 exports) 的模块化方案 ESM( EcmaScript modules),也就是 ES modules。下面先来看看具体是怎么使用的。

简单使用

比如现在有一个主入口文件 index.js 和一个模块文件 a.js,在 index.js 中想要使用 a.js 中定义的变量 flag,我们可以在 a.js 中在变量声明前加上 export,将 flag 导出:

javascript 复制代码
// a.js
export const flag = true

在 index.js 中使用 import 将变量导入:

javascript 复制代码
// index.js
import { flag } from './a.js'
console.log(flag)

然后在 html 文件引入 index.js 时,记得加上 type="module"

html 复制代码
// index.html
<script src="./js/index.js" type="module"></script>

如此,在 vs code 中通过 Live Server 将页面运行于浏览器(node 环境下不同版本用法不同),就可以在控制台看到打印了一个 true

注意必须得是通过服务器运行,这样才能保证是通过 http 协议去加载 js 文件,如果是直接本地文件通过浏览器打开,会报错:

可以看到 'file' 不在 'Cross origin requests' 支持的协议范围内。

导出的 3 种方式

1. 使用 export 后面直接跟上变量/函数/类的声明

javascript 复制代码
// 例 1.1
export const flag = true
export function fn() {
  console.log('fn')
}
export class Parent {}

2. export 和声明分开写:

javascript 复制代码
// 例 1.2
const flag = true
function fn() {
  console.log('fn')
}
class Parent {}

export { flag, fn, Parent }

注意,第 8 行的 export 后面的这个 {},不是一个对象,是 ES modules 的固定写法,不要误以为这里是对象的 key 和 value 同名情况下的对象字面量的增强写法,也就是不能写成类似 export { flag: flag } 这样。

3. 在第 2 种方式的基础上给导出的变量起别名

比如现有 a.js 和 b.js 两个文件,都导出了变量 flag,如果在 index.js 同时需要引入 a.js 和 b.js 的 flag,就会导致命名冲突,所以我们可以在导出时通过 as 起别名:

javascript 复制代码
// 例 1.3.1
// a.js
const flag = true
export { flag as aFlag }
javascript 复制代码
// 例 1.3.2
// b.js
const flag = false
export { flag as bFlag }

在导入时,就需要对应导入别名而不是原本的 flag

javascript 复制代码
// 例 1.3.3
// index.js
import { aFlag } from './a.js'
import { bFlag } from './b.js'

导入的 3 种方式

1. 普通的导入

比如例 1.1 或例 1.2 导出了 3 个变量,那么我们可以用下面这种方式导入:

javascript 复制代码
// 例 2.1
import { flag, fn, Parent } from './a.js'

flag, fn, Parent 这 3 个变量名需与导出时的变量名相同。注意这里的 {} 也不是代表对象。

2. 导入时起别名

相较于在导出时起别名,更为常见的做法是在导入时起别名,比如还是有 2 个文件各自导出了变量 flag

javascript 复制代码
// 例 2.2.1
// a.js
export const flag = true

// b.js
export const flag = false

在引入是如果直接都引入 flag

javascript 复制代码
// 例 2.2.2
import { flag } from './a.js'
import { flag } from './b.js'

就会报语法错误:'Uncaught SyntaxError: Identifier 'flag' has already been declared',为避免报错我们可以给导入的变量起别名,然后在使用时直接使用别名:

javascript 复制代码
// 例 2.2.3
import { flag as aFlag } from './a.js'
import { flag as bFlag } from './b.js'

console.log(aFlag) // true
console.log(bFlag) // false

3. 将导出的所有内容放入模块对象

还是以例 1.1 或例 1.2 导出的 3 个变量为例,直接用通配符 * 代表 a.js 导出的所有内容,然后把它们作为模块对象 moduleA 的成员使用:

javascript 复制代码
// 例 2.3
import * as moduleA from './a.js'
console.log(moduleA.flag, moduleA.fn, moduleA.Parent)

这种写法也可以有效避免命名冲突的问题。

导出与导入结合使用

在项目中我们一般都会有个 utils 目录,用于存放一些工具函数,比如用于国际化的 i18n.js,用于发送请求的 request.js 等。然后为了方便在其它地方统一使用这些工具函数,我们会在 utils 目录下新建一个 index.js 文件,将各个工具函数统一导入再统一导出,此时如果是像如下这样导入和导出分开的写法,就略显繁琐:

javascript 复制代码
// utils\i18n.js
export function i18nFn() {
  console.log('i18n')
}

// utils\request.js
export function requestFn() {
  console.log('request')
}

// utils\index.js
import { requestFn } from './request.js'
import { i18nFn } from './i18n.js'

export { requestFn, i18nFn }

我们可以直接将导入和导出做个结合:

javascript 复制代码
// utils\index.js
export { requestFn } from './request.js'
export { i18nFn } from './i18n.js'

还可以更进一步,直接使用 *,使得代码更为简洁:

javascript 复制代码
// utils\index.js
export * from './request.js'
export * from './i18n.js'

默认导出

以例 1.2 为例,如果把第 8 行改写成:

javascript 复制代码
// 例 3.1
// a.js
export { flag as default, fn, Parent }

即为默认导出变量 flag,那么在导入时,对 flag 的导入就不要写在 {} 里,并且名字不用与导出时一致:

javascript 复制代码
// 例 3.2
// index.js
import aModuleDefault, { fn, Parent } from './a.js'
console.log(aModuleDefault) // true

相较于例 3.1 的写法,默认导出更常见的是下面第 2 行这样的写法:

javascript 复制代码
// 例 3.3
export default flag
export { fn, Parent }

export default 后面还可以直接跟上匿名函数或非匿名函数或是类的声明或者直接是一个值:

javascript 复制代码
// 匿名函数
export default function () {
  console.log('匿名函数')
}

// 非匿名函数
export default function fn() {
  console.log('fn')
}

// 类的声明
export default class Parent {}

// 值
export default {
  getList() {
    console.log('写请求接口时经常这样写')
  }
}

但是不能直接跟上变量的声明,如 export default const flag = true 就是错误的写法。

export default 的本质就是将其后面跟着的值赋给 default 变量,当然在一个模块中只能有一个默认导出。

动态加载模块

前面这些导入的例子都是静态地对模块进行加载,比如下面的例 4.1,需要等第 6 行的导入完成后,才会执行之后的语句,所以打印顺序为先是 'true' 后是 '1':

javascript 复制代码
// 例 4.1
// a.js
export const flag = true

// index.js
import { flag } from './a.js'
console.log(flag)
console.log(1)

我们还可以通过 import() 函数,来异步地加载模块进行导入,需要导入的模块加载成功后函数返回一个 promise,导入的内容放入到该 promise 的 then 方法的参数中:

javascript 复制代码
// 例 4.2
// index.js
import('./a.js').then(res => console.log(res.flag))
console.log(1)

执行例 4.2 的打印顺序就是先打印 '1',再打印 'true '。

补充

在 ES11(ES2020)中,给 import 对象新添加了一个 meta 属性,也是个对象,用于保存当前模块,也就是当前这个 js 文件的下载路径,比如我们在 index.js 中打印,得到的结果为:{url: 'http://127.0.0.1:5500/js/index.js'}

javascript 复制代码
// index.js
console.log(import.meta)

原理

有一篇英文文章对于浏览器如何解析处理 ES modules 做了很好的解释,包括还拿 CommonJS 做了对比,下文用到的示意图即截取自该文。

总体来说,浏览器解析 ES modules 的过程分为 3 步:

  1. 构建(Construction )

查找并下载所有的 js 文件到浏览器,然后解析成模块记录(module records)。

  1. 实例化(Instantiation )

在内存中分配一块空间,用于存放所有的导出,但此时并没有对这些导出的变量求值,然后让导出导入的模块指向对应的内存地址,称为建立连接(linking)。

  1. 运行(Evaluation )

运行代码并将实际的值填充到对应的内存中:

下面我们详细说说每个阶段是怎么回事。

阶段一:构建(Construction )

比如我们的入口文件为 main.js,那么在 html 我们会添加下面这一句:

html 复制代码
<script src="mian.js" type="module"></script>

浏览器通过 <script> 标签的 src 得到 js 文件的地址,去服务器把文件下载下来。因为 <script> 标签的 type 属性值为 module,浏览器就知道这是模块化编写的文件,就会在去解析的时候生成模块记录(MODULE RECORD),其中有个属性叫做 RequestedModules,记录着 main.js 里 import 了哪些文件,如下图所示:

发现 main.js 导入了 counter.js 和 display.js,就会去下载这 2 个文件然后再解析生成模块记录,再去下载解析它们各自需要 import 的文件,直到某个文件不再需要导入其它文件:

请注意,这里的解析属于静态解析,并不会对文件的代码进行运算,所以类似下面这种需要运行代码时才能进行的导入写法是错误的:

javascript 复制代码
// 错误代码
if (true) {
  import { flag } from './counter.js'
}

如果需要进行运算后再引入可以用前面提到过的动态加载模块的方法。

Module Map

如果某个模块,也就是某个文件,被多次导入,也只会被下载 1 次。因为每次下载都会被记录到一个叫做 Module Map 的 Map 结构中,下载文件前会先确定 Module Map 中不存在该文件,再去下载。

阶段二:实例化(Instantiation )

实例化就是为了将那些需要导出的数据写入内存。由 js 引擎根据第一阶段解析得到的 Module Record 创建模块环境记录(Module Environment Record),里面有个 Bindings 属性,记录着 Module Record 里记录的对应模块导出的变量,比如下图中 counter.js 里的数字类型的 count 和 display.js 里的函数 render, 然后写入内存,但是此时它们的值都还是 undefined。另一边,解析 main.js 生成的 Module Record 也有一个对应的 Module Environment Record,里面的 Bindings 记录着导入的 count 和 render,也会与内存中对应地址建立联系。

阶段三:运行(Evaluation )

执行到第三阶段对代码进行运算求值后,才会将真正的值填写到内存中,比如 count 值为 5,render 为一个函数。然后在 main.js 里如果用到了 count 或 render 就可以从内存中获取正确的值了:

上图还有两句话,"exporting module can update variable value" 和 "importing module cannot update variable value"。意思是在导出值的模块更新导出的值是允许的,但不允许在导入的模块更新导入的值,比如我们在 counter.js 有如下代码,导出的 count 一开始为 1,1s 后赋值为 2

javascript 复制代码
// counter.js
let count = 1
setTimeout(() => (count = 2), 1000)
export { count }

在 main.js 对 count 进行导入,并先打印一次 count,2s 后再打印一次:

javascript 复制代码
// main.js 
import { count } from './render.js'
console.log(count) // 1
setTimeout(() => console.log(count), 2000) // 2

结果为先 1 后 2,可见 Module Environment Record 对内存中的导出值是持续的跟踪,这点与 CommonJS 不同, CommonJS 的导入值相当于是导出值的备份,所以改变导出不会让导入改变。

至于导入的值是不允许做更改的,我们可以尝试在 main.js 中改变 count 的值:count = 5,浏览器会报错"Uncaught TypeError: Assignment to constant variable"。

One More Thing

最后说一下,通过 webpack 构建的项目,运行在浏览器时,CommonJS 或 ES modules 是可以相互使用的:

javascript 复制代码
// a.js 使用 ES modules 导出 aName
export const aName = 'a'

// b.js 使用 CommonJS 导出 bName
const bName = 'b'
module.exports = {
  bName
}

// index.js
// 使用 CommonJS 导入 aName
const { aName } = require('./a')
console.log(aName)
// 使用 ES modules 导入 bName
import { bName } from './b'
console.log(bName)

这也是为什么在平常工作时,在一个 vue 项目中,有时候可能用到某个第三方包是使用 CommonJS 导出的,但我们依旧可以使用 import 导入。

相关推荐
爱的叹息15 分钟前
解决 Dart Sass 的旧 JS API 弃用警告 的详细步骤和解决方案
javascript·rust·sass
涛哥码咖15 分钟前
Rule.resourceQuery(通过路径参数指定loader匹配规则)
前端·webpack
夕水1 小时前
这个提升效率宝藏级工具一定要收藏使用
前端·javascript·trae
会飞的鱼先生1 小时前
vue3 内置组件KeepAlive的使用
前端·javascript·vue.js
斯~内克1 小时前
前端浏览器窗口交互完全指南:从基础操作到高级控制
前端
Mike_jia2 小时前
Memos:知识工作者的理想开源笔记系统
前端
前端大白话2 小时前
前端崩溃瞬间救星!10 个 JavaScript 实战技巧大揭秘
前端·javascript
loveoobaby2 小时前
Shadertoy着色器移植到Three.js经验总结
前端
蓝易云2 小时前
在Linux、CentOS7中设置shell脚本开机自启动服务
前端·后端·centos
浩龙不eMo2 小时前
前端获取环境变量方式区分(Vite)
前端·vite