在一个最小的 Mini Cursor 中,模型需要通过工具读取和写入本地文件。
模型之所以能创建前端项目,并不是因为它直接获得了本地文件权限,而是 Runtime 给它注册了文件工具:
js
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, content, 'utf-8');
这三行代码看起来很普通,却决定了文件能否写到正确位置:
text
path 负责理解和构造路径
fs 负责真正操作文件系统
如果路径处理错误,Agent 可能在错误目录创建文件;如果使用同步 I/O 处理耗时任务,Node.js 主线程又可能被阻塞。
本文从一组可以直接运行的最小示例出发,拆清 path 和 fs 的核心用法,以及异步文件读取从 Callback 到 Promise、再到 async/await 的演进过程。
path 模块解决什么问题?
path 是 Node.js 内置模块,不需要额外安装:
js
import path from 'path';
它不会读取或修改磁盘,只负责处理路径字符串。
例如:
text
拼接目录和文件名
把相对路径解析为绝对路径
取得父目录、文件名和扩展名
消除重复分隔符、`.` 和 `..`
把完整路径拆成结构化对象
使用 path 而不是手动拼接字符串,还有一个重要原因:不同操作系统使用的路径分隔符可能不同。
text
POSIX:/a/b/c.js
Windows:C:\a\b\c.js
path 会按照当前平台规则组织路径。
path.join():拼接并规范化路径
先看一个最基础的路径拼接:
js
console.log(path.join('a', 'b', 'c'));
在 macOS 和 Linux 上输出:
text
a/b/c
join() 会完成两件事:
text
把所有路径片段连接起来
规范化重复分隔符、`.` 和 `..`
例如:
js
path.join('/hello', 'world', '../a', 'b');
// /hello/a/b
这里容易出现一个误解:join() 并不是只做机械字符串拼接,它同样会处理 ..。
再把当前工作目录作为第一个路径片段:
js
console.log(path.join(process.cwd(), '/hello', 'world'));
假设当前工作目录是 /project/path_fs,输出为:
text
/project/path_fs/hello/world
process.cwd() 返回 Node.js 进程当前的工作目录。
因此,join() 很适合在一个已知目录后继续追加子目录或文件名:
js
const srcDir = path.join(process.cwd(), 'src');
const configPath = path.join(srcDir, 'config', 'app.js');
path.resolve():从右向左解析绝对路径
resolve() 最终一定返回绝对路径。
js
console.log(path.resolve('a', 'b', 'c'));
所有参数都是相对路径时,它会使用当前工作目录作为起点:
text
/当前工作目录/a/b/c
它的核心规则是:
text
从右向左处理参数,直到遇到绝对路径;
遇到绝对路径后,更左侧的参数不再参与结果。
例如:
js
path.resolve(process.cwd(), '/hello', 'world');
// /hello/world
虽然传入了 process.cwd(),但后面的 /hello 已经是绝对路径,所以它覆盖了前面的工作目录。
再看两个包含 . 和 .. 的例子:
js
console.log(path.resolve('/hello', 'world', './a', 'b'));
// /hello/world/a/b
console.log(path.resolve('/hello', 'world', '../a', 'b'));
// /hello/a/b
resolve() 会处理 .、..,并返回规范化后的绝对路径。
join() 和 resolve() 到底怎么选?
| 对比 | path.join() |
path.resolve() |
|---|---|---|
| 核心作用 | 拼接所有路径片段 | 解析得到绝对路径 |
| 是否一定返回绝对路径 | 否 | 是 |
是否处理 .、.. |
是 | 是 |
是否依赖 process.cwd() |
不主动依赖 | 没有绝对参数时会使用 |
| 遇到后面的绝对路径 | 仍作为片段连接 | 丢弃它左侧的路径 |
| 常见场景 | 已知目录后追加子路径 | 计算项目根目录或目标绝对路径 |
可以这样记:
text
我要在已有路径后追加内容:join()
我要得到确定的绝对路径:resolve()
但不要只背口诀,尤其需要注意 resolve() 会被靠右的绝对路径重置。
dirname():取得父目录
dirname() 会返回路径的目录部分:
js
console.log(path.dirname('/a/b/c'));
// /a/b
dirname() 返回路径的目录部分。
它在 Mini Cursor 的 write_file 工具里非常关键:
js
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, content, 'utf-8');
假设模型要求写入:
text
react-todo-app/src/components/TodoItem.tsx
dirname() 会得到:
text
react-todo-app/src/components
Runtime 先创建父目录,再写入文件。否则父目录不存在时,writeFile() 会直接失败。
basename():取得文件名
js
path.basename('a/b/c.js');
// c.js
第二个参数可以删除末尾完全匹配的后缀:
js
path.basename('a/b/c.js', '.js');
// c
这里不是"智能识别扩展名",而是普通的末尾字符串匹配。
下面几个例子可以看出,第二个参数只是按字面删除末尾字符串:
js
path.basename('a/b/c.js', 'js');
// c.
path.basename('a/b/c.js', 's');
// c.j
因为 'js' 和 's' 都能匹配文件名末尾,所以对应字符会被删除,点号不会自动处理。
如果目标是先获得扩展名,再取得不带扩展名的文件名,可以写成:
js
const filePath = 'a/b/c.js';
const ext = path.extname(filePath);
const name = path.basename(filePath, ext);
console.log(ext); // .js
console.log(name); // c
extname():取得扩展名
js
path.extname('a/b/c.js');
// .js
返回值包含点号。
对于多段扩展名:
js
path.extname('archive.tar.gz');
// .gz
它只返回最后一段扩展名。
normalize():规范化路径
例如:
js
path.normalize('a/b//c/d/e/..');
// a/b/c/d
normalize() 会处理:
text
重复路径分隔符
当前目录标记 .
返回上一级目录 ..
当前操作系统的路径分隔符
它只处理路径字符串,不会检查对应文件是否真实存在。
parse():把路径拆成对象
js
path.parse('/home/user/dir/file.txt');
返回:
js
{
root: '/',
dir: '/home/user/dir',
base: 'file.txt',
ext: '.txt',
name: 'file'
}
当工具需要同时使用目录、文件名和扩展名时,parse() 比连续调用多个 API 更直观。
fs 模块:真正操作文件系统
path 只处理路径,真正读取和写入文件的是 fs。
Node.js 提供了不同风格的文件 API:
js
import fs from 'fs';
import fs from 'fs/promises';
第一种包含同步 API 和回调 API,第二种提供 Promise 风格 API。
readFileSync():同步读取会阻塞
先看同步读取文件:
js
const syncData = fs.readFileSync('./test.txt', 'utf-8');
readFileSync() 必须等文件读取完成,后面的 JavaScript 才能继续执行。
text
开始读取文件
→ 当前线程等待
→ 文件读取完成
→ 执行下一行代码
读取小型配置文件、程序启动阶段加载固定内容时,同步 API 有时更简单。
但如果在需要持续处理请求的服务中读取大文件,主线程会在这段时间无法继续执行其他 JavaScript。
readFile():异步读取与 Error-First Callback
回调风格的异步读取:
js
fs.readFile('./test.txt', 'utf-8', (err, data) => {
if (!err) {
console.log(data);
} else {
console.log(err);
}
});
console.log('111');
readFile() 发起文件读取后,JavaScript 可以继续执行,所以 111 通常先输出。
Node.js 传统回调有一个常见约定:
text
第一个参数是错误对象 err
后面的参数才是成功结果
这就是 Error-First Callback。
回调地狱是怎么出现的?
如果三个文件必须按顺序读取,回调会一层层嵌套:
js
fs.readFile('./file1.txt', 'utf8', (err, data) => {
if (!err) {
console.log(data);
fs.readFile('./file2.txt', 'utf8', (err, data) => {
if (!err) {
console.log(data);
fs.readFile('./file3.txt', 'utf8', (err, data) => {
if (!err) {
console.log(data);
}
});
}
});
}
});
代码的问题不是"不能运行",而是随着业务步骤增加:
text
嵌套越来越深
错误处理不断重复
主流程被大量括号切碎
后续维护和修改困难
这就是 Callback Hell。
Promise:把横向嵌套改成链式流程
fs/promises 中的 readFile() 返回 Promise:
js
import fs from 'fs/promises';
fs.readFile('./file1.txt', 'utf-8')
.then(data => {
console.log('file1', data);
return fs.readFile('./file2.txt', 'utf-8');
})
.then(data => {
console.log('file2', data);
return fs.readFile('./file3.txt', 'utf-8');
})
.then(data => {
console.log('file3', data);
});
每个 .then() 返回下一个 Promise,三个步骤形成纵向链条,不再向右不断嵌套。
真实代码还应该统一处理错误:
js
.catch(error => {
console.error('读取文件失败:', error);
});
async/await:让异步流程更接近顺序代码
使用立即执行异步函数,可以把三个读取步骤写成:
js
import fs from 'fs/promises';
(async () => {
const file1Data = await fs.readFile('./file1.txt', 'utf-8');
console.log('file1', file1Data);
const file2Data = await fs.readFile('./file2.txt', 'utf-8');
console.log('file2', file2Data);
const file3Data = await fs.readFile('./file3.txt', 'utf-8');
console.log('file3', file3Data);
})();
await 会暂停当前 async 函数,等待 Promise 完成,再执行下一行。
但这不等于 readFileSync():
text
readFileSync():阻塞当前 JavaScript 线程
await readFile():暂停当前 async 函数,把执行机会交还事件循环
async/await 是 Promise 的语法形式,不会把异步 I/O 变成同步阻塞 I/O。
一张图看懂异步文件读取的演进

