第三章: 模块化与项目结构

第三章: 模块化与项目结构

摘要: 在上一章,我们用 http 模块亲手搭建了一个底层服务器,感受到了 Node.js 的原始力量。然而,当应用逻辑变得复杂时,将所有代码都堆砌在一个文件里会迅速演变成一场维护的噩梦。本章,我们将聚焦于软件工程的基石——模块化。我们将深入探讨 Node.js 中并存的两种模块化体系:传统的 CommonJS 和现代的 ES Modules。更重要的是,我们将学习如何设计一个清晰、可扩展的项目结构,为即将到来的 Express 框架和复杂的业务逻辑搭建一个坚实的骨架。


在本章中,我们将从“能跑”的代码,迈向“健壮”的工程:

  1. 首先,我们将深入对比 Node.js 的两大模块系统 CommonJS (CJS)ES Modules (ESM),理解它们的核心工作原理和差异。
  2. 接着,我们将详解 package.json 这个项目的“身份证”,掌握其核心字段的深层含义。
  3. 然后,我们将解决一个现实问题:如何在现代 ESM 项目中优雅地使用旧的 CJS 模块,即 模块间的互操作性
  4. 最后,我们将给出一套 项目结构的最佳实践,学习如何规划目录,为未来的功能扩展预留空间。

3.1. 模块化的两大世界:CommonJS vs. ES Modules

痛点背景: 作为一名前端开发者,您对 ES Modules (import/export) 已经非常熟悉。但当您踏入 Node.js 的世界,会立刻遇到 requiremodule.exports。为什么存在两套系统?它们有什么不同?我应该用哪一个?这些困惑是每个 Node.js 新手的必经之路。

解决方案: 我们需要清晰地理解这两种模块化规范的设计哲学和使用场景。CommonJS 是 Node.js 诞生之初就内置的、为服务端设计的同步加载模块系统。而 ES Modules 是 ECMAScript 官方标准,旨在统一前后端的模块化方案,其设计上更倾向于静态分析和异步加载。

核心思想
同步加载 —— require() 会阻塞代码执行,直到文件读取并执行完毕,返回 module.exports值拷贝

cjs-math.js

1
2
3
4
5
6
// 给 exports 对象添加方法
exports.add = (a, b) => a + b;
exports.subtract = (a, b) => a - b;

// 若要导出一个整体,可覆盖 module.exports
// module.exports = class Calculator { ... };

cjs-app.js

1
2
3
4
const math = require('./cjs-math.js');

console.log(`3 + 5 = ${math.add(3, 5)}`);
console.log(`10 - 4 = ${math.subtract(10, 4)}`);

终端执行

1
2
3
$ node cjs-app.js
3 + 5 = 8
10 - 4 = 6

关键点:require 同步执行,导出的是值的浅拷贝。

核心思想
静态分析 + 异步加载 —— import/export 必须位于顶层,打包工具可在编译期确定依赖关系,为 Tree-Shaking 提供基础。

启用方式:在 package.json 中添加 "type": "module"

esm-math.js

1
2
3
4
5
6
7
// 命名导出
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;

// 默认导出
const PI = 3.14;
export default PI;

esm-app.js

1
2
3
4
5
import PI, { add, subtract } from './esm-math.js';

console.log(`3 + 5 = ${add(3, 5)}`);
console.log(`10 - 4 = ${subtract(10, 4)}`);
console.log(`默认导出的 PI: ${PI}`);

终端执行

1
2
3
4
$ node esm-app.js
3 + 5 = 8
10 - 4 = 6
默认导出的 PI: 3.14

2025 最佳实践:新项目全面使用 ES Modules,与前端生态保持一致,减少心智负担。


3.2. 项目的“身份证”:package.json 详解

痛点背景: 我们知道 package.json 用来管理依赖,但它的作用远不止于此。它定义了项目的元数据、入口文件、可执行脚本,甚至决定了项目使用的模块系统。不深入理解它,就无法真正掌控一个 Node.js 项目。

解决方案: 让我们把 package.json 看作是项目的“宪法”或“身份证”,它规定了项目的基本属性和行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// package.json
{
// 1. 基本信息:项目名称和版本,对于发布到 npm 至关重要
"name": "nodejs-architecture-journey",
"version": "1.0.0",
"description": "一个演示 Node.js 最佳实践的项目",

// 2. 入口文件:当别人 require(this-package) 时,会加载这个文件
"main": "src/index.js",

// 3. 模块系统:决定 .js 文件被解析为 CJS 还是 ESM
// "type": "module" -> ESM
// "type": "commonjs" (默认) -> CJS
"type": "module",

// 4. 脚本命令:项目的“快捷指令中心”
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "jest"
},

// 5. 生产依赖:项目线上运行时必需的包
"dependencies": {
"express": "^4.19.2"
},

// 6. 开发依赖:仅在开发和测试阶段需要的包(如测试框架、打包工具)
"devDependencies": {
"jest": "^29.7.0",
"nodemon": "^3.1.0"
}
}

"type": "module" 是一个关键的开关。一旦设置,项目中所有的 .js 文件都会被当作 ES Module 来解析。如果你想在 ESM 项目中使用一个 CommonJS 语法的旧文件,需要将其重命名为 .cjs 后缀。


3.3. 互操作性:在 ESM 中使用 CJS 模块

