强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Node.js 开发指南 / 第 5 章 · 模块系统

第 5 章 · 模块系统

5.1 模块系统概述

Node.js 的模块系统是其最重要的特性之一。它允许我们将代码拆分为独立、可复用的单元。

两种模块系统对比

特性CommonJS (CJS)ES Modules (ESM)
加载方式同步加载异步加载
语法require() / module.exportsimport / export
运行时解析❌(编译时解析)
动态导入✅ 天然支持import() 函数
循环依赖部分支持支持(活绑定)
this 指向module.exportsundefined
文件扩展名.js(默认).mjs.js"type": "module"
__dirname✅ 可用❌ 需要手动构造
浏览器兼容✅ 原生支持
Tree Shaking✅ 静态分析
Node.js 默认✅(当前)趋势方向

5.2 CommonJS 详解

基本导出

// math.js — 导出方式 1:module.exports
function add(a, b) {
  return a + b;
}

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

const PI = 3.14159;

module.exports = { add, multiply, PI };
// math.js — 导出方式 2:逐个挂载 exports
exports.add = function (a, b) {
  return a + b;
};

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

// ⚠️ 不能直接赋值 exports,会断开引用
// exports = { add, multiply }; // 错误!
// module.exports = { add, multiply }; // 正确

基本导入

// app.js
// 方式 1:解构导入(推荐)
const { add, multiply, PI } = require('./math');
console.log(add(1, 2));       // 3
console.log(multiply(3, 4));  // 12

// 方式 2:整体导入
const math = require('./math');
console.log(math.add(1, 2));  // 3

// 方式 3:只执行模块(无导出)
require('./init');

// 导入核心模块
const fs = require('fs');
const path = require('path');

// 导入第三方模块
const express = require('express');

// 导入 JSON 文件
const config = require('./config.json');

CommonJS 的加载机制

require('./math')
    │
    ▼
1. 路径解析
   ├── 核心模块 → 直接返回
   ├── 相对路径 → 解析为绝对路径
   ├── 绝对路径 → 直接使用
   └── 第三方模块 → node_modules 查找
    │
    ▼
2. 文件定位(按顺序尝试)
   ├── math.js
   ├── math.json
   ├── math.node (C++ 插件)
   └── math/ → math/index.js
    │
    ▼
3. 编译执行
   ├── 读取文件内容
   ├── 包装为函数
   │   (function(exports, require, module, __filename, __dirname) {
   │     // 你的代码
   │   })
   ├── 编译执行
   └── 缓存到 require.cache

模块缓存

// module-a.js
console.log('module-a 被加载');
module.exports = { loaded: true };

// app.js
const a1 = require('./module-a'); // 输出: module-a 被加载
const a2 = require('./module-a'); // 不会再次输出(已缓存)
console.log(a1 === a2);           // true

// 查看缓存
console.log(Object.keys(require.cache));

// 清除缓存(谨慎使用)
delete require.cache[require.resolve('./module-a')];

循环依赖

// a.js
console.log('a 开始');
exports.done = false;
const b = require('./b');
console.log('在 a 中, b.done =', b.done);
exports.done = true;
console.log('a 结束');

// b.js
console.log('b 开始');
exports.done = false;
const a = require('./a');
console.log('在 b 中, a.done =', a.done); // false(a 还没执行完)
exports.done = true;
console.log('b 结束');

// main.js
const a = require('./a');
console.log('在 main 中, a.done =', a.done, ', b.done =', require('./b').done);

// 输出:
// a 开始
// b 开始
// 在 b 中, a.done = false
// b 结束
// 在 a 中, b.done = true
// a 结束
// 在 main 中, a.done = true, b.done = true

5.3 ES Modules 详解

启用 ESM

方式 1:在 package.json 中设置 type

{
  "name": "my-project",
  "type": "module"
}

方式 2:使用 .mjs 扩展名:

