跳到主要内容

详解:JavaScript 模块的导出与导入

下面把 JavaScript 模块系统(主要是 ES Modules(ESM)CommonJS(CJS))的导出/导入机制逐项讲清楚,包含语法、运行时行为、常见坑和最佳实践。示例用中文注释并给出可复制的代码片段。


1. 两个主要模块体系概览

  • ES Modules(ESM):现代浏览器、现代 Node.js 的标准模块系统。静态解析(编译时确定导入导出),支持 import / export / export default / 动态 import() / import.meta / top-level await(在支持环境)。
  • CommonJS(CJS):Node.js 传统的模块系统(require / module.exports / exports)。运行时加载(同步 require)。

现代开发中推荐使用 ESM(便于静态分析、tree-shaking、按需加载),但仍需了解 CJS 以便与旧代码互操作。


2. ESM:导出(export)语法

2.1 命名导出(Named exports)

// lib.js
export const pi = 3.14159;
export function sum(a, b) { return a + b; }
export class MyClass { /* ... */ }

或一次性导出:

const pi = 3.14;
function sum(a,b){ return a+b; }
export { pi, sum };

也可以重命名导出:

export { pi as PI, sum as add };

2.2 默认导出(Default export)

一个模块只能有一个 default:

// defaultExport.js
export default function greet(name) { return `Hello ${name}`; }

// 或导出一个表达式/对象
export default { a: 1, b: 2 };

2.3 重新导出(Re-export)

转发另一个模块的导出:

// reexport.js
export { foo, bar } from './other.js';
export * from './other.js'; // 导出除 default 外的所有命名导出
export { default as OtherDefault } from './other.js';

2.4 导出变量的“活绑定”(live bindings)

ESM 的导出是引用绑定 —— 导出的变量在原模块被修改时,导入方能看到最新值(不是拷贝)。

// counter.js
export let count = 0;
export function inc(){ count++; }

// main.js
import { count, inc } from './counter.js';
console.log(count); // 0
inc();
console.log(count); // 1 (live binding)

3. ESM:导入(import)语法

3.1 基本导入

import { pi, sum } from './lib.js';
import defaultExport from './defaultExport.js';
import defaultExport, { named1, named2 } from './mixed.js';

3.2 重命名导入

import { pi as PI } from './lib.js';

3.3 导入整个模块为命名空间对象

import * as utils from './lib.js';
console.log(utils.pi, utils.sum(1,2));

注意:utils 是只读的命名空间对象(内部属性可变如果导出变量可变,但不能直接给 utils = ... 赋新值)。

3.4 动态导入(import())

返回 Promise,可用于按需加载 / 条件加载 / SSR 时延迟加载:

if (needFeature) {
const module = await import('./heavy.js'); // 在支持 top-level await 的环境可直接 await
module.doThing();
}

3.5 import.meta

包含模块元信息(例如 import.meta.url 表示当前模块文件 URL):

console.log(import.meta.url);

3.6 import 位置与静态解析

import(静态形式)必须位于顶层(不能在条件语句里),以便工具和打包器静态解析依赖关系。动态 import() 可以在运行时调用。


4. CommonJS(CJS):导出与导入

4.1 导出

// lib.cjs
module.exports = {
pi: 3.14,
sum: (a,b)=>a+b
};
// 或单独导出
exports.pi = 3.14;

4.2 导入

const lib = require('./lib.cjs'); // 同步
console.log(lib.pi);

4.3 与 ESM 的主要差异

  • CJS 的 require 是运行时求值,返回值是拷贝的对象或引用(module.exports)。
  • ESM 支持静态分析、live bindings,而 CJS 不然。
  • CJS 可以在任何代码位置 require,而 ESM 静态 import 必须在顶层。

5. Node.js 中 ESM 与 CJS 的互操作(常见问题)

  • 在 Node.js 项目中,要使用 ESM 文件扩展名 .mjs,或在 package.json 中设置 "type": "module" 以将 .js 视为 ESM。
  • 从 ESM import CJS 模块:import pkg from './cjs.js',导入得到的是 CJS 模块的 module.exports 值,不能像命名导出那样直接绑定 CJS 的单个属性(需要 pkg.someProp)。
  • 从 CJS require ESM 模块:Node.js 对此有限支持(需要动态 import() 或特殊处理),通常更复杂。
  • 默认导出与命名导出的互相映射会产生兼容性问题(例如 export default 转 CJS 通常会变成 { default: ... })。很多打包器/工具提供 interop 处理(例如 Babel、Rollup、Webpack)。

