Menu

JavaScript ES 模块:import、export 与动态加载

JavaScript 中的 ES 模块是怎么工作的:具名导出和默认导出、import 语法、动态 import(),以及区分模块与普通脚本的那些规则。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

模块是一个拥有独立作用域的文件

在 ES 模块出现之前,每个 <script> 标签都会把它的变量倾倒到全局命名空间中,谁能看到什么取决于加载顺序。ES 模块解决了这个问题:每个文件都有自己的作用域。除非你显式地 export,否则什么都不会泄露出去;除非你显式地 import,否则什么都不会进来。

两个文件,一个导出,一个导入:

add 和 multiply 存在于 math.js 中。它们之所以在 main.js 中可见,完全是因为 import。math.js 中的其他任何东西,辅助函数、常量等,在外部都无法访问。

由此得出两条规则,值得尽早牢记:

  • 模块自动运行在严格模式下,不需要写 'use strict'。
  • 顶层的 this 是 undefined,而不是全局对象。

具名导出:边写边导出

这是最常见的形式。在任何 function、class、const 或 let 前面加上 export,它就成为模块对外公开的一部分:

花括号中的名称必须与导出的名称完全一致,import { circlearea } 就会失败。如果某个名称与你已有的东西冲突,可以在导入时用 as 重命名:

你也可以不在声明处导出,而是在文件底部统一列出导出项,有些人更喜欢这种写法,因为它有一个清晰的“公开 API”区域:

两种写法的效果完全相同。选择其中一种,在整个项目中保持一致。

默认导出:每个模块只能有一个

一个模块还可以有一个默认导出。默认导出适用于真正只有一个主要内容的文件:一个组件、一个类、一个配置对象:

有三点需要注意:

  • 导入时 log 两边没有花括号。
  • 导入的名称可以随便取。import shout from './logger.js' 的效果完全一样。
  • 每个文件只能有一个默认导出。如果再加一个,文件就无法解析。

具名导出和默认导出可以同时存在:

先写默认导出,再在花括号中写具名导出。顺序是固定的。

该用哪一种? 具名导出更容易重构:在整个代码库中重命名只需要一次查找替换,因为每个导入都使用同一个名称。默认导出很灵活,但每个调用方都可以取不同的名字,这让 grep 搜索变得更难。大多数现代代码风格指南都倾向于具名导出,只在真正单一用途的模块中使用默认导出。

全部导入、重新导出与副作用导入

你还会遇到另外几种导入形式。

把所有具名导出收集到一个命名空间对象中:

从另一个模块重新导出,而不导入到当前作用域:

库的入口文件就是用这种方式,把内部各个文件中的部分汇集成一个统一的公开接口。

最后,还有一种不绑定任何名称的导入,用于那些唯一作用就是产生副作用的模块(polyfill、CSS-in-JS、注册处理函数):

这个文件会运行一次,不会按名称导入任何东西。

导入是静态的,也是实时的

import 有两个特性偶尔会让人感到意外。

静态。 import 声明在你的任何代码运行之前就已解析完毕。你不能把它放在 if、函数或 try 里面。路径必须是字符串字面量,不能是变量。正因如此,工具无需执行代码就能分析导入:打包工具、类型检查器和 tree-shaking 工具都依赖这一点。

// Not allowed - SyntaxError.
if (userWantsFancy) {
  import { fancy } from './fancy.js';
}

如果需要按条件加载,就用 import()(下一节会讲到)。

实时。 导入的绑定是指向导出项的只读引用,而不是快照。如果导出模块重新给这个值赋值,导入方看到的就是新值:

你也不能在使用方重新给导入项赋值:在 main.js 中写 count = 5 会抛出错误。导入项是只读的视图。

用动态 import() 按需加载

当你需要在运行时才决定是否加载某个模块时(大型功能、基于路由的代码分割、按条件加载的 polyfill),就把 import() 当作函数来用。它返回一个 Promise,兑现为该模块的导出内容:

