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\d,path.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
这里有两个关键细节:
- 遇到绝对路径会重置基准 :
/hello以/开头,表示绝对路径,resolve 会忽略当前工作目录,从根目录开始拼接。而 join 不会区分这一点。 - 支持
../回退 :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:把路径解析成一个对象,包含root、dir、base、ext、name五个字段,一次性拿到路径的所有信息。
这些方法看起来零碎,但在实际开发中用得非常频繁。比如你想拿到一个上传文件的扩展名来判断类型,就是 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 让异步代码"形似同步",这不是锦上添花,而是让更多的人能读懂异步逻辑------这本身就是巨大的工程价值。