🚀 Node.js path 和 fs 模块,从回调地狱到 async/await 的进化之路!
摘要 :每个 Node.js 开发者都绕不开
path和fs这两个内置模块。本文从实际代码出发,带你彻底搞懂路径操作的那些坑,以及文件读写从回调地狱到 async/await 的完整进化史。
📌 前言
刚学 Node.js 的时候,我以为文件操作就是"读一下、写一下"的事。直到我遇到了:
path.join和path.resolve到底有啥区别?- 为什么我的路径拼出来多了个
/? - 读三个文件,代码缩进了三层,这是人写的?
如果你也有过类似的困惑,这篇文章就是为你写的。我会用大量可运行的代码示例,把这些知识点一次讲透。
🎯 本文适合谁
- 🌱 Node.js 初学者,想搞懂内置模块的用法
- 🔧 前端开发者转后端,对文件系统操作不熟悉
- 💡 想理清异步编程演进脉络的开发者
- 📝 准备面试,需要梳理 Node.js 基础的同学
📚 一、path 模块 --- 路径操作的瑞士军刀
path 是 Node.js 内置的路径处理模块,不需要安装,直接引入:
javascript
import path from 'path';
1.1 path.join vs path.resolve --- 最容易混淆的两个方法
这两个方法都能拼接路径,但行为完全不同。面试高频考点!
path.join --- 纯拼接,不关心路径是否合法
javascript
// 简单拼接
console.log(path.join('a', 'b', 'c'));
// 输出: a\b\c(Windows)或 a/b/c(Mac/Linux)
// 拼接相对路径
console.log(path.join('hello', 'world', './a', 'b'));
// 输出: hello\world\a\b
// 会处理 .. 相对路径
console.log(path.join('\\hello', 'world', '../a', 'b'));
// 输出: \hello\a\b(world 被 .. 回退了)
path.resolve --- 从右到左解析,直到生成绝对路径
javascript
// 相对路径 → 以当前工作目录为基准,返回绝对路径
console.log(path.resolve('a', 'b', 'c'));
// 输出: D:\your\current\dir\a\b\c
// 遇到绝对路径就停止,以该绝对路径为准
console.log(path.resolve(process.cwd(), '/hello', 'world'));
// 输出: D:\hello\world(/hello 是绝对路径,前面的被忽略了!)
🔑 核心区别速记表
| 场景 | path.join |
path.resolve |
|---|---|---|
| 纯拼接 | ✅ 直接拼接 | ❌ 不是纯拼接 |
| 相对路径 | 返回相对路径 | 返回绝对路径 |
| 遇到绝对路径 | 继续拼接 | 以该绝对路径为准,前面的丢弃 |
| 第一个参数是绝对路径 | 类似 resolve | 直接返回该绝对路径 |
💡 工程化思维 :在项目中,我们通常用
path.resolve(process.cwd(), 'src')来获取src目录的绝对路径,这比手写__dirname更灵活。
为什么不用 __dirname?
__dirname 是 CommonJS(CJS)模块中 Node.js 自动注入的全局变量,表示当前文件所在的目录:
javascript
// CommonJS 中可以直接使用
console.log(__dirname); // D:\project\src\utils
console.log(__dirname + '/config'); // 拼接路径(不推荐,用 path.join)
但在 ESM 模块 (type: "module" 或 .mjs 文件)中,__dirname 不存在!你需要手动模拟:
javascript
// ESM 中模拟 __dirname(很麻烦)
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
所以用 process.cwd() + path.resolve 更灵活,原因有三:
| 对比项 | __dirname |
process.cwd() + path.resolve |
|---|---|---|
| 模块兼容 | ❌ 仅 CJS 可用,ESM 需要手动模拟 | ✅ CJS 和 ESM 通用 |
| 参照基准 | 当前文件所在的目录 | 当前进程启动的目录(项目根目录) |
| 构建工具 | 会被打包工具(Webpack/Vite)改写,行为不可预期 | 不受打包工具影响,行为稳定 |
| CLI 场景 | ❌ 文件位置不固定,路径会变 | ✅ 用户在哪执行命令,就以哪里为基准 |
javascript
// ✅ 推荐:通用、稳定、不受模块系统限制
const srcDir = path.resolve(process.cwd(), 'src');
const configPath = path.resolve(process.cwd(), 'config', 'app.json');
📌 简单记忆:
__dirname是文件视角 (我在哪),process.cwd()是项目视角(项目根在哪)。工程化场景下,项目视角更可靠。
1.2 path.dirname --- 获取目录名
去掉路径的最后一部分,返回父目录:
javascript
console.log(path.dirname('/a/b/c'));
// 输出: /a/b
console.log(path.dirname(process.cwd()));
// 输出: 当前工作目录的父目录
1.3 path.basename --- 获取文件名
获取路径的最后一部分(文件名),可以可选地去掉扩展名:
javascript
// 获取完整文件名
console.log(path.basename('/a/b/c.js'));
// 输出: c.js
// 去掉 .js 扩展名
console.log(path.basename('/a/b/c.js', '.js'));
// 输出: c
// ⚠️ 注意:第二个参数是"要移除的后缀",不是"扩展名"
console.log(path.basename('/a/b/c.js', 'js'));
// 输出: c.(只移除了 js,点号保留了)
console.log(path.basename('/a/b/c.js', 's'));
// 输出: c.j(只移除了最后一个 s)
1.4 path.extname --- 获取扩展名
javascript
console.log(path.extname('/a/b/c.js'));
// 输出: .js
console.log(path.extname('/a/b/c'));
// 输出: ''(没有扩展名)
console.log(path.extname('/a/b/.gitignore'));
// 输出: ''(以 . 开头的文件名不算扩展名)
1.5 path.normalize --- 规范化路径
清理路径中多余的斜杠和 ..、.:
javascript
console.log(path.normalize('a/b//c/d/e/..'));
// 输出: a\b\c\d
console.log(path.normalize('/a///b/c///'));
// 输出: \a\b\c
1.6 path.parse --- 路径解析为对象
一把梭,把路径拆成各个部分:
javascript
console.log(path.parse('/home/user/dir/file.txt'));
// 输出:
// {
// root: '/',
// dir: '/home/user/dir',
// base: 'file.txt',
// ext: '.txt',
// name: 'file'
// }
📊 path 模块 API 速查表
| 方法 | 作用 | 返回值 |
|---|---|---|
path.join() |
拼接路径片段 | 规范化后的路径字符串 |
path.resolve() |
解析为绝对路径 | 绝对路径字符串 |
path.dirname() |
获取父目录 | 目录路径字符串 |
path.basename() |
获取文件名 | 文件名字符串 |
path.extname() |
获取扩展名 | 扩展名字符串(含 .) |
path.normalize() |
规范化路径 | 规范化后的路径字符串 |
path.parse() |
解析路径为对象 | { root, dir, base, ext, name } |
📚 二、fs 模块 --- 文件系统的读写操作
fs(File System)是 Node.js 的文件系统模块,用于读写文件和目录。
javascript
import fs from 'fs'; // 回调版本
import fs from 'fs/promises'; // Promise 版本(推荐)
💡
fs和path是 Node.js 用 C++ 实现的,通过 V8 引擎暴露给 JS 代码,实现了异步无阻塞的 I/O 操作。这也是 Node.js 能用单线程处理高并发的秘诀之一。
2.1 同步读取 --- 简单粗暴,但会阻塞
javascript
const data = fs.readFileSync('./test.txt', 'utf-8');
console.log(data); // hello world
同步方法会阻塞线程 ,在它执行完毕之前,后面的代码都不会执行。适合初始化阶段或简单脚本,不推荐在服务端使用。
2.2 回调方式 --- Node.js 的传统异步
javascript
fs.readFile('./test.txt', 'utf-8', (err, data) => {
if (!err) {
console.log(data);
} else {
console.log(err);
}
});
console.log('这行会先执行!');
Node.js 的回调函数约定:第一个参数永远是 err 错误对象。
2.3 回调地狱 --- 当需求变复杂时...

