何谓模块化
所谓模块化,就是指将我们编写的程序分割为一个个小的单独模块,每个模块都有自己独立的作用域,有着自己的逻辑代码,而不会影响其它的模块。一个模块可以将自己想暴露给其它模块的变量或函数等导出,然后由另一个需要使用的模块进行导入。
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
有影响,因为它们分别作为 moduleA
和 moduleB
对象的属性暴露在全局:
javascript
// b.js
const moduleB = (function () {
const flag = true
})()
ES modules
用法
自 ES6 起,js 终于有了被写入官方规范的依赖于 import
和 export
(注意没有 '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 步:
- 构建(Construction )
查找并下载所有的 js 文件到浏览器,然后解析成模块记录(module records)。
- 实例化(Instantiation )
在内存中分配一块空间,用于存放所有的导出,但此时并没有对这些导出的变量求值,然后让导出导入的模块指向对应的内存地址,称为建立连接(linking)。
- 运行(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
导入。