第二章: Node.js 核心:异步编程与内置模块
第二章: Node.js 核心:异步编程与内置模块
Prorise第二章: Node.js 核心:异步编程与内置模块
摘要: 在第一章,我们完成了从浏览器到服务器的思想转变。现在,是时候从理论走向实践,深入探索 Node.js 的“引擎室”了。本章将聚焦于 Node.js 的核心——异步编程模型。我们将从您熟悉的前端异步方案(回调、Promise、async/await)出发,深入理解它们在 Node.js 环境下的细微差别与最佳实践,特别是对事件循环中宏任务与微任务的再深化。随后,我们将掌握第一批最强大的“后端工具”:使用 fs
模块与文件系统交互,使用 path
模块构建健壮的路径,并最终使用底层的 http
模块,亲手创建一个真正的 Web 服务器。
在本章中,我们将像一位工程师熟悉自己的工具箱一样,逐一掌握 Node.js 的核心部件:
- 首先,我们将系统梳理 JavaScript 异步编程的演进,看看如何用现代化的
async/await
编写出优雅的后端代码。 - 接着,我们将再次深入 事件循环,通过理解宏任务与微任务的执行机制,彻底搞懂异步代码的执行顺序。
- 然后,我们将学习第一个核心模块
fs
(文件系统),掌握服务端最基本的能力:读写文件,并了解处理大文件的“流”式思想。 - 之后,我们将学习
path
(路径处理) 模块,培养编写跨平台、无错误的路径代码的专业习惯。 - 最后,我们将迎来一个激动人心的里程碑:使用
http
模块,不依赖任何框架,从零搭建一个能响应网络请求的服务器,揭开后端框架的底层面纱。
2.1. 异步编程的演进:从回调地狱到优雅的 Async/Await
在上一节中,我们已经初步体验了 Node.js 的异步特性。现在,让我们系统地探讨如何管理和编排这些异步操作。作为一名前端专家,您对这个话题并不陌生,但我们将更加关注其在后端 I/O 密集型场景下的重要性。
痛点背景: 假设我们需要执行一系列有依赖关系的异步操作:先读取一个配置文件,根据文件内容再去读取另一个用户数据文件,最后将整合的信息写入一个新的日志文件。如果使用最原始的回调函数风格,代码会变成什么样?
解决方案: 这就是所谓的 回调地狱 (Callback Hell),代码会形成一种向右无限延伸的“金字塔”结构,极难阅读和维护。幸运的是,JavaScript 社区早已提供了更好的解决方案:Promises
和 async/await
。
这是我们极力避免的编码风格。注意代码的嵌套层级。
1 | const fs = require('fs'); |
这种结构不仅难以阅读,而且错误处理非常分散和重复,是滋生 Bug 的温床。
Node.js 的 fs
模块提供了一个 fs/promises
版本,它将所有基于回调的函数都转换成了返回 Promise 的版本。这让我们可以使用 .then()
和 .catch()
链式调用,将代码结构拉平。
1 | // 注意引入方式的变化 |
async/await
是建立在 Promise 之上的语法糖,它允许我们用看起来像同步代码的方式来编写异步逻辑,是 2025 年的推荐最佳实践。
1 | // 同样使用 promises 版本的 fs 模块 |
async/await
极大地提升了代码的可读性和可维护性,让复杂的异步流程变得像同步流程一样清晰直观。
2.2. 事件循环再深化:宏任务与微任务
痛点背景: 即使我们熟练使用了 async/await
,有时仍然会对代码的执行顺序感到困惑。例如,一个 setTimeout
和一个 Promise.resolve()
,哪个会先执行?要精准地预测异步代码的行为,我们必须更深入地理解事件循环的调度规则。
解决方案: 事件循环中的任务队列,实际上分为两种类型:宏任务队列 和 微任务队列。
- 宏任务: 可以理解为一次独立的、较大的工作单元。常见的宏任务包括:
setTimeout
,setInterval
的回调- I/O 操作(文件读写、网络请求)的回调
- 主脚本代码的执行本身(第一个宏任务)
- 微任务: 可以理解为当前宏任务执行完毕后,需要立即处理的、优先级更高的小任务。常见的微任务包括:
Promise.prototype.then()
,.catch()
,.finally()
的回调process.nextTick()
(Node.js 特有,优先级最高)
核心执行规则:
事件循环的每一次迭代(称为一个 tick
)都遵循以下流程:
- 从宏任务队列中取出一个宏任务并执行。
- 执行完毕后,立即检查微任务队列。
- 循环执行微任务队列中的所有任务,直到队列清空。
- 进行 UI 渲染(浏览器环境)或准备下一个 tick。
- 回到第 1 步,开始下一个宏任务。
让我们看一个经典的例子:
1 | console.log('1. 同步代码:脚本开始'); |
1
2
3
4
1. 同步代码:脚本开始
2. 同步代码:脚本结束
3. 微任务:Promise.then 回调
4. 宏任务:setTimeout 回调
执行流程拆解:
- 第一个宏任务(主脚本)开始执行。
- 打印
1
和2
。 - 遇到
setTimeout
,将其回调函数注册到 宏任务队列。 - 遇到
Promise.then
,将其回调函数注册到 微任务队列。 - 第一个宏任务(主脚本)执行完毕。
- 检查微任务队列,发现不为空。
- 执行微任务:打印
3
。微任务队列清空。 - 本轮 tick 结束。
- 下一轮 tick 开始,从宏任务队列中取出
setTimeout
的回调执行。 - 执行宏任务:打印
4
。
2.3. 与世界交互的第一步:文件系统 fs
模块
痛点背景: 几乎所有的后端应用都需要与文件系统打交道:读取配置文件、写入日志、上传和存储用户文件等等。这是 Node.js 提供的、而浏览器完全不具备的核心能力。
解决方案: Node.js 内置了强大的 fs
模块来满足所有文件操作的需求。我们将重点掌握其 异步、同步和流式 三种操作方式。
这是服务器编程中的 首选方式,它不会阻塞事件循环,能保持服务的高响应性。我们通常使用 fs/promises
模块。
场景: 读取一个 JSON 配置文件。
1 | // file: read-config.js |
同步 API 会 阻塞 整个 Node.js 进程,直到操作完成。
场景: 仅限于在 应用程序启动时 读取必要的、一次性的配置。因为此时服务器还未开始接收请求,短暂的阻塞是可以接受的。
1 | // file: read-sync.js |
严禁 在处理用户请求的业务逻辑(如 API 路由中)使用任何同步 I/O 函数!这会灾难性地影响服务器性能。
新痛点: 如果要处理一个几 GB 大的视频文件,一次性读入内存会导致内存溢出。
解决方案: 流 (Streams)。它像一根管道,让数据以小块(chunk)的形式流动,我们只需要处理一小块数据,而无需将整个文件加载到内存中。
场景: 复制一个大文件。
1 | // file: copy-large-file.js |
2.4. 专业之路:永不拼接的路径 path
模块
痛点背景: 当我们在代码中需要引用一个文件时,比如 ../config/db.json
,这种硬编码的相对路径非常脆弱。如果文件的层级结构发生变化,代码就得修改。更严重的是,Windows 和 macOS/Linux 使用不同的路径分隔符(\
和 /
)。手动拼接字符串 folder + '/' + file
在 Windows 上可能会产生无效路径。
解决方案: 永远不要手动拼接路径字符串!Node.js 提供了内置的 path
模块,它能根据当前的操作系统,以正确的方式处理和转换路径。
1 | const path = require('path'); |
1
2
3
4
5
当前文件所在目录: /Users/prorise/my-project/src
目标配置文件路径: /Users/prorise/my-project/src/config/database.json
文件名: database.json
目录名: /Users/prorise/my-project/src/config
扩展名: .json
最佳实践: 始终使用 path.join(__dirname, ...)
或 path.resolve(...)
来构建指向项目文件的绝对路径。这让你的代码无论在哪个目录下被执行,都能正确地找到文件。
2.5. 从零到一:使用 http
模块构建你的第一个服务器
痛点背景: 我们一直在使用 node
命令在终端里运行脚本。但后端的真正价值在于响应来自外部的请求。像 Express、Koa、Next.js 这些框架,它们是如何监听网络端口,接收 HTTP 请求,并返回响应的呢?
解决方案: 所有这些上层框架,其最底层都构建于 Node.js 的 http
模块之上。通过学习它,我们能揭开 web 框架的神秘面纱,理解最基本的请求-响应生命周期。
1 | // file: basic-server.js |
如何运行:
- 在终端运行
node basic-server.js
。 - 打开你的浏览器,访问
http://localhost:3000
和http://localhost:3000/api/user
,看看会发生什么。 - 每次访问,你都会在运行脚本的终端里看到打印的请求日志。
2.6. 本章核心速查总结
分类 | 关键项 | 核心描述 |
---|---|---|
异步编程 | async / await | (推荐) 使用同步的风格编写异步代码,基于 Promises 的语法糖。 |
fs/promises | fs 模块的 Promise 版本,是使用 async/await 进行文件操作的基础。 | |
事件循环 | 微任务 (Microtask) | 优先级高,在当前宏任务执行完后立即清空队列。典型:Promise.then 。 |
宏任务 (Macrotask) | 优先级低,每次事件循环 tick 执行一个。典型:setTimeout , I/O 回调。 | |
核心模块 | fs.readFile(path) | (异步) 读取文件内容。 |
fs.readFileSync(path) | (同步) 慎用! 仅在程序启动时使用。 | |
fs.createReadStream(path) | 创建可读流,用于高效处理大文件。 | |
path.join(...paths) | (推荐) 使用操作系统的特定分隔符安全地拼接路径片段。 | |
http.createServer(callback) | 创建一个 HTTP 服务器实例。 | |
server.listen(port) | 启动服务器并监听指定端口。 | |
全局变量 | __dirname | 获取当前模块文件所在的目录的绝对路径。 |
2.7. 高频面试题与陷阱
在 Node.js 中,fs.readFile 和 fs.readFileSync 有什么区别?你在项目中会如何选择使用它们?
它们的主要区别在于一个是异步的,一个是同步的。fs.readFile 是非阻塞的,它会把文件读取操作交给底层系统,然后立即返回,不会阻塞主线程。当文件读取完成后,它会通过回调函数或 Promise 的方式通知我们。而 fs.readFileSync 是阻塞的,它会暂停整个 Node.js 进程,直到文件完全读取到内存中,才会继续执行后面的代码。
说得很好。那你能具体举例说明,在什么场景下应该使用哪个吗?
当然。在绝大多数情况下,尤其是在一个 Web 服务器的业务逻辑中,我们应该 始终 使用异步的 fs.readFile。因为服务器需要同时处理大量并发请求,如果使用 readFileSync,一个请求在读取大文件时就会阻塞整个进程,导致其他所有请求都无法被响应,这是灾难性的。
那 readFileSync 就完全没有用武之地了吗?
也不是。它的一个合理使用场景是在应用程序 启动阶段。比如,程序启动时需要同步加载一些全局的、必需的配置文件。在这个阶段,服务器还没有开始接收外部请求,短暂的阻塞是完全可以接受的,并且同步代码可以让启动逻辑更简单直观。一旦程序完成初始化并开始监听端口,就应该严格避免任何同步 I/O 操作了。