Node.js 基础与模块:CommonJS 与 ES 模块系统
Node.js 入门
什么是 Node.js?
Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行时环境,它让 JavaScript 脱离了浏览器的限制,可以在服务器端运行。通过事件驱动、非阻塞 I/O 模型,Node.js 非常适合构建高并发、实时性的网络应用,如 API 服务、聊天工具或命令行工具。它拥有庞大的包生态系统(npm),是全球最大的开源库集合。
安装与环境配置
访问 Node.js 官网 下载对应操作系统的 LTS(长期支持)版本。安装完成后,打开终端(或命令提示符)输入以下命令验证安装:
node -v
npm -v
看到版本号即表示安装成功。建议同时安装一个代码编辑器(如 VS Code)并熟悉终端的基本操作。
你的第一个 Node.js 程序
创建一个名为 app.js 的文件,输入以下代码:
// app.js
const message = 'Hello, Node.js!';
console.log(message);
在终端中执行 node app.js,你将看到打印出的问候语。这看似简单,但背后体现了 Node.js 直接执行 JavaScript 的能力。接下来,我们将深入理解 Node.js 的核心——模块系统。
Node.js 模块系统详解
为什么需要模块化?
在大型项目中,将所有代码写在一个文件里会导致难以维护、命名冲突和复用性差。模块化将代码分割成独立的功能单元,每个模块拥有自己的作用域,通过接口暴露需要共享的部分。Node.js 原生支持两种模块系统:CommonJS(历史默认)和 ES 模块(现代标准)。
CommonJS 模块规范
CommonJS 是 Node.js 从诞生起就采用的模块规范,主要使用 require() 导入和 module.exports 或 exports 导出。它是同步加载的,适合服务器环境,因为文件就在本地磁盘,加载速度很快。每个文件都被视为一个独立的模块。
ES 模块 (ECMAScript Modules)
ES 模块是 JavaScript 官方标准(ES2015+),使用 import 和 export 语法。它在浏览器和现代 Node.js 中均受支持。ES 模块静态结构允许在编译时确定依赖关系,支持异步加载和高级优化(如 tree-shaking)。Node.js 从 v12 起稳定支持该特性。
CommonJS 实战
使用 require 加载模块
在 CommonJS 中,通过 require 函数引入模块。可以加载内置模块、第三方包或自定义文件模块。加载文件时需要指定路径,可省略 .js 后缀。
// 加载内置 fs 模块
const fs = require('fs');
// 加载自定义模块(路径省略 .js)
const math = require('./math');
console.log(math.add(2, 3));
require 是同步的,它会读取文件、执行文件代码并返回模块的 exports 对象。模块第一次加载后会缓存,多次引用同一个模块会直接返回缓存结果。
使用 module.exports 导出内容
每个模块中 module 对象有一个 exports 属性,该属性就是模块对外暴露的接口。可以直接给 module.exports 赋值,也可以使用快捷方式 exports 添加属性。
// math.js
const add = (a, b) => a + b;
const multiply = (a, b) => a * b;
// 方式一:直接赋值(推荐,清晰)
module.exports = { add, multiply };
// 方式二:逐个添加属性(注意:不能对 exports 直接赋值,会切断引用)
// exports.add = add;
// exports.multiply = multiply;
若使用 exports = { add } 是错误的,因为它仅仅改变了 exports 变量的指向,不会影响 module.exports,最终导出的仍为空对象。始终记住:真正导出的是 module.exports。
模块缓存与循环依赖
Node.js 会在第一次 require 时执行模块代码并缓存其结果对象。如果出现模块 A 引用模块 B,模块 B 又引用模块 A 的循环依赖,Node.js 会返回尚未执行完的模块 A 当前已导出的部分(可能是不完整的对象),从而避免死循环。应当尽量避免循环依赖,保持单向依赖是最佳实践。
ES 模块实战
在 Node.js 中启用 ES 模块
Node.js 默认将 .js 文件视为 CommonJS 模块。要使用 ES 模块,有三种方式:
- 将文件扩展名改为
.mjs - 在
package.json中设置"type": "module",此时该目录下的.js文件将被解释为 ES 模块 - 在命令行使用
--input-type=module标志(适用于字符串输入)
推荐在项目根目录的 package.json 中添加 "type": "module",统一使用 ES 模块标准。
// package.json
{
"name": "my-node-app",
"version": "1.0.0",
"type": "module"
}
命名导出与默认导出
ES 模块提供两种导出方式:命名导出(可以多个)和默认导出(每个模块一个)。
// utils.mjs
// 命名导出
export function formatDate(date) { /* ... */ }
export const PI = 3.14159;
// 默认导出(通常用于导出类、函数或主要对象)
export default class HttpClient { /* ... */ }
导入时,命名导出需要使用解构语法 { },默认导出可以任意命名。
import HttpClient, { formatDate, PI } from './utils.mjs';
// HttpClient 为默认导出,名称可自定义
// formatDate, PI 必须使用花括号并保持名称一致,除非使用 `as` 重命名
动态导入 import()
ES 模块的 import 声明是静态的,必须位于模块顶层。若想在运行时按需加载模块,可以使用动态 import(),它返回一个 Promise。
// 按需加载模块
if (condition) {
import('./heavy-module.mjs')
.then(module => module.doWork())
.catch(err => console.error('加载失败', err));
}
这在代码拆分、懒加载场景中非常有用,且兼容 CommonJS 模块。
CommonJS 与 ES 模块对比
语法差异与互操作性
| 特性 | CommonJS | ES 模块 |
|---|---|---|
| 加载语法 | const mod = require('./mod') |
import mod from './mod.mjs' |
| 导出语法 | module.exports = { ... } |
export default ... / export { ... } |
| 加载时机 | 同步,运行时加载 | 静态解析,编译时确定依赖;支持异步动态导入 |
| 值绑定 | 输出值的拷贝(原始类型),对象是引用 | 输出实时只读引用(live bindings) |
| 顶层 await | 不支持 | 支持(仅在 ES 模块中可用) |
| 文件扩展名 | 通常 .js,可省略 |
.mjs 或 .js(需 "type": "module") |
在 Node.js 中,ES 模块可以导入 CommonJS 模块(当做默认导出),但反之不行——CommonJS 不能直接同步 require 一个 ES 模块,需要使用动态异步 import()。
何时使用哪一种?
- CommonJS:成熟的旧项目、许多 npm 包仍默认采用此规范,同步加载适合简单的服务器脚本。
- ES 模块:新项目的首选标准,更好的静态分析能力,前端工程化统一(如 Webpack、Vite 深度支持),支持
顶层 await,是现代 JavaScript 的发展方向。
目前许多类库已同时提供两种导出方式。建议新应用从一开始就采用 ES 模块,并了解两者的转换限制。
总结与最佳实践
- 理解 Node.js 运行时和模块化思想是构建一切应用的基础。
- 掌握 CommonJS 的
require/module.exports机制,尤其注意exports指向问题。 - 熟练运用 ES 模块的
import/export,合理搭配命名与默认导出。 - 在新项目中通过
package.json的"type": "module"明确使用 ES 模块;处理旧代码时注意互操作性。 - 利用动态
import()实现按需加载,优化应用启动性能。
模块系统的选择直接影响代码结构与维护成本。现在你已经掌握了 Node.js 模块的核心知识,可以动手拆分自己的代码,体验模块化带来的清晰与高效。