Node path 与 fs 模块:从回调地狱到 async await 的异步进化

Node path 与 fs 模块:从回调地狱到 async await 的异步进化

写在前面:这节课正式进入了 Node 的后端世界。从两个看起来差不多、实际差很多的路径方法开始,到文件系统的同步与异步之争,最后亲手经历了"回调地狱 → Promise.then → async/await"这场代码可读性的进化------我突然意识到,异步流程控制的演进,本质上就是程序员和"代码越来越难读"这件事做斗争的历史

从一个路径拼接问题说起

后端开发绕不开文件操作,而文件操作的第一步,就是拼对路径。

先看这一段代码:

javascript 复制代码
// node 的内置模块 join , resolve 两种方法的区别
import path from 'path'

// join 方法 路径拼接 简陋 ,不太验证正确性
console.log(path.join('a','b','c','d'));
console.log(path.join('a','b','c'));
console.log(path.resolve('a','b','c'));

path.join('a','b','c','d') 的输出是 a\b\c\dpath.resolve('a','b','c') 的输出却变成了 D:\LIU\桌面\workspace\a\b\c------一个相对路径,一个绝对路径。同样是把几个路径片段拼在一起,为什么结果差这么多?

这就是这节课的第一个核心问题:join 和 resolve 到底有什么区别。如果不搞清楚这个,后面写文件读写的时候,路径指错了都不知道错在哪。

path.join vs path.resolve:看着像,其实不一样

先说结论:两者都能拼接路径,但对"绝对路径"的处理方式完全不同

join:老老实实拼接

path.join 的行为很朴素------你给我什么,我就拼什么,不做额外解析:

javascript 复制代码
console.log(path.join('/hello','world','./a','b'));
// 输出: \hello\world\a\b

它会把 ./ 这种相对引用处理掉,但不会帮你补全成绝对路径。换句话说,join 是"简陋"的拼接,不太验证路径的正确性。

resolve:一定要给你一个绝对路径

path.resolve 的行为就不一样了------它返回的是一个解析后的绝对路径

javascript 复制代码
console.log(path.resolve('/hello','world','./a','b'));
// 输出: D:\hello\world\a\b

console.log(path.resolve('/hello','world','../a','b'));
// 输出: D:\hello\a\b

这里有两个关键细节:

  1. 遇到绝对路径会重置基准/hello/ 开头,表示绝对路径,resolve 会忽略当前工作目录,从根目录开始拼接。而 join 不会区分这一点。
  2. 支持 ../ 回退 :resolve 会正确解析 ../,回退到上一级目录再继续拼接。join 也会处理 ../,但因为不解析绝对路径基准,结果含义不同。

最关键的区别:相对路径时的基准

当传入的都是相对路径时,两者的差异最明显:

javascript 复制代码
console.log(path.join('a','b','c'));
// 输出: a\b\c  (还是相对路径)

console.log(path.resolve('a','b','c'));
// 输出: D:\LIU\桌面\workspace\a\b\c  (变成绝对路径了)

path.resolve('a','b','c') 会以当前工作目录process.cwd())为基准,计算出一个绝对路径。而 join 只是单纯地把它们拼在一起,仍然是个相对路径。

笔记里有一句总结特别到位:

如果是相对路径,resolve 会以当前工作目录为基准,计算绝对路径,join 则会直接拼接路径。

那什么时候两者会返回相同的结果? 当第一个参数就是绝对路径时(比如传入 process.cwd()),resolve 不需要再去找基准目录,join 也不会改变绝对路径的性质,两者就殊途同归了:

javascript 复制代码
console.log(path.join(process.cwd(),'/hello','world'));
console.log(path.resolve(process.cwd(),'/hello','world'));
// 两者输出相同

工程化思维:路径不只是路径

这节课还提到了一个工程化的约定:

根目录 /,开发代码目录 src,静态资源目录 /src/assets,工程函数目录 /src/libs

路径不只是字符串拼接,它背后是一套项目结构的约定 。当大家都遵循同样的目录规范时,path 模块的方法才能真正发挥作用------你才能确信 path.join(__dirname, 'assets', 'logo.png') 指向的就是你想要的那个文件。

path 家族的其他成员

