还在手动拼路径、写回调地狱?一文吃透 Node.js 的 path 和 fs

从零搞懂 Node.js 的 path 和 fs 模块 ------ 文件操作不再迷糊

前言

刚开始学 Node.js 的时候,最常打交道的就是路径处理文件读写。这两个东西几乎是每个后端开发者的基本功------读配置、写日志、操作文件,哪个都离不开它们。

Node.js 给我们内置了两个模块:

  • path:专门处理文件路径,拼接、解析、规范化,统统一把梭。
  • fs(FileSystem):操作文件本身------读、写、删、改。

今天这篇文章就带你从零开始,把这两个模块的核心用法和踩坑点都捋清楚。


一、path 模块 ------ 路径处理利器

1.1 path.join vs path.resolve ------ 99% 的新手都会搞混

这俩方法都能拼接路径,但行为有本质区别。先看代码:

js 复制代码
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 (以当前工作目录为基准)

是不是看起来差不多?别急,关键在于参数里出现绝对路径时

js 复制代码
// 当第一个参数是绝对路径时,两者结果一样
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,忽略你当前在哪个目录。

工程化中的应用场景:

js 复制代码
// ✅ 推荐:用 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 命令

看个对比加深理解:

js 复制代码
// 包含 ./ 和 ../ 时 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 ------ 取目录名

把路径中最后一段(文件名或文件夹名)去掉,返回剩下的部分:

js 复制代码
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 ------ 取文件名

从路径中提取最后一段,还能顺手去掉扩展名:

js 复制代码
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 ------ 取扩展名

js 复制代码
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 ------ 路径规范化

帮你清理掉路径里的冗余符号:

js 复制代码
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 ------ 路径拆解(压轴好戏)

把路径拆成一个对象,一目了然:

js 复制代码
path.parse('/home/user/dir/file.txt');

// 返回:
{
  root: '/',           // 根路径
  dir: '/home/user/dir',  // 目录
  base: 'file.txt',    // 文件名(含扩展名)
  ext: '.txt',         // 扩展名
  name: 'file'         // 文件名(不含扩展名)
}

💡 知识点: path.parsepath.format 是一对互逆操作。format 可以把 parse 的结果重新拼回路径字符串。


1.7 小总结

方法 作用 注意点
join 拼接路径 纯拼接,不解析 ..,返回相对路径
resolve 拼接并解析为绝对路径 遇到绝对路径会"重置"起点
dirname 取目录部分 就是去掉最后一段
basename 取文件名部分 第二参数可去除扩展名
extname 取扩展名 只取最后一个 . 之后
normalize 规范化路径 清理 //...
parse 拆解路径为对象 format 互为逆操作

二、fs 模块 ------ 文件操作核心

2.1 先理解 Node.js 的异步哲学

在开始操作文件之前,必须先理解一个关键问题:为什么 Node.js 要搞异步?

Node.js 是单线程的,JavaScript 代码跑在一条线程上。如果读文件用同步方式,那线程就被卡住了------在文件读完之前,什么也干不了。

markdown 复制代码
同步读取(阻塞):
┌─────────┐     ┌──────────────┐
│ 读文件   │ ──→ │ 线程卡住等待... │ ──→ 后面的代码才能执行
└─────────┘     └──────────────┘

异步读取(非阻塞):
┌─────────┐
│ 发起读取  │ ──→ 继续执行后面的代码 ──→ ...
└─────────┘         │
                    ↓ (文件读完了)
              ┌──────────┐
              │ 回调执行   │
              └──────────┘

这就是 Node.js 能扛高并发的秘密------绝不傻等 I/O。而实现这套机制的,就是事件循环(Event Loop)

💡 知识点------事件循环(Event Loop): Node.js 内部由 C++ 的 libuv 库提供事件循环支持。简单理解就是:把耗时的 I/O 操作(读文件、网络请求等)丢给系统线程池去干,主线程接着处理其他任务。等 I/O 完成了,再把回调函数塞回主线程执行。后面我们会看到这个过程的具体演进。


2.2 同步读取 ------ 最简单但别乱用

js 复制代码
import fs from 'fs';

const data = fs.readFileSync('./test.txt', 'utf-8');
console.log(data);  // hello world
                   // bye bye

⚠️ 什么时候用同步?

  • 启动时读配置文件(反正还没开始接受请求,阻塞也无所谓)
  • 写工具脚本(一次性任务,不用考虑并发)
  • 绝对不要在请求处理中用它! 一个用户读文件卡住,所有用户都等着。

2.3 回调函数方式 ------ 异步的"原始时代"

Node.js 最早期的异步编程方式就是回调函数(Callback)

js 复制代码
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,读完后两结果一起处理。这时候回调就开始"套娃"了:

js 复制代码
// 需求:按顺序读取 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 模块:

js 复制代码
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,这是目前写异步代码最舒服的方式:

js 复制代码
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)
代表 setTimeoutsetInterval、I/O 回调 Promise.thenasync/await
执行时机 下一轮事件循环 当前轮事件循环的末尾
优先级 高(一个宏任务执行完后,会清空所有微任务)
js 复制代码
setTimeout(() => console.log('宏任务'), 0);
Promise.resolve().then(() => console.log('微任务'));

// 输出顺序:
// 微任务
// 宏任务

🔑 跟你有什么关系? fs/promises 里的 readFile 返回的是 Promise,它的 .then 回调是微任务 。了解这一点,当你同时用 setTimeoutawait 时,就能正确预测代码的执行顺序。


三、进化之路全景图

回顾一下 JS 处理异步的完整演进路径:

javascript 复制代码
同步阻塞
  ↓ (单线程受不了)
异步 + 回调函数(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

两个小建议收尾:

  1. 路径处理用 resolve 最安全------始终拿到绝对路径,不依赖当前工作目录。
  2. 文件操作用 fs/promises + async/await------代码清晰、错误处理方便,别再写回调了。

如果这篇文章对你有帮助,欢迎点赞收藏~有问题也可以在评论区交流 👏

相关推荐
Darling噜啦啦1 小时前
Node.js path 与 fs 模块完全指南:从路径处理到异步文件操作的进化之路
node.js
mONESY1 小时前
LangChain JS 高性能并行执行解析、工具调用底层封装
javascript
子兮曰1 小时前
Node.js v26.5.0 深度解读:import Text、流式 ReadableStreamTee、以及隐藏的安全加固浪潮
前端·后端·node.js
YIAN2 小时前
LangChain JS 工具调用实战:基于 DeepSeek-V4-Flash 实现本地文件读取 AI 代码助手(完整可运行源码)
javascript·langchain·前端框架
天赐印记2 小时前
从 Hexo 到前后端,再到现在这个「不用伺候服务器」的个人站
人工智能·程序员
尼斯湖皮皮怪2 小时前
iceCoder 记忆系统深度分析
前端·javascript
她的男孩3 小时前
当 Spring Boot 遇上 Flowable 7:Forge 框架企业级工作流引擎架构全解析
人工智能·后端·程序员
爸爸6193 小时前
07 ArkTS 基础类型与接口定义:从 TypeScript 到鸿蒙的类型升级
javascript·华为·typescript·harmonyos·鸿蒙系统
万敏4 小时前
TypeScript 入门第一周完整记录:知识点总结 + 踩坑实录
node.js·全栈