第二章: Node.js 核心:异步编程与内置模块

第二章: Node.js 核心:异步编程与内置模块

摘要: 在第一章,我们完成了从浏览器到服务器的思想转变。现在,是时候从理论走向实践,深入探索 Node.js 的“引擎室”了。本章将聚焦于 Node.js 的核心——异步编程模型。我们将从您熟悉的前端异步方案(回调、Promise、async/await)出发,深入理解它们在 Node.js 环境下的细微差别与最佳实践,特别是对事件循环中宏任务与微任务的再深化。随后,我们将掌握第一批最强大的“后端工具”:使用 fs 模块与文件系统交互,使用 path 模块构建健壮的路径,并最终使用底层的 http 模块,亲手创建一个真正的 Web 服务器。


在本章中,我们将像一位工程师熟悉自己的工具箱一样,逐一掌握 Node.js 的核心部件:

  1. 首先,我们将系统梳理 JavaScript 异步编程的演进,看看如何用现代化的 async/await 编写出优雅的后端代码。
  2. 接着,我们将再次深入 事件循环,通过理解宏任务与微任务的执行机制,彻底搞懂异步代码的执行顺序。
  3. 然后,我们将学习第一个核心模块 fs (文件系统),掌握服务端最基本的能力:读写文件,并了解处理大文件的“流”式思想。
  4. 之后,我们将学习 path (路径处理) 模块,培养编写跨平台、无错误的路径代码的专业习惯。
  5. 最后,我们将迎来一个激动人心的里程碑:使用 http 模块,不依赖任何框架,从零搭建一个能响应网络请求的服务器,揭开后端框架的底层面纱。

2.1. 异步编程的演进:从回调地狱到优雅的 Async/Await

在上一节中,我们已经初步体验了 Node.js 的异步特性。现在,让我们系统地探讨如何管理和编排这些异步操作。作为一名前端专家,您对这个话题并不陌生,但我们将更加关注其在后端 I/O 密集型场景下的重要性。

痛点背景: 假设我们需要执行一系列有依赖关系的异步操作:先读取一个配置文件,根据文件内容再去读取另一个用户数据文件,最后将整合的信息写入一个新的日志文件。如果使用最原始的回调函数风格,代码会变成什么样?

解决方案: 这就是所谓的 回调地狱 (Callback Hell),代码会形成一种向右无限延伸的“金字塔”结构,极难阅读和维护。幸运的是,JavaScript 社区早已提供了更好的解决方案:Promisesasync/await

这是我们极力避免的编码风格。注意代码的嵌套层级。

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
const fs = require('fs');

fs.readFile('./config.json', 'utf8', (err, configData) => {
if (err) {
console.error('读取配置文件失败:', err);
return;
}

const config = JSON.parse(configData);

fs.readFile(config.userDataPath, 'utf8', (err, userData) => {
if (err) {
console.error('读取用户数据失败:', err);
return;
}

const logContent = `配置的用户路径是: ${config.userDataPath}, 用户数据是: ${userData}`;

fs.writeFile('./app.log', logContent, (err) => {
if (err) {
console.error('写入日志失败:', err);
return;
}

console.log('所有操作完成,日志已写入!');
});
});
});

这种结构不仅难以阅读,而且错误处理非常分散和重复,是滋生 Bug 的温床。

Node.js 的 fs 模块提供了一个 fs/promises 版本,它将所有基于回调的函数都转换成了返回 Promise 的版本。这让我们可以使用 .then().catch() 链式调用,将代码结构拉平。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 注意引入方式的变化
const fs = require('fs/promises');

let config;

fs.readFile('./config.json', 'utf8')
.then(configData => {
config = JSON.parse(configData);
// 返回下一个 Promise
return fs.readFile(config.userDataPath, 'utf8');
})
.then(userData => {
const logContent = `配置的用户路径是: ${config.userDataPath}, 用户数据是: ${userData}`;
// 返回下一个 Promise
return fs.writeFile('./app.log', logContent);
})
.then(() => {
console.log('所有操作完成,日志已写入!');
})
.catch(err => {
// 集中处理所有环节的错误
console.error('操作链中出现错误:', err);
});

