likes
comments
collection
share

ESModule规范详解

作者站长头像
站长
· 阅读数 26

在介绍 ESModule 规范之前,我们先了解下 AMDCMD 两种规范。

AMD 规范

  • AMD 规范采用非同步加载模块,允许指定回调函数;
  • node 模块通常位于本地,加载速度快,所以适用于同步加载;
  • 浏览器环境下,模块需要远程请求获取,所以适用于异步;
  • require.js 是 AMD 的一个具体实现库;

CMD 规范

  • CMD 整合了Commonjs 和 AMD 的优点,模块加载是异步的
  • CMD 专门用于浏览器端,sea.js 是 CMD 规范的实现

AMD 和 CMD 最大的问题是没有通过语法升级来解决模块化的问题。它们去定义模块化还是调用js方法的方式去生成一个模块,如果当项目模块达到成百上千个,这种方式无法进行模块规模化的应用。要想模块规模化应用则需要一种标准的语法规范,这是 AMD 和 CMD 都没有实现的。

ESModule 规范

  • ESModule 设计理念是希望在编译时就确定模块依赖关系即输入输出
  • Commonjs 和 AMD 必须在运行时才能确定依赖和输入、输出
  • ESModule 通过 import 加载模块,通过 export 输出模块

下面我们来详细介绍ESModule。

ESModule 使用

export 正常导出,import 导入

所有通过 export 导出的属性,在 import 中可以通过解构的方式获取。

export 导出

const name = 'dog' 
const author = 'xiaoming' 
export { name, author } 
export const say = function () { console.log('hello , world') }

import 导入

// name , author , say 对应 a.js 中的 name , author , say 
import { name , author , say } from './a.js'

默认导出 export default

const name = 'dog' 
const author = 'xiaoming' 
const say = function () {
  console.log('hello , world')
}
export default {
  name,
  author,
  say
}

导入

import mes from './a.js' 
console.log(mes) // { name: 'dog', ... }
  • export default anything 默认导出。 anything 可以是函数,属性方法,或者对象。
  • 对于引入默认导出的模块,import anyName from 'module', anyName 可以是自定义名称。

混合导入|导出

export const name = 'dog' 
export const author = 'xiaoming'

export default function say() {
  console.log('hello , world')
}

导入有两种方式,第一种是:

import theSay, { name, author as bookAuthor } from './a.js'
console.log(
  theSay,       // ƒ say() {console.log('hello , world') }
  name,         // 'dog'
  bookAuthor    // 'xiaoming'
)

第二种:

import theSay, * as mes from './a'
console.log(
  theSay,   // ƒ say() { console.log('hello , world') }
  mes
)

mes对象如下,可以看到把导出的所有属性都收敛到了一个对象里面,其中 export default 导出值的key 为 default

{ 
   name:  'dog' , 
   author: 'xiaoming',
   default: ƒ say() { console.log('hello , world') } 
}

ESModule 特点

静态语法

ESModule 的设计理念是希望在编译时就确定模块依赖关系即输入输出,那如何在编译时就能确定依赖关系呢?

在传统编译语言的流程中,程序中的一段源代码在执行之前都需要经过"编译"。对于 JavaScript 这样的解释型语言来说,也是需要编译的,只不过编译过程发生在代码执行前的几微秒(甚至更短)的时间内。

要想在编译阶段就能确定依赖关系,那必须要把import进行类似于变量提升。我们来看一下JavaScript 中的变量提升。

function test() {
  console.log(a)
  console.log(foo())

  var a = 1
  function foo() {
    return 2
  }
}
test()

在编译阶段发生了变量提升,经过预编译,执行顺序就变成了这样:

function test() {
  function foo() {
    return 2
  }
  var a
  console.log(a)
  console.log(foo())
  a = 1
}
test()

所以打印结果是:

undefined
2

import 提升 其实 JavaScript 代码在编译阶段发现有 import 也会像 var 一样进行提升。为了验证这一点,看一下如下 demo。

main.js

console.log('main.js开始执行')
import say from './a.js'
import say1 from './b.js'
console.log('main.js执行完毕')

a.js

