ES Modules(简称 ESM)是 ECMAScript 官方推出的模块化方案,从 ES6(ES2015)开始正式纳入语言标准。与社区自创的 CommonJS、AMD 不同,ESM 从语法层面原生支持模块化,意味着所有符合标准的 JavaScript 引擎都能直接理解 import 和 export 关键字,无需借助第三方库或构建工具(在支持的运行环境中)。
19.4.1 导出方式:让模块对外暴露接口
ESM 提供两种核心导出方式:命名导出 和 默认导出。一个模块可以同时使用两者,但默认导出通常用于模块的“主体”功能,命名导出用于辅助功能。
命名导出(Named Export)
命名导出可以导出多个值,导出时的名称必须与导入时使用的名称一致(除非使用 as 重命名)。可以直接在声明时导出,也可以统一在底部导出。
// math.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
// 或者统一导出
// export { PI, add, subtract };
默认导出(Default Export)
每个模块只能有一个默认导出。默认导出在导入时可以使用任意名称,无需关注原始名称。
// user.js
const user = {
name: 'Alice',
age: 30
};
export default user;
// 也可以直接导出值
// export default function() { ... }
默认导出的本质是导出名为 default 的命名导出,导入时的语法糖让我们可以给它起任意名字。
重命名导出与聚合导出
ESM 支持在导出时重命名,方便解决命名冲突;也支持将多个模块的导出聚合后重新暴露,常用来创建统一入口文件。
// utils.js
export { add as sum } from './math.js';
export { default as User } from './user.js';
export * from './constants.js'; // 全部导出
19.4.2 导入方式:静态导入与动态导入
静态导入(Static Import)
静态导入是 ESM 最显著的特征,import 语句必须在模块顶层使用,不能嵌套在条件判断或函数中。导入的路径必须是字符串字面量,不能是动态拼接的变量。
// 导入命名导出
import { PI, add } from './math.js';
// 导入默认导出
import user from './user.js';
// 同时导入默认和命名导出
import React, { useState } from 'react';
// 全部导入并指定命名空间
import * as math from './math.js';
console.log(math.add(2, 3));
// 仅执行模块(导入副作用)
import './styles.css';
静态导入的结构在编译时就能确定,这使得打包工具(如 Webpack、Rollup)可以进行 Tree Shaking ——分析哪些导出被使用,哪些未被使用,在最终产物中剔除未使用的代码,从而减小体积。这是 CommonJS 动态加载模型不具备的能力。
动态导入(Dynamic Import)
动态导入通过 import() 函数实现,返回一个 Promise。它可以出现在代码的任何位置,路径可以是动态拼接的字符串,非常适合于按需加载和条件加载。
// 条件加载
if (needFeature) {
import('./feature.js')
.then(module => {
module.default();
});
}
// 异步函数中使用
async function loadComponent() {
const { default: Component } = await import('./Component.js');
return Component;
}
// 路由懒加载(React 示例)
const LazyPage = React.lazy(() => import('./Page.js'));
动态导入不会参与静态分析,因此无法被 Tree Shaking 完全优化,但它解决了静态导入必须提前加载所有依赖的问题,是代码分割和性能优化的关键工具。
19.4.3 ESM 与 CommonJS 的核心差异
虽然两者都实现了模块化,但设计理念和运行机制有本质区别。理解这些差异是跨环境开发的必备知识。
| 特性 | ES Modules (ESM) | CommonJS (CJS) |
|------|------------------|----------------|
| 语法 | import / export | require() / module.exports |
| 加载时机 | 静态加载,在编译阶段确定依赖关系 | 动态加载,在运行时执行 |
| 输出形式 | 导出的是值的只读引用 | 导出的是值的拷贝 |
| this 指向 | 模块顶层的 this 为 undefined | 模块顶层的 this 指向当前模块的 exports |
| 是否支持 Tree Shaking | 支持(静态结构) | 不支持(动态特性) |
| 循环引用处理 | 可以正确解析,因为引用是动态的 | 导出的值可能是不完整的副本 |
只读引用 vs 值拷贝
这是两者在行为上最大的区别。CommonJS 在 require() 时,会执行模块代码并将其 module.exports 的对象作为结果返回,后续即使原模块内部变量变化,导入方拿到的仍然是之前拷贝的引用(除非直接修改该对象的属性)。
而 ESM 的 import 得到的并非一个拷贝,而是对原模块内部的只读引用。这意味着如果原模块的变量发生变化,导入方能够实时获取到最新值(但不能修改它)。这种设计配合静态结构,尤其适用于工具函数、常量集合等场景。
// counter.js (ESM)
export let count = 0;
export function increment() {
count++;
}
// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1,能感知到变化
如果是 CommonJS,同样的写法可能得到的是 0 和 0,因为导出时 count 是原始类型的值拷贝。
循环引用行为
ESM 依靠静态引用能更好地处理循环引用。当模块 A 和模块 B 互相引用时,由于导入的是引用,只要保证实际访问接口时相关值已经初始化,就不会拿到 undefined(除非真的还未初始化)。而 CommonJS 中,如果 A 在加载时执行到 require('B'),B 又 require('A'),可能会拿到尚不完整的 module.exports 对象,导致运行时错误或得到 undefined。
19.4.4 浏览器原生支持与编译兼容
浏览器中的原生 ESM
现代浏览器(Chrome 61+、Firefox 60+、Safari 11+、Edge 16+)已经原生支持 ESM。在 HTML 中通过 <script type="module"> 引入模块,浏览器会自动加载依赖,模块内支持 import 和 export,而且路径必须是完整的相对路径或绝对 URL(不能省略扩展名)。
<script type="module">
import { add } from './math.js';
console.log(add(1, 2));
</script>
这种原生支持意味着一些场景下可以不使用打包工具,直接在浏览器上运行模块化代码,大幅简化开发流程和调试体验。Vite 在开发服务器中就利用了这一特性,通过原生 ESM 实现极速的模块热更新。
但直接使用原生 ESM 也有限制:
- 需要对每个依赖发起单独的 HTTP 请求,模块数量多时性能堪忧(这可以通过 HTTP/2 多路复用缓解)。
- 老旧浏览器完全不支持(可结合
nomodule提供降级方案)。 - 不能直接使用 npm 包,因为 npm 包大多采用 CommonJS 或需要路径解析(
bare import如import React from 'react'在浏览器中无法直接识别),必须借助构建工具或 Import Maps 解决。
编译兼容策略
生产环境中,绝大多数项目仍需要将 ESM 编译为兼容更多浏览器和 Node.js 版本的格式。常见的工具链策略是:
- 开发阶段:使用 ESM 语法编写所有模块,享受静态分析和 IDE 智能提示的便利。
- 构建阶段:通过 Babel 将
import/export转换为 CommonJS 或其他格式,再由 Webpack/Rollup 打包为浏览器可直接运行的脚本(如 IIFE、UMD 等)。或者如果目标环境支持<script type="module">,则保留 ESM 格式并做模块合并/分包。
- Node.js 环境:Node.js 从 12.x 版本开始稳定支持 ESM(通过
.mjs文件扩展名或在package.json中设置"type": "module")。但 CommonJS 和 ESM 之间的互操作比较复杂:
- CJS 模块可以通过
import加载 ESM 模块,但只能使用动态导入。 - ESM 模块可以通过
import加载 CJS 模块,但 CJS 的默认导出会被映射为 ESM 的默认导出,命名导出只有通过静态分析才能部分支持(Node.js 做了兼容处理)。
因此,目前主流实践是库作者同时提供 ESM 和 CJS 的双版本产物(通过 package.json 的 main、module 和 exports 字段指定),让使用者按需选择。
19.4.5 最佳实践建议
- 写代码时统一使用 ESM 语法:它是标准,也是未来的主流方向,能享受更好的静态分析、Tree Shaking 和编辑器支持。
- 掌握动态导入:合理使用
import()实现路由懒加载、异步组件加载和按需功能,优化首屏性能。 - 了解双版本产物:当开发一个 npm 包时,考虑同时输出 ESM 和 CJS 版本,以覆盖更广泛的用户环境。
- 谨慎处理互操作:在 Node.js 中混用 ESM 和 CJS 时,务必测试加载行为,避免因导入导出方式不同导致的值不一致或循环引用问题。
ESM 的出现结束了 JavaScript 模块化多年的分裂局面,它为语言带来了真正的静态化模块规范。虽然实际项目中依然需要构建工具来抹平环境差异,但在标准层面,import 和 export 已是开发者书写的“通用语言”。