# .mjs 文件自动使用 ESM
math.mjs
app.mjs

方式 3:通过 --input-type 标志:

node --input-type=module -e "import os from 'os'; console.log(os.platform())"

命名导出(Named Exports)

// math.mjs — 方式 1:逐个导出
export function add(a, b) {
  return a + b;
}

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

export const PI = 3.14159;

// math.mjs — 方式 2:统一导出
function add(a, b) {
  return a + b;
}

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

const PI = 3.14159;

export { add, multiply, PI };

// math.mjs — 方式 3:重命名导出
export { add as sum, multiply as product };

默认导出(Default Export)

// logger.mjs
export default class Logger {
  constructor(prefix) {
    this.prefix = prefix;
  }

  log(message) {
    console.log(`[${this.prefix}] ${message}`);
  }
}

// 也可以同时有默认导出和命名导出
export const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };

导入方式

// app.mjs
// 命名导入
import { add, multiply, PI } from './math.mjs';
console.log(add(1, 2));

// 重命名导入
import { add as sum } from './math.mjs';
console.log(sum(1, 2));

// 默认导入
import Logger from './logger.mjs';
const logger = new Logger('APP');
logger.log('Hello');

// 混合导入
import Logger, { LOG_LEVELS } from './logger.mjs';

// 命名空间导入(导入所有)
import * as math from './math.mjs';
console.log(math.add(1, 2));
console.log(math.PI);

// 仅执行副作用
import './init.mjs';

// 动态导入
const module = await import('./math.mjs');
console.log(module.add(1, 2));

ESM 的活绑定

// counter.mjs
export let count = 0;
export function increment() {
  count++;
}

// app.mjs
import { count, increment } from './counter.mjs';
console.log(count);   // 0
increment();
console.log(count);   // 1(活绑定,自动更新!)

5.4 动态导入

// 动态导入返回 Promise
async function loadModule(condition) {
  if (condition) {
    const { add } = await import('./math.mjs');
    return add(1, 2);
  } else {
    const { multiply } = await import('./math.mjs');
    return multiply(3, 4);
  }
}

// 按需加载(代码分割)
async function handleRequest(type) {
  switch (type) {
    case 'json': {
      const { parseJSON } = await import('./handlers/json.mjs');
      return parseJSON();
    }
    case 'xml': {
      const { parseXML } = await import('./handlers/xml.mjs');
      return parseXML();
    }
  }
}

// CJS 中使用动态导入
async function main() {
  const esm = await import('./esm-module.mjs');
  console.log(esm.default);
}
main();

5.5 CJS 与 ESM 互操作

从 ESM 中导入 CJS

// ESM 可以导入 CJS 模块
import fs from 'fs';                    // 核心模块
import express from 'express';          // 第三方 CJS 模块
import lodash from 'lodash';

// CJS 模块的 default export 是 module.exports
import myCjsModule from './my-cjs-module.cjs';

从 CJS 中导入 ESM

// CJS 不能直接 require ESM,但可以使用动态 import
async function main() {
  const { add } = await import('./math.mjs');
  console.log(add(1, 2));
}
main();

// 或者使用顶层 await(Node.js 14.8+,仅 ESM)
// CJS 不支持顶层 await

互操作注意事项

场景结果
ESM import CJS✅ 可以,module.exports 变为默认导出
ESM import ESM✅ 正常工作
CJS require() ESM❌ 不可以,报错
CJS await import() ESM✅ 可以,使用动态导入
CJS require() CJS✅ 正常工作

文件扩展名与互操作

扩展名包含 CJS 时包含 ESM 时
.js"type": "commonjs"(默认)"type": "module"
.mjs❌ 不允许✅ 始终 ESM
.cjs✅ 始终 CJS❌ 不允许

5.6 模块解析策略

node_modules 查找

require('lodash')
    │
    ▼
从当前目录向上查找 node_modules
/home/user/project/node_modules/lodash
/home/user/node_modules/lodash
/home/node_modules/lodash
/node_modules/lodash
    │
    ▼