import b from './b.js'
console.log('a模块加载')
export default function say() {
  console.log('hello , world')
}

b.js

console.log('b模块加载') 
export default function sayhello(){ 
    console.log('hello,world') 
}

执行顺序如下:

b模块加载
a模块加载
main.js开始执行
main.js执行完毕

当执行main.js,可以看到先答应"b模块加载",但是 main.js第一行代码是console.log('main.js开始执行'), 但是并没有执行。这是因为在编译阶段把import进行了提升,类似于var的变量提升,所以首先执行import语句

也就是在编译阶段去加载模块,然后在执行阶段就去执行文件,这跟var变量的执行顺序是一样的,即首先会把var a = undefined提升,然后在执行阶段去赋值。

因为这种静态语法,所以import , export 不能放在块级作用域或条件语句中。

// 错误写法
function say() {
  import name from './a.js'
  export const author = 'dog'
}
// 错误写法
isexport && export const name = '《React进阶实践指南》'

在编译过程中确定了导入和导出的关系,所以更方便去查找依赖,这为tree shaking(摇树)创造了条件,这也是 ESModule 支持tree-shaking操作的原因。同时,还可以使用各种 lint 工具对模块依赖进行检查,比如: eslint。

导出绑定: 不能修改import导入的属性

// a.js
export let num = 1
export const addNumber = () => {
  num++
}

// main.js
import { num, addNumber } from './a'
num = 2 

当执行 main.js的时候会报错:Uncaught TypeError: Assignment to constant variable。通过 import 导入的值可以看出是一个 const 常量,不能修改。从这里其实可以看出导出的变量num并不是值的拷贝,而是引用关系。

引用传递

Common.js 是值的拷贝,ESModule 是引用传递。

Common.js

// test.js
let a = 1
function plus() {
  a++
}
function get() {
  return a
}
exports.a = a
exports.plus = plus
exports.get = get

// main.js
const { a, plus, get } = require('./test.js')
console.log(a)   // 1
plus()
console.log(a)   // 1
console.log(get()) // 2

当第一次打印导入的变量a的值是 1,然后执行plus方法,再次打印a发现值仍然是 1。当我们通过get方法获取模块内的变量a的时候,发现值为2。所以,在Commonjs规范下,导入的变量只是值的拷贝而已,具体细节可以参考上一篇文章# CommonJS规范详解

ESModule

// test.js
export let a = 1
export function plus() {
  a++
}

// main.js
import { a, plus } from './test.js'
console.log(a) // 1
plus()
console.log(a) // 2

当第一次打印导入的变量a的值是 1,然后执行plus方法,再次打印a发现值是 2。也就是,使用 import 导入的变量是与原变量是引用关系,而不是拷贝。

import() 动态引入

import() 返回一个 Promise 对象, 返回的 Promise 的 then 成功回调中,可以获取模块的加载成功信息。我们来简单看一下 import() 是如何使用的。

// main.js
const result = import('./b')
result.then(res => {
  console.log(res)
})

// test.js
export const name = 'alien'
export default function sayhello() {
  console.log('hello,world')
}

打印结果如下: ESModule规范详解

Commonjs VS ESModule

通过上面的介绍,我们来总结下 Commonjs 和 ESModule 的区别:

  • Commonjs 的输出是值的拷贝,ESModule 的输出是值的引用。
  • Commonjs 是运行时加载,只有在运行结束后才能确定输入和输出,ESModule 在编译的时候就能明确的知道输入和输出,它是一个确定的结果。

像这段代码在仅仅被 parse 成 AST(抽象语法树) 时,很难分析出究竟依赖了哪些模块。

const bar = require(`all/${['f', 'o', 'o'].join('')}`)
const foo = _.get(require('all'), 'foo')

同样,Commonjs 在做模块导出时也无法静态识别:

module.exports = {}
Object.assign(module.exports, {bar: 'foo'})

但是在 ESModule 中,import/exports 一目了然,对于没有被 import 的部分,也很自然的容易区分出来。总之,就是通过限定语法,让本来需要运行代码才能确定的依赖,可以在 AST 阶段就能确定下来。