先记住这个答案
能安全调用。ESM 在执行任何模块代码之前,会先给每个模块创建好作用域,这一步叫实例化,函数声明就在这时候创建并赋值。所以循环依赖里,即使导出方一行代码都还没跑,导入方调用它导出的函数声明也没问题。换成 const fn = () => {} 就不行了,const 要等代码执行到那一行才赋值,提前访问会抛 ReferenceError。
function声明在实例化时就创建,循环里能提前调用- 箭头函数、函数表达式、
class提前用会抛ReferenceError - 函数能调用,不代表函数体里用到的变量已经初始化
export default function声明也会提升
声明和箭头函数的对比
a.js 是入口,它导入 b.js;b.js 又反过来导入 a.js,并且在顶层直接调用 a.js 导出的两个函数。
// a.js(入口)
import './b.js'
export function hello() { return 'hello' } // 函数声明
export const hi = () => 'hi' // 箭头函数赋给 const
// b.js
import { hello, hi } from './a.js'
console.log(hello()) // 'hello',a.js 还没执行,但函数已经存在
console.log(hi()) // ReferenceError: Cannot access 'hi' before initialization执行顺序是:a.js 的第一行 import 让 b.js 先执行。这时 a.js 里一行代码都还没跑。hello 是函数声明,实例化时已经放进 a.js 的作用域里了,所以能调。hi 是 const,要等 a.js 执行到那一行才赋值,现在还在暂时性死区里。
为什么函数声明这么特殊
这和普通脚本里的函数提升是同一件事,只是发生在模块层面。模块实例化时,引擎扫描一遍顶层声明:function 声明直接创建函数对象并赋值;let、const、class 只登记名字,不赋值;var 登记名字并设成 undefined。
ESM 的导入是实时绑定,b.js 里的 hello 就是 a.js 里那个 hello 变量本身。实例化结束时它已经有值,所以不管 a.js 执行到哪儿,b.js 都能调用它。
export default function hello() {} 也是函数声明,同样会被提升,匿名的 export default function () {} 也一样。但 export default () => {}、export default someFactory() 这类表达式,要等执行到那一行才有值,提前调用会抛 ReferenceError。
容易答错的地方
- 以为导出的函数都能提前调用
- 只有
function声明可以。项目里很多人习惯写export const fn = () => {},这种写法在循环依赖里提前调用一定会报错。改成函数声明是最快的修法之一。 - 以为是 ESM 导出做了特殊处理
- 不是导出机制特殊,是函数声明本身在实例化时就创建。同样的函数声明不导出,在自己模块里也能在声明之前调用。ESM 做的只是让导入方看到的就是这个变量本身。
- 以为 CommonJS 里也一样
- CommonJS 里
b.js拿到的是a.js当时的exports对象。exports.hello = hello这行还没执行,exports.hello就是undefined,调用会报TypeError: a.hello is not a function,即使hello是函数声明也没用。
面试官还会怎么问?
class 导出在循环依赖里能提前 new 吗?
不能。class 声明和 let 一样有暂时性死区,执行到声明那行之前访问会抛 ReferenceError。要在循环里提前用,只能改结构,把类抽到不参与循环的模块里。
如果入口换成 b.js,上面的例子还会报错吗?
不会。入口是 b.js 时,它先执行依赖 a.js,a.js 执行完 hi 已经有值,再回到 b.js 调用两个函数都正常。循环依赖的代码能不能跑,和谁先被加载有关,这也是它难排查的原因。
打包成 CommonJS 格式后还能这样用吗?
要看打包工具怎么转换。Babel 转 CommonJS 时会把函数声明的导出赋值提到模块最前面,所以通常还能提前调用。但这依赖工具的实现细节,不要把业务正确性建立在这上面。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。