# 什么是ESModule
# 定义
ESModule(简称ESM)是Javascript
程序拆分成多个单独模块,并能按需导入的标准。ES6的设计思想是静态化,所以能在编译阶段(静态分析)时就确定模块的依赖关系,以及输入/输出的变量
不同于webpack
和babel
,ESM是Javascript
的标准功能,在浏览器和Node
都已实现。使用ESM浏览器可以最优化加载模块,比使用库更有效率
ESM标准分别通过import
和export
实现模块变量的导入和导出
# 机制
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一个重要特性是编译时加载,有利于静态分析,加载过程先于代码执行。import
和export
是在静态分析阶段做的分析处理,而条件语句要等到执行时才会解析到。
// 报错
if (true) {
import add from './md.js'
}
使用import()
函数可实现动态加载模块,它接受要加载模块的相对路径,返回一个Promise
对象,内容是要加载的模块对象。
import('./md.js').then(md => {
console.log(md)
})
# 循环加载
ESM模块是动态引用,用import
从一个模块加载变量(import foo from 'foo'
),变量不会被缓存,而是成为一个指向被加载模块的引用
# 举个🌰
看看👇,a.js
与b.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)