这条演进路线解决的是代码组织方式和主线程阻塞问题:
text
readFileSync:写法直接,但会阻塞
readFile + Callback:异步非阻塞,但多步骤容易嵌套
Promise.then:把嵌套改成链式调用
async/await:保留异步本质,同时提高可读性
顺序 await 和 Promise.all 怎么选?
如果业务要求依次读取 file1 → file2 → file3,可以使用连续 await。
如果三个文件彼此没有依赖,可以并行发起:
js
const [file1Data, file2Data, file3Data] = await Promise.all([
fs.readFile('./file1.txt', 'utf-8'),
fs.readFile('./file2.txt', 'utf-8'),
fs.readFile('./file3.txt', 'utf-8')
]);
判断标准是:
text
后一步依赖前一步结果:顺序 await
多个任务互不依赖:Promise.all
path 和 fs 如何组成 Mini Cursor 的文件工具?
把这些能力放回 Mini Cursor 的 write_file 工具:
js
const writeFileTool = tool(
async ({ filePath, content }) => {
try {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, content, 'utf-8');
return `成功写入 ${filePath}`;
} catch (err) {
return `文件写入失败:${err.message}`;
}
},
{
name: 'write_file',
description: '向指定路径写入文件内容,自动创建目录',
schema: z.object({
filePath: z.string().describe('文件路径'),
content: z.string().describe('要写入文件的内容')
})
}
);
执行顺序是:
text
模型生成 filePath 和 content
→ path.dirname() 取得父目录
→ fs.mkdir() 创建缺失目录
→ fs.writeFile() 写入文件
→ Runtime 把结果作为 ToolMessage 返回模型
这里 path 负责路径正确性,fs 负责 I/O,async/await 负责让多个异步步骤按清晰顺序执行。
常见问题
join() 会不会处理 ..?
会。
js
path.join('/hello', 'world', '../a', 'b');
// /hello/a/b
join() 和 resolve() 都会规范化 .、..,区别不在这里。
为什么 resolve(process.cwd(), '/hello') 没使用 cwd?
因为 resolve() 从右向左处理,/hello 已经是绝对路径,所以它左边的 process.cwd() 被忽略。
如果想在当前目录下追加 hello,不要写开头斜杠:
js
path.resolve(process.cwd(), 'hello');
await 和 readFileSync() 不都是"等待"吗?
等待方式不同。
readFileSync() 会阻塞 JavaScript 线程;await fs.readFile() 只暂停当前 async 函数,事件循环仍然可以处理其他任务。
为什么相对路径有时找不到文件?
相对路径通常基于 process.cwd(),也就是启动 Node.js 进程时的工作目录,并不一定等于当前模块所在目录。
因此同一个脚本从不同目录启动,./test.txt 可能指向不同位置。
总结
path 和 fs 是 Coding Agent 操作本地项目时最基础的两个 Node.js 模块。
text
join():拼接并规范化路径
resolve():解析绝对路径
dirname():取得父目录
basename():取得文件名并按字面后缀裁剪
extname():取得最后一段扩展名
normalize():规范化路径字符串
parse():把路径拆成结构化对象
文件读取则经历了:
text
同步阻塞
→ 异步回调
→ Promise 链
→ async/await
Mini Cursor 能正确创建目录、写入代码并继续下一轮 ReAct,离不开这三个环节:
text
path 确定文件应该放在哪里
fs 真正完成文件 I/O
async/await 组织异步执行顺序
理解这些基础能力以后,再看 Agent 的 read_file、write_file 和 list_directory,就不只是会调用工具,而是能看懂工具内部真正发生了什么。