从回调地狱到优雅异步:Node.js 文件操作进化史

一个前端学习者的 pathfs 踩坑笔记:先把路径找对,再把异步写顺。

写在前面

刚开始学 Node.js 文件操作时,我的第一反应很直接:读文件嘛,readFileSync 一把梭。

ini 复制代码
import fs from 'node:fs';
​
const data = fs.readFileSync('data.txt', 'utf-8');
console.log(data);

代码跑通的那一刻,确实很有成就感。直到后来我在脚本里连续处理多个文件,甚至把同步 I/O 写进了接口逻辑里,才意识到问题:Node.js 的优势之一是非阻塞 I/O,而同步文件操作会把主线程按在原地等待。

这篇文章就是我整理 pathfs 模块时的一条学习路线:

  • 先搞清楚文件路径:path.join()path.resolve() 到底差在哪;
  • 再理解文件读取:同步、回调、Promise、async/await 分别解决了什么问题;
  • 最后总结在真实项目里该怎么选。

1. 文件操作之前,先把路径想明白

操作文件之前,最容易被忽略的问题是:文件到底在哪里?

路径就像文件的地址。你可以说"幸福小区 3 号楼 201",也可以说"从当前位置出发,左转再右转"。在 Node.js 里,path 模块就是专门处理这类"地址"的工具。

其中最容易混淆的两个方法是:

  • path.join():把路径片段拼起来,并规范化结果;
  • path.resolve():把路径片段解析成一个绝对路径。

1.1 path.join():路径拼接器

path.join() 的核心职责是拼接路径片段,并处理多余的分隔符、...

javascript 复制代码
import path from 'node:path';
​
console.log(path.join('a', 'b', 'c'));
// a/b/c
​
console.log(path.join('a', '/b', 'c'));
// a/b/c
​
console.log(path.join('a', '..', 'b'));
// b

它很适合用来拼接相对路径,但要注意:join() 不保证返回绝对路径。

arduino 复制代码
console.log(path.join('src', 'assets', 'logo.png'));
// src/assets/logo.png

如果第一个片段本身不是绝对路径,结果通常也不是绝对路径。

1.2 path.resolve():绝对路径解析器

path.resolve() 更像一个导航系统。它会从右往左处理路径片段,直到构造出绝对路径。

arduino 复制代码
console.log(path.resolve('a', 'b', 'c'));
// 当前工作目录/a/b/c
​
console.log(path.resolve('/hello', 'world'));
// /hello/world
​
console.log(path.resolve('/hello', 'world', '/a', 'b'));
// /a/b

这几个例子里有两个关键点:

  • 如果传入的都是相对路径,resolve() 会基于 process.cwd() 补成绝对路径;
  • 如果遇到以 / 开头的片段,前面的片段会被"重置"掉。

也就是说,resolve() 的目标不是简单拼接,而是得到一个可以定位文件的绝对路径

1.3 joinresolve 怎么选?

我之前踩过一个小坑:想在 src/app.js 里读取项目根目录下的 config.json

项目结构大概是这样:

arduino 复制代码
project/
├── src/
│   └── app.js
└── config.json

如果在 CommonJS 中这样写:

ini 复制代码
const configPath = path.join(__dirname, '/config.json');

很多人会以为 /config.json 会让路径回到根目录,但 join() 会把它当成普通路径片段处理,结果仍然是在 src 目录下找文件。

更明确的写法是:

ini 复制代码
const configPath = path.resolve(__dirname, '../config.json');

如果你写的是 ESM,也就是使用 import 的模块,需要先自己得到当前文件目录:

ini 复制代码
import path from 'node:path';
import { fileURLToPath } from 'node:url';
​
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
​
const configPath = path.resolve(__dirname, '../config.json');

我的理解可以总结成这张表:

场景 推荐方法 原因
想拿到绝对路径 path.resolve() 结果始终是绝对路径,定位更明确
拼接多个路径片段 path.join() 简洁,自动处理多余分隔符
基于当前工作目录找文件 path.resolve() 会结合 process.cwd()
基于当前文件目录找文件 两者都能用 关键是先拿到正确的 __dirname 或等价值

1.4 常用的 path 工具方法

除了 joinresolve,我在练习里也整理了几个常用方法:

arduino 复制代码
console.log(path.dirname('a/b/c.js'));
// a/b

console.log(path.basename('a/b/c.js'));
// c.js

console.log(path.basename('a/b/c.js', '.js'));
// c

console.log(path.extname('a/b/c.js'));
// .js

console.log(path.normalize('a/b//c/d/e/..'));
// a/b/c/d

console.log(path.parse('home/user/dir/file.txt'));
// {
//   root: '',
//   dir: 'home/user/dir',
//   base: 'file.txt',
//   name: 'file',
//   ext: '.txt'
// }

这些方法看起来很基础,但在写脚本时非常实用。比如批量扫描 Markdown 文件、根据扩展名过滤文件、从路径里提取文件名等,都离不开它们。

2. 同步读取:最容易理解,也最容易阻塞

终于来到 fs 模块。