搞清楚了 join 和 resolve,其他方法就相对直观了。它们都是对路径字符串做"切片"操作:

javascript 复制代码
import path from 'path';

console.log(path.dirname(process.cwd()));
console.log(path.dirname('a/b/c'));

console.log(path.basename('a/b/c.js'));
console.log(path.basename('a/b/c.js','.js'));
console.log(path.basename('a/b/c.js','js'));

console.log(path.normalize('a/b/c//d/e/...'));
console.log(path.extname('a/b/c.js'));
console.log(path.parse('/home/user/dir/file.txt'));

逐个来解释:

  • path.dirname :返回路径中的目录名,也就是去掉最后的文件名或最后一级路径后,返回它的父目录。path.dirname('a/b/c') 返回 a/b
  • path.basename :返回路径中最后的文件名。它还有一个可选的第二个参数,用来去除扩展名------path.basename('a/b/c.js', '.js') 返回 c。但注意,如果传 'js' 不带点,它匹配的是结尾的 js 字符串,所以 path.basename('a/b/c.js', 'js') 返回 c.,这点容易踩坑。
  • path.extname :返回文件扩展名,a/b/c.js 返回 .js(注意带点)。
  • path.normalize :规范化路径,把多余的斜杠、... 都处理掉。a/b/c//d/e/... 会被整理成 a\b\c\d\e\...
  • path.parse :把路径解析成一个对象,包含 rootdirbaseextname 五个字段,一次性拿到路径的所有信息。

这些方法看起来零碎,但在实际开发中用得非常频繁。比如你想拿到一个上传文件的扩展名来判断类型,就是 path.extname;想给文件换个名字但保留目录,就是 path.dirname + path.basename 的组合。

fs 模块:文件系统的入口

路径搞定了,接下来就是真正读写文件------这就是 fs(FileSystem)模块的职责。

javascript 复制代码
// fs FileSystem 是文件系统模块,用于操作文件和目录
import fs from 'fs'; // 没加 /promise

fs 模块提供了同步和异步两套 API。先看同步读取:

javascript 复制代码
// I/O 操作 可异步,可同步  readFileSync 同步阻塞线程的执行
// 同步读取文件 简单粗暴,但是不建议在生产环境中使用,因为会阻塞线程的执行,性能有问题
// js 单线程语言,代码中充斥着异步操作,可以提升性能。单线程,高性能的解决方案 (前端)
// node 和 前端 环境不一样 可异步(node 的 优势)可同步
// node 异步无阻塞  no blocking
// node 可以省很多服务器
// node 用 c++ 写出来(fs,path 模块,暴露给 js 代码使用),封装了 v8 引擎(解析 js,性能高)
const syncData = fs.readFileSync('./test.txt','utf-8');
console.log(syncData);

readFileSync 简单粗暴,读完了才往下走。test.txt 里的内容是 hello world / bye bye,所以这段代码会直接打印出来。

但笔记里特别强调了一点:不建议在生产环境中使用同步方法。原因很直接------JS 是单线程语言,同步方法会阻塞线程,性能有问题。

这里有一个很有意思的对比:前端 JS 是单线程的,异步是为了不卡住页面;而 Node 在服务端,异步的优势更大------一台服务器可以同时处理更多请求,省服务器。Node 的底层是用 C++ 写的(fs、path 这些模块都是 C++ 实现,再暴露给 JS 调用),它封装了 V8 引擎来解析 JS,所以性能很高。

回调地狱:当异步任务有了顺序依赖

同步方法不能用,那就用异步的。fs.readFile 就是异步版本:

javascript 复制代码
// 异步 跳过 继续执行后面的代码,将异步任务中的回调函数放入事件循环(event loop)
// 需要控制流程  用回调函数
// es6 之前是使用回调函数,现在可以使用 promise 或 async/await 来处理异步操作
// 回调函数处理异步有缺陷,所以 引入了 promise 来处理异步操作
// 回调地狱 是指回调函数嵌套调用,导致代码结构复杂,难以维护
fs.readFile('./test.txt','utf-8',(err,data)=>{
    // node 第一个参数是错误对象,第二个参数是结果对象
    if(!err){
        console.log(data);
    }else{
        console.log(err);
    }
});
console.log('11');

