🚀 Node.js path 和 fs 模块,从回调地狱到 async/await 的进化之路!

🚀 Node.js path 和 fs 模块,从回调地狱到 async/await 的进化之路!

摘要 :每个 Node.js 开发者都绕不开 pathfs 这两个内置模块。本文从实际代码出发,带你彻底搞懂路径操作的那些坑,以及文件读写从回调地狱到 async/await 的完整进化史。


📌 前言

刚学 Node.js 的时候,我以为文件操作就是"读一下、写一下"的事。直到我遇到了:

  • path.joinpath.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 版本(推荐)

💡 fspath 是 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);
    }
})();

💡 重点总结

  1. path.join vs path.resolve:join 纯拼接,resolve 生成绝对路径。遇到绝对路径时,resolve 会以它为准
  2. path 的常用 API:dirname(目录名)、basename(文件名)、extname(扩展名)、normalize(规范化)、parse(拆解)
  3. fs 同步 vs 异步:同步阻塞线程,适合脚本;异步不阻塞,适合服务端
  4. 回调地狱:嵌套回调导致代码可读性差、维护困难
  5. 异步进化路线:Callback → Promise + then → async/await
  6. async/await 本质:Promise 的语法糖,让异步代码看起来像同步,但不会阻塞主线程

🔗 参考资料

💬 交流讨论

你在学习 Node.js 的过程中,遇到过哪些让你困惑的点?欢迎在评论区分享交流!


觉得有用?点个赞👍收藏⭐关注👆,后续会更新更多 Node.js 实战干货!

相关推荐
绝世唐门三哥2 小时前
vue3中页面返回时刷新首页的处理方案
开发语言·前端·javascript
sugar__salt2 小时前
Node.js path 与 fs 核心模块完全指南 —— 从路径处理到异步流程控制
javascript·node.js
Csvn2 小时前
Python 开发技巧 · 生成器(Generator)进阶 —— 从 yield 到 yield from,写出省内存的生产者-消费者模式
后端·python
geovindu2 小时前
CSharp: Dijkstra Algorithms
开发语言·后端·算法·c#
屑曦晨2 小时前
npm 全局包迁移指南:从系统目录到用户目录
npm·node.js
掘金者阿豪3 小时前
听书别再被会员和广告打断:把NAS变成自己的私人有声图书馆
后端
张元清3 小时前
React useObjectUrl Hook:预览文件与 Blob,不留内存泄漏(2026)
javascript·react.js
姑苏倾城客3 小时前
Flutter中,html 与 dart 桥接沟通
javascript·flutter·html
拉长的苗条3 小时前
细说 ASP.NET Cache 及其高级用法
后端·asp.net