ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Node系列 · Node基础:文件 I/O

2026/8/17 3:22:51 拓冰建站 浏览量
Node系列 · Node基础:文件 I/O

Node系列 · Node基础:文件 I/O

Node 的文件 I/O 几乎全靠fs模块。它同一份能力提供三套 API(同步 / 异步回调 / Promise),混用会产生"事件循环被阻塞"或"回调金字塔"问题。理解三者的取舍,就能写好所有 Node 文件操作。

一、fs 模块的三套 API

同一个操作readFilefs提供三种写法:

风格函数返回阻塞主线程
同步fs.readFileSync()数据 / 抛异常✅ 阻塞
异步回调fs.readFile(cb)undefined / 通过 cb(err, data) 返回❌ 非阻塞
Promisefs.promises.readFile()Promise<data> / reject(err)❌ 非阻塞

1.1 同步 API

const fs = require('node:fs'); try { const data = fs.readFileSync('./config.json', 'utf-8'); console.log(JSON.parse(data)); } catch (err) { console.error('读取失败:', err.message); }

1.2 异步回调 API

const fs = require('node:fs'); fs.readFile('./config.json', 'utf-8', (err, data) => { if (err) { console.error('读取失败:', err.message); return; } console.log(JSON.parse(data)); });

回调第一个参数永远是err,这是 Node 的"错误优先回调"约定。

1.3 Promise API(推荐)

const fs = require('node:fs/promises'); async function loadConfig() { try { const data = await fs.readFile('./config.json', 'utf-8'); return JSON.parse(data); } catch (err) { console.error('读取失败:', err.message); throw err; } } loadConfig().then((cfg) => console.log(cfg));

::: tip
默认用 Promise 版本。它和 async/await 配合最自然,错误用 try/catch 捕获,与同步代码视觉上接近。同步版本只适合"启动期必须串行"的场景(如读取配置文件初始化),回调风格已基本被淘汰。
:::

二、读写文件

2.1readFile完整签名

// 异步 Promise 版 const buf = await fs.readFile(path, options); // options 可以是: // string → 'utf-8' / 'hex' / 'base64' / 'latin1' / 'ascii' // object → { encoding, flag } // 省略 → 返回 Buffer(不自动转字符串) const text = await fs.readFile('./README.md', 'utf-8'); // string const bytes = await fs.readFile('./image.png'); // Buffer

2.2writeFile完整签名

await fs.writeFile(file, data, options); // data 可以是: // string → 按 encoding 写入 // Buffer → 字节写入 // TypedArray / DataView → 字节写入 await fs.writeFile('./out.txt', 'hello\n', 'utf-8'); await fs.writeFile('./out.bin', Buffer.from([0xff, 0xfe]));

writeFile默认覆盖整个文件。要追加内容用appendFile

await fs.appendFile('./access.log', `${new Date().toISOString()} GET /\n`);

::: warning
writeFile不存在会创建,存在会覆盖。对重要文件先用fs.access检查或直接用追加模式('a'flag)更安全。
:::

2.3 常用 flag 一览

flag含义
'r'只读(默认)
'w'写入,不存在则创建,存在则清空
'a'追加,不存在则创建
'r+'读写,不存在则报错
'w+'读写,不存在则创建,存在则清空
'ax'追加,文件已存在则失败(用于"独占创建"场景,避免并发覆盖)

三、文件描述符(File Descriptor)

open/read/close三个底层调用组合出"打开文件 → 读 N 字节 → 关闭"的精细控制:

