在Node.js项目里,path和fs是两个高频内置模块:前者负责路径拼接、解析和提取,后者负责文件读取、写入与目录操作。无论是CLI工具、后端服务还是自动化脚本,都绕不开路径处理与文件读写。本文围绕path.join与path.resolve的核心差异、path常用API、fs从同步到async/await的演进,以及批量读取文件的三种写法进行梳理。
一、path.join与path.resolve的核心差异
path.join用于拼接路径片段,path.resolve用于拼接并解析为绝对路径。相对路径场景下,join直接拼接,resolve则以当前工作目录为基准计算绝对路径;遇到绝对路径片段时,两者行为又有区别。
- import path from 'path';
- // 假设当前工作目录是 /home/user/project
- path.join('a', 'b', 'c');
- // → 'a/b/c'
- path.resolve('a', 'b', 'c');
- // → '/home/user/project/a/b/c'
- path.join('/hello', 'world', './a', 'b');
- // → '/hello/world/a/b'
- path.resolve('/hello', 'world', './a', 'b');
- // → '/hello/world/a/b'
- path.resolve('/hello', 'world', '../a', 'b');
- // → '/hello/a/b'
- path.join(process.cwd(), '/hello', 'world');
- // → '/home/user/project/hello/world'
- path.resolve(process.cwd(), '/hello', 'world');
- // → '/hello/world'
复制代码
关键结论:当第一个参数都是绝对路径时,join和resolve可能返回相同结果;当传入相对路径时,join直接拼接,resolve会以当前工作目录为基准解析为绝对路径。工程中拼接相对路径、URL路径片段可优先用path.join;需要确保拿到绝对路径、解析入口文件时可用path.resolve。
二、path其他常用API
path.dirname取目录名,path.basename取文件名,path.extname取扩展名,path.normalize规范化路径,path.parse一次性解析路径所有组成部分。
- import path from 'path';
- path.dirname('/a/b/c.js');
- // → '/a/b'
- path.basename('a/b/c.js');
- // → 'c.js'
- path.basename('a/b/c.js', '.js');
- // → 'c'
- path.basename('a/b/c.js', 'js');
- // → 'c.'
- path.extname('a/b/c.js');
- // → '.js'
- path.extname('a/b/.gitignore');
- // → ''
- path.normalize('a/b//c/d/e/..');
- // → 'a/b/c/d'
- path.parse('/home/user/dir/file.txt');
- // → { root: '/', dir: '/home/user/dir', base: 'file.txt', ext: '.txt', name: 'file' }
复制代码
这里要注意:path.basename的第二个参数是后缀字符串匹配,不是扩展名匹配,传.js才能正确去掉.js后缀;path.extname对以点开头的文件名如.gitignore返回空字符串。实际写文件前,常用path.dirname(filePath)提取目录,再配合fs.mkdir创建不存在的目录。
三、fs模块的三种模式与导入方式
fs模块的API经历了同步、回调、Promise+async/await三个阶段。常见导入形式如下:
- import fs from 'fs'; // 回调版 fs
- import fsPromises from 'fs/promises'; // Promise 版 fs
- const fsCommonJS = require('fs'); // CommonJS 版
复制代码
同步读取使用readFileSync,代码直观,但会阻塞线程。读取完成前后续代码无法执行,在Web服务器请求处理中会阻塞事件循环,降低并发能力。它更适合启动时读取配置、CLI工具脚本或简单本地文件操作。
- import fs from 'fs';
- const data = fs.readFileSync('./test.txt', 'utf-8');
- console.log(data);
复制代码
回调版readFile是非阻塞的,读取文件时后续代码可以继续执行,文件读取完成后通过事件循环触发回调。Node.js约定回调函数的第一个参数是err错误对象。多个文件按顺序读取时,回调层层嵌套会形成回调地狱,代码向右漂移,难以维护。
- import fs from 'fs';
- fs.readFile('./file1.txt', 'utf-8', (err, data) => {
- if (err) { console.log(err); return; }
- console.log('file1.txt 内容:', data);
- fs.readFile('./file2.txt', 'utf-8', (err, data) => {
- if (err) { console.log(err); return; }
- console.log('file2.txt 内容:', data);
- fs.readFile('./file3.txt', 'utf-8', (err, data) => {
- if (err) { console.log(err); return; }
- console.log('file3.txt 内容:', data);
- });
- });
- });
复制代码
Promise版本可写成then链式调用,比回调嵌套更清晰,但代码仍显冗长。
- 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');
- })
- .then(data => {
- console.log('file2.txt 内容:', data);
- return fs.readFile('./file3.txt', 'utf-8');
- })
- .then(data => {
- console.log('file3.txt 内容:', data);
- })
- .catch(err => {
- console.log('出错了:', err);
- });
复制代码
async/await是Promise的语法糖,代码看起来像同步写法,但底层仍然是异步的微任务。它把流程控制交给await,错误处理可用try/catch,调试也更直观,现代Node.js项目推荐使用fs/promises配合async/await。
- 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);
- })();
复制代码
四、为什么需要异步:单线程与事件循环
JavaScript是单线程的。如果使用fs.readFileSync读取大文件耗时1秒,这1秒内整个调用栈都被占住,其他请求无法推进。Node.js的优势在于异步无阻塞:fs.readFile发起读取请求后立即返回,主线程继续执行后续代码;底层通过libuv和线程池处理I/O,读取完成后由事件循环触发回调。
await fs.readFile(...)会暂停当前async函数的执行,把后续代码包装为微任务,放入微任务队列;当前调用栈清空后,事件循环检查微任务队列并继续执行async函数后续代码。微任务如Promise.then、await优先级高于宏任务如setTimeout、setInterval,会在当前事件循环阶段立即执行。
五、批量读取文件的三种写法对比
任务都是依次读取file1.txt、file2.txt、file3.txt。回调写法嵌套3层;then链式写法不再嵌套,但链式调用仍较长;async/await写法像同步代码一样顺序执行,同时保持异步性能优势,是推荐方案。选择上,拼接相对路径用path.join,需要绝对路径用path.resolve;读多个文件按顺序处理可用async/await逐步await,同步读取只放在启动配置或CLI等非高并发场景。理解join与resolve的差异、fs三种模式的取舍,以及单线程加异步I/O的模型,才能在实际脚本和后端服务中写出稳定、易维护的文件操作代码。 |