从"地狱"到"楼梯"再到"同步写法":Node.js 异步的二十年与内置fs模块详解
摘要:fs 模块是 Node.js 文件操作的基石。本文从同步与异步的对比出发,沿着"同步 → 回调 → Promise → async/await"的演进路线,拆解 fs 模块的核心 API,并梳理 JavaScript 异步编程的发展历程。
📑 目录
- fs 模块是什么?Node.js 的文件系统能力
- 三种 API 风格:同步、回调、Promise
- 核心 API 速览
- 同步 vs 异步:阻塞与非阻塞的本质差异
- 回调地狱:异步流程控制的痛点
- Promise + then:从"地狱"到"楼梯"
- async/await:异步代码同步化(最终形态)
- JavaScript 异步编程进化史
- 一点总结
- 互动讨论
fs 模块是什么?Node.js 的文件系统能力
fs(File System)是 Node.js 的核心模块之一,提供了与文件系统交互的各种能力------读文件、写文件、创建目录、删除文件、监视文件变化等等。可以把它理解成 Node.js 操作硬盘的"通道"。
javascript
javascript
import fs from 'fs'; // 传统方式
import fs from 'fs/promises'; // Promise 方式(推荐)
在 3.mjs 中,我用了同步方式读取文件:
javascript
ini
const syncData = fs.readFileSync('./test.txt', 'utf-8');
console.log(syncData);
readFileSync 是阻塞的------它会等文件读完才继续执行后面的代码。这在简单脚本中没问题,但在服务器环境中会阻塞主线程,影响其他请求的处理。
三种 API 风格:同步、回调、Promise
fs 模块的所有操作都提供了三种形式:
| 风格 | 特点 | 适用场景 |
|---|---|---|
| 同步(Sync) | 阻塞事件循环,异常直接抛出 | 简单脚本、启动配置阶段 |
| 回调(Callback) | 非阻塞,最后一个参数是回调函数 | 传统异步编程 |
| Promise | 非阻塞,支持 async/await |
现代推荐方式 |
三种风格对比:
javascript
javascript
// 1. 同步风格------阻塞
try {
const data = fs.readFileSync('./file.txt', 'utf8');
console.log(data);
} catch (err) {
console.error(err);
}
// 2. 回调风格------非阻塞
fs.readFile('./file.txt', 'utf8', (err, data) => {
if (err) console.error(err);
else console.log(data);
});
// 3. Promise 风格------非阻塞(推荐)
import fs from 'fs/promises';
const data = await fs.readFile('./file.txt', 'utf8');
核心 API 速览
文件读写
| 方法 | 作用 | 同步版 | Promise版 |
|---|---|---|---|
readFile |
读取整个文件内容 | readFileSync |
fs/promises 中的 readFile |
writeFile |
写入文件内容(覆盖) | writeFileSync |
fs/promises 中的 writeFile |
appendFile |
追加内容到文件末尾 | appendFileSync |
fs/promises 中的 appendFile |
copyFile |
复制文件 | copyFileSync |
fs/promises 中的 copyFile |
unlink |
删除文件 | unlinkSync |
fs/promises 中的 unlink |
writeFile 的 flags 参数:
'w':写入(默认),覆盖已有内容'a':追加'wx':排他写入,文件已存在则失败
目录操作
| 方法 | 作用 | 同步版 | Promise版 |
|---|---|---|---|
mkdir |
创建目录 | mkdirSync |
fs/promises 中的 mkdir |
readdir |
读取目录内容列表 | readdirSync |
fs/promises 中的 readdir |
rmdir |
删除空目录 | rmdirSync |
fs/promises 中的 rmdir |
rm |
删除文件或目录(推荐) | rmSync |
fs/promises 中的 rm |
创建目录时递归创建父目录:
javascript
css
await fs.mkdir('./a/b/c', { recursive: true });
// 自动创建 a → a/b → a/b/c,如果目录已存在也不会报错
文件信息
| 方法 | 作用 | Promise版 |
|---|---|---|
stat |
获取文件/目录的详细信息 | fs/promises 中的 stat |
access |
检查文件是否存在及权限 | fs/promises 中的 access |
javascript
arduino
const stats = await fs.stat('./file.txt');
stats.isFile() // 是否为文件
stats.isDirectory() // 是否为目录
stats.size // 文件大小(字节)
同步 vs 异步:阻塞与非阻塞的本质差异
Node.js 最核心的优势就是异步非阻塞 I/O。为什么这一点如此重要?
在 3.mjs 中,我分别测试了同步和异步的行为:
javascript
javascript
// 同步:阻塞主线程
const syncData = fs.readFileSync('./test.txt', 'utf-8');
console.log(syncData);
console.log('111'); // 必须等文件读完才执行
// 异步:不阻塞主线程
fs.readFile('./test1.txt', 'utf-8', (err, data) => {
if (!err) console.log(data);
});
console.log('111'); // 立即执行,不等文件读完
同步方式下,console.log('111') 要等文件读完才执行。如果文件很大,主线程就一直被占着,其他请求无法处理。
异步方式下,fs.readFile 把读取任务交给底层(libuv),主线程立刻执行 console.log('111')。文件读完后再通过事件循环执行回调。这就是 Node.js 能够支撑高并发的底层原理。
Node.js 底层是使用 C++ 编写的(fs、path 等模块),封装了 V8 引擎来解析 JavaScript。异步 I/O 由 libuv 库实现,让主线程永不阻塞。
回调地狱:异步流程控制的痛点
在 Promise 出现之前,Node.js 用回调函数处理异步。如果多个异步任务有先后顺序要求,就需要在回调中嵌套回调,形成"回调地狱"。
在 3.mjs 中,我模拟了需要顺序读取三个文件的场景:
javascript
javascript
fs.readFile('./file1.txt', 'utf-8', (err, data) => {
if (!err) {
console.log('file1', data);
}
fs.readFile('./file2.txt', 'utf-8', (err, data) => {
if (!err) {
console.log('file2', data);
}
fs.readFile('./file3.txt', 'utf-8', (err, data) => {
if (!err) {
console.log('file3', data);
}
});
});
});
这段代码的问题很明显:
- 缩进越来越深,像一座金字塔
- 难以阅读和维护,逻辑被嵌套"淹没"
- 错误处理重复 ,每个回调都要检查
err - 难以扩展,加一个步骤就要再包一层
这就是著名的回调地狱(Callback Hell)------代码像十八层地狱一样一层一层往下走,繁杂到几乎无法容忍。
Promise + then:从"地狱"到"楼梯"
ES6 推出了 Promise,把嵌套结构变成了链式结构。在 4.mjs 中,我实现了同样的需求:
javascript
kotlin
import fs from 'fs/promises';
fs.readFile('./file1.txt', 'utf-8')
.then(data => {
console.log('file1', data);
return fs.readFile('./file2.txt', 'utf-8');
})
.then(data => {
console.log('file2', data);
return fs.readFile('./file3.txt', 'utf-8');
})
.then(data => {
console.log('file3', data);
});
变化很明显:
- 从嵌套 变成了平铺
- 从"回调地狱"变成了"爬楼梯"
- 语义清晰:
then就是"然后做下一件事" - 错误处理统一:可以用一个
.catch()捕获所有错误
但链式调用仍然显得繁琐,每个步骤都要写 return 和 then。那还能更简洁吗?
async/await:异步代码同步化(最终形态)
ES8 推出了 async/await。它不是新机制,只是 Promise.then 链式调用的语法糖。但它让异步代码的写法接近同步代码。
先看三种写法的直观对比------同样是顺序读取三个文件,callback、Promise.then 和 async/await 的差异一目了然:
callback 版本(回调地狱)
javascript
javascript
fs.readFile('./file1.txt', 'utf-8', (err, data) => {
if (!err) {
console.log('file1', data);
} else {
console.log(err);
}
fs.readFile('./file2.txt', 'utf-8', (err, data) => {
if (!err) {
console.log('file2', data);
} else {
console.log(err);
}
fs.readFile('./file3.txt', 'utf-8', (err, data) => {
if (!err) {
console.log('file3', data);
} else {
console.log(err);
}
});
});
});
Promise.then 版本(链式)
javascript
kotlin
fs.readFile('./file1.txt', 'utf-8')
.then(data => {
console.log('file1', data);
return fs.readFile('./file2.txt', 'utf-8');
})
.then(data => {
console.log('file2', data);
return fs.readFile('./file3.txt', 'utf-8');
})
.then(data => {
console.log('file3', data);
});
async/await 版本(最简洁)
javascript
ini
(async () => {
const file1Data = await fs.readFile('./file1.txt', 'utf-8');
console.log('file1', file1Data);
const file2Data = await fs.readFile('./file2.txt', 'utf-8');
console.log('file2', file2Data);
const file3Data = await fs.readFile('./file3.txt', 'utf-8');
console.log('file3', file3Data);
})();
三个版本的对比:
| 维度 | callback | Promise.then | async/await |
|---|---|---|---|
| 代码行数 | 最多(21行) | 中等(12行) | 最少(7行) |
| 缩进层级 | 3层嵌套 | 1层平铺 | 0层平铺 |
| 错误处理 | 每层重复检查 | 统一 catch | try/catch |
| 可读性 | 差(嵌套地狱) | 较好(链式) | 好(像同步代码) |
关键理解 :这里不是退化为同步。三个任务是异步 执行的,只是通过 await 控制了流程------必须等到 file1Data 拿到手,才能执行后面的代码。在 await 等待期间,主线程是可以处理其他事情的,这就是"非阻塞"的本质。
await 帮我们实现了流程控制,不需要手动写 then 链。它只是写法上的简化,底层的异步非阻塞本质没有变。
async/await的本质是"写法变同步,运行仍异步"。它没有创造新机制,而是让开发者用更自然的方式写异步代码。 理解这一点非常重要:async/await让异步代码看起来像同步 ,但并没有把异步变成同步。主线程在await时会被"让出",去处理其他任务,等 Promise 完成后才恢复执行。
JavaScript 异步编程进化史
从 3.mjs 和 4.mjs 的注释中,可以清晰地看到 JavaScript 异步编程的演进路线:
text
vbnet
同步(阻塞)
↓
异步(event loop + 回调)
↓
流程控制复杂 → 回调地狱
↓
Promise + then(链式,可读性提升)
↓
async/await(语法糖,异步代码同步化)
| 阶段 | 代表技术 | 特点 |
|---|---|---|
| 同步 | readFileSync |
简单直接,但阻塞主线程 |
| 异步回调 | readFile + callback |
非阻塞,但容易陷入回调地狱 |
| Promise 链 | readFile + .then() |
链式调用,解决嵌套问题 |
| async/await | await readFile() |
语法糖,代码最简洁 |
async/await 不是新机制,它只是 Promise 的语法糖。异步的本质没有变,变的是我们写代码的方式。
一点总结
- fs 模块是 Node.js 文件操作的核心,提供了同步、回调、Promise 三种风格。
- Node.js 的异步非阻塞 I/O 是其高性能的根基,由 C++ 层和 libuv 库支撑。
- 回调地狱 是异步流程控制的早期痛点,代码嵌套深、可读性差、难维护。
- Promise + then 把嵌套变成链式,解决回调地狱问题,但写法仍略显繁琐。
- async/await 是 Promise 的语法糖,让异步代码看起来像同步,是当前最推荐的写法。
- 整个演进路线:同步 → 回调 → 回调地狱 → Promise.then → async/await(异步代码同步化)。
async/await的本质是"写法变同步,运行仍异步"。它没有创造新机制,而是让开发者用更自然的方式写异步代码。
互动讨论
- 同步和异步的根本区别是什么? Node.js 为什么选择异步非阻塞模型?
- 回调地狱除了可读性差,还有什么实际问题? 比如错误处理、流程控制?
Promise.then链式调用中,如果某个步骤出错,后续的then还会执行吗?async/await和Promise.then本质是一样的,那么用async/await有什么好处? 有缺点吗?- 你更喜欢用
Promise.then还是async/await? 为什么?
📌 一点心得:理解 JavaScript 异步编程的进化史,才能真正理解
async/await的价值。它不是为了"炫技",而是为了让开发者用更自然的方式处理异步逻辑。