ES Module

53 阅读6分钟

介绍

ES6 最大的一个改进就是引入了模块规范,也得到了浏览器的原生支持,它的核心关键字是 import 和 export。

关键字

export

export 关键字导出一个模块中的变量、函数、类等导出,例如:

export const name = 'zayyo'

export function sayHello() {
  console.log('hello!')
}

export class Foo{
  constructor() {
  }
}

当然我们也可以这样做:

class Foo{
  constructor() {
  }
}

function sayHello() {
  console.log('hello!')
}

export {
  Foo,
  sayHello
}

值得注意的是,export 后面的 {} 并不是所谓对象,只是一个标识符,例如下面的代码就是不合法的(不要和 CommonJS 混淆了):

// 下面的代码不合法!!!
export {
  name:'yzl',
  age:20
}

另外,我们也可以为导出的内容起个别名:

export {
  Foo as FooClass,
  sayHello as sayHelloFn
}

import

使用 import 关键字可以从一个模块中导入内容,基于上面的 export 语句,我们来写一下 import:

import {sayHello, Foo} from './foo'

sayHello()
const f = new Foo()

另外,我们可以在 import 时起别名:

import {sayHello as helloFn, Foo as FooClass} from './foo'

helloFn()
const f = new FooClass()

export default

export default 关键字用来实现默认导出,下面是一个案例,注意,在一个模块中只能有一个默认导出:

export default function sayHello() {
  console.log('hello!')
  }

针对这种默认的导出,我们的 import 语句要这样写:

import sayHello from './xxxx'

sayHello()

import()

值得注意的是,我们的 import 语句是不可以放到逻辑代码里面的(只能放在 js 文件的开头),具体原因后面会说,但有时候我们需要在逻辑判断中动态加载模块,import() 语句用来实现动态加载某一个模块,浏览器原生支持这个功能。

import 和 export 混用

如果你开发一个库,那么你有必要暴露一些接口到库入口中,方便用户调用,例如:

import { Foo } from './scales/foo';
import { Bar } from './scales/bar';

export {
  Band,
  Ordinal
}

如果 import 很多,代码不太好看,于是我们可以将 import 和 export 混用,像这样:​

export { Foo } from './scales/foo';
export { Bar } from './scales/bar';

这份代码和上面的代码效果等价。

特点

先解析后执行

  • 和 CommonJS 不同,ES Module 处理依赖关系的时机不是在运行时,而是在解析时(这种行为可以称为静态解析)。

  • 这就是为什么上面我们提到 import 不可以直接在代码块中嵌入。

  • 同样我们又可以解决一个问题:为什么 import 语句不可以携带变量、而 require 语句可以,这是因为 require 语句是边执行边处理依赖关系。例如下面的代码:

// CommonJS
const a = '/home/app.js'
const res = require(a)
  • 在执行第三行时,第二行已经执行,a 变量在内存中,所以 require 有效。

再看 ESModule 的 "实现",注意,下面的代码本身就不合法,只是为了对比。

// ES Module
const a = '/home/app.js'
import {count} from `${a}`

假设浏览器能够解析第2行,在解析阶段,第1行并没有运行,故变量 a 是不存在的,所以这个 import 将无法解析。

异步递归加载解析

所有的模块都是异步递归加载解析的,类似于添加

工作机制

ES Module 的工作机制如下:

构建阶段

构建阶段就是浏览器尝试下载、解析所有需要的模块文件,并形成模块记录的过程,它的基本流程如下:

  • 浏览器会解析入口模块,确定依赖,并发送对依赖模块的请求。这些文件通过网络返回后,浏览器就会解析它们的内容、确定它们的依赖,其形式如下:
<script src='./bar.js' type='module'></script>
  • 如果这些二级依赖(入口脚本的依赖)还没有加载,则会发送更多请求。这个异步递归加载过程会持续到整个应用程序的依赖图都解析完成。

  • 上面的每个模块加载完成之后,都会创建相应的模块记录,同时浏览器还会维护一张模块映射表,它保存了模块路径 -- 模块记录的映射关系。

实例化阶段

