javascript

模块与导入导出

By AI-Writer 12 min read

随着 JavaScript 应用规模的增长,模块化成为组织代码的必备手段。JavaScript 主要有两套模块系统:CommonJS(CJS)和 ES Modules(ESM),它们在设计理念和语法上有着本质区别。

CommonJS(CJS)

CommonJS 是 Node.js 最初的模块系统,使用 requiremodule.exports

基本语法

javascript
// math.js
function add(a, b) {
  return a + b;
}

function multiply(a, b) {
  return a * b;
}

module.exports = { add, multiply };

// 也可以这样导出单个值
// module.exports = add;
javascript
// main.js
const math = require('./math.js');
console.log(math.add(2, 3));      // 5
console.log(math.multiply(4, 5)); // 20

// 解构导入
const { add, multiply } = require('./math.js');

CJS 的特点

javascript
// 1. 运行时加载 —— 可以在条件语句中使用
if (process.env.NODE_ENV === 'development') {
  const logger = require('./dev-logger');
  logger.init();
}

// 2. 同步加载
const fs = require('fs');
const data = fs.readFileSync('./data.json');

// 3. 值的拷贝 —— 导出后修改内部值不影响已导入的副本
let count = 0;
module.exports = { count };
// 导入方拿到的是 count=0 的拷贝

module.exports 与 exports

javascript
// exports 是 module.exports 的引用
exports.add = add;           // OK
exports = { add };           // 危险!切断了与 module.exports 的链接
module.exports = { add };    // OK,推荐写法

ES Modules(ESM)

ESM 是 ECMAScript 官方标准,使用 importexport,现代浏览器和 Node.js 均原生支持。

命名导出与导入

javascript
// utils.js
export const PI = 3.14159;

export function capitalize(str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

export class Validator {
  static isEmail(str) {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
  }
}
javascript
// main.js
import { PI, capitalize, Validator } from './utils.js';

console.log(PI); // 3.14159
console.log(capitalize('hello')); // 'Hello'
console.log(Validator.isEmail('test@example.com')); // true

// 重命名导入
import { capitalize as cap } from './utils.js';
console.log(cap('world')); // 'World'

// 批量导入
import * as utils from './utils.js';
console.log(utils.PI);

默认导出

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

javascript
// config.js
const config = {
  apiBase: 'https://api.example.com',
  timeout: 5000,
  retries: 3
};

export default config;
javascript
// main.js
import config from './config.js';           // 任意命名
import myConfig from './config.js';         // 可以叫任何名字
import { default as config } from './config.js'; // 等价写法

混合导出

javascript
// api.js
export const API_VERSION = 'v2';

export function get(url) { /* ... */ }
export function post(url, data) { /* ... */ }

// 默认导出 Axios 风格实例
export default {
  get,
  post,
  request(config) { /* ... */ }
};
javascript
import api, { API_VERSION, get } from './api.js';

CJS vs ESM 对比

特性CommonJSES Modules
语法require / module.exportsimport / export
加载时机运行时同步加载编译时静态解析
加载方式同步异步(浏览器)/ 同步(Node)
顶层 thismodule.exportsundefined
条件导入支持不支持顶层,需用动态导入
循环依赖部分支持支持(更优)
tree-shaking困难天然支持

动态导入

ESM 的 import() 函数允许在运行时动态加载模块,返回一个 Promise:

javascript
// 基本用法
async function loadPlugin(name) {
  const plugin = await import(`./plugins/${name}.js`);
  plugin.init();
}

// 条件加载
if (process.env.ENABLE_ANALYTICS) {
  const { track } = await import('./analytics.js');
  track('page_view');
}

// 同时加载多个
const [moduleA, moduleB] = await Promise.all([
  import('./a.js'),
  import('./b.js')
]);

动态导入是实现代码分割(Code Splitting)和懒加载的核心手段:

javascript
// React 中的路由懒加载
const Dashboard = lazy(() => import('./pages/Dashboard.jsx'));

循环依赖

循环依赖(A 依赖 B,B 又依赖 A)在复杂项目中难以避免。

CJS 中的循环依赖

javascript
// a.js
console.log('a.js 开始执行');
exports.done = false;
const b = require('./b.js');
console.log('a.js: b.done =', b.done);
exports.done = true;
console.log('a.js 执行完毕');

// b.js
console.log('b.js 开始执行');
const a = require('./a.js');
console.log('b.js: a.done =', a.done);
exports.done = true;
console.log('b.js 执行完毕');

输出:

plaintext
a.js 开始执行
b.js 开始执行
b.js: a.done = false  // a 还没执行完,拿到不完整导出
b.js 执行完毕
a.js: b.done = true
a.js 执行完毕

CJS 在循环依赖时返回已执行部分的导出副本,可能导致拿到未完成的模块。

ESM 中的循环依赖

ESM 通过实时绑定(Live Binding)更好地处理循环依赖:

javascript
// a.mjs
console.log('a.mjs 开始');
export let done = false;
import * as b from './b.mjs';
console.log('a.mjs: b.done =', b.done);
done = true;
console.log('a.mjs 结束');

// b.mjs
console.log('b.mjs 开始');
import * as a from './a.mjs';
console.log('b.mjs: a.done =', a.done);
export let done = true;
console.log('b.mjs 结束');

ESM 的导出是引用绑定而非值拷贝,当 a.done 被赋值为 true 时,所有引用它的地方都能看到最新值。

避免循环依赖的建议

  1. 提取公共模块:将共享代码提取到第三个模块
  2. 延迟引用:在函数内部动态 require/import
  3. 重构代码:重新考虑模块边界
javascript
// 方案:在函数内部延迟引用
// a.js
import { helper } from './common.js';

export function doA() {
  // 延迟加载,避免顶层循环依赖
  const b = require('./b.js');
  return helper() + b.doB();
}

实际项目中的模块策略

Node.js 中使用 ESM

json
// package.json
{
  "type": "module",
  "scripts": {
    "start": "node server.js"
  }
}

设置 "type": "module" 后,所有 .js 文件按 ESM 解析。如需使用 CJS,将文件改为 .cjs

导入路径映射

json
// package.json
{
  "imports": {
    "#config/*": "./config/*",
    "#utils/*": "./src/utils/*"
  }
}
javascript
import { db } from '#config/database.js';
import { formatDate } from '#utils/date.js';

包导出映射(exports field)

json
{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./helpers": {
      "import": "./dist/helpers.mjs",
      "require": "./dist/helpers.cjs"
    }
  }
}

这样包可以同时支持 CJS 和 ESM 用户。

小结

场景推荐方案
新项目(Node.js 14+)ESM + "type": "module"
浏览器应用ESM(配合打包工具)
需要动态加载import() 动态导入
库作者同时提供 CJS + ESM 双格式
避免循环依赖提取公共模块、延迟引用

ESM 是 JavaScript 模块化的未来,CJS 在 Node.js 生态中仍有大量存量代码。理解两者的差异和互操作方式,是现代化 JavaScript 开发的必修课。

#javascript #esm #commonjs #module #import #export

评论

A

Written by

AI-Writer

Related Articles

javascript
#9

ES2021~ES2025 新特性

系统梳理 ES2021 到 ES2025 每年引入的语言新特性,包括逻辑赋值运算符、顶层 await、装饰器、Records & Tuples 等前沿语法。

Read More
javascript
#8

模块与导入导出

对比 ESM 与 CommonJS 两种模块系统,掌握动态导入、循环依赖处理及实际项目中的模块组织策略。

Read More