6. 高级与细节行为

6.1 Tree-shaking(摇树优化)

ESM 的静态结构允许打包器移除未使用导出,从而减小包体积。命名导出更利于 tree-shaking,默认导出可能不那么友好(视工具而定)。

6.2 循环依赖(circular dependencies)

两模块互相导入是允许的,但行为受模块系统影响:

  • ESM:由于 live bindings,循环依赖下你可能在某个模块中看到另一个模块的未完全初始化状态(但不会阻塞);通常需要设计避免在模块初始化时执行大量逻辑。
  • CJS:循环依赖可能导致 require 到的模块对象不完整(模块尚在执行中),会得到部分导出的对象。

示例:

// a.js
import { bVal } from './b.js';
console.log('a sees', bVal);
export const aVal = 'A';

// b.js
import { aVal } from './a.js';
console.log('b sees', aVal);
export const bVal = 'B';

执行顺序和可见值需谨慎处理。

6.3 导出语句的提升(hoisting)

export 语句本身在模块解析阶段被处理,所以导出可以在模块顶部被其它模块引入;但导出的变量如果未初始化,则在访问时会抛错。总体上导入/导出是静态的。

6.4 Top-level await

在支持的环境(现代 Node.js、某些打包器运行时),ESM 支持顶层 await,这会使模块执行等待相应 Promise 完成:

// config.js
export const config = await loadConfig();

6.5 import assertions(例如 JSON 模块)

现代规范允许 import x from './data.json' assert { type: 'json' }(浏览器/Node 支持取决于版本)。用于明确导入 JSON 模块。


7. 常见错误与陷阱(以及如何避免)

  1. import 放在条件语句内(静态 import 必须顶层)

    • 使用 import() 动态导入替代。
  2. 混合使用 CJS 和 ESM 不处理互操作

    • 在 Node.js 中,明确项目类型("type": "module")并使用合适的扩展名,或使用工具进行转译。
  3. 忘记文件扩展名(在浏览器中)

    • 浏览器的 <script type="module"> 使用相对或绝对 URL,通常需要带 .js 扩展名。打包器可以省略。
  4. 循环依赖导致未初始化的值

    • 避免模块在顶层执行副作用逻辑;将初始化推迟到函数或工厂。
  5. 默认导出与命名导出混淆

    • 明确使用命名导出或默认导出,不要同时依赖工具的互操作细节。

8. 浏览器与 <script type="module">

<script type="module" src="./app.js"></script>
  • 模块在严格模式下运行,脚本延迟加载(类似 defer),且相互依赖使用相对/绝对 URL。
  • 支持 import / export
  • 同源策略和 CORS:跨域导入模块需要适当的 CORS 响应头。

9. 实用示例:构建一个小模块图

文件结构:

/math/
add.js
index.js
main.js

add.js

export function add(a,b){ return a+b; }

index.js

export * from './add.js';
export const name = 'math-utils';

main.js

import { add, name } from './math/index.js';
console.log(name, add(1,2)); // math-utils 3

10. 最佳实践(简短清单)

  • 新项目尽量使用 ESM
  • 使用 命名导出export const ...)便于 tree-shaking与维护;在需要时再使用 export default
  • 避免模块顶层副作用(使模块更容易测试和复用)。
  • 明确 package.json"type",并统一文件扩展名约定(.mjs.js + "type":"module")。
  • 遇到兼容问题使用构建工具(Babel/Rollup/Webpack/Vite)处理 CJS↔ESM 互操作。
  • 在循环依赖场景下延迟初始化或重构依赖关系。

如果你想,我可以:

  • 给你一个针对 Node.js(带 "type":"module")的示例仓库 package.json + 代码,或者
  • 把上面的示例改成可在浏览器 <script type="module"> 直接跑的版本,或者
  • 针对你现有代码检查并给出如何把 CJS 转为 ESM 的具体改动建议。

你想先看哪个示例?