写在前面
上次手写 mini-Cursor,write_file 工具里有一行 path.dirname(filePath) 配 fs.mkdir------当时一笔带过。
但 Agent 读写文件的每一步,底下踩的全是这两个模块:path 管路径,fs 管文件。顺手还牵出了 JS 异步的整部进化史。
今天把这些地基挖开看看。
path.join vs path.resolve:都能拼,性格迥异
俩函数都能把几段路径拼起来,但脾气完全不同:
js
csharp
path.join('a', 'b', 'c'); // a/b/c ------ 老实拼接
path.resolve('a', 'b', 'c'); // /Users/xxx/a/b/c ------ 拼完还给你算成绝对路径
区别的核心:resolve 会做"解析" 。遇到相对路径,它以当前工作目录 process.cwd() 为基准,换算成绝对路径;join 则是无脑字符串拼接。
更狠的一条------resolve 碰到绝对路径参数会直接重置:
| 调用 | 结果 | 发生了什么 |
|---|---|---|
resolve('/hello', 'world', './a', 'b') |
/hello/world/a/b |
正常累积 |
resolve('/hello', 'world', '../a', 'b') |
/hello/a/b |
.. 回退一级 |
join('/hello', 'world', '../a', 'b') |
/hello/a/b |
join 也认 ..,但不管 cwd |
join('a', 'b') |
a/b |
相对就相对,不管你 |
resolve('a', 'b') |
/当前目录/a/b |
硬给你算成绝对 |
当第一个参数就是绝对路径时,俩货返回结果才长得一样。
什么时候用谁 :拼 URL 子路径、拼资源目录用 join 足够;要拿到文件在磁盘上的真实位置(比如 Agent 的 write_file 要确认落盘位置),上 resolve。
路径手术三件套:dirname / basename / extname
把一条路径放到手术台上,三个函数各切一刀:
js
ini
const p = '/home/user/dir/file.txt';
path.dirname(p); // /home/user/dir → 目录部分
path.basename(p); // file.txt → 文件名
path.extname(p); // .txt → 扩展名
一个真实场景你就懂了------mini-Cursor 写文件前要先建目录:
js
csharp
const dir = path.dirname('/a/b/c/App.tsx'); // 切出 /a/b/c
await fs.mkdir(dir, { recursive: true }); // 先把目录建好
await fs.writeFile('/a/b/c/App.tsx', content); // 再落文件
basename 还有个隐藏技能------去扩展名:
js
arduino
path.basename('a/b/c.js', '.js'); // c
配合 path.parse 更是直接拆成对象:
js
bash
path.parse('/home/user/dir/file.txt');
// { dir: '/home/user/dir', base: 'file.txt', ext: '.txt', name: 'file' }
| 函数 | 切出来 | 典型用途 |
|---|---|---|
dirname |
目录部分 | 写文件前先 mkdir |
basename |
文件名(可去后缀) | 改名、去重 |
extname |
扩展名 | 按类型分发处理 |
normalize |
规范化路径 | 清理 //、.. 之类的脏东西 |
parse |
整个拆成对象 | 一次性全要 |
normalize 值得一提:a/b//c/d/e/.. 进去,a/b/c/d 出来------LLM 生成的路径经常脏得没法看,过一遍 normalize 是好习惯。
fs:同步爽一时,异步保平安
fs 是文件系统模块,读写全靠它。它有两副面孔:
js
javascript
const syncData = fs.readFileSync('test.txt', 'utf-8'); // 同步:卡住线程,读完才走
fs.readFile('./test.txt', 'utf-8', (err, data) => {}); // 异步:先走别的,读完回调
| 维度 | readFileSync | readFile |
|---|---|---|
| 执行方式 | 阻塞线程 | 非阻塞,进事件循环 |
| 写法 | 直白爽快 | 要回调 / Promise |
| 性能 | 一个文件卡全线程 | 并发处理,互不等待 |
| 适用 | 启动时读一次配置 | 高并发场景 |
为什么 Node 死磕异步?因为 JS 单线程。同步读一个大文件,整个进程原地罚站------服务器上几百个请求全在排队。异步把耗时任务丢给底层(Node 用 C++ 实现 fs),线程继续跑别的,事件循环(event loop)回头再收结果。
这就是 Node 用区区单线程撑起高并发的底气------同样的流量,异步架构能省一大把服务器。
异步进化四部曲:从回调地狱到 async/await
异步是好,但"先读 file1、再读 file2、再读 file3"这种有顺序的活,早期的写法长这样:
第一代:回调地狱
js
javascript
fs.readFile('./file1.txt', 'utf-8', (err, data) => {
fs.readFile('./file2.txt', 'utf-8', (err, data) => {
fs.readFile('./file3.txt', 'utf-8', (err, data) => {
// 三层嵌套只是开始,五个文件就是一座金字塔
})
})
})
每读一个文件嵌一层,缩进右移一次。文件多了之后,这段代码 sideways-scroll 都拉不完------史称回调地狱 。注意回调的第一个参数永远是 err,这是 Node 的老约定:错误优先。
第二代:Promise then 链
js
kotlin
fs.readFile('./file1.txt', 'utf-8')
.then(data => {
console.log('file1', data);
return fs.readFile('./file2.txt', 'utf-8'); // then 返回新 Promise,链下去
})
.then(data => {
return fs.readFile('./file3.txt', 'utf-8');
})
地狱拆成了平面链,但 then 一长,跟爬楼梯似的,还是烦。
第三代:async/await(现状最优解)
js
ini
import fs from 'fs/promises'; // 注意:Promise 版要 import 'fs/promises'
(async () => {
const file1Data = await fs.readFile('./file1.txt', 'utf-8');
const file2Data = await fs.readFile('./file2.txt', 'utf-8');
const file3Data = await fs.readFile('./file3.txt', 'utf-8');
})();
长得跟同步代码一模一样,可读性直接拉满。
| 阶段 | 写法 | 痛点 |
|---|---|---|
| 回调函数 | 嵌套金字塔 | 回调地狱,错误处理稀碎 |
| Promise + then | 平面链式 | 比 callback 优雅,但链条一长还是爬楼 |
| async/await | 同步写法异步本质 | 基本无痛 |
划重点 :async/await 只是语法糖,本质还是 Promise、还是异步------它解决的是可读性,不是把异步变同步。该走 event loop 的照样走,一行没少。
5 个踩坑提醒
1. import fs from 'fs' 没有顶层 await 版的 readFile。 想用 await fs.readFile(...),必须 import fs from 'fs/promises'。两个模块同名不同命,写错直接 TypeError。
2. 回调里忘了判 err。 Node 回调第一个参数永远是错误对象,不判直接用 data,报错时 data 是 undefined,崩得莫名其妙。
3. path.join 拼出绝对路径的幻觉。 join('a', 'b') 给你 a/b------它就是个相对路径字符串,没有基于 cwd 计算。要绝对路径用 resolve。
4. resolve 的"重置"特性暗算你。 resolve('/hello', '/world') 结果是 /world------第二个参数是绝对路径,前面全作废。join 不会,join('/hello', '/world') 给你 /hello/world。
5. 顶层 await 不用再写 IIFE。 .mjs 文件里 await fs.readFile(...) 可以直接裸写在顶层,不用 (async () => {})() 包一层。但普通 .js(CJS)里顶层 await 会直接语法报错。
写在最后
Agent 看着高大上,拆到底全是这些老手艺:path 负责算清楚文件在哪,fs 负责把文件真的读写掉,异步模型保证干活时线程不傻等。
mini-Cursor 的每一个工具调用、LangChain 的每一次 invoke,底下都是这套东西在转。上层再花哨,地基打不牢,Agent 写文件照样翻车。