Node 的回调函数有个约定:第一个参数永远是错误对象(err),第二个才是结果(data) 。这就是所谓的"error-first callback"模式。而且 console.log('11') 会先于回调执行------因为 readFile 是异步任务,会被丢进事件循环,主线程不会等它。

单个异步任务没问题。但真实的业务场景往往有顺序依赖:先读 file1.txt,再读 file2.txt,最后读 file3.txt。用回调函数来实现,就变成了这样:

javascript 复制代码
// 希望先读取 file1.txt 文件,再读取 file2.txt 文件,最后读取 file3.txt 文件
// js 异步业务,流程控制越来越复杂,callback 函数 就太麻烦了,会形成回调地狱,难以维护
fs.readFile('./file1.txt','utf-8',(err,data)=>{
    if(!err){
        console.log('file1.txt',data);
    }else{
        console.log(err);
    }
    fs.readFile('./file2.txt','utf-8',(err,data)=>{
        if(!err){
            console.log('file2.txt',data);
        }else{
            console.log(err);
        }
        fs.readFile('./file3.txt','utf-8',(err,data)=>{
            if(!err){
            console.log('file3.txt',data);
            }else{
                console.log(err);
            }
        });
    });
});

这就是鼎鼎大名的回调地狱(Callback Hell)------回调函数嵌套调用,代码形成一个向右缩进的金字塔。才三个文件就已经这么深了,如果是十个文件的链式操作,这段代码基本就没法维护了。

回调地狱的问题不在于"能不能跑",而在于代码结构复杂到难以阅读和修改。你想在中间插一步?得小心地拆开嵌套。你想处理某一个环节的错误?每个回调里都得写一遍 if-else。这种代码写多了,人真的会崩溃。

Promise.then:用链式调用爬楼梯

ES6 引入了 Promise,专门来解决回调地狱。Node 也提供了 fs/promises 模块,让文件操作返回 Promise 对象:

javascript 复制代码
import fs from 'fs/promises';

// es6 es8
// 用 then 的链式调用(爬楼梯),解决回调地狱问题
fs.readFile('./file1.txt','utf-8')
    .then(data=>{
    console.log('file1.txt',data);
    // fs.readFile 返回的是一个 promise 实例
    // 如果 then 返回的是一个 promise ,就可以继续调用 then 方法,链式调用
    return fs.readFile('./file2.txt','utf-8');
})
    .then(data=>{
        console.log('file2.txt',data);
        return fs.readFile('./file3.txt','utf-8');
    })
    .then(data=>{
        console.log('file3.txt',data);
    })

同样是"先 file1 再 file2 最后 file3",代码结构完全不同了------不再是向右嵌套的金字塔,而是一条从上到下的链 。笔记里用了一个很形象的比喻:爬楼梯

这里有一个关键机制:如果 .then() 的回调函数返回的是一个 Promise,就可以继续调用 .then()。这就是链式调用的基础------每一个 then 等上一个 Promise 完成后才执行,天然实现了顺序控制。

相比回调地狱,Promise.then 的进步是巨大的:代码不再无限嵌套,错误也可以用一个 .catch() 统一处理。但说实话,如果链路特别长,一串 then 排下来也并不好读------你还是得在心里默默追踪"这个 data 到底是哪个文件的内容"。

async/await:让异步代码看起来像同步

ES8 引入了 async/await,这是异步流程控制的最终形态------至少目前是。

javascript 复制代码
// es8 引入了 async/await 语法糖,可以更方便地处理异步操作
// 用 async/await 处理异步操作,代码结构更清晰,更易维护

// 同步 -> js 单线程,耗时性任务(block) -> 异步(event loop)-> callback(事件完成后来回调)
// -> 业务复杂并且要进行流程控制(回调地狱)-> promise + then (略显复杂,爬楼梯)
// -> async/await (es8 语法糖,形似同步的异步操作,
// 看起来更易懂,语义清晰,异步代码同步化,本质还是 promise.then 方法 异步中的微任务)
// setTimeout 是宏任务

// 用 async/await 处理异步操作,代码结构更清晰,更易维护
(async ()=>{
    const data1 = await fs.readFile('./file1.txt','utf-8');
    console.log('file1.txt',data1);
    const data2 = await fs.readFile('./file2.txt','utf-8');
    console.log('file2.txt',data2);
    const data3 = await fs.readFile('./file3.txt','utf-8');
    console.log('file3.txt',data3);
})()

