先记住这个答案
ESM 的 import 声明会被提升到模块的最顶部。引擎在执行模块代码之前,会先扫描所有 import 声明,递归加载并执行所有依赖模块。等所有依赖都准备好了,当前模块的代码才开始从上往下执行。所以不管 import 写在文件的第 1 行还是第 100 行,导入的绑定在模块一开始就可以使用。
import声明会被提升,在模块代码执行前完成- 不管
import写在哪,导入的绑定在第一行就可用 - 被导入的模块先于当前模块执行,深度优先
require()不提升,在执行到那一行时才加载
先看一个直观的例子
把 import 写在 console.log 后面,按直觉应该先打印再导入,但实际上 import 已经被提升了。
import 写在后面也能用JavaScript// main.mjs
console.log(add(1, 2)) // 3 —— import 已经提升,add 可用
import { add } from './math.mjs'
// math.mjs
// export function add(a, b) { return a + b }虽然 import 写在 console.log 后面,引擎不会按文本顺序来处理 import。它先把所有 import 收集起来,加载完依赖模块之后,add 就已经绑定好了。等到执行 console.log(add(1, 2)) 时,add 已经是一个可用的函数了。
实际项目里不推荐把 import 写在文件中间,虽然语法上没问题,但会降低可读性。ESLint 的 import/first 规则会强制 import 写在文件顶部。
模块的执行顺序:深度优先
当 A 依赖 B,B 依赖 C 时,执行顺序是 C 先执行,然后 B,最后 A。引擎按深度优先的方式遍历模块图,叶子模块最先执行。
// a.mjs
import { b } from './b.mjs'
console.log('A 执行')
// b.mjs
import { c } from './c.mjs'
console.log('B 执行')
export const b = 'b'
// c.mjs
console.log('C 执行')
export const c = 'c'
// 加载 a.mjs 后控制台输出:
// C 执行
// B 执行
// A 执行这和 require() 的行为有个关键区别。require() 是同步执行到那一行时才去加载和执行目标模块,代码的文本顺序就是执行顺序。ESM 是在所有代码执行之前就完成了整个模块图的加载和链接。
和 require() 的行为对比
CommonJS 的 require() 不会被提升。引擎执行到 require('./b') 那一行时才去加载 b.js、执行它的代码、返回 module.exports。如果 require 写在 if 里,条件不满足就不会加载。
ESM 的 import 不能写在 if 里——它是声明,不是表达式,只能出现在模块的顶层。想要条件加载就得用 import()(动态导入)。
这个区别也影响了循环依赖的处理。require() 遇到循环依赖时,返回的是目标模块当前已经执行完的部分导出(可能是空对象或者不完整的对象)。ESM 遇到循环依赖时,绑定本身在解析阶段就建立了,但如果源模块还没执行到初始化语句,let 和 const 的绑定会处于暂时性死区(TDZ),读取会报 ReferenceError。
容易答错的地方
- 以为
import提升和var提升是同一种机制 var提升是把变量声明移到函数顶部,但赋值留在原地,所以提升后值是undefined。import提升不一样——它不只是"声明了一个名字",而是在执行前就完成了模块加载和绑定的建立。提升之后绑定的值就是源模块导出的实际值(或者实时绑定引用),不会是undefined。- 以为可以在
import前面修改导入的值 - 有人以为
import写在后面,前面的代码就能"抢先"使用同名的变量。其实不行。import声明创建的绑定和let以及const一样,在同一作用域里不允许有同名的变量声明。试图在import之前用let add = ...声明同名变量会报SyntaxError。
面试官还会怎么问?
如果一个模块被多个文件 import,它会执行几次?
只执行一次。引擎维护一个模块映射表,每个 URL 对应的模块只会被加载和执行一次。后续的 import 直接从映射表里拿到已经创建好的绑定,不会重新执行模块代码。
循环依赖时 import 提升会导致什么问题?
如果 A 导入 B,B 又导入 A,加载顺序取决于谁先被入口引用。假设先加载 A,执行到 A 的 import B 时去加载 B,B 里 import A 发现 A 还在加载中,就使用 A 已经建立但还没初始化的绑定。如果 A 的导出是 export const x = 1,在 B 执行时 x 还处于 TDZ,读取会报 ReferenceError。用 export function 导出函数就没有这个问题,因为函数声明会被提升。
import 提升对副作用有什么影响?
如果你 import './polyfill.mjs' 只是为了执行它的副作用(比如往全局对象上挂东西),不管这行 import 写在文件哪里,polyfill 都会在当前模块的任何代码之前执行。这通常是你想要的效果,但也意味着你没办法控制"先执行一些代码再加载 polyfill"。需要延后加载副作用模块的话,只能用 import()。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。