async/await 是建立在 Promise 之上的语法糖,它允许我们用看起来像同步代码的方式来编写异步逻辑,是 2025 年的推荐最佳实践

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
// 同样使用 promises 版本的 fs 模块
const fs = require('fs/promises');

// 必须在一个 async 函数中才能使用 await
async function processFiles() {
try {
// 1. await 会“暂停”函数执行,直到 Promise 完成,并返回结果
const configData = await fs.readFile('./config.json', 'utf8');
const config = JSON.parse(configData);

// 2. 代码像同步一样,从上到下执行
const userData = await fs.readFile(config.userDataPath, 'utf8');

const logContent = `配置的用户路径是: ${config.userDataPath}, 用户数据是: ${userData}`;

// 3. 再次 await 等待写入完成
await fs.writeFile('./app.log', logContent);

console.log('所有操作完成,日志已写入!');
} catch (err) {
// 4. 使用标准的 try...catch 结构来捕获任何一个 await 操作的失败
console.error('操作过程中出现错误:', err);
}
}

// 调用这个 async 函数
processFiles();

async/await 极大地提升了代码的可读性和可维护性,让复杂的异步流程变得像同步流程一样清晰直观。


2.2. 事件循环再深化:宏任务与微任务

痛点背景: 即使我们熟练使用了 async/await,有时仍然会对代码的执行顺序感到困惑。例如,一个 setTimeout 和一个 Promise.resolve(),哪个会先执行?要精准地预测异步代码的行为,我们必须更深入地理解事件循环的调度规则。

解决方案: 事件循环中的任务队列,实际上分为两种类型:宏任务队列微任务队列

  • 宏任务: 可以理解为一次独立的、较大的工作单元。常见的宏任务包括:
    • setTimeout, setInterval 的回调
    • I/O 操作(文件读写、网络请求)的回调
    • 主脚本代码的执行本身(第一个宏任务)
  • 微任务: 可以理解为当前宏任务执行完毕后,需要立即处理的、优先级更高的小任务。常见的微任务包括:
    • Promise.prototype.then(), .catch(), .finally() 的回调
    • process.nextTick() (Node.js 特有,优先级最高)

核心执行规则:
事件循环的每一次迭代(称为一个 tick)都遵循以下流程:

  1. 从宏任务队列中取出一个宏任务并执行。
  2. 执行完毕后,立即检查微任务队列。
  3. 循环执行微任务队列中的所有任务,直到队列清空。
  4. 进行 UI 渲染(浏览器环境)或准备下一个 tick。
  5. 回到第 1 步,开始下一个宏任务。

让我们看一个经典的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
console.log('1. 同步代码:脚本开始');

// setTimeout 的回调是一个宏任务
setTimeout(() => {
console.log('4. 宏任务:setTimeout 回调');
}, 0);

// Promise.resolve().then 的回调是一个微任务
Promise.resolve().then(() => {
console.log('3. 微任务:Promise.then 回调');
});

console.log('2. 同步代码:脚本结束');

执行流程拆解:

  1. 第一个宏任务(主脚本)开始执行
  2. 打印 12
  3. 遇到 setTimeout,将其回调函数注册到 宏任务队列
  4. 遇到 Promise.then,将其回调函数注册到 微任务队列
  5. 第一个宏任务(主脚本)执行完毕
  6. 检查微任务队列,发现不为空。
  7. 执行微任务:打印 3。微任务队列清空。
  8. 本轮 tick 结束
  9. 下一轮 tick 开始,从宏任务队列中取出 setTimeout 的回调执行。
  10. 执行宏任务:打印 4

2.3. 与世界交互的第一步:文件系统 fs 模块

