【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 后:
logPath和writeLog只在函数内部有效,不会污染全局;module.exports被赋值为{ info, error },app.js的logger变量就是这个对象;- 其他模块导入
logger.js时,拿到的是module.exports,不会影响原模块的内部状态。
2. CommonJS 的加载流程:同步的“寻径-编译-执行”
CommonJS 的加载是同步的(因为 Node.js 最初是为服务器设计的,同步加载更简单),流程分为三步:
(1)寻径:找到模块的真实路径
当你调用require('xxx')时,Node.js 会按以下规则优先级顺序找模块:
| 类型 | 示例 | 说明 |
|---|---|---|
| 内置模块 | require('fs') | 直接从 Node.js 内置模块列表中加载(如fs、path) |
| 相对/绝对路径 | require('./logger') | 直接解析为文件路径(如./logger.js或/usr/lib/logger.js) |
| 第三方模块 | require('lodash') | 从当前目录的node_modules开始往上找,直到根目录(如./node_modules/lodash) |
实际项目示例:加载第三方模块lodash
假设app.js里写const _ = require('lodash');,Node.js 的寻径流程:
- 检查是否是内置模块:
lodash不是,跳过; - 检查是否是相对/绝对路径:
lodash不是,跳过; - 查找第三方模块:
- 进入当前目录的
node_modules文件夹,找lodash子文件夹; - 读取
lodash/package.json的main字段(通常是index.js); - 加载
node_modules/lodash/index.js(lodash的入口文件)。
- 进入当前目录的
(2)编译:把模块代码变成可执行函数
找到模块文件后,Node.js 会用**vm模块**(V8 的虚拟机)编译代码,避免全局污染。具体步骤:
- 同步读取文件内容:用
fs.readFileSync读取模块文件的内容(如logger.js的代码); - 包装成 IIFE:给代码前后添加 IIFE 的包裹(如前所述);
- 编译成 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.exportsmodule.exports = { add: (a, b) => a + b }
// 方式2:exportsexports.add = (a, b) => a + b它们的关系是:exports是module.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中看到:
2024-05-20T12:00:00.000Z [INFO] Server running on http://localhost:30002024-05-20T12:00:05.000Z [INFO] GET /3. ESM 的加载流程:异步的“静态分析-加载-执行”
ESM 的加载是异步的(适合前端和复杂的后端应用),流程分为三步:
(1)静态分析:提前知道依赖关系
ESM 的import语句必须写在模块顶层(不能写在函数里),打包工具(如 Webpack)可以静态解析出模块的依赖树。比如,server.mjs的依赖树:
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:核心区别
为了更清晰地对比,我们用表格总结两者的核心区别:
| 维度 | CommonJS | ESM |
|---|---|---|
| 加载方式 | 同步(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 模块的导出对象(包含info和error方法);- 由于
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 官方文档:Modules
- MDN 文档:ECMAScript modules
- 《深入浅出 Node.js》(朴灵):CommonJS 部分详解
- Vite 官方文档:ESM 支持
希望这篇文章能帮你彻底搞懂 Node.js 的模块机制!如果有疑问,欢迎在评论区交流~
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!