痛点背景: 理想情况下,我们希望整个生态都是 ESM。但现实是,依然有大量优秀的、久经考验的 npm 包只提供了 CommonJS 版本。我们不能因为技术栈更新就放弃这些宝贵的轮子。那么,在我们的 ESM 项目中,该如何使用它们呢?

解决方案: ES Modules 提供了向后兼容的能力,可以直接 import CommonJS 模块。Node.js 会智能地将其 module.exports 对象包装成一个默认导出。

cjs-legacy-logger.cjs (注意后缀,这是一个 CJS 模块)

1
2
3
4
5
6
7
8
9
class LegacyLogger {
log(message) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] - ${message}`);
}
}

// 这个 CJS 模块导出了一个类
module.exports = LegacyLogger;

esm-app.js (我们的主应用是 ESM)

1
2
3
4
5
6
// 直接 import 一个 .cjs 文件
// 'LegacyLogger' 变量接收了 module.exports 的值
import LegacyLogger from './cjs-legacy-logger.cjs';

const logger = new LegacyLogger();
logger.log('ESM 项目成功加载了 CJS 模块!');

这个特性保证了 Node.js 生态的平滑过渡,让我们可以放心地在现代项目中使用历史悠久的库。


3.4. 最佳实践:构建可扩展的项目结构

痛点背景: 项目初期,文件随意摆放似乎没什么问题。但随着功能增多,路由、数据库逻辑、工具函数混杂在一起,代码会变得难以定位和维护,新人接手项目更是痛苦不堪。

解决方案: 在项目开始之初就建立一个清晰、符合“关注点分离”原则的目录结构。这是一种对未来的投资,能极大地提升项目的可维护性和团队协作效率。

在开始编码前,我们先看一下这些文件在标准 Node.js 项目中的位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# src/main/java/com/prorise/  (这是一个Java路径示例,我们将展示Node.js的)
# 以下是推荐的 Node.js/Express 项目结构
my-awesome-api/
├── node_modules/ # 依赖存放处
├── src/ # 源代码的主目录
│ ├── api/ # (或 routes) API 路由定义层
│ │ ├── index.js # 聚合所有路由
│ │ └── users.routes.js # 用户相关的路由
│ ├── config/ # 配置文件,如数据库连接、环境变量
│ │ └── index.js
│ ├── controllers/ # 控制器层:负责解析请求、调用服务、返回响应
│ │ └── users.controller.js
│ ├── middlewares/ # 自定义中间件,如认证、日志
│ │ └── auth.middleware.js
│ ├── services/ # 服务层:封装核心业务逻辑
│ │ └── users.service.js
│ ├── utils/ # 通用工具函数
│ │ └── logger.js
│ └── app.js # Express 应用主入口
├── .env # 环境变量文件(不应提交到 Git)
├── .gitignore # Git 忽略配置
├── package.json # 项目“身份证”
└── README.md # 项目说明文档

各层职责:

  • api/routes: 定义 API 的 URL 路径,并将它们映射到对应的 controller 函数。
  • controllers: 充当“交通警察”。它不包含复杂的业务逻辑,只负责从 req 中提取数据,调用 services,然后将结果格式化并通过 res 返回。
  • services: 项目的“心脏”。所有复杂的业务逻辑、数据处理、与数据库的交互都封装在这一层。
  • middlewares: 可复用的请求处理单元,像“安检口”一样,在请求到达 controller 之前或之后执行特定任务。

3.5. 本章核心速查总结

分类关键项核心描述
模块系统require()(CJS) 同步导入模块,返回 module.exports 的拷贝。
module.exports / exports(CJS) 定义模块的对外接口。exportsmodule.exports 的别名。
import / export(ESM) 静态导入/导出模块,支持 Tree Shaking,是推荐标准。
项目配置package.json项目的元数据、脚本和依赖配置文件。
"type": "module"package.json 中的关键字段,用于在 Node.js 中启用 ES Modules。
项目结构关注点分离设计项目结构的核心原则,将不同职责的代码分离到不同目录。
src/存放所有应用源代码的根目录。

3.6. 高频面试题与陷阱

面试官深度追问
2025-09-16

在 CommonJS 模块中,module.exportsexports 有什么区别?

exports 可以看作是 module.exports 的一个快捷方式或别名。在模块开始执行时,Node.js 会初始化一个 module 对象,其中 module.exports 是一个空对象 {}。同时,还有一个变量 exports 指向了这个空对象。也就是说,exports === module.exports

嗯,那既然它们指向同一个对象,为什么我们有时会看到一些代码建议“始终使用 module.exports”?

这是因为 require() 函数最终返回的是 module.exports 的值,而不是 exports 的值。如果我给 exports 添加属性,比如 exports.add = ...,因为它们指向同一个对象,所以 module.exports 也会被修改,这是没问题的。

关键点来了,那什么情况下会出问题?

当我尝试直接给 exports 重新赋值时,问题就出现了。例如,如果我写 exports = function() { ... },这只是让 exports 这个变量指向了一个新的函数地址,但 module.exports 仍然指向最初的那个空对象。这样一来,require 拿到的依然是 {},而不是我想要的函数。而如果我写 module.exports = function() { ... },则是直接修改了最终要被导出的对象,这才是正确的做法。

非常清晰。所以结论是,为了避免这种引用断裂的陷阱,最安全和一致的做法就是始终通过 module.exports 来导出模块内容,对吗?

是的,完全正确。