const fs = require('node:fs/promises'); const fd = await fs.open('./big.txt', 'r'); try { const buf = Buffer.alloc(1024); // 1KB 缓冲区 let pos = 0; while (true) { const { bytesRead } = await fd.read(buf, 0, 1024, pos); if (bytesRead === 0) break; process.stdout.write(buf.subarray(0, bytesRead)); pos += bytesRead; } } finally { await fd.close(); }

什么时候用 fd 而不是readFile

场景推荐
整个文件能装入内存readFile简洁
大文件(GB 级别)流式处理createReadStream(第 7 章)
部分读取(读头 1KB 看 magic number)fd.read(buf, 0, len, position)
频繁读小块 + 需要 seekfd

四、文件信息查询

查询文件元信息(大小、时间戳、类型)用fs.stat

const fs = require('node:fs/promises'); const stats = await fs.stat('./config.json'); stats.isFile(); // 是否普通文件 stats.isDirectory(); // 是否目录 stats.size; // 字节数 stats.atime; // 上次访问 stats.mtime; // 上次修改内容 stats.ctime; // 上次修改元数据(权限等) stats.birthtime; // 创建时间(不保证可用) stats.atimeMs; // 毫秒时间戳(用于计算)

fs.stat跟随符号链接;要查链接本身用fs.lstat

五、目录操作

目录的创建、读取、重命名、删除由一组 API 承担:

const fs = require('node:fs/promises'); // 创建(recursive: true 等价于 mkdir -p) await fs.mkdir('./a/b/c', { recursive: true }); // 读取目录条目 const entries = await fs.readdir('./src'); // entries 是 string[];Node 20+ 可加 { withFileTypes: true } 拿到 Dirent[] // 删除(recursive: true 才能删非空目录;Node 14.14+) await fs.rm('./a', { recursive: true, force: true }); // 重命名 / 移动 await fs.rename('./old.txt', './new.txt'); // 读目录 + 过滤 const jsFiles = (await fs.readdir('./src')) .filter((f) => f.endsWith('.js'));

六、文件路径处理

文件 I/O 几乎总要配合path模块,避免字符串拼接:

const fs = require('node:fs/promises'); const path = require('node:path'); // ❌ 错误:直接拼 const filePath = __dirname + '/config/' + filename; // ✅ 正确:path.join const filePath = path.join(__dirname, 'config', filename); // ✅ 跨平台:os.homedir() + path.join const userConfig = path.join(os.homedir(), '.myapp', 'config.json'); // ✅ 读取 package.json 同目录的相对路径文件 const pkgDir = path.dirname(require.resolve('./package.json'));

七、错误处理

文件操作的错误类型固定可枚举,常见的err.code

code含义
ENOENT文件 / 目录不存在
EACCES权限不足
EISDIR当成文件打开目录
ENOTDIR当成目录进入文件
EEXIST文件已存在(创建时)
EMFILE打开的文件描述符过多
const fs = require('node:fs/promises'); async function readConfig() { try { return await fs.readFile('./config.json', 'utf-8'); } catch (err) { if (err.code === 'ENOENT') { // 配置文件不存在是预期情况:用默认值 return '{}'; } if (err.code === 'EACCES') { throw new Error('配置目录无读取权限'); } throw err; // 其他错误继续上抛 } }

::: tip
不要吞掉错误码就 throw 一个字符串。保留err.code方便上游根据类型决策(如ENOENT走默认配置,EACCES走错误提示)。
:::

八、并发读写同一文件

多个fs调用并发操作同一文件,Node 不保证原子性。例如两个writeFile并发,后写的覆盖先写的,且中间状态可能损坏文件:

// ❌ 危险:两个 writeFile 并发 await Promise.all([ fs.writeFile('log.txt', 'A'), fs.writeFile('log.txt', 'B'), ]); // 结果不可预测:可能是 "A"、"B",也可能是 "BA" 或损坏 // ✅ 方案 1:用 appendFile(追加模式,POSIX 保证原子) await Promise.all([ fs.appendFile('log.txt', 'A'), fs.appendFile('log.txt', 'B'), ]); // ✅ 方案 2:串行(必要时) await fs.writeFile('log.txt', 'A'); await fs.writeFile('log.txt', 'B');

大文件或频繁更新的场景,文件 I/O 也不适合;用 SQLite / Redis / 专门的日志库(pino + 日志聚合)替代。

九、文件锁

Node没有内置文件锁。需要互斥时:

场景方案
单进程内await串行调用 /fs.promises的串行队列
多进程间proper-lockfile
分布式Redis / Zookeeper / etcd

十、性能与最佳实践

场景推荐反例
启动期读配置readFileSync(必须阻塞到读完才能继续)用异步版本然后.then启动
请求处理中读文件fs.promises.readFilereadFileSync(阻塞事件循环)
大文件createReadStream(见第 7 章)readFile(一次性读入内存)
批量小文件Promise.all([...])并发串行 await(延迟叠加)
错误处理err.code分支catch (e) { /* 静默 */ }
路径拼接path.join字符串+

十一、小结

  • fs提供同步 / 异步回调 / Promise 三套 API;默认用 Promise
  • readFile/writeFile是最简单的读写;大文件用createReadStream
  • 写文件注意 flag:'w'覆盖、'a'追加、'ax'独占创建
  • 错误处理按err.code分支(ENOENT/EACCES/EMFILE等)
  • 并发写同一文件无原子保证,要串行或用追加模式
  • 路径处理永远走path模块,不要直接拼字符串