找到后读取 package.json 的 "main" 字段
(ESM 优先读取 "exports" 和 "module" 字段)

package.json 的模块字段

{
  "name": "my-package",
  "version": "1.0.0",
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./utils": {
      "import": "./dist/utils.mjs",
      "require": "./dist/utils.cjs"
    },
    "./package.json": "./package.json"
  },
  "type": "module"
}

exports 字段详解

{
  "exports": {
    // 条件导出
    ".": {
      "import": "./esm/index.mjs",
      "require": "./cjs/index.cjs",
      "types": "./types/index.d.ts",
      "default": "./esm/index.mjs"
    },
    // 子路径导出
    "./utils": "./utils/index.mjs",
    "./config": "./config/index.mjs",
    // 通配符导出
    "./features/*": "./features/*.mjs"
  }
}

5.7 实战:创建可复用模块

项目结构

my-utils/
├── package.json
├── src/
│   ├── index.mjs
│   ├── string.mjs
│   ├── array.mjs
│   └── validation.mjs
└── test/
    └── index.test.mjs

模块实现

// src/string.mjs
export function capitalize(str) {
  if (typeof str !== 'string') throw new TypeError('Expected a string');
  return str.charAt(0).toUpperCase() + str.slice(1);
}

export function camelCase(str) {
  return str
    .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ''))
    .replace(/^[A-Z]/, (c) => c.toLowerCase());
}

export function truncate(str, length = 100, suffix = '...') {
  if (str.length <= length) return str;
  return str.slice(0, length) + suffix;
}
// src/array.mjs
export function unique(arr) {
  return [...new Set(arr)];
}

export function chunk(arr, size) {
  const chunks = [];
  for (let i = 0; i < arr.length; i += size) {
    chunks.push(arr.slice(i, i + size));
  }
  return chunks;
}

export function groupBy(arr, keyFn) {
  return arr.reduce((groups, item) => {
    const key = keyFn(item);
    (groups[key] ??= []).push(item);
    return groups;
  }, {});
}
// src/index.mjs — 统一导出
export { capitalize, camelCase, truncate } from './string.mjs';
export { unique, chunk, groupBy } from './array.mjs';
export { isEmail, isURL, isUUID } from './validation.mjs';

package.json 配置

{
  "name": "@my/utils",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": "./src/index.mjs",
    "./string": "./src/string.mjs",
    "./array": "./src/array.mjs",
    "./validation": "./src/validation.mjs"
  },
  "engines": {
    "node": ">=20.0.0"
  }
}

使用模块

// 使用完整导入
import { capitalize, unique } from '@my/utils';

// 使用子路径导入(只加载需要的部分)
import { capitalize } from '@my/utils/string';
import { unique, chunk } from '@my/utils/array';

注意事项

⚠️ 不要混用 CJS 和 ESM:在同一个包中尽量统一使用一种模块系统。如果需要同时支持,使用 exports 字段分别指定入口。

⚠️ ESM 中没有 __dirname__filename:需要手动构造:

import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

⚠️ CJS 是同步加载的require() 会阻塞主线程,在启动时加载大量模块可能影响启动性能。

⚠️ 循环依赖的陷阱:CJS 循环依赖时,获取到的可能是未完成的导出对象。尽量避免循环依赖,使用依赖注入或提取公共模块。

业务场景

  1. 单体仓库(Monorepo):使用 exports 字段管理多个子包入口
  2. 渐进式迁移:新代码用 ESM,旧代码保持 CJS,通过动态 import() 桥接
  3. 插件系统:使用动态 import() 按需加载插件
  4. 构建工具配置:Webpack/Vite 的代码分割基于 ESM 的 import() 实现

扩展阅读


上一章第 4 章 · 变量与数据类型 下一章第 6 章 · 异步编程基础 — 回调、Promise 和 async/await。