【Node.js】模块机制深度解析:从 CommonJS 到 ESM 的进化之路

4177 字
21 分钟
【Node.js】模块机制深度解析:从 CommonJS 到 ESM 的进化之路

一、为什么模块机制是 Node.js 的“生态基石”?#

早年间做前端开发时,我曾遇到过一个经典痛点:引入 jQuery 后,自己写的utils.js里也用了$作为函数名,结果页面加载后,jQuery 的$被覆盖,所有依赖 jQuery 的代码都报错了。那时的 JavaScript 没有模块机制,所有变量都暴露在全局作用域——代码越多,冲突越频繁。

Node.js 的模块机制彻底解决了这个问题:它让代码可以拆分成独立的文件(模块),每个模块有自己的作用域,只导出需要共享的内容,导入其他模块的功能。这不仅让代码更易维护,更催生了npm 生态——全世界的开发者都可以发布自己的模块,让其他人直接复用。比如,你要实现一个日志功能,不需要自己写,直接npm install winston就能用成熟的日志模块。

今天,我们就深入剖析 Node.js 的两种模块机制:CommonJS(Node.js 传统模块)和ESM(ECMAScript 官方模块),看看它们的本质、区别,以及如何在实际项目中选择。

二、CommonJS:Node.js 的“传统艺能”#

CommonJS 是 Node.js 最初的模块规范,灵感来自ServerJS(服务器端 JavaScript 规范)。它的设计目标很简单:让 JavaScript 可以像 Python、Ruby 一样写后端应用——同步加载、简单易用。

1. CommonJS 的本质:“泡芙式”模块#

CommonJS 模块的核心是**Module实例**——每个模块文件都会被 Node.js 包装成一个Module对象,包含以下关键属性:

  • module.exports:模块导出的内容(默认是空对象);
  • require:导入其他模块的函数;
  • __filename:模块的绝对路径;
  • __dirname:模块所在目录的绝对路径。

用一个实际项目示例解释 CommonJS 的加载过程:

假设我们写一个日志模块logger.js,用来记录应用的信息和错误:

// logger.js(CommonJS模块)
const fs = require('fs')
const path = require('path')
// 私有变量:日志文件路径(仅模块内部可见)
const logPath = path.join(__dirname, 'app.log')
// 私有函数:写入日志到文件(仅模块内部可见)
function writeLog(level, message) {
const timestamp = new Date().toISOString()
const logLine = `${timestamp} [${level}] ${message}\n`
fs.appendFileSync(logPath, logLine) // 同步写入,适合简单服务
}
// 导出公共方法(模块对外暴露的接口)
module.exports = {
info: (message) => writeLog('INFO', message),
error: (message) => writeLog('ERROR', message),
}

app.js里导入并使用这个模块:

// app.js(CommonJS入口文件)
const logger = require('./logger')
// 记录应用启动信息
logger.info('应用启动成功,监听端口3000')
// 模拟数据库连接失败
try {
// 假设这里调用了数据库连接函数,失败抛出错误
throw new Error('数据库连接超时')
} catch (err) {
logger.error(err.message)
}

为什么说 CommonJS 是“泡芙式”模块?#

Node.js 会自动把logger.js包装成一个IIFE(立即执行函数),就像给“泡芙壳”(Module实例)注入“奶油”(模块逻辑):

// Node.js自动生成的IIFE
(function (exports, require, module, __filename, __dirname) {
// 模块原代码开始
const fs = require('fs');
const path = require('path');
const logPath = path.join(__dirname, 'app.log');
function writeLog(level, message) { /* ... */ }
module.exports = { info: /* ... */, error: /* ... */ };
// 模块原代码结束
})(module.exports, require, module, '/path/to/logger.js', '/path/to');

执行这个 IIFE 后:

  • logPathwriteLog只在函数内部有效,不会污染全局;
  • module.exports被赋值为{ info, error }app.jslogger变量就是这个对象;
  • 其他模块导入logger.js时,拿到的是module.exports,不会影响原模块的内部状态。

2. CommonJS 的加载流程:同步的“寻径-编译-执行”#

