第三章: 模块化与项目结构
第三章: 模块化与项目结构
Prorise第三章: 模块化与项目结构
摘要: 在上一章,我们用 http
模块亲手搭建了一个底层服务器,感受到了 Node.js 的原始力量。然而,当应用逻辑变得复杂时,将所有代码都堆砌在一个文件里会迅速演变成一场维护的噩梦。本章,我们将聚焦于软件工程的基石——模块化。我们将深入探讨 Node.js 中并存的两种模块化体系:传统的 CommonJS 和现代的 ES Modules。更重要的是,我们将学习如何设计一个清晰、可扩展的项目结构,为即将到来的 Express 框架和复杂的业务逻辑搭建一个坚实的骨架。
在本章中,我们将从“能跑”的代码,迈向“健壮”的工程:
- 首先,我们将深入对比 Node.js 的两大模块系统 CommonJS (CJS) 与 ES Modules (ESM),理解它们的核心工作原理和差异。
- 接着,我们将详解
package.json
这个项目的“身份证”,掌握其核心字段的深层含义。 - 然后,我们将解决一个现实问题:如何在现代 ESM 项目中优雅地使用旧的 CJS 模块,即 模块间的互操作性。
- 最后,我们将给出一套 项目结构的最佳实践,学习如何规划目录,为未来的功能扩展预留空间。
3.1. 模块化的两大世界:CommonJS vs. ES Modules
痛点背景: 作为一名前端开发者,您对 ES Modules (import
/export
) 已经非常熟悉。但当您踏入 Node.js 的世界,会立刻遇到 require
和 module.exports
。为什么存在两套系统?它们有什么不同?我应该用哪一个?这些困惑是每个 Node.js 新手的必经之路。
解决方案: 我们需要清晰地理解这两种模块化规范的设计哲学和使用场景。CommonJS 是 Node.js 诞生之初就内置的、为服务端设计的同步加载模块系统。而 ES Modules 是 ECMAScript 官方标准,旨在统一前后端的模块化方案,其设计上更倾向于静态分析和异步加载。
核心思想
同步加载 —— require()
会阻塞代码执行,直到文件读取并执行完毕,返回 module.exports
的 值拷贝。
cjs-math.js
1 | // 给 exports 对象添加方法 |
cjs-app.js
1 | const math = require('./cjs-math.js'); |
终端执行
1 | $ node cjs-app.js |
关键点:require
同步执行,导出的是值的浅拷贝。
核心思想
静态分析 + 异步加载 —— import/export
必须位于顶层,打包工具可在编译期确定依赖关系,为 Tree-Shaking 提供基础。
启用方式:在 package.json
中添加 "type": "module"
。
esm-math.js
1 | // 命名导出 |
esm-app.js
1 | import PI, { add, subtract } from './esm-math.js'; |
终端执行
1 | $ node esm-app.js |
2025 最佳实践:新项目全面使用 ES Modules,与前端生态保持一致,减少心智负担。
3.2. 项目的“身份证”:package.json
详解
痛点背景: 我们知道 package.json
用来管理依赖,但它的作用远不止于此。它定义了项目的元数据、入口文件、可执行脚本,甚至决定了项目使用的模块系统。不深入理解它,就无法真正掌控一个 Node.js 项目。
解决方案: 让我们把 package.json
看作是项目的“宪法”或“身份证”,它规定了项目的基本属性和行为。
1 | // package.json |
"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 | class LegacyLogger { |
esm-app.js
(我们的主应用是 ESM)
1 | // 直接 import 一个 .cjs 文件 |
1
[2025-09-13T14:30:00.000Z] - ESM 项目成功加载了 CJS 模块!
这个特性保证了 Node.js 生态的平滑过渡,让我们可以放心地在现代项目中使用历史悠久的库。
3.4. 最佳实践:构建可扩展的项目结构
痛点背景: 项目初期,文件随意摆放似乎没什么问题。但随着功能增多,路由、数据库逻辑、工具函数混杂在一起,代码会变得难以定位和维护,新人接手项目更是痛苦不堪。
解决方案: 在项目开始之初就建立一个清晰、符合“关注点分离”原则的目录结构。这是一种对未来的投资,能极大地提升项目的可维护性和团队协作效率。
在开始编码前,我们先看一下这些文件在标准 Node.js 项目中的位置:
1 | # src/main/java/com/prorise/ (这是一个Java路径示例,我们将展示Node.js的) |
各层职责:
api/routes
: 定义 API 的 URL 路径,并将它们映射到对应的controller
函数。controllers
: 充当“交通警察”。它不包含复杂的业务逻辑,只负责从req
中提取数据,调用services
,然后将结果格式化并通过res
返回。services
: 项目的“心脏”。所有复杂的业务逻辑、数据处理、与数据库的交互都封装在这一层。middlewares
: 可复用的请求处理单元,像“安检口”一样,在请求到达controller
之前或之后执行特定任务。
3.5. 本章核心速查总结
分类 | 关键项 | 核心描述 |
---|---|---|
模块系统 | require() | (CJS) 同步导入模块,返回 module.exports 的拷贝。 |
module.exports / exports | (CJS) 定义模块的对外接口。exports 是 module.exports 的别名。 | |
import / export | (ESM) 静态导入/导出模块,支持 Tree Shaking,是推荐标准。 | |
项目配置 | package.json | 项目的元数据、脚本和依赖配置文件。 |
"type": "module" | package.json 中的关键字段,用于在 Node.js 中启用 ES Modules。 | |
项目结构 | 关注点分离 | 设计项目结构的核心原则,将不同职责的代码分离到不同目录。 |
src/ | 存放所有应用源代码的根目录。 |
3.6. 高频面试题与陷阱
在 CommonJS 模块中,module.exports
和 exports
有什么区别?
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
来导出模块内容,对吗?
是的,完全正确。