一个前端学习者的
path与fs踩坑笔记:先把路径找对,再把异步写顺。
写在前面
刚开始学 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,而同步文件操作会把主线程按在原地等待。
这篇文章就是我整理 path 和 fs 模块时的一条学习路线:
- 先搞清楚文件路径:
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 join 和 resolve 怎么选?
我之前踩过一个小坑:想在 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 工具方法
除了 join 和 resolve,我在练习里也整理了几个常用方法:
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('我在读取文件之后执行');
这段代码很好理解:
- 执行
readFileSync; - 等文件读取完成;
- 打印文件内容;
- 继续执行后面的代码。
同步写法的优点是简单,缺点也非常明显:它会阻塞主线程。
如果只是写一个一次性脚本,读取几个很小的本地文件,同步方式问题不大。但如果你在 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('我在读取文件之后执行');
这次执行顺序就不一样了:
- 调用
fs.readFile,把读取任务交出去; - 主线程继续向下执行;
- 文件读取完成后,回调函数被放回事件循环中执行;
- 回调里拿到
err或data。
这就是非阻塞 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 为什么好用,也能避免把它误用成"披着异步外衣的串行等待"。
下一次再写文件操作脚本时,我会先问自己三个问题:
- 这个路径是否稳定、明确?
- 这个文件操作会不会阻塞线上请求?
- 这些异步任务是该串行,还是可以并行?
能回答清楚这三个问题,path 和 fs 就不再只是几个 API,而是真正能服务工程实践的工具了。