ESModule

# 什么是ESModule

# 定义

ESModule(简称ESM)是Javascript程序拆分成多个单独模块,并能按需导入的标准。ES6的设计思想是静态化,所以能在编译阶段(静态分析)时就确定模块的依赖关系,以及输入/输出的变量

不同于webpackbabelESMJavascript的标准功能,在浏览器和Node都已实现。使用ESM浏览器可以最优化加载模块,比使用库更有效率

ESM标准分别通过importexport实现模块变量的导入和导出

# 机制

Javascript是解析执行,每一个代码块的执行过程都分为:语法分析,预编译阶段,解析执行。

其中预编译阶段会对变量和函数做👇处理:

  • 变量和函数的声明都提升到顶部
  • 变量赋值会先开辟一块内存空间且指向变量名,赋值为undefined
  • 函数声明则直接把函数体放入开辟的内存空间,在预编译阶段就完成了函数的创建工作

ESM没有规定模块加载的细节,大致分为四个步骤👇:

  • 解析:读取模块的源码并检查语法错误
  • 加载:递归加载所有import的模块
  • 绑定:为每个加载的模块都生成一个模块作用域,该模块下所有全局声明都绑定到该作用域上(包括其他模块导入的内容 )
  • 执行:完成以上步骤后,脚本执行每个已加载模块中的语句。执行到全局声明时,什么都不做,因为已经把声明都绑定到模块作用域中。

# 使用

html中需要给script标签设置type=module,用来标识模块为顶级模块,浏览器识别模块的import语句加载

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

# export/import

export导出模块中的变量

import导入模块中的变量

# 命名导出/导入

导出变量的数量不限

支持导出的值类型:函数、对象或原始值

// exp1.js
// 导出变量
export let count = 0
export const str = 'string'

// 导出函数
export function acc() {
    return count += 1
}

// 导出类
export class Person {

}

// 命名导入
// 静态执行,变量名不能使用表达式和变量
// 导入两次也只执行一次
import { count, str, acc } from './exp1.js'
import { count, str, acc } from './exp1.js'
console.log(count, str, acc)
// 导入的变量只是一个只读引用,不可重新赋值,虽然可以修改对象的属性,但是不建议
count = 1 // 报错

# 批量导出/导入

可以批量导出,但必须是先定义好的变量,必须以,分隔多个变量

// exp2.js
// 批量导出

let v1 = 'v1'
const C1 = 'C1'

function message(msg) {
  console.log(msg)
}

export {
  v1,
  C1,
  // 导出重命名
  message as msg
}

// 报错 不允许以对象字面量形式
// export {
//   name: 123
// }

// 批量导入 (通过as重命名导入的变量)
import { v1, C1, msg as message } from './exp2.js'
console.log(v1, C1, message)

// 也可创建模块对象
import * as md from './index.js'
console.log(md.v1)

# 默认导出/导入

使用default关键字固定格式导出,且只能是一个变量

导入时可以任意命名,方便用户使用,无需查API

// 默认导出
function add(n) {
  return n + count
}

export default add

// 默认导入
import add from './index1.js'
import {default as defAdd } from './exp3.js'

console.log(add)
console.log(defAdd)

# 其他模块导出/导入

// exp1.js
// 导出其他模块
export { v1 } from './exp3.js'

// 批量导出其他模块
export * from './exp3.js'

// 导入(包含exp1&exp3模块的变脸)
import { count, str, acc, v1, C1, msg } from './exp1.js'
console.log(count, str, acc, v1, C1, msg)

# 副作用导入

// 整个模块为副作用,仅运行模块中的全局代码,不导入模块内任何接口
import './exp1.js'
console.log(count) // 报错

# 动态加载

ESM一个重要特性是编译时加载,有利于静态分析,加载过程先于代码执行。importexport是在静态分析阶段做的分析处理,而条件语句要等到执行时才会解析到。

// 报错
if (true) {
  import add from './md.js'
}

使用import()函数可实现动态加载模块,它接受要加载模块的相对路径,返回一个Promise对象,内容是要加载的模块对象。

import('./md.js').then(md => {
  console.log(md)
})

# 循环加载

ESM模块是动态引用,用import从一个模块加载变量(import foo from 'foo'),变量不会被缓存,而是成为一个指向被加载模块的引用

# 举个🌰

看看👇,a.jsb.js相互加载

// a.js
import { bar } from './b.js'
console.log('a.js', bar)
export const foo = 'foo'

// b.js
import { foo } from './a.js'
console.log('b.js', foo)
export const bar = 'bar'

// 报错 Uncaught ReferenceError: Cannot access 'foo' before initialization
// 执行a.js后,引擎发现它加载了b.js,就优先执行b.js。
// 执行b.js后,发现导入了a.js的foo接口,这时不会去执行a.js,而是认为这个接口已经存在了
// 继续往下执行,发现a.js还没执行到导出接口的位置,所以这个接口还没定义,因此报错

# 解决方法

b.js允许的时候, foo有定义,可以通过把foo写成函数来解决

// a.js
import { bar } from './b.js'
console.log('a.js', bar())
export function foo() { return 'foo' }

// b.js
import { foo } from './a.js'
console.log('b.js', foo())
// function bar() { return 'bar' }
export function bar() { return 'bar' }

// 函数具有提升作用,所以加载b.js的时候函数foo就有定义了
// b.js foo
// a.js bar

// 换成函数表达式就不具有提升作用,执行就会报错
export const foo = () => { return 'foo' }

# 总结

  • ESM是编译时加载,所有模块加载完才会执行
  • 模块中可以用import命令加载其他模块(.js后缀不可省略,需提供绝对/相对路径),也可用export命令输出对外接口
  • 同一模块重复加载,只会执行一次
  • 模块自动采取严格模式,不管是否声明user strict
  • 代码是在模块作用域中运行,而非全局作用域。模块内部的顶层作用域仅内部可见
  • 模块中顶层的关键字this返回undefined,而非指向window。所以在模块顶层使用this没有意义

更多细节可移步阮一峰老师的【Module 的加载实现】 (opens new window)