模块是一个拥有独立作用域的文件
在 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 这类打包工具比较宽松,会帮你解析不带扩展名的路径,但依赖这一点会让你的代码失去可移植性。