一、path 模块 --- 路径处理
path 是 Node.js 内置模块,专门用来处理文件路径。项目中凡是跟路径打交道的地方,都离不开它。
1.1 path.join() 和 path.resolve() 的区别
两个方法都能拼路径,但做事的方式不一样。
path.join() :把传入的各段用 / 拼接起来,然后对结果做一次归一化(消除多余的 .、.. 和连续斜杠)。它有一个特殊规则:如果某一段是绝对路径(以 / 开头),前面已经拼好的部分会被丢弃,从这个绝对路径段重新开始拼。
path.resolve() :像在终端里敲 cd 命令一样,一段一段地"走进"目录,最终输出一个绝对路径。如果全程没有遇到绝对路径段,就把当前工作目录(cwd)作为起点。
js
import path from 'path';
// join --- 拼接 + 归一化
path.join('a', 'b', 'c'); // 'a/b/c'
path.join(process.cwd(), '/hello', 'world'); // '/hello/world'
// ↑ /hello 是绝对路径,前面拼的 cwd 被丢弃,从 /hello 重新开始
// resolve --- 像 cd 一样逐段解析,始终返回绝对路径
path.resolve('a', 'b', 'c'); // '/当前工作目录/a/b/c'
path.resolve('/hello', 'world', './a', 'b'); // '/hello/world/a/b'
path.resolve('/hello', 'world', '../a', 'b'); // '/hello/a/b'(.. 回退了一级)
// 对比:当第一个参数是绝对路径时,两者结果一样(readme 中的重要结论)
path.join('/hello', 'world', './a', 'b'); // '/hello/world/a/b'
path.join('/hello', 'world', '../a', 'b'); // '/hello/a/b'
// ↑ join 把 .. 也处理掉了,所以结果和 resolve 相同
什么时候结果不一样? 当所有参数都是相对路径时:
js
path.resolve('a', 'b'); // '/当前工作目录/a/b' --- resolve 补上了 cwd
path.join('a', 'b'); // 'a/b' --- join 就老老实实拼,不补绝对路径
简单记:join 拼完归一下,resolve 拼完一定给绝对路径。
实际项目里怎么用:
lua
项目根目录 / → process.cwd() 或 __dirname
├── src/ → path.resolve(__dirname, 'src')
│ ├── assets/ → path.resolve(__dirname, 'src/assets') 静态资源目录
│ └── libs/ → path.resolve(__dirname, 'src/libs') 工具函数目录
└── dist/ → 构建输出目录
用 path.resolve(__dirname, 'src') 来定位目录,不管脚本从哪个路径执行,都能拿到正确的绝对路径。
1.2 其它常用的 path 方法
js
import path, { normalize } from 'path';
// normalize 既可以通过 path.normalize() 调用,也可以作为具名导出直接使用
// dirname --- 取目录名
path.dirname('/a/b/c.js'); // '/a/b'
path.dirname(process.cwd()); // 上一级目录
// basename --- 取文件名,第二个参数可以指定要去掉的后缀
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')
// basename 去掉后缀时是纯粹做字符串尾部匹配删除,不会自动处理 . 号
// extname --- 取扩展名(含点号)
path.extname('/a/b/c.js'); // '.js'
path.extname('/a/b/.gitignore'); // ''(以点开头的文件不算有扩展名)
path.extname('/a/b/index.html'); // '.html'
// normalize --- 把路径里的 . 和 .. 和多余斜杠清理干净
normalize('a/b//c/d/e/..'); // 'a/b/c/d'
// parse --- 把路径拆成一个对象
path.parse('/home/user/dir/file.txt');
// { root: '/', dir: '/home/user/dir', base: 'file.txt', ext: '.txt', name: 'file' }
// isAbsolute --- 判断一个路径是不是绝对路径
path.isAbsolute('/a/b/c'); // true(POSIX)
path.isAbsolute('a/b/c'); // false
// relative --- 计算从一个路径到另一个路径的相对路径
path.relative('/a/b/c', '/a/b/d/e'); // '../d/e'
path.relative('/a/b/c', '/a/b/c/d'); // 'd'
// sep --- 当前平台的分隔符(这个值用的时候取,不是函数调用)
path.sep; // Linux/Mac 上是 '/',Windows 上是 '\'
// delimiter --- 环境变量 PATH 的分隔符
path.delimiter; // Linux/Mac 上是 ':',Windows 上是 ';'
二、fs 模块 --- 文件系统操作
fs 是 Node.js 用来读写文件的核心模块。Node.js 本身用 C++ 写底层的 fs、path 这些模块,上面包了一层 V8 引擎来跑 JavaScript。
2.1 同步读和异步读
js
import fs from 'fs';
// 同步读取 --- 代码停在这里等结果,后面的代码全被堵住
const data = fs.readFileSync('./test.txt', 'utf-8');
console.log(data);
// 异步读取 --- 不堵路,读完了通过回调函数拿到结果
fs.readFile('./test.txt', 'utf-8', (err, data) => {
// Node 的惯例:回调的第一个参数永远是 err
if (err) {
console.log(err);
return;
}
console.log(data);
});
为什么 Node.js 要用异步?
JavaScript 是单线程的。如果用同步读一个大文件,整个程序就得停下来等着,什么别的事都干不了。异步读发起之后立刻返回,等文件读完了再通过事件循环(event loop)把结果送回来,中间的时间可以处理别的请求。这也是 Node.js 能撑住高并发的秘密------一台服务器能干别的语言好几台的事。
前端环境里没有文件系统这回事,所以读写文件是 Node.js 独有的能力,同步和异步两条路都可以选,但绝大多数场景都应该走异步。
2.2 异步流程控制的进化过程
这是 Node.js 异步编程最值得搞清楚的一条线:
scss
同步(堵路) → 异步(不堵路) → callback(回调)
→ 回调地狱(嵌套太深) → Promise.then(链式调用)
→ async/await(ES8 语法糖)
第一阶段:回调 --- 写着写着就掉进了回调地狱
js
// 需求:按顺序读 file1 → file2 → file3
fs.readFile('./file1.txt', 'utf-8', (err, data) => {
console.log('file1:', data);
fs.readFile('./file2.txt', 'utf-8', (err, data) => {
console.log('file2:', data);
fs.readFile('./file3.txt', 'utf-8', (err, data) => {
console.log('file3:', data);
// 还要读 file4、file5 的话...... 一层一层往里套,人就懵了
});
});
});
这里的麻烦在于:每一步依赖上一步的结果,只能在前一步的回调里发起下一步。文件一多,代码就缩进成金字塔,俗称"回调地狱"。
第二阶段:Promise + then --- 链式调用
js
import fs from 'fs/promises'; // 换用 Promise 版本的 fs
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);
})
.catch(err => {
console.log('出错了:', err);
});
把嵌套拍平了,代码从左往右缩进变成从上往下展开,读起来舒服不少。catch 一个地方就能兜住所有步骤的错误。
还有点小遗憾:每个 .then() 里都得写 return,步骤多了也有点啰嗦。
第三阶段:async/await --- 看起来像同步的异步代码
js
import fs from 'fs/promises';
(async () => {
const file1 = await fs.readFile('./file1.txt', 'utf-8');
console.log('file1:', file1);
const file2 = await fs.readFile('./file2.txt', 'utf-8');
console.log('file2:', file2);
const file3 = await fs.readFile('./file3.txt', 'utf-8');
console.log('file3:', file3);
})();
代码看起来跟同步写法几乎一样,从上到下一行行读下来,很直观。这里要搞清几点:
async/await是语法糖,底层跑的仍然是 Promise。它只是让写法更舒服,不改变执行机制。await帮我们干了"等结果拿到再往下走"这件事,不用自己去写.then()链。- await 后面的代码进的是微任务队列(microtask),
setTimeout那种进的是宏任务(macrotask),两条队不一样。 - 代码读起来像同步,但线程不会被堵住,这就是"异步代码同步化写"的意思。
三、总结
path 管路径怎么走:join 拼接 + 归一化,resolve 拼完一定返回绝对路径。工程里用 resolve 来定位目录比较稳,因为拿到的总是绝对路径。
fs 管文件怎么读写:能用异步就用异步,不堵路是 Node 的优势。异步写法从 callback 进化到 Promise 再进化到 async/await,每一步都是为了让人少掉头发。
async/await 让异步代码可读性好了一大截,但心里要知道它背后依然是 Promise 加事件循环那套东西。