痛点背景: 几乎所有的后端应用都需要与文件系统打交道:读取配置文件、写入日志、上传和存储用户文件等等。这是 Node.js 提供的、而浏览器完全不具备的核心能力。

解决方案: Node.js 内置了强大的 fs 模块来满足所有文件操作的需求。我们将重点掌握其 异步、同步和流式 三种操作方式。

这是服务器编程中的 首选方式,它不会阻塞事件循环,能保持服务的高响应性。我们通常使用 fs/promises 模块。

场景: 读取一个 JSON 配置文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// file: read-config.js
const fs = require('fs/promises');

async function getConfig() {
try {
const data = await fs.readFile('./package.json', 'utf8');
const packageInfo = JSON.parse(data);
console.log(`项目名称: ${packageInfo.name}`);
console.log(`项目版本: ${packageInfo.version}`);
} catch (error) {
console.error('读取或解析 package.json 文件失败:', error);
}
}

getConfig();

同步 API 会 阻塞 整个 Node.js 进程,直到操作完成。

场景: 仅限于在 应用程序启动时 读取必要的、一次性的配置。因为此时服务器还未开始接收请求,短暂的阻塞是可以接受的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// file: read-sync.js
const fs = require('fs');

try {
// 注意函数名带有 "Sync" 后缀
const data = fs.readFileSync('./package.json', 'utf8');
const packageInfo = JSON.parse(data);
console.log('配置加载成功!');
console.log(`项目名称: ${packageInfo.name}`);
} catch (error) {
console.error('致命错误: 无法加载配置文件,程序退出。', error);
process.exit(1); // 失败时退出进程
}

console.log('这行代码必须等待文件读取完成后才会执行。');

严禁 在处理用户请求的业务逻辑(如 API 路由中)使用任何同步 I/O 函数!这会灾难性地影响服务器性能。

新痛点: 如果要处理一个几 GB 大的视频文件,一次性读入内存会导致内存溢出。

解决方案: 流 (Streams)。它像一根管道,让数据以小块(chunk)的形式流动,我们只需要处理一小块数据,而无需将整个文件加载到内存中。

场景: 复制一个大文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// file: copy-large-file.js
const fs = require('fs');

// 创建一个可读流,从源文件读取数据
const readStream = fs.createReadStream('./source-video.mp4');
// 创建一个可写流,将数据写入目标文件
const writeStream = fs.createWriteStream('./destination-video.mp4');

// pipe() 方法会自动处理数据流动,从可读流“管道”到可写流
readStream.pipe(writeStream);

readStream.on('end', () => {
console.log('大文件复制完成!');
});

readStream.on('error', (err) => {
console.error('读取流出错:', err);
});

writeStream.on('error', (err) => {
console.error('写入流出错:', err);
});

2.4. 专业之路:永不拼接的路径 path 模块

痛点背景: 当我们在代码中需要引用一个文件时,比如 ../config/db.json,这种硬编码的相对路径非常脆弱。如果文件的层级结构发生变化,代码就得修改。更严重的是,Windows 和 macOS/Linux 使用不同的路径分隔符(\/)。手动拼接字符串 folder + '/' + file 在 Windows 上可能会产生无效路径。

解决方案: 永远不要手动拼接路径字符串!Node.js 提供了内置的 path 模块,它能根据当前的操作系统,以正确的方式处理和转换路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const path = require('path');

// __dirname 是一个 Node.js 提供的全局变量,代表当前文件所在的目录的绝对路径
console.log(`当前文件所在目录: ${__dirname}`);

// 使用 path.join() 来安全地拼接路径
// 它会自动使用正确的路径分隔符('/' 或 '\')
const configFile = path.join(__dirname, 'config', 'database.json');
console.log(`目标配置文件路径: ${configFile}`);

// 其他常用 API
console.log(`文件名: ${path.basename(configFile)}`); // database.json
console.log(`目录名: ${path.dirname(configFile)}`); // .../config
console.log(`扩展名: ${path.extname(configFile)}`); // .json