同样是三个文件顺序读取,async/await 版本的代码看起来就像同步代码 ------从上到下一行行执行,每个 await 等待前一个操作完成。没有嵌套,没有 then 链,变量名也清清楚楚:data1 就是 file1 的内容,data2 就是 file2 的内容。

这里有几个要点:

  • async 标记的函数返回的一定是 Promise,即使函数体里返回的是普通值。
  • await 只能在 async 函数内部使用,它会暂停函数的执行,等待 Promise 完成,然后拿到结果。
  • 底层用的还是 IIFE(立即执行函数),因为 await 必须在 async 函数里。
  • async/await 本质是 Promise 的语法糖 ------它底层还是 Promise.then,属于异步中的微任务。setTimeout 是宏任务,优先级低于微任务。

笔记里有一句注释特别精准:"await 帮我们实现了流程控制,不需要手动处理 then 链式" 。这就是 async/await 的核心价值------它没有改变 JS 异步执行的本质,只是把异步代码的写法变成了同步的样子,让代码的语义更清晰。

我现在怎么理解这节课

这节课的内容可以串成一条很清晰的线:

路径处理 → 文件读写 → 异步流程控制的演进

path 模块解决的是"路径怎么拼"的问题------join 老实拼接,resolve 一定要给你一个绝对路径。fs 模块解决的是"文件怎么读写"的问题------同步会阻塞线程,异步才适合生产环境。而当异步任务有了顺序依赖,回调地狱就出现了------于是 Promise.then 用链式调用把金字塔拉平成了楼梯,async/await 又把楼梯变成了平地。

我现在很确定的一点是:异步流程控制的每一次演进,本质上都是在解决"代码可读性"的问题 。回调能跑,Promise.then 也能跑,async/await 还是能跑------它们解决的不是"能不能实现"的问题,而是"代码好不好读、好不好维护"的问题。这让我意识到,工程化思维的核心不是"实现功能",而是"让功能可维护"

还有一个点让我感触很深:Node 的底层是 C++ 写的,fs 和 path 这些模块都是 C++ 实现后暴露给 JS 用的。这让我对"前端"和"后端"的边界有了新的理解------JS 是一门语言,Node 是一个运行时,它让 JS 有了操作文件、网络的能力。所谓的"全栈",不是要你精通所有底层,而是要知道在什么场景下用什么工具

最后,关于 async/await 是"语法糖"这个说法,我以前觉得语法糖就是"换种写法",没什么大不了。但这节课让我明白了:语法糖的真正价值不在于少写几行代码,而在于改变代码的阅读体验。async/await 让异步代码"形似同步",这不是锦上添花,而是让更多的人能读懂异步逻辑------这本身就是巨大的工程价值。

相关推荐
大家的林语冰3 小时前
😭 Electron 麻了,Deno 直接支持二进制应用,无需第三方桌面应用框架!
前端·javascript·node.js
Ivanqhz3 小时前
NFM(神经因子分解机)
开发语言·javascript·人工智能·算法·蓝桥杯
用户298698530144 小时前
在 React 中拆分 Excel 文件:从工作表到行列的实践
javascript·react.js·excel
想要成为糕糕手5 小时前
238. 除了自身以外数组的乘积 — 面试向深度解析
javascript·算法·面试
kisshyshy5 小时前
前端存储、表单异步提交与 this 指向完全指南
前端·javascript·html
Kel5 小时前
管道、IPC、Socket:你以为的三种技术,其实是同一件事
javascript·设计模式·node.js
心中有国也有家5 小时前
AtomGit Flutter 鸿蒙客户端: AnimatedScale 与 AnimatedContainer 联合实战
android·javascript·flutter·华为·harmonyos
嘉泰姆半导体官方5 小时前
CXAR41337C 双通道数字音频功放 | 2×20W | EQ+DRC | I²S | MCLK-Less - 嘉泰姆电子
前端·javascript·数据库
胡萝卜术5 小时前
从逐一合并到多路归并:力扣23「合并K个升序链表」的三种解法进化之路
前端·javascript·面试