CommonJS 的加载是同步的(因为 Node.js 最初是为服务器设计的,同步加载更简单),流程分为三步:

(1)寻径:找到模块的真实路径#

当你调用require('xxx')时,Node.js 会按以下规则优先级顺序找模块:

类型示例说明
内置模块require('fs')直接从 Node.js 内置模块列表中加载(如fspath
相对/绝对路径require('./logger')直接解析为文件路径(如./logger.js/usr/lib/logger.js
第三方模块require('lodash')从当前目录的node_modules开始往上找,直到根目录(如./node_modules/lodash

实际项目示例:加载第三方模块lodash 假设app.js里写const _ = require('lodash');,Node.js 的寻径流程:

  1. 检查是否是内置模块lodash不是,跳过;
  2. 检查是否是相对/绝对路径lodash不是,跳过;
  3. 查找第三方模块
    • 进入当前目录的node_modules文件夹,找lodash子文件夹;
    • 读取lodash/package.jsonmain字段(通常是index.js);
    • 加载node_modules/lodash/index.jslodash的入口文件)。

(2)编译:把模块代码变成可执行函数#

找到模块文件后,Node.js 会用**vm模块**(V8 的虚拟机)编译代码,避免全局污染。具体步骤:

  1. 同步读取文件内容:用fs.readFileSync读取模块文件的内容(如logger.js的代码);
  2. 包装成 IIFE:给代码前后添加 IIFE 的包裹(如前所述);
  3. 编译成 V8 可执行函数:用vm.Script类编译 IIFE 代码,生成可执行函数。

(3)执行:注入模块逻辑#

执行编译后的函数,把模块逻辑注入Module实例,最终返回module.exports

关键细节:CommonJS 模块会被缓存——第一次加载后,Node.js 会把Module实例存在require.cache中,后续require会直接取缓存,不会重新加载。比如,多次require('./logger')只会加载一次logger.js,避免重复执行。

3. module.exports vs exports:容易混淆的“双胞胎”#

你可能见过两种导出方式:

// 方式1:module.exports
module.exports = { add: (a, b) => a + b }
// 方式2:exports
exports.add = (a, b) => a + b

它们的关系是:exportsmodule.exports的引用。比如:

console.log(exports === module.exports) // true(默认情况下)

但如果直接赋值exports,会断开引用,导致导出失败:

实际错误示例

// 错误的utils.js(CommonJS模块)
exports = (a, b) => a + b // 直接赋值exports,断开引用
// app.js(导入错误模块)
const add = require('./utils')
console.log(add(1, 2)) // 输出undefined!

原因

  • exports原本指向module.exports(默认是空对象);
  • 直接赋值exports = ...会让exports指向新的函数,而module.exports仍然是空对象;
  • require导入的是module.exports,所以得到的是undefined

结论:优先用module.exports,避免混淆。如果要导出多个属性,可以用exports.xxx(但不要直接赋值exports)。

三、ESM:ECMAScript 的“官方模块”#

随着前端工程化的发展,CommonJS 的缺点逐渐暴露:

  • 同步加载不适合前端:浏览器无法同步加载模块(会阻塞渲染);
  • 动态导出无法做静态分析:比如 Tree Shaking(删除未使用的代码)无法生效。

于是,ECMAScript 6(ES6)推出了ESM(ECMAScript Modules),作为官方模块规范。Node.js 从 v12 开始支持 ESM,现在已经成为现代前端和 Node.js 的主流选择

1. ESM 的本质:“静态化”的模块#

ESM 的核心是**ModuleWrap对象**(Node.js 的实现)和V8 的Module(底层引擎支持)。它的设计目标是静态化——所有的导入导出都在代码顶层,能被静态分析(打包工具可以提前知道模块的依赖关系)。

ESM 的语法很直观:

  • export:导出模块内容(可以是变量、函数、类);
  • import:导入其他模块内容(可以是默认导出、命名导出)。

2. ESM 的实际项目示例:React 组件与 Node.js 服务#

示例 1:前端 React 组件(静态化与 Tree Shaking)#

假设我们写一个React Button 组件,用 ESM 导出:

// Button.mjs(ESM模块)
export const PrimaryButton = ({ children }) => (
<button
style={{
padding: '8px 16px',
border: 'none',
borderRadius: '4px',
backgroundColor: '#007bff',
color: 'white',
cursor: 'pointer',
}}
>
{children}
</button>
)
export const SecondaryButton = ({ children }) => (
<button
style={{
padding: '8px 16px',
border: '1px solid #6c757d',
borderRadius: '4px',
backgroundColor: 'white',
color: '#6c757d',
cursor: 'pointer',
}}
>
{children}
</button>
)
export default PrimaryButton // 默认导出(方便导入)

App.mjs里导入并使用:

// App.mjs(ESM入口文件)
import PrimaryButton from './Button.mjs' // 导入默认导出
// import { PrimaryButton } from './Button.mjs'; // 也可以导入命名导出
function App() {
return (
<div style={{ padding: '20px' }}>
<PrimaryButton>提交订单</PrimaryButton>
{/* 未使用SecondaryButton */}
</div>
)
}
export default App

静态化的优势:打包工具(如 Webpack、Vite)会做Tree Shaking——未使用的SecondaryButton会被删掉,减少最终的bundle体积。比如,原本Button.mjs有两个组件,打包后只有PrimaryButton的代码。

示例 2:Node.js Koa 服务(异步加载与 ESM 配置)#

假设我们写一个Koa 服务,用 ESM:

  • 配置package.json:设置"type": "module",告诉 Node.js 这是 ESM 项目:
{
"name": "koa-esm-demo",
"type": "module",
"scripts": {
"start": "node server.mjs"
},
"dependencies": {
"koa": "^2.14.0"
}
}
  • server.mjs(Koa 服务入口):
// server.mjs(ESM模块)
import Koa from 'koa' // 导入CommonJS模块(koa是CommonJS)
import { info, error } from './logger.mjs' // 导入ESM模块
const app = new Koa()
// 中间件1:日志记录(ESM模块的使用)
app.use(async (ctx, next) => {
info(`${ctx.method} ${ctx.url}`) // 记录请求方法和路径
await next()
})
// 中间件2:处理请求(返回Hello ESM)
app.use(async (ctx) => {
ctx.body = 'Hello ESM!'
})
// 启动服务
app.listen(3000, () => {
info('Server running on http://localhost:3000')
})
  • logger.mjs(ESM 日志模块):
// logger.mjs(ESM模块)
import fs from 'fs'
import path from 'path'
const logPath = path.join(process.cwd(), 'app.log') // 日志文件路径
// 导出info方法(命名导出)
export function info(message) {
const logLine = `${new Date().toISOString()} [INFO] ${message}\n`
fs.appendFileSync(logPath, logLine)
console.log(logLine.trim()) // 打印到控制台
}
// 导出error方法(命名导出)
export function error(message) {
const logLine = `${new Date().toISOString()} [ERROR] ${message}\n`
fs.appendFileSync(logPath, logLine)
console.error(logLine.trim()) // 打印到控制台(错误级别)
}

运行结果:执行npm start,服务启动后,访问http://localhost:3000,会在app.log中看到:

Terminal window
2024-05-20T12:00:00.000Z [INFO] Server running on http://localhost:3000
2024-05-20T12:00:05.000Z [INFO] GET /

3. ESM 的加载流程:异步的“静态分析-加载-执行”#

ESM 的加载是异步的(适合前端和复杂的后端应用),流程分为三步:

(1)静态分析:提前知道依赖关系#

ESM 的import语句必须写在模块顶层(不能写在函数里),打包工具(如 Webpack)可以静态解析出模块的依赖树。比如,server.mjs的依赖树:

Terminal window
server.mjs koa(CommonJS) logger.mjs(ESM)

(2)加载:异步获取模块代码#

Node.js 会按以下规则加载 ESM:

  • 文件扩展名.mjs(默认是 ESM)、.js(需要package.json"type": "module");
  • 寻径规则:和 CommonJS 类似,但支持node:前缀(如import fs from 'node:fs')和URL(如import utils from 'https://cdn.jsdelivr.net/npm/utils.mjs')。

(3)执行:按依赖顺序执行#

ESM 的执行是按依赖顺序的——比如模块 A 依赖模块 B,会先执行模块 B,再执行模块 A。比如,server.mjs依赖logger.mjs,会先执行logger.mjs的代码,再执行server.mjs的代码。

三、CommonJS vs ESM:核心区别#

为了更清晰地对比,我们用表格总结两者的核心区别:

维度CommonJSESM
加载方式同步(fs.readFileSync异步(fs.promises.readFile
导出方式动态(module.exports可随时修改)静态(export必须在顶层)
作用域模块作用域(var不污染全局)文件作用域(更严格,无隐式全局)
静态分析不支持(无法 Tree Shaking)支持(打包工具可优化)
互通性不能require ESM,但可用import()可以import CommonJS(默认导出)
适用场景简单后端服务、旧项目现代前端、复杂后端服务、库开发

四、两者如何互通?#

虽然 CommonJS 和 ESM 是两套规范,但 Node.js 允许它们部分互通,满足实际项目的需求。我们用实际场景示例逐一说明:

1. ESM 导入 CommonJS 模块#

ESM 可以直接import CommonJS 模块,CommonJS 的module.exports会被当作 ESM 的默认导出(Default Export)。

实际场景示例:前端 Vite 项目导入 CommonJS 模块
假设我们有一个CommonJS 模块utils.cjs(封装了日期格式化函数):

// utils.cjs(CommonJS模块)
module.exports = {
formatDate: (date) => {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date)
},
}

ESM 模块App.mjs(Vite 前端项目)中导入并使用:

// App.mjs(ESM模块,Vite项目入口)
import utils from './utils.cjs' // CommonJS的module.exports作为默认导出
import { useState } from 'react'
function App() {
const [currentDate, setCurrentDate] = useState(new Date())
return (
<div className="app">
<h1>当前日期:{utils.formatDate(currentDate)}</h1>
<button onClick={() => setCurrentDate(new Date())}>刷新日期</button>
</div>
)
}
export default App

关键说明

  • ESM 的import utils from './utils.cjs'等价于 CommonJS 的const utils = require('./utils.cjs')
  • 如果 CommonJS 模块导出的是单个函数/值(比如module.exports = (a, b) => a + b),ESM 导入后直接使用即可(import add from './add.cjs'; add(1,2))。

2. CommonJS 导入 ESM 模块#

CommonJS 不能用require直接导入 ESM 模块(因为require同步的,而 ESM 是异步的),但可以用**import()动态导入**(返回 Promise)。

实际场景示例:CommonJS 的 Koa 服务导入 ESM 的日志模块
假设我们有一个ESM 模块logger.mjs(之前的日志模块):

// logger.mjs(ESM模块)
import fs from 'fs/promises'
import path from 'path'
const logPath = path.join(process.cwd(), 'app.log')
export async function info(message) {
const logLine = `${new Date().toISOString()} [INFO] ${message}\n`
await fs.appendFile(logPath, logLine) // 异步写入,适合高并发服务
console.log(logLine.trim())
}
export async function error(message) {
const logLine = `${new Date().toISOString()} [ERROR] ${message}\n`
await fs.appendFile(logPath, logLine)
console.error(logLine.trim())
}

CommonJS 模块app.cjs(Koa 服务入口)中导入并使用:

// app.cjs(CommonJS模块,Koa服务入口)
const Koa = require('koa')
const app = new Koa()
// 动态导入ESM的logger.mjs(CommonJS无法用require直接导入)
let logger
;(async () => {
logger = await import('./logger.mjs') // 动态导入返回Promise
await logger.info('Logger initialized') // 初始化日志模块
})()
// 中间件1:日志记录(使用ESM的logger)
app.use(async (ctx, next) => {
if (!logger) await new Promise((resolve) => setTimeout(resolve, 100)) // 等待logger初始化
await logger.info(`${ctx.method} ${ctx.url}`)
await next()
})
// 中间件2:处理请求
app.use(async (ctx) => {
ctx.body = 'Hello CommonJS + ESM!'
})
// 启动服务
app.listen(3000, async () => {
if (!logger) await new Promise((resolve) => setTimeout(resolve, 100))
await logger.info('Server running on http://localhost:3000')
})

关键说明

  • import('./logger.mjs')返回一个 Promise,resolve 后得到 ESM 模块的导出对象(包含infoerror方法);
  • 由于import()是异步的,需要用async/await处理,确保模块加载完成后再使用;
  • 这种方式适合复杂的 CommonJS 项目逐步迁移到 ESM的场景(比如旧服务需要使用新的 ESM 模块)。

3. 互通的“边界条件”#

虽然两者可以互通,但有几个边界条件需要注意:

  • ESM 导入 CommonJS 时,无法解构命名导出:比如 CommonJS 的module.exports = { add, multiply },ESM 导入后只能通过import utils from './utils.cjs',再utils.add,不能import { add } from './utils.cjs'(除非 CommonJS 模块用exports.add = ...导出,但不推荐);
  • CommonJS 导入 ESM 时,必须处理异步import()返回 Promise,必须用async/await.then()处理,否则会得到未解析的 Promise;
  • ESM 的import()可以在 CommonJS 中使用:即使是 CommonJS 模块,也可以用import()动态导入 ESM 模块,这是 Node.js 提供的“桥接”机制。

五、如何在项目中选择模块机制?#

通过前面的分析,我们可以根据项目类型需求选择合适的模块机制:

1. 新项目:优先选 ESM#

如果是新启动的项目(无论是前端还是后端),优先选择 ESM:

  • 前端:ESM 支持 Tree Shaking,减少打包体积,配合 Vite、Webpack 等工具更高效;
  • 后端:ESM 的异步加载适合高并发服务(比如 Koa、Fastify),且是 ECMAScript 官方规范,未来兼容性更好;
  • 库开发:ESM 可以同时支持 Node.js 和浏览器(通过打包工具生成 CommonJS 版本),覆盖更广泛的用户。

2. 旧项目:逐步迁移到 ESM#

如果是已有 CommonJS 项目,可以逐步迁移到 ESM:

  • 第一步:将package.json"type"设为"module"(默认所有.js文件是 ESM);
  • 第二步:将旧的 CommonJS 模块(.js)改为 ESM(.mjs或保持.js并调整导出方式);
  • 第三步:用import()处理未迁移的 CommonJS 模块(比如第三方库)。

3. 第三方库:发布双版本#

如果是开发第三方库,建议发布ESM 和 CommonJS 双版本(用打包工具如 Rollup、TypeScript):

  • ESM 版本:供现代项目使用("module": "dist/index.mjs");
  • CommonJS 版本:供旧项目使用("main": "dist/index.cjs");
  • 类型定义:用 TypeScript 生成.d.ts文件,支持类型提示。

六、总结#

Node.js 的模块机制从 CommonJS 到 ESM 的进化,本质是从“同步、动态”到“异步、静态”的升级: 无论是哪种模块机制,其最本质都是闭包的体现。就是这两种模块机制,作为 Node.js 中代码的执行单位,聚合成 npm 包,才有了今日 Node.js 庞大的生态。

  • CommonJS 是 Node.js 的“传统艺能”,适合简单的后端服务和旧项目;
  • ESM 是 ECMAScript 的“官方规范”,适合现代前端、复杂后端服务和库开发。

理解两者的本质、区别和互通方式,能帮你在实际项目中做出更合理的选择——无论是维护旧项目,还是开发新项目,都能写出更高效、更易维护的代码。

最后,用一句话总结:CommonJS 是“过去时”,ESM 是“现在时”,未来属于 ESM

参考资料

希望这篇文章能帮你彻底搞懂 Node.js 的模块机制!如果有疑问,欢迎在评论区交流~

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

【Node.js】模块机制深度解析:从 CommonJS 到 ESM 的进化之路
https://blog.fridolph.top/posts/2023-03-02__node-modules/
作者
Fridolph
发布于
2023-03-02
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Fridolph
热爱 Coding、音乐和羽毛球的 90 后全栈工程师
公告
欢迎访问我的小站 ^_^ 我是昇哥,热爱Coding,喜爱音乐、羽毛球和摄影的 90后全栈工程师
分类
标签

文章目录