人人都会AI编程

11.7 模块化:ESM 静态导入导出

更新时间:2026-07-11

在 ES6 之前,JavaScript 一直没有官方的模块系统,社区各自发展出了 CommonJS、AMD、CMD 等方案。ES6 正式引入了 ES Modules(ESM),将模块化作为语言标准,从根本上改变了代码组织方式。

11.7.1 为什么需要模块化

没有模块化时,所有代码都在全局作用域中,变量和函数会互相污染、覆盖,依赖关系难以追踪。模块化的核心目标很简单:

  • 封装性:每个模块拥有自己独立的作用域,不会污染全局。
  • 显式依赖:模块之间的依赖关系通过 import 和 export 清晰表达,一眼即可看清结构。
  • 可复用性:功能拆分到独立模块后,可以在不同项目中复用。

11.7.2 导出(export)

ESM 提供两种主要导出方式:命名导出默认导出

命名导出

一个模块可以导出多个具有名称的变量、函数或类。导出语句并不要求放在末尾,可以在声明时直接导出,也可以单独导出。

// math.js
export const PI = 3.14159;
export function add(a, b) {
  return a + b;
}
export function multiply(a, b) {
  return a * b;
}

// 或者先声明再导出
const PI = 3.14159;
function add(a, b) { return a + b; }
export { PI, add };

导出时也可以使用 as 进行重命名:

export { add as sum };

默认导出

每个模块只能有一个默认导出,通常用于导出模块的“主力”功能,比如一个函数、类或对象。

// logger.js
export default function log(msg) {
  console.log(`[LOG] ${msg}`);
}

默认导出可以匿名,但给一个名称有助于可读性。一个文件中可以同时存在命名导出和默认导出:

export const version = '1.0';
export default class Calculator { /* ... */ }

11.7.3 导入(import)

导入命名导出

要导入命名导出,必须使用花括号,且名称必须与导出时的名称完全一致(除非用 as 重命名)。

import { PI, add } from './math.js';
console.log(PI);        // 3.14159
console.log(add(2, 3)); // 5

可以通过 as 在导入时重命名,避免命名冲突:

import { add as mathAdd } from './math.js';
import { add as stringAdd } from './string.js';

也可以使用 * 将整个模块作为一个对象导入:

import * as math from './math.js';
console.log(math.PI);
console.log(math.add(1, 2));

导入默认导出

导入默认导出时不需要花括号,可以自定义一个名称:

import log from './logger.js';
log('hello');

混合导入

同时导入默认导出和命名导出:

import Calculator, { version } from './calculator.js';

仅执行模块而不导入内容

有些模块只是执行副作用(如注册全局变量、polyfill),并不需要显式导入任何绑定:

import './polyfills.js';

11.7.4 静态特性与限制

ESM 的导入导出是静态的,这意味着:

  1. import 和 export 必须位于模块顶层,不能放在条件语句或函数内部。

这保证了所有依赖关系在代码执行前就已经确定,为打包工具的树摇(Tree Shaking)和静态分析提供了可能。

  1. 导入的绑定是只读的“实时引用”,而不是值的拷贝。

这与 CommonJS 完全不同。在 ESM 中,导入的变量是对导出模块中对应变量的直接引用,如果导出模块内部改变了该变量的值,导入方会立即反映。

// counter.js
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 的 module.exports 是值拷贝形成鲜明对比。CommonJS 的模块导出的是值的快照,后续原始变量的变化不会影响已导入的值(反之亦然)。

11.7.5 动态导入(Dynamic Import)

尽管 ESM 强调静态结构,ES2020 也引入了 import() 动态导入 函数,它返回一个 Promise,可以在任意位置按需加载模块,实现代码分割和条件加载。

if (condition) {
  import('./feature.js')
    .then(module => {
      module.doSomething();
    });
}

动态导入常用于路由懒加载、大体积库的按需加载等场景。需要注意,动态导入破坏了静态分析的优势,打包工具无法完全去除未使用的导出,因此应合理使用。

11.7.6 ESM 与 CommonJS 的核心差异

| 特性 | ES Modules | CommonJS |
|------|------------|----------|
| 引入时机 | 编译时静态解析 | 运行时加载 |
| 导出绑定 | 实时引用(live binding) | 值拷贝 |
| this 指向 | 模块顶层 this 为 undefined | 模块顶层 this 指向当前模块(module.exports) |
| 循环引用处理 | 可正确处理(引用已存在) | 可能因加载顺序导致部分导出为空对象 |
| 动态导入 | import() 支持 | require() 本身就是同步动态加载 |
| 使用环境 | 浏览器 & Node.js (>=12) | Node.js |

11.7.7 实用建议

  • 优先使用 ESM。在现代项目(无论是前端还是 Node.js)中,ESM 已经成为标准,打包工具也深度依赖其静态结构进行优化。
  • 模块路径清晰。浏览器中需要指定完整的路径(包括扩展名 .js),而打包工具和 Node.js 通常可以省略(Node.js 支持解析无扩展名的文件)。
  • 避免在模块顶层执行副作用。纯导出功能的模块更容易被 Tree Shaking 优化,也更容易测试。
  • 使用命名导出还是默认导出?

命名导出更有利于编辑器自动导入和树摇,且名字明确;默认导出适合模块只有一个主要输出物的场景。团队内部应统一规范,以减少混淆。

掌握了 ESM 的设计和使用,你就拥有了构建大型、可维护 JavaScript 应用的基石。它与 CommonJS 的兼容性、打包工具的集成等内容,将在后续工程化章节进一步展开。