因为它是一个普通的函数调用,所以你可以:

  • 在 async 函数中 await 它。
  • 传入一个变量作为路径。
  • 在 if 或 try/catch 中使用它。

对兑现得到的对象进行解构,和静态导入的效果一样:

解构时,default 是默认导出对应的键。你可以把它重命名为任何你想要的名称。

实际的使用场景包括代码分割(只有当用户点击“显示图表”时才加载图表库)、基于特性检测的 polyfill,以及在运行时才被发现的插件。

运行 ES 模块:浏览器与 Node

语法在任何地方都一样,变化的是运行时如何找到并加载文件。

在浏览器中,把入口脚本标记为模块:

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

有了 type="module",浏览器就会识别 import/export,以严格模式运行代码,并推迟到 HTML 解析完成后再执行。路径必须是相对路径(./、../)或绝对 URL。像 import 'lodash' 这样的裸模块名,在没有 import map 或打包工具的情况下无法使用。

在 Node 中,有两种方式启用模块:

  • 把文件命名为 .mjs,或者
  • 在最近的 package.json 中设置 "type": "module",这会让每个 .js 文件都成为模块。

Node 同样要求写完整的路径和扩展名:import './utils.js',而不是 import './utils'。

// package.json
{
  "type": "module",
  "main": "./index.js"
}

在原生 ESM 中,两种环境都要求显式写出扩展名。打包工具(Vite、webpack、esbuild)会在开发时帮你解析不带扩展名的路径。这很方便,但依赖它意味着你的源代码离开构建步骤就无法运行。

常见陷阱

几个容易让人踩坑的地方:

  • 在浏览器中忘了写 type="module"。 没有它,<script> 会作为传统脚本运行,import 就成了语法错误。
  • 在 Node 中漏写文件扩展名。 import './utils' 会失败,import './utils.js' 才行。打包工具会掩盖这个问题,原生运行时则不会。
  • 期待在 ES 模块中使用 __dirname 或 require。 它们只属于 CommonJS。在 ESM 中,使用 import.meta.url,需要路径时再进行转换。
  • 循环导入时访问了尚未就绪的值。 两个模块互相导入是合法的,但读取一个还没被赋值的导出项会得到 undefined。调整代码结构,让初始化过程中不会触发这个循环,或者把循环拆开。
  • 试图按条件 import。 静态的 import 语句不允许这样做。凡是依赖运行时的情况,都用动态 import()。

下一步:CommonJS 与 ESM

ES 模块是标准,但实际项目中仍有大量 Node 代码在使用 CommonJS:require、module.exports,以及关于代码何时运行的另一套规则。了解这两者以及它们之间如何互操作,就是下一页的内容。

常见问题

JavaScript 中如何使用 ES 模块?

在一个文件中用 export 或 export default 导出值,然后在另一个文件中用 import 引入。在浏览器中,用 <script type="module" src="main.js"></script> 加载入口文件。在 Node 中,要么使用 .mjs 扩展名,要么在 package.json 中设置 "type": "module"。

默认导出和具名导出有什么区别?

一个模块可以有多个具名导出(export function foo() {}),但只能有一个默认导出(export default ...)。具名导出必须在花括号中用完全相同的名称导入:import { foo } from './x.js'。默认导出则可以用任意名称导入:import whatever from './x.js'。

JavaScript 中的动态 import() 是什么?

把 import() 当作函数调用,它会返回一个 Promise,兑现为该模块的导出内容。与静态的 import 语句不同,它在调用时才执行,所以你可以按条件或按需加载代码。代码分割和懒加载就是用它实现的。

import 路径中需要写文件扩展名吗?

在原生 ES 模块中(浏览器和 Node 的 ESM 加载器),需要。你必须写 ./utils.js,而不是 ./utils。Vite 和 webpack 这类打包工具比较宽松,会帮你解析不带扩展名的路径,但依赖这一点会让你的代码失去可移植性。

Coddy 编程语言插图

和 Coddy 一起学编程

开始