上面的构建阶段只是起到了解析模块、将模块转换成模块记录的过程,在实例化阶段,代码和内存将建立起关系。

  • 首先,JS 引擎创建一个模块环境记录(module environment record),它管理模块记录中的变量。

  • 为每一个 export 在内存中开辟相应的空间,相应的模块环境记录也会指向这些内存空间,现在这些内存空间并没有被填充值,赋值操作将在执行阶段发生。

  • 引擎在分析完模块所有的导出之后,开始处理模块的导入,导入和导出都指向内存中相同的位置。

执行阶段

有趣的现象

循环依赖的特性

一个循环依赖的有趣现象:

CommonJS 环境

index.js

let count = require('./counter.js').count

console.log(count)
exports.message = 'hello world!'
    

counter.js

let message = require('./index.js').message

exports.count = 5
setTimeout(() => {
  console.log(message)
}, 0)

上面的代码将输出:

image.png

原因:

  • 我们先加载 index.js,执行第一行,进入 counter.js

  • 进入 counter.js,执行第一行,循环依赖了 index.js

  • 由于 index.js 没有加载完毕,message 的值为 undefined。

  • counter.js 导出了 count 变量。

  • 回到 index.js

  • 打印 count 的值,也就是 5。

  • 导出 message 变量,这个已经没什么用了,因为 counter 的 message 已经被解析成 undefined。

  • 执行 setTimeout() 中的任务,打印 counter 的 message,结果自然是 undefined。

  • 导出 message 变量,这个已经没什么用了,因为 counter 的 message 已经被解析成 undefined。

  • 执行 setTimeout() 中的任务,打印 counter 的 message,结果自然是 undefined。

对于 ESModule,是什么效果呢?

index.js

import { count } from './counter.js'

const message = '666'

console.log(count)

export {
 message
}

counter.js

import { message } from './index.js'

16const count = 5

18setTimeout(() => {
  console.log(message)
}, 0)

export {
  count
}

输出如下:

image.png

其流程如下:

  • JS 引擎解析模块,分析 importexport,进行连接,此时存放导出值的内存(下面简称为导出内存)中还没有被填充值:

  • 进入执行阶段,先执行 index.js,index 的 message 被赋值为 5,现在它作为一个局部变量,并未被写入到导出内存中。

  • index.js 需要导入 count 变量,如上图的下半部分,它执行导出内存的 count 部分,由于此时 count 没有被填充,我们自上而下执行 counter.js

  • counter.js 执行到 export 语句时,内存中的 count 被填充为 5。

  • counter.js 继续执行 setTimeout(), console.log(message) 被加入任务队列中。

  • index.js 打印 count,值为 5,接着执行到 export, 将 message 写入导出内存中。

  • 主线程代码结束,执行任务队列中的 console.log(message),这个 message 是从 index.js 导入的,它的值就是 666。

通过这个案例,我们可以更好地理解 ESModule 和 CommonJS 的工作机制。

值得注意的是,如果我们移除包裹的 setTimeout,那么会报错,这个现象进一步证明了上面的描述 -- 只有扫描到 export 语句,相应的变量才会被写入到导出内存中。

export 特点

来看下面的代码:

main.js

// 
import {name} from './foo.js'

console.log(name)

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

foo.js

let name = 'zayyo'

setTimeout(() => {
  name = 'zay'
}, 0)

export {
  name
}

执行 main.js,得到输出:

image.png

如果是 CommonJS 呢?

main.js

const foo = require('./foo.js')

console.log(foo.name)

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

foo.js


let name = 'zayyo'

setTimeout(() => {
  name = 'zay'
}, 0)

module.exports = {
  name
}

输出如下:

image.png

导出的是 module.exports 的引用,改变 name,由于 name 是一个原始值(不是一个引用类型),和导出的 module.exports 对象的 name 属性并不相同,所以 name 的值不会被修改。

为什么 ESModule 可以跟踪修改呢?这个机制叫实时绑定(live binding),本质上是export 端和 import 端指向内存中的相同位置,和 CommonJS 导出 module.exports 对象的机制是不同的。

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 20 天,点击查看活动详情