当我们需要按顺序读取三个文件时,事情就变得丑陋了:
javascript
// 先读 file1.txt,再读 file2.txt,最后读 file3.txt
fs.readFile('./file1.txt', 'utf-8', (err, data) => {
if (!err) {
console.log('file1.txt', data);
} else {
console.log(err);
}
// 读取 file2.txt(嵌套第 1 层)
fs.readFile('./file2.txt', 'utf-8', (err, data) => {
if (!err) {
console.log('file2.txt', data);
} else {
console.log(err);
}
// 读取 file3.txt(嵌套第 2 层)
fs.readFile('./file3.txt', 'utf-8', (err, data) => {
if (!err) {
console.log('file3.txt', data);
} else {
console.log(err);
}
});
});
});
看到没?才读三个文件,就已经嵌套了三层。如果再多几个,代码就变成了金字塔结构 ,可读性极差,维护起来更是噩梦。这就是著名的回调地狱(Callback Hell)。
有图有真相

📚 三、从回调地狱到 async/await --- 异步的三次进化
3.1 第一次进化:Promise + then 链式调用
ES6 引入了 Promise,用链式调用替代嵌套:
javascript
import fs from 'fs/promises';
fs.readFile('./file1.txt', 'utf-8')
.then(data => {
console.log('file1.txt', data);
return fs.readFile('./file2.txt', 'utf-8'); // 返回新的 Promise
})
.then(data => {
console.log('file2.txt', data);
return fs.readFile('./file3.txt', 'utf-8');
})
.then(data => {
console.log('file3.txt', data);
});
比嵌套好看多了!但 .then().then().then() 像爬楼梯,代码量一多还是不太直观。
3.2 第二次进化:async/await 语法糖
ES8(ES2017)引入了 async/await,让异步代码看起来像同步代码:
javascript
import fs from 'fs/promises';
(async () => {
const file1Data = await fs.readFile('./file1.txt', 'utf-8');
console.log('file1.txt', file1Data);
const file2Data = await fs.readFile('./file2.txt', 'utf-8');
console.log('file2.txt', file2Data);
const file3Data = await fs.readFile('./file3.txt', 'utf-8');
console.log('file3.txt', file3Data);
})();
console.log('这行会先执行!'); // async/await 不会阻塞主线程
代码从上到下,清清爽爽!await 帮我们实现了流程控制 ,不需要手动处理 .then() 链。
3.3 异步进化路线图
vbnet
同步(阻塞线程)
↓ 需要高性能
异步(Event Loop)
↓ 业务变复杂
回调函数 Callback
↓ 嵌套地狱
Promise + .then()
↓ 链式调用也繁琐
async/await(ES8 语法糖)
🔑 关键理解:async/await 的本质
| 特性 | 说明 |
|---|---|
| 本质 | Promise 的语法糖,底层还是 Promise |
| 执行 | await 后面的代码会进入微任务队列 |
| 不阻塞 | 不会阻塞主线程,setTimeout 等宏任务正常执行 |
| 语法糖 | 异步代码同步化,提升可读性 |
| 错误处理 | 配合 try/catch 使用,比 .catch() 更直观 |
javascript
// try/catch 处理错误
(async () => {
try {
const data = await fs.readFile('./不存在的文件.txt', 'utf-8');
} catch (err) {
console.log('文件读取失败:', err.message);
}
})();
💡 重点总结
- path.join vs path.resolve:join 纯拼接,resolve 生成绝对路径。遇到绝对路径时,resolve 会以它为准
- path 的常用 API:dirname(目录名)、basename(文件名)、extname(扩展名)、normalize(规范化)、parse(拆解)
- fs 同步 vs 异步:同步阻塞线程,适合脚本;异步不阻塞,适合服务端
- 回调地狱:嵌套回调导致代码可读性差、维护困难
- 异步进化路线:Callback → Promise + then → async/await
- async/await 本质:Promise 的语法糖,让异步代码看起来像同步,但不会阻塞主线程
🔗 参考资料
💬 交流讨论
你在学习 Node.js 的过程中,遇到过哪些让你困惑的点?欢迎在评论区分享交流!
觉得有用?点个赞👍收藏⭐关注👆,后续会更新更多 Node.js 实战干货!