fs 是 File System 的缩写,用来操作文件和目录。最直观的读取方式是同步读取:

javascript 复制代码
import fs from 'node:fs';

const syncData = fs.readFileSync('test.txt', 'utf-8');
console.log(syncData);
console.log('我在读取文件之后执行');

这段代码很好理解:

  1. 执行 readFileSync
  2. 等文件读取完成;
  3. 打印文件内容;
  4. 继续执行后面的代码。

同步写法的优点是简单,缺点也非常明显:它会阻塞主线程。

如果只是写一个一次性脚本,读取几个很小的本地文件,同步方式问题不大。但如果你在 Web 服务的请求处理函数里使用 readFileSync,某个请求读文件时,整个 Node.js 进程都要等待这次 I/O 结束,其他请求也会被影响。

可以这样记:

场景 是否适合同步 I/O
启动时读取配置 可以接受
一次性本地脚本 可以接受
Express/Koa 路由里读文件 不推荐
高并发服务处理用户请求 尽量避免

3. 异步回调:不阻塞了,但代码开始变形

Node.js 更常见的方式是异步 I/O:

javascript 复制代码
import fs from 'node:fs';

fs.readFile('./test.txt', 'utf-8', (err, data) => {
  if (err) {
    console.error(err);
    return;
  }

  console.log(data);
});

console.log('我在读取文件之后执行');

这次执行顺序就不一样了:

  1. 调用 fs.readFile,把读取任务交出去;
  2. 主线程继续向下执行;
  3. 文件读取完成后,回调函数被放回事件循环中执行;
  4. 回调里拿到 errdata

这就是非阻塞 I/O 的味道。

3.1 为什么 Node.js 喜欢异步?

Node.js 的 JavaScript 主线程一次只能执行一段 JS 代码。如果主线程被耗时任务卡住,后面的请求就只能排队。

但是文件读取、网络请求这类 I/O 操作,真正耗时的部分并不是 JS 计算,而是等待磁盘或系统调用返回。Node.js 的思路是:主线程别傻等,把 I/O 交出去,等结果回来再继续处理。

所以异步回调解决了同步阻塞的问题,但它也带来了新的麻烦。

3.2 回调地狱:当流程控制越来越深

如果只是读一个文件,回调写法还算清楚。可一旦业务需要"先读 A,再根据 A 读 B,最后读 C",代码就会开始向右生长:

javascript 复制代码
fs.readFile('./file1.txt', 'utf-8', (err, data1) => {
  if (err) {
    console.error(err);
    return;
  }

  console.log(data1);

  fs.readFile('./file2.txt', 'utf-8', (err, data2) => {
    if (err) {
      console.error(err);
      return;
    }

    console.log(data2);

    fs.readFile('./file3.txt', 'utf-8', (err, data3) => {
      if (err) {
        console.error(err);
        return;
      }

      console.log(data3);
    });
  });
});

这就是常说的 Callback Hell。

它的问题不只是缩进变深:

  • 每层都要重复处理 err
  • 主流程被埋在回调里面;
  • 条件分支一多,代码会更难读;
  • 想拆函数复用,也会变得别扭。

我的感觉是:回调让 Node.js 跑得更顺了,但让人读代码读得更累了。

4. Promise:把嵌套改成链式流程

为了解决回调嵌套,Promise 出场了。

在现代 Node.js 里,可以直接使用 node:fs/promises

javascript 复制代码
import fs from 'node:fs/promises';

fs.readFile('./file1.txt', 'utf-8')
  .then((data1) => {
    console.log(data1);
    return fs.readFile('./file2.txt', 'utf-8');
  })
  .then((data2) => {
    console.log(data2);
    return fs.readFile('./file3.txt', 'utf-8');
  })
  .then((data3) => {
    console.log(data3);
  })
  .catch((err) => {
    console.error('读取文件失败:', err);
  });

和回调相比,Promise 的变化很明显:

  • 嵌套变成了链式调用;
  • 错误可以统一交给末尾的 .catch()
  • 每一步 return 一个 Promise,下一步就会等待它完成。

Promise 有三种状态:

rust 复制代码
pending   -> 等待中
fulfilled -> 已成功
rejected  -> 已失败

常用的静态方法也很实用:

less 复制代码
const [data1, data2, data3] = await Promise.all([
  fs.readFile('./file1.txt', 'utf-8'),
  fs.readFile('./file2.txt', 'utf-8'),
  fs.readFile('./file3.txt', 'utf-8'),
]);

Promise.all() 适合多个任务互不依赖、可以并行执行的场景。相比一个一个 await,它能减少等待时间。

但 then 链也不是完美答案。步骤一多,它还是会变长;条件分支复杂时,可读性也会下降。

这时就轮到 async/await 了。

5. async/await:让异步代码看起来像同步流程

async/await 是 Promise 的语法糖。它不会把异步变成同步,也不会阻塞整个主线程;它只是让我们能用更接近同步代码的方式组织异步流程。

javascript 复制代码
import fs from 'node:fs/promises';

