前端学习笔记 · 第 121 天:读文件与写文件
- 2026-11-06 09:47:27
前端学习笔记 · 第 121 天:读文件与写文件——三种读法、覆盖追加与原子替换
Day 121 · 第五阶段 · Node.js 与后端开发
昨天把路径与文件信息理清,知道文件在哪、多大、是什么类型。今天进一步:把内容读出来,把内容写回去。
读写各有多种写法,差别主要在两处:内容一次性进内存还是一块块处理,目标文件是原地改写还是先写副本再替换。顺序按「读、写、替换」展开,最后用一个能读、能追加、能整体替换的笔记脚本收尾。
一、问题现场
四个现象:
用 readFile读一个几百兆的日志,内存直接冲高,进程被系统结束。内容整体读入内存,文件越大占用越多。同一个读取逻辑换三种写法,返回结果不一样:有的给字符串,有的给 Buffer,比较时永远不相等。编码参数决定了类型。每写一次日志都整个文件重写,两个进程同时写,后写的把先写的覆盖掉。 服务运行中改写配置文件,读取方恰好读到写了一半的内容,启动直接失败。
前两个属于读的姿势,后两个属于写的姿势。读要按大小选方法,写要按「覆盖、追加、替换」分清语义。
二、三种读法
readFile 有两种入口:回调与 Promise。回调把错误放在第一个参数,Promise 版本在失败时抛出错误。实测两者读同一文件返回的内容完全一致,差异只在调用方式。
编码决定返回类型。实测不传编码得到 Buffer,字节数为 30;传入 "utf8" 得到字符串。比较、拼接、输出前先统一类型,避免「看起来一样却不相等」。
const buffer = await readFile(path); // Bufferconst text = await readFile(path, "utf8"); // string文件较大时用流。createReadStream 把文件按块送到 data 事件,每块都是 Buffer;需要逐行处理时,把流交给 readline,crlfDelay: Infinity 让 Windows 的换行也按一行算。
块的数量与大小相关。实测一份 30 字节的文件只读到 1 块;一份 202000 字节的文件读到 4 块。也就是说,小文件用流反而多一层开销,大文件用 readFile 才会把内存顶起来。
两种读法在缺文件时都报 ENOENT,但错误到达的方式不同:readFile 抛出,用 try 捕获;createReadStream 走 error 事件,要注册监听器,否则错误会被当成未处理异常。
三、写入:覆盖与追加
writeFile 的语义是覆盖:文件不存在时创建,存在时整个替换。实测连续写两次,最终只保留第二次的内容。
appendFile 的语义是追加:保留原有内容,在末尾接上新的部分。实测并发发起 10 次追加,10 行全部保留,没有互相覆盖。追加打开文件时带追加标志,每次调用把内容接到末尾,这是它比 readFile 加 writeFile 更安全的地方。
流写入用 createWriteStream,适合边生成边写。实测写入三段后关闭,文件内容按顺序拼成 甲乙丙。写完之后要等 finish 事件再继续,end 只是声明没有更多数据,缓冲区可能还没落盘。
三种写法的选择很简单:整体替换用 writeFile,日志类追加用 appendFile,内容很大或需要边算边写用流。
四、原子替换
覆盖写有一个风险:写入过程中目标文件已经被截断,读取方在这一刻读到的可能是半份内容。解决方式是把「写」和「替换」分开:
先写同目录的临时文件,例如 data.txt.tmp。此刻目标文件还是完整的旧版本。写完再用 rename把临时文件改名成目标文件。
实测 rename 能覆盖已存在的目标:旧文件 10 字节,替换后内容为新内容,临时文件不再存在。读取方在替换前后只会看到完整的旧版或新版,不会读到中间状态。
Windows 上有一条限制:目标文件被另一个句柄打开时,rename 失败,实测错误码为 EPERM;句柄关闭后同样的操作成功。写配置、写缓存这类场景要按可重试处理,或在设计上避开「写入期间有人正在读同一个文件」。
临时文件放在目标所在目录,还有一个附带好处:同一个目录一定在同一个分区,改名不需要跨分区搬运数据。
五、实战示例:笔记的读、追加与替换
把三种读法里的流、两种写法与替换组合成一个脚本:read 逐行输出编号,append 追加一行,replace 用临时文件整体替换。
// notes.mjs —— 读取、追加与整体替换同一个笔记文件import { writeFile, appendFile, rename } from "node:fs/promises";import { createReadStream } from "node:fs";import readline from "node:readline";import path from "node:path";const file = path.resolve("notes.txt");const [, , command, ...words] = process.argv;if (command === "read") { const input = readline.createInterface({ input: createReadStream(file), crlfDelay: Infinity }); let number = 0; for await (const line of input) { number += 1; console.log(`${number}. ${line}`); }} else if (command === "append") { await appendFile(file, `${words.join(" ")}\n`, "utf8"); console.log("追加完成");} else if (command === "replace") { const temp = `${file}.tmp`; await writeFile(temp, `${words.join(" ")}\n`, "utf8"); await rename(temp, file); console.log("替换完成");} else { console.log("用法:node notes.mjs read|append|replace 文本");}准备一份三行的笔记,依次执行三条命令,输出与文件状态对得上:
$ node notes.mjs read1. 第一行2. 第二行3. 第三行$ node notes.mjs append 第四行追加完成$ node notes.mjs read1. 第一行2. 第二行3. 第三行4. 第四行$ node notes.mjs replace 只有一行替换完成$ node notes.mjs read1. 只有一行不带参数时打印用法。read 用流加 readline,文件再大也只占一行内存;append 只加不覆盖;replace 走临时文件与改名,写完才替换目标。三个分支对应三种语义,互不混用。
常见问题
Q1:readFile 读出来为什么不是字符串?
没传编码。缺省返回 Buffer,字节内容原样给出。要字符串就在第二个参数写 "utf8",或在返回的 Buffer 上调用 toString("utf8")。
Q2:读大文件该用 readFile 还是流?
看文件大小与处理方式。几百兆的日志用 readFile 会把整体放进内存;用流按块处理,或交给 readline 逐行消费,内存占用只与单块大小相关。小文件用 readFile 更省事。
Q3:writeFile 会不会保留原内容?
不会。它的语义是覆盖:不存在则创建,存在则整体替换。要保留原内容用 appendFile,或先读出再拼接,但后者在并发场景下不安全。
Q4:多个进程同时追加日志,会不会丢行?
每次 appendFile 都是一次追加写入,实测 10 次并发追加 10 行都在。真正的风险来自「读出来、拼上、再整体写回」的做法,后写的一方会覆盖先写的一方。日志类场景直接用追加。
Q5:为什么替换文件要绕一圈用临时文件?
直接覆盖时,目标文件在写入期间是被截断的,读取方可能读到半份内容。先写临时文件、再 rename,目标文件在替换前始终完整。
Q6:rename 报 EPERM 是什么原因?
在 Windows 上,目标文件被别的句柄打开时改名会失败。实测有读句柄时得到 EPERM,句柄关闭后成功。遇到时按重试处理,或让写入方与读取方错开。
知识点总结
两种入口:
回调与 Promise 两个入口结果一致,差别只在错误传递方式
类型由编码决定:
不传编码得到
Buffer,传"utf8"得到字符串流按块:
大文件交给
createReadStream,逐块拿到Buffer,块数与文件大小相关逐行读取:
把流交给
readline,适合日志与长文本缺文件:
缺文件时
readFile抛ENOENT,流走error事件,两者都要处理覆盖:
整体替换用
writeFile,文件不存在时创建追加:
保留原内容用
appendFile,并发追加不互相覆盖流写入:
边写边落盘用
createWriteStream,结束要等finish原子替换:
先写临时文件再
rename,目标始终是完整的旧版或新版
(本文为学习笔记整理)