从零搞懂 Node.js 的 path 和 fs 模块 —— 文件操作不再迷糊
前言
刚开始学 Node.js 的时候,最常打交道的就是路径处理和文件读写。这两个东西几乎是每个后端开发者的基本功——读配置、写日志、操作文件,哪个都离不开它们。
Node.js 给我们内置了两个模块:
path:专门处理文件路径,拼接、解析、规范化,统统一把梭。fs(FileSystem):操作文件本身——读、写、删、改。
今天这篇文章就带你从零开始,把这两个模块的核心用法和踩坑点都捋清楚。
一、path 模块 —— 路径处理利器
1.1 path.join vs path.resolve —— 99% 的新手都会搞混
这俩方法都能拼接路径,但行为有本质区别。先看代码:
import path from 'path';
// join:单纯拼接路径
console.log(path.join('a', 'b', 'c')); // a\b\c (Windows) 或 a/b/c (Mac/Linux)
// resolve:拼接并解析为绝对路径
console.log(path.resolve('a', 'b', 'c')); // C:\Users\...\a\b\c (以当前工作目录为基准)
是不是看起来差不多?别急,关键在于参数里出现绝对路径时:
// 当第一个参数是绝对路径时,两者结果一样
console.log(path.join('/hello', 'world')); // /hello/world
console.log(path.resolve('/hello', 'world')); // /hello/world
// 但当相对路径和绝对路径混用时,差异就来了
console.log(path.join(process.cwd(), '/hello', 'world'));
// → C:\Users\...\hello\world
// join 只是机械拼接,/hello 被当作普通字符串拼上去
console.log(path.resolve(process.cwd(), '/hello', 'world'));
// → /hello/world (注意!前面的 cwd 被忽略了!)
// resolve 遇到绝对路径就会"重置",以最后一个绝对路径为起点
🔑 核心区别:
join:纯拼接,不管路径是不是绝对,通通粘在一起。resolve:从左到右解析,一旦碰到绝对路径,就以它为起点,最终一定返回一个绝对路径。类似于cd命令在终端里的行为——cd /hello/world会直接跳到根目录下的/hello/world,忽略你当前在哪个目录。
工程化中的应用场景:
// ✅ 推荐:用 resolve 获取绝对路径(安全可靠)
const rootDir = path.resolve(process.cwd(), 'src');
const assetsDir = path.resolve(rootDir, 'assets');
const utilsDir = path.resolve(rootDir, 'libs');
// ✅ 推荐:用 join 拼接已知的相对路径片段
const configPath = path.join('config', 'app.json');
💡 知识点:
process.cwd()返回 Node.js 进程的当前工作目录(Current Working Directory)。注意它不是你脚本文件所在的目录(那个是__dirname),而是你在哪个目录下执行的node命令。
看个对比加深理解:
// 包含 ./ 和 ../ 时 resolve 的行为
path.resolve('/hello', 'world', './a', 'b'); // /hello/world/a/b
path.join('/hello', 'world', './a', 'b'); // /hello/world/a/b
// ↑ 第一个参数是绝对路径时,join 和 resolve 结果相同
path.resolve('/hello', 'world', '../a', 'b'); // /hello/a/b (.. 回退了一级)
path.join('/hello', 'world', '../a', 'b'); // /hello/world/../a/b (join 不会解析 ..!)
⚠️ 注意:
join不会解析..和.,它只负责拼。而resolve会把路径真正"解析"出来。
1.2 path.dirname —— 取目录名
把路径中最后一段(文件名或文件夹名)去掉,返回剩下的部分:
path.dirname('/a/b/c'); // /a/b
path.dirname('/a/b/c/index.js'); // /a/b/c
💡 知识点:
__dirname是 Node.js 的全局变量,总是指向当前脚本文件所在的目录的绝对路径。在 ES Module(.mjs)中,可以用import.meta.url配合fileURLToPath获取同样的效果。
1.3 path.basename —— 取文件名
从路径中提取最后一段,还能顺手去掉扩展名:
path.basename('/a/b/c.js'); // c.js
path.basename('/a/b/c.js', '.js'); // c ← 去掉 .js 扩展名
path.basename('/a/b/c.js', 'js'); // c. ← 第二个参数不带点,就会去掉 'js' 这个后缀
path.basename('/a/b/c.js', 's'); // c.j ← 只去掉最后的 's'
🔑 重点: 第二个参数是精确匹配后缀,
.js匹配的是.js,而js匹配的是文件名末尾的js字符串。注意区分!
1.4 path.extname —— 取扩展名
path.extname('/a/b/c.js'); // .js
path.extname('/a/b/c/index.js'); // .js
path.extname('/a/b/c/index'); // '' (空字符串)
// 多个点的情况
path.extname('/a/b/c.tar.gz'); // .gz ← 只取最后一个点后面的部分
💡 知识点:
extname只返回最后一个.之后的内容(包括点本身)。如果文件名没有.,则返回空字符串。对于.tar.gz这种复合扩展名,它只能拿到.gz,这是设计使然。
1.5 path.normalize —— 路径规范化
帮你清理掉路径里的冗余符号:
path.normalize('/a/b//c/d/e/..'); // /a/b/c/d
// 更直观的例子
path.normalize('/a/b/c///'); // /a/b/c (多余的斜杠被清理)
path.normalize('/a/b/c///./'); // /a/b/c (. 当前目录,不影响)
path.normalize('/a/b/c///../'); // /a/b (.. 回退一级)
💡 知识点: 当你从用户输入、配置文件或外部来源获取路径时,
normalize可以帮你统一格式,避免//、./、../这些冗余符号造成的路径错误。
1.6 path.parse —— 路径拆解(压轴好戏)
把路径拆成一个对象,一目了然:
path.parse('/home/user/dir/file.txt');
// 返回:
{
root: '/', // 根路径
dir: '/home/user/dir', // 目录
base: 'file.txt', // 文件名(含扩展名)
ext: '.txt', // 扩展名
name: 'file' // 文件名(不含扩展名)
}
💡 知识点:
path.parse和path.format是一对互逆操作。format可以把parse的结果重新拼回路径字符串。
1.7 小总结
| 方法 | 作用 | 注意点 |
|---|---|---|
join | 拼接路径 | 纯拼接,不解析 ..,返回相对路径 |
resolve | 拼接并解析为绝对路径 | 遇到绝对路径会"重置"起点 |
dirname | 取目录部分 | 就是去掉最后一段 |
basename | 取文件名部分 | 第二参数可去除扩展名 |
extname | 取扩展名 | 只取最后一个 . 之后 |
normalize | 规范化路径 | 清理 //、.、.. |
parse | 拆解路径为对象 | 与 format 互为逆操作 |
二、fs 模块 —— 文件操作核心
2.1 先理解 Node.js 的异步哲学
在开始操作文件之前,必须先理解一个关键问题:为什么 Node.js 要搞异步?
Node.js 是单线程的,JavaScript 代码跑在一条线程上。如果读文件用同步方式,那线程就被卡住了——在文件读完之前,什么也干不了。
同步读取(阻塞):
┌─────────┐ ┌──────────────┐
│ 读文件 │ ──→ │ 线程卡住等待... │ ──→ 后面的代码才能执行
└─────────┘ └──────────────┘
异步读取(非阻塞):
┌─────────┐
│ 发起读取 │ ──→ 继续执行后面的代码 ──→ ...
└─────────┘ │
↓ (文件读完了)
┌──────────┐
│ 回调执行 │
└──────────┘
这就是 Node.js 能扛高并发的秘密——绝不傻等 I/O。而实现这套机制的,就是事件循环(Event Loop)。
💡 知识点——事件循环(Event Loop): Node.js 内部由 C++ 的 libuv 库提供事件循环支持。简单理解就是:把耗时的 I/O 操作(读文件、网络请求等)丢给系统线程池去干,主线程接着处理其他任务。等 I/O 完成了,再把回调函数塞回主线程执行。后面我们会看到这个过程的具体演进。
2.2 同步读取 —— 最简单但别乱用
import fs from 'fs';
const data = fs.readFileSync('./test.txt', 'utf-8');
console.log(data); // hello world
// bye bye
⚠️ 什么时候用同步?
- 启动时读配置文件(反正还没开始接受请求,阻塞也无所谓)
- 写工具脚本(一次性任务,不用考虑并发)
- 绝对不要在请求处理中用它! 一个用户读文件卡住,所有用户都等着。
2.3 回调函数方式 —— 异步的"原始时代"
Node.js 最早期的异步编程方式就是回调函数(Callback):
import fs from 'fs';
fs.readFile('./test.txt', 'utf-8', (err, data) => {
if (!err) {
console.log(data);
} else {
console.log(err);
}
});
console.log('hello world'); // 这行会先打印!
🔑 Node.js 回调函数的约定——Error-First Callback: 回调函数的第一个参数一定是
err(错误对象),第二个才是真正的数据。这是 Node.js 社区的铁律,所有核心模块都遵守。
err === null→ 操作成功,data有数据err !== null→ 操作失败,通过err获取错误信息
回调地狱 —— 好日子还没来
现实开发中,文件操作往往有顺序依赖。比如读完 A,再用 A 的结果去决定读 B,读完后两结果一起处理。这时候回调就开始"套娃"了:
// 需求:按顺序读取 file1 → file2 → file3
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);
}
});
});
});
这个层层嵌套的结构,就是臭名昭著的 回调地狱(Callback Hell)。才 3 个文件已经向右缩进成这样,试想 10 个操作串在一起的样子……
2.4 Promise + then —— 第一次进化
ES6 带来了 Promise,Node.js 也提供了 fs/promises 模块:
import fs from 'fs/promises';
fs.readFile('./file1.txt', 'utf-8')
.then(data => {
console.log('file1', data);
return fs.readFile('./file2.txt', 'utf-8'); // 返回新的 Promise
})
.then(data => {
console.log('file2', data);
return fs.readFile('./file3.txt', 'utf-8');
})
.then(data => {
console.log('file3', data);
});
💡 知识点——Promise 链式调用: 每个
.then()都返回一个新的 Promise 实例。如果你在.then()的回调里return了一个值(或另一个 Promise),下一个.then()就能拿到它。这让异步流程从"横向嵌套"变成了"纵向串联",可读性大幅提升。
虽然比回调好了很多,但这种"爬楼梯"式的 .then().then().then() 还是有优化空间。
2.5 async/await —— 异步代码写得像同步(终极形态)
ES8(ES2017)带来了 async/await,这是目前写异步代码最舒服的方式:
import fs from 'fs/promises';
(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);
})();
代码看起来像是同步执行的——一行接一行,清清楚楚。但它本质上还是异步的!await 只是暂停当前函数的执行,并不会阻塞整个线程。
💡 知识点——IIFE(立即执行函数): 顶层的
await在早期 Node.js 版本中是不支持的(除非用.mjs或配置"type": "module"),所以常见的写法是用一个立即执行的异步函数包起来:(async () => { ... })();。现在较新的 Node.js 版本已经支持顶层await了,但在某些场景下 IIFE 仍然有用。
2.6 异步的底层:宏任务 vs 微任务
你可能听过这两个词,这里做个通俗的解释:
| 宏任务 (MacroTask) | 微任务 (MicroTask) | |
|---|---|---|
| 代表 | setTimeout、setInterval、I/O 回调 | Promise.then、async/await |
| 执行时机 | 下一轮事件循环 | 当前轮事件循环的末尾 |
| 优先级 | 低 | 高(一个宏任务执行完后,会清空所有微任务) |
setTimeout(() => console.log('宏任务'), 0);
Promise.resolve().then(() => console.log('微任务'));
// 输出顺序:
// 微任务
// 宏任务
🔑 跟你有什么关系?
fs/promises里的readFile返回的是 Promise,它的.then回调是微任务。了解这一点,当你同时用setTimeout和await时,就能正确预测代码的执行顺序。
三、进化之路全景图
回顾一下 JS 处理异步的完整演进路径:
同步阻塞
↓ (单线程受不了)
异步 + 回调函数(Callback)
↓ (回调地狱受不了)
Promise + .then() 链式调用
↓ (爬楼梯还是有点烦)
async/await 语法糖(ES8)
→ 异步代码同步化的终极体验 ✨
每一步演进都是在解决前一步的痛点:
- 回调 → Promise:解决了"横向嵌套"问题,错误处理也更优雅(
.catch()一把梭) - Promise → async/await:解决了"链式调用的心智负担",写起来和同步代码一样直观
但记住:async/await 只是语法糖,底层依然是 Promise,执行的依然是微任务。它不是魔法,只是更好看的写法。
四、总结
这篇文章我们覆盖了:
| 模块 | 学到的 |
|---|---|
| path | join vs resolve 的核心区别、dirname/basename/extname/normalize/parse 的用法 |
| fs | 同步 vs 异步的本质区别、回调地狱 → Promise → async/await 的进化路线 |
| 底层概念 | Event Loop、Error-First Callback、宏任务/微任务、IIFE |
两个小建议收尾:
- 路径处理用
resolve最安全——始终拿到绝对路径,不依赖当前工作目录。 - 文件操作用
fs/promises+async/await——代码清晰、错误处理方便,别再写回调了。
如果这篇文章对你有帮助,欢迎点赞收藏~有问题也可以在评论区交流 👏