最佳实践: 始终使用 path.join(__dirname, ...)path.resolve(...) 来构建指向项目文件的绝对路径。这让你的代码无论在哪个目录下被执行,都能正确地找到文件。


2.5. 从零到一:使用 http 模块构建你的第一个服务器

痛点背景: 我们一直在使用 node 命令在终端里运行脚本。但后端的真正价值在于响应来自外部的请求。像 Express、Koa、Next.js 这些框架,它们是如何监听网络端口,接收 HTTP 请求,并返回响应的呢?

解决方案: 所有这些上层框架,其最底层都构建于 Node.js 的 http 模块之上。通过学习它,我们能揭开 web 框架的神秘面纱,理解最基本的请求-响应生命周期。

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
34
// file: basic-server.js
const http = require('http');

// 定义服务器监听的端口号
const PORT = 3000;

// http.createServer() 方法创建一个服务器实例
// 它接收一个回调函数,这个函数会在每次有请求进来时被执行
const server = http.createServer((req, res) => {
// req (request) 对象包含了客户端的所有请求信息,如 URL、请求头、方法等
console.log(`接收到请求: ${req.method} ${req.url}`);

// res (response) 对象用于向客户端发送响应

// 简单的路由逻辑
if (req.url === '/') {
// 1. 设置响应头:状态码 200,内容类型为 HTML
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
// 2. 发送响应内容
res.end('<h1>欢迎来到我的第一个 Node.js 服务器!</h1>');
} else if (req.url === '/api/user') {
res.writeHead(200, { 'Content-Type': 'application/json' });
const user = { name: '架构思维的觉醒者', level: 1 };
res.end(JSON.stringify(user));
} else {
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<h1>404 - 页面未找到</h1>');
}
});

// server.listen() 启动服务器,开始监听指定端口的连接
server.listen(PORT, () => {
console.log(`服务器已启动,正在监听 http://localhost:${PORT}`);
});

如何运行:

  1. 在终端运行 node basic-server.js
  2. 打开你的浏览器,访问 http://localhost:3000http://localhost:3000/api/user,看看会发生什么。
  3. 每次访问,你都会在运行脚本的终端里看到打印的请求日志。

2.6. 本章核心速查总结

分类关键项核心描述
异步编程async / await(推荐) 使用同步的风格编写异步代码,基于 Promises 的语法糖。
fs/promisesfs 模块的 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. 高频面试题与陷阱

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

在 Node.js 中,fs.readFile 和 fs.readFileSync 有什么区别?你在项目中会如何选择使用它们?

它们的主要区别在于一个是异步的,一个是同步的。fs.readFile 是非阻塞的,它会把文件读取操作交给底层系统,然后立即返回,不会阻塞主线程。当文件读取完成后,它会通过回调函数或 Promise 的方式通知我们。而 fs.readFileSync 是阻塞的,它会暂停整个 Node.js 进程,直到文件完全读取到内存中,才会继续执行后面的代码。

说得很好。那你能具体举例说明,在什么场景下应该使用哪个吗?

当然。在绝大多数情况下,尤其是在一个 Web 服务器的业务逻辑中,我们应该 始终 使用异步的 fs.readFile。因为服务器需要同时处理大量并发请求,如果使用 readFileSync,一个请求在读取大文件时就会阻塞整个进程,导致其他所有请求都无法被响应,这是灾难性的。

那 readFileSync 就完全没有用武之地了吗?

也不是。它的一个合理使用场景是在应用程序 启动阶段。比如,程序启动时需要同步加载一些全局的、必需的配置文件。在这个阶段,服务器还没有开始接收外部请求,短暂的阻塞是完全可以接受的,并且同步代码可以让启动逻辑更简单直观。一旦程序完成初始化并开始监听端口,就应该严格避免任何同步 I/O 操作了。