先记住这个答案
import() 是一个运行时加载模块的运算符,返回一个 Promise。这个 Promise resolve 之后,拿到的是模块的命名空间对象,具名导出是它的属性,默认导出挂在 .default 上。它必须是异步的,因为模块文件可能还没有被加载过——浏览器要发网络请求、解析代码、执行完模块里的逻辑,这些步骤不可能一步到位。
import()返回 Promise,resolve 值是命名空间对象- 模块可能需要网络请求和执行,所以必须异步
- 路径错误或模块有语法问题,Promise 直接 reject
- 同一模块多次
import()只执行一次,后续走缓存
命名空间对象长什么样
假设有一个工具模块,导出了一个函数和一个常量。用 import() 加载之后,Promise resolve 的值就是一个对象,每个导出都是它的属性。
import() 的基本用法JavaScript// 假设 utils.js 导出了 add 和 VERSION
// import('./utils.js') 返回 Promise
async function loadUtils() {
const mod = await import('./utils.js')
// mod 是命名空间对象,结构类似:
// { add: Function, VERSION: '1.0', default: ... }
console.log(mod.add(1, 2)) // 3
console.log(mod.VERSION) // '1.0'
console.log(mod.default) // 默认导出,没有则是 undefined
}命名空间对象是冻结的,不能给它添加新属性,也不能修改已有属性的指向。不过如果导出的是一个对象,修改那个对象内部的属性是可以的——这跟 Object.freeze 的浅冻结同一个道理。
实际项目里最常见的写法是直接用解构拿需要的导出。比如 const { renderChart } = await import('./chart.js'),这样比通过 mod.renderChart 访问更简洁。
为什么不能像静态 import 一样同步拿结果
静态 import 写在文件顶部,引擎在解析阶段就知道要加载哪些模块。代码还没开始跑,依赖关系已经确定了,引擎可以在执行前把所有模块都准备好。
import() 不一样。它是一个运行时表达式,可以出现在 if 里、函数里、事件回调里。引擎在解析阶段没办法知道这行代码会不会执行、什么时候执行。执行到这行时,目标模块可能还在服务器上,所以必须返回一个 Promise,等加载完了再 resolve。
即使目标模块已经被加载过(比如之前某处用过 import() 加载了同一个路径),import() 仍然返回 Promise。引擎从模块缓存里直接取,不会重新下载和执行,但返回值类型始终是 Promise,这样调用方不用关心模块有没有被缓存过,写法始终一致。
加载失败怎么处理
路径不存在、网络超时、模块内部有语法错误,这些情况 import() 返回的 Promise 都会 reject。
try...catch 捕获错误JavaScriptasync function loadChart() {
try {
const { renderChart } = await import('./chart.js')
renderChart('#container')
} catch (err) {
// 路径错会报 TypeError
// 模块语法错会报 SyntaxError
console.error('图表模块加载失败', err)
showFallback()
}
}这跟 fetch 请求失败的处理方式一样。如果不写 catch,Promise rejection 会变成未捕获异常。生产环境里动态加载的模块一般都要加降级逻辑,比如显示一个"加载失败,请刷新"的提示。
需要注意的是,一旦某个模块加载失败,下次再 import() 同一个路径,不会自动重试,而是直接返回上次的错误。想重试的话,需要给路径加上不同的查询参数来绕过缓存,比如 import('./chart.js?retry=1')。
容易答错的地方
- 以为
import()是函数 - 很多人说"调用
import函数",其实import()是运算符,和typeof x类似。你不能写const myImport = import然后myImport('./a.js'),也不能用.call()改变上下文。规范把它定义为运算符而不是函数,是为了让引擎能对它做特殊的静态分析优化。 - 以为返回的是默认导出
import()返回的是整个命名空间对象,不是默认导出。想拿默认导出要写(await import('./a.js')).default。常见错误是const Component = await import('./Button.js')然后直接当组件用,结果拿到的是一个有default属性的对象,渲染什么都出不来。- 以为每次
import()都会重新执行模块 - 模块只会被执行一次。第一次
import()触发加载和执行,之后再import()同一个路径,引擎从缓存里取,直接返回上次的命名空间对象。如果模块里有副作用(比如console.log('模块执行了')),只会打印一次。
面试官还会怎么问?
import() 能在 CommonJS 文件里用吗?
可以。从 Node.js 12 开始,CommonJS 文件里也能用 import() 来加载 ESM 模块。这是 CJS 和 ESM 互通的主要桥梁,因为 CJS 不支持静态 import,而 require() 又不能加载 ESM 文件。
import() 和 require() 的区别在哪?
require() 是同步的,模块代码在 require() 那一行就执行完了,返回的是 module.exports 的值拷贝。import() 是异步的,返回 Promise。另外 require() 拿到的是值的快照(对基本类型来说),而 import() 拿到的命名空间对象里的属性是实时绑定的——源模块改了值,这边读到的也跟着变。
Webpack 里 import() 是怎么做到代码分割的?
Webpack 在编译时把 import() 的目标模块及其依赖打成一个单独的 chunk 文件。运行时 import() 被替换成动态创建 <script> 标签加载该 chunk 的逻辑,加载完成后 resolve Promise。这就是路由懒加载的底层原理——每个路由对应一个 chunk,进入该路由时才下载。
多个 import() 可以并行加载吗?
可以。用 Promise.all 就行,比如 const [modA, modB] = await Promise.all([import('./a.js'), import('./b.js')])。两个模块会同时开始加载,总耗时取决于最慢的那个,而不是两者之和。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。