async function readFiles() {
  try {
    const data1 = await fs.readFile('./file1.txt', 'utf-8');
    console.log('file1', data1);

    const data2 = await fs.readFile('./file2.txt', 'utf-8');
    console.log('file2', data2);

    const data3 = await fs.readFile('./file3.txt', 'utf-8');
    console.log('file3', data3);
  } catch (err) {
    console.error('读取文件失败:', err);
  }
}

readFiles();

这段代码读起来像同步流程,但底层仍然是 Promise:

  • async 函数总是返回 Promise;
  • await 会等待右侧 Promise settle;
  • Promise rejected 时,await 会抛出异常,可以用 try...catch 捕获。

5.1 串行还是并行,要看任务有没有依赖

如果后一个文件必须依赖前一个文件的结果,就应该串行:

ini 复制代码
const configText = await fs.readFile('./config.json', 'utf-8');
const config = JSON.parse(configText);

const data = await fs.readFile(config.dataPath, 'utf-8');

如果几个文件互不依赖,就可以并行:

less 复制代码
const [file1, file2, file3] = await Promise.all([
  fs.readFile('./file1.txt', 'utf-8'),
  fs.readFile('./file2.txt', 'utf-8'),
  fs.readFile('./file3.txt', 'utf-8'),
]);

这里有个学习时很容易混淆的点:await 写得像同步,不代表它适合无脑串行。 能并行的 I/O,就别一个接一个等。

5.2 循环里怎么写 await

如果需要按顺序处理文件,可以用 for...of

ini 复制代码
async function readInOrder(fileNames) {
  const results = [];

  for (const fileName of fileNames) {
    const data = await fs.readFile(fileName, 'utf-8');
    results.push(data);
  }

  return results;
}

不要用 forEach 搭配 await 来表达"按顺序等待",因为 forEach 不会等待内部的异步回调完成。

如果文件之间没有顺序依赖,则可以直接 map 成 Promise,再交给 Promise.all()

javascript 复制代码
async function readTogether(fileNames) {
  return Promise.all(
    fileNames.map((fileName) => fs.readFile(fileName, 'utf-8')),
  );
}

6. 我的实践建议

整理完这一轮学习后,我对 Node.js 文件操作的选择大概是这样的:

写法 优点 缺点 适用场景
readFileSync 简单直观 阻塞主线程 启动配置、一次性脚本
fs.readFile 回调 非阻塞 容易嵌套、错误处理重复 维护旧代码时会遇到
fs/promises + .then() 链式流程,统一捕获错误 链长了仍然不够清爽 需要组合 Promise 的场景
fs/promises + async/await 可读性最好,接近同步流程 容易误写成无意义串行 日常推荐写法

再补几条我会在项目里遵守的习惯:

  • 新代码优先使用 node:fs/promises 和 async/await;
  • 路径处理尽量用 path.resolve() 得到明确的绝对路径;
  • 服务端请求处理中避免同步文件 I/O;
  • 有依赖关系就串行,没有依赖关系就 Promise.all() 并行;
  • await 外层记得设计错误处理,不要让异常悄悄变成未处理的 Promise rejection。

结语

readFileSync 到回调,再到 Promise 和 async/await,Node.js 文件操作的演进其实是在不断回答同一个问题:

怎样在不阻塞主线程的前提下,把异步流程写得更清楚?

回调解决了阻塞问题,却带来了嵌套地狱;Promise 把嵌套压平成链;async/await 又把链式流程写回了接近同步的样子。

但我觉得学习时不要直接跳到 async/await 就结束了。只有理解回调和 Promise 解决过什么问题,才会真正明白 async/await 为什么好用,也能避免把它误用成"披着异步外衣的串行等待"。

下一次再写文件操作脚本时,我会先问自己三个问题:

  1. 这个路径是否稳定、明确?
  2. 这个文件操作会不会阻塞线上请求?
  3. 这些异步任务是该串行,还是可以并行?

能回答清楚这三个问题,pathfs 就不再只是几个 API,而是真正能服务工程实践的工具了。

相关推荐
Java编程爱好者2 小时前
深入浅出Agent: Harness最全调研与原理解析(持续更新中)
后端
日月云棠2 小时前
Spring 源码深度剖析:DispatcherServlet 的自动化注册机制
java·后端
熊猫钓鱼>_>2 小时前
Electron:当 Web 技术统治桌面
大数据·前端·javascript·人工智能·架构·electron·agent
码事漫谈2 小时前
让 Codex CLI 跑上 DeepSeek:用 LiteLLM 做 Responses↔Chat 协议翻译
后端
JavaGuide2 小时前
又一个画图 Skill 开源,再见手动画 draw.io!
前端·后端·github
吾诺3 小时前
2026大直径紧固件双叠自锁垫圈怎么选型
后端
tyung3 小时前
zhenyi-base zqueue 基准测试分享
后端·go
江华森3 小时前
Python 实现高德地图找房(三):地图可视化与高德 JS API
开发语言·javascript·python
谭光志3 小时前
AI 是怎么操作浏览器的——browser use 实现原理
前端·javascript·ai编程