Node.js文件写入全解析:回调、Promise、async/await 怎么选

发布时间:2026/8/30 17:48:27
Node.js文件写入全解析:回调、Promise、async/await 怎么选 第一次在 Express 项目里接触文件写入大多数人都会经历一个有点懵的瞬间接口写好了想把请求数据落盘顺手打开 Node.js 的 fs 文档结果看到fs.writeFile、fs.writeFileSync、fs.promises.writeFile三个入口文档里还分别给出回调、Promise、async/await 三种写法。看起来只是语法差异于是随便挑一种复制能跑就继续往下写。这个做法不算错但会埋雷。因为异步写入真正要理解的事情根本不是“调用哪个函数”而是两件事第一从发起写入到确认写入完成中间有延迟这个延迟不能阻塞其他请求第二三种写法本质上是三种不同的“完成通知机制”它们的错误处理方式、组合能力和适用场景都不一样。把这句话想清楚再看 Express 里的文件写入几乎不会再纠结。1. 先想明白为什么 Node.js 会强迫你选异步1.1 一次普通写入背后的两个时间点fs.writeFile这类异步 API调用之后并不会立刻把数据写到磁盘。它的实际流程大概是主线程把写文件的任务提交给 libuv 的线程池。主线程立刻返回继续处理下一个请求。线程池里的线程真正打开文件、写入数据、关闭文件。操作完成后通过事件循环把回调函数或 Promise 的完成状态带回主线程。这里最关键的地方是发起写入和确认写入完成是两个时间点。在回调写法里fs.writeFile返回的是 undefined在 Promise 和 async/await 写法里返回的是一个 Promise 对象。如果你不理解这个时间差就容易写出“看起来在写文件其实根本没等它写完”的代码。1.2 同步写文件会把 Express 变成什么样子fs.writeFileSync是同步版本调用它时主线程会一直卡住直到磁盘操作完成。在脚本工具里这样做问题不大但在 Express 服务里问题很严重。用一个容易理解的类比Node.js 主线程相当于餐厅里唯一能点单的服务员。同步写文件相当于服务员离开餐桌跑到后台复印一份文件等复印完再回来继续服务其他客人。如果这个文件很大或者磁盘比较慢所有请求都在排队。所以只要运行环境是 Web 服务文件写入就应该默认走异步。这不是风格偏好而是事件循环模型下的必然选择。1.3 三种写法本质上是三种通知机制回调、Promise、async/await 解决的是同一个问题异步操作完成后程序怎么知道怎么拿结果怎么处理错误回调写法操作完成时Node.js 调用你传进去的回调函数结果通过参数传递。Promise 写法操作完成时Promise 状态变为 fulfilled 或 rejected你通过.then和.catch订阅。async/await 写法本质还是 Promise只是用await把异步代码“拍扁”成类似同步的顺序写法。三者的底层机制没有变化变化的是代码的组织方式和错误处理的直观程度。写法完成通知方式错误处理可组合性适合场景回调回调函数被调用靠 err 参数弱容易嵌套老代码、一次性写入Promisethen 被调用catch 捕获强支持 Promise.all批量写入、并发组合async/await恢复执行下一行try/catch很强接近同步代码Express 路由、业务流程2. 三种写法放在同一张桌子上对比2.1 回调写法最传统也最容易漏错误回调写法是 Node.js 最早期就存在的风格核心是 error-first 回调也就是第一个参数留给错误对象。const fs require(fs); fs.writeFile(./msg.txt, hello nodejs, utf8, (err) { if (err) { console.error(写入失败, err); return; } console.log(写入完成); });这种写法的问题在复杂业务里会逐渐暴露。第一个问题是嵌套如果写入完成后还要再写另一个文件并且需要等待前一次完成就会出现回调套回调的“金字塔”。第二个问题更隐蔽如果忘记处理err写入失败时可能没有任何输出程序并不知道已经出错排查起来非常被动。在简单脚本里回调写法完全可以接受。但如果你在 Express 路由里用它要确保每个分支都处理了错误。2.2 Promise 写法把“完成”变成了可组合的值从 Node.js 10 开始fs模块提供了fs.promises命名空间可以用 Promise 风格调用文件 API。const fs require(fs).promises; fs.writeFile(./msg.txt, hello nodejs, utf8) .then(() { console.log(写入完成); }) .catch((err) { console.error(写入失败, err); });Promise 最大的优势是“可组合”。你可以把多个写入合并成一个整体等待const fs require(fs).promises; const path require(path); const tasks [ fs.writeFile(path.join(__dirname, a.json), JSON.stringify({ a: 1 })), fs.writeFile(path.join(__dirname, b.json), JSON.stringify({ b: 2 })), fs.writeFile(path.join(__dirname, c.json), JSON.stringify({ c: 3 })), ]; Promise.all(tasks) .then(() console.log(三个文件都写完了)) .catch((err) console.error(至少有一个失败, err));这里有个版本前提要确认。fs.promises在 Node.js 10 中开始加入早期标记为实验特性到了 Node.js 14 左右基本转正。如果你的生产环境还在维护很老的 Node.js 版本直接require(fs).promises可能会拿到 undefined。处理老版本的通用办法是用util.promisifyconst fs require(fs); const { promisify } require(util); const writeFile promisify(fs.writeFile); writeFile(./msg.txt, hello nodejs, utf8) .then(() console.log(写入完成)) .catch((err) console.error(写入失败, err));所以在学习三种写法之前先确认本机 Node.js 版本省得把版本问题当成代码问题。2.3 async/await 写法把异步代码写成同步顺序async/await 是 Promise 的语法糖但它把代码的可读性提升了一个档次。const fs require(fs).promises; async function writeMessage() { try { await fs.writeFile(./msg.txt, hello nodejs, utf8); console.log(写入完成); } catch (err) { console.error(写入失败, err); } } writeMessage();这种写法特别适合 Express 路由因为路由处理函数本身就可以是 async 函数业务逻辑可以按照自然顺序写下去。但这里有一个 Express 4 的经典坑如果你用的是 Express 4async 路由处理函数里抛出的 Promise rejection 不会被自动捕获必须自己 try/catch或者用一个统一包装函数。Express 5 才开始内置对 rejected Promise 的兜底处理。所以实际项目中要么确认框架版本要么老老实实写 try/catch。2.4 三选一我的建议新项目优先用fs.promises加 async/await理由很简单可读性最好错误处理最直观也最容易配合Promise.all做并发。老项目如果已经全是回调不要急着大改。先把需要组合的地方用promisify包一层再逐步迁移。最忌讳的是同一个模块里三种写法混着用一会回调一会 Promise 一会 await后续维护的人会非常痛苦。3. 在 Express 路由里三种写法分别适合什么场景3.1 简单落盘async/await 最省心假设有一个反馈接口用户提交 JSON你要把内容保存到文件。const express require(express); const fs require(fs).promises; const path require(path); const app express(); app.use(express.json()); app.post(/api/feedback, async (req, res) { try { const dataDir path.join(__dirname, data); await fs.mkdir(dataDir, { recursive: true }); const fileName ${Date.now()}.json; const filePath path.join(dataDir, fileName); await fs.writeFile(filePath, JSON.stringify(req.body, null, 2), utf8); res.json({ ok: true, file: fileName }); } catch (err) { console.error(写入失败, err); res.status(500).json({ ok: false, message: 保存失败 }); } });这段代码里有两个容易被新手漏掉的细节。第一个是fs.mkdir(dataDir, { recursive: true })。fs.writeFile不会自动创建父目录直接写入一个不存在的目录会报 ENOENT。提前mkdir并且加recursive: true可以保证目录存在后再写。第二个是路径写法。这里用的是path.join(__dirname, data)而不是./data。因为./data是相对于进程当前工作目录的而__dirname是当前文件所在目录。用 npm script、pm2、systemd 启动项目时工作目录可能不同相对路径很容易写进意想不到的位置。3.2 批量并发Promise.all 和它的边界如果需要一次性导出多个 JSON 文件比如把一份大列表拆成多个部分落盘最直接的做法是 for 循环加 awaitfor (const record of records) { await fs.writeFile(path.join(outDir, ${record.id}.json), JSON.stringify(record)); }这个写法没问题但它是串行的。写第一个文件的时候第二个文件还排在后面。如果文件数量多总耗时就会偏长。更快的办法是并行发起再统一等待const tasks records.map((record) fs.writeFile(path.join(outDir, ${record.id}.json), JSON.stringify(record)) ); await Promise.all(tasks);这里的代价是同时会有多个写文件任务占用 libuv 线程池。Node.js 默认线程池大小是 4任务量很大时依然会排队但通常比逐个 await 更快。要注意Promise.all的语义是“任何一个失败整体进入拒绝状态”。如果一批写入里只想逐个确认结果不想被一个失败打断可以换成Promise.allSettledconst results await Promise.allSettled(tasks); for (const result of results) { if (result.status rejected) { console.error(某个文件写入失败, result.reason); } }并行写入不等于无限制并发。更稳妥的做法是给任务分片比如每 20 个一组用Promise.all处理一组再处理下一组避免瞬间产生大量文件句柄。3.3 持续追加和高频写入这是 Stream 的战场前面三种写法解决的核心是“一次写入等待完成”。如果业务是持续追加日志每条日志都调用一次fs.writeFile或fs.appendFile虽然能跑但不够高效。每次调用都要打开文件、写入、再关文件高频场景下开销很大。更合适的方式是用流式写入const fs require(fs); const path require(path); const logStream fs.createWriteStream(path.join(__dirname, app.log), { flags: a, }); function writeLog(line) { logStream.write(${new Date().toISOString()} ${line}\n); }createWriteStream会维持一个可写的流内部会做缓冲不需要每条日志都重新打开关闭文件。write方法会返回一个布尔值如果返回 false说明内部缓冲已满应该等待drain事件后再继续写。对大多数中小项目来说记住“高频日志用流低频业务数据用 writeFile”就够用了。这里要澄清一个容易混淆的点三种写法和三种 API 不是一回事。回调、Promise、async/await 是组织异步代码的语法风格writeFile、appendFile、createWriteStream是不同场景下的文件写入 API。它们可以交叉组合重要的是先判断场景再选工具。4. 真实项目中最容易踩的五个坑4.1 你以为在等它其实没有这是我见过最多的错误。代码长这样app.post(/api/export, (req, res) { for (const record of records) { fs.promises.writeFile(./out/${record.id}.json, JSON.stringify(record)); } res.json({ ok: true }); });问题在于for 循环里没有 await也没有把 Promise 收集起来。请求返回时写入可能还没完成。如果此时进程重启或者磁盘发生错误数据就丢了而且错误会变成 unhandledRejection很难追踪。正确做法是收集 Promise 再统一等待const promises records.map((record) fs.promises.writeFile(./out/${record.id}.json, JSON.stringify(record)) ); await Promise.all(promises); res.json({ ok: true });4.2 相对路径跑偏在本地终端直接node app.js启动和用 npm script、pm2、systemd 启动进程工作目录可能完全不一样。./data/file.json看起来很简单但最终写到哪里取决于进程的工作目录。稳妥做法是统一用path.join(__dirname, ...)或显式传入绝对路径。4.3 写入前没有确认目录存在fs.writeFile不会递归创建目录。目标目录不存在直接报ENOENT: no such file or directory。这个错误信息很常见排查思路很简单先看目录是否真的存在不存在就先fs.mkdir(dir, { recursive: true })。4.4 编码、flag 和权限问题文件写入的第三个参数 options 里encoding默认是utf8大多数文本场景不用改。但如果你写的是二进制数据应该直接传 Buffer避免编码转换导致内容损坏。flag参数也值得留意。默认w会截断已有文件a是追加wx是文件已存在时直接报错。需要生成“一次性导出文件不想覆盖上次结果”的场景用wx更安全。权限问题则集中在 Linux 服务器上。如果进程运行用户对目标目录没有写权限会报EACCES: permission denied。这时候先确认目录归属和权限而不是去改代码。4.5 Promise.all 的短路行为被误解Promise.all在任意一个 Promise 失败时会立刻进入 rejected 状态但其他 Promise 并不会被取消它们仍然会在后台继续。所以“一个失败就全部失败”这个表述并不完全准确。更准确的理解是整体状态会被第一个失败影响但其他任务仍然执行只是你拿不到它们的完成结果。如果你需要把每个写入的成败都记录下来用Promise.allSettled更合适。4.6 版本环境坑别甩给代码很多“写入报错”最终查下来是 Node.js 版本不匹配比如用了旧版本 Node.js但代码里用了新版本才有的 API或者通过 nvm 安装了一个版本号不存在的 Node.js 版本导致环境一直没装好。遇到怪异行为时先确认三件事node -v输出的版本号是否正常、当前 npm 依赖是否安装完整、fs.promises在你这个版本下是否可用。尤其是从旧 Node.js 升到新版本或者反过来从新版本降到旧版本文件 API 的行为差异很容易让人误判。5. 写入异常排查按层级来不要乱调参文件写入出问题最怕一上来就改代码。更高效的思路是按层级排查先把问题定位到某一层再决定改什么。5.1 先看现象先判断现象属于哪一类完全没反应也没有报错。直接抛异常比如 ENOENT、EACCES。文件生成了但内容为空。内容乱码。文件生成了但不是目标位置。服务响应变慢接口卡顿。同一个根因在不同表现下可能完全不同。比如目录不存在可能表现为 ENOENT也可能因为你传了catch里空实现了表现为“没反应”。所以第一步永远是把错误打出来不要吞掉。5.2 看输入层检查路径、文件名、内容、编码。路径用绝对路径还是相对路径文件名是否包含非法字符内容是不是空字符串Buffer 和字符串有没有混用这一层的问题通常能很快定位。5.3 看环境层确认 Node.js 版本、进程工作目录、目标目录权限、磁盘空间、线程池状态。排查命令在 Linux 上可以用node -v pwd ls -ld ./data df -h如果代码在本地正常、上了服务器报错优先怀疑目录权限和磁盘空间。5.4 看参数层检查encoding、flag、recursive这些参数是否和你预想一致。比如你想追加日志但写的是默认w每次都会覆盖之前的文件。这个问题不容易在第一次运行时发现往往要等数据丢了一部分才会注意到。5.5 看日志层代码里加一个兜底监听防止 Promise 被静默拒绝process.on(unhandledRejection, (reason) { console.error(未处理的 Promise 拒绝, reason); });写文件相关操作至少在开发阶段要把 error 完整打印出来不要只打印console.log(error)却不带错误对象。下面是一个快速排查参考表现象优先排查常见解法报 ENOENT目录是否存在mkdir recursive报 EACCES / EPERM目录权限、运行用户调整权限或运行用户文件内容是空的content 是否为空、flag 是否是 w打印内容长度中文乱码encoding 参数明确指定 utf8写入后重启丢数据没有 await 或没有收集 PromisePromise.all 统一等待文件写到了意外位置相对路径 vs __dirname使用 path.join(__dirname, ...)接口响应变慢同步写或高频 writeFile改流、限制并发6. 把最小写入逻辑封装成模块让三个关键词变成工程习惯如果项目里需要在多个路由中写文件最省事的做法是把“确保目录存在”和“写入”捆绑成一个模块。这样所有调用点只需要传文件路径和内容。// fileWriter.js const fs require(fs).promises; const path require(path); async function ensureDir(dir) { await fs.mkdir(dir, { recursive: true }); } async function safeWrite(filePath, content, options {}) { const dir path.dirname(filePath); await ensureDir(dir); return fs.writeFile(filePath, content, { encoding: utf8, flag: w, ...options, }); } async function safeAppend(filePath, content, options {}) { const dir path.dirname(filePath); await ensureDir(dir); return fs.appendFile(filePath, content, { encoding: utf8, ...options, }); } module.exports { safeWrite, safeAppend };在路由中使用const path require(path); const { safeWrite } require(./fileWriter); app.post(/api/users, async (req, res) { try { const filePath path.join(__dirname, data, ${req.body.id}.json); await safeWrite(filePath, JSON.stringify(req.body, null, 2)); res.json({ ok: true }); } catch (err) { res.status(500).json({ ok: false }); } });这个模块有几个设计要点用path.dirname(filePath)自动推导目录调用者不用单独建目录。默认encoding: utf8文本场景不用反复传。默认flag: w覆盖写入是业务里的常见预期。返回 Promise调用者可以自由选择 await、Promise.all或Promise.allSettled。边界要说明白这个封装适合中低频的业务数据落盘不适合高频日志。每小时几千条以内的日志用safeAppend还能接受每秒几十上百条的时候就不要用这个模块了直接上createWriteStream。最后说一个学习建议。如果你刚接触这几种写法可以新建一个 Node.js 版本不低于 16 的演示项目把同一个写入任务分别用回调、Promise、async/await 各写一遍。重点不是看它们谁更快而是观察每次调用返回什么、错误怎么传、后续逻辑什么时候能执行。跑通一遍你对事件循环里“发起”和“完成”的时间差感受会深很多。回到最初的问题三种写法选哪个我的答案是如果只是写脚本选你顺手的那一个如果要放进 Express 服务里统一用fs.promises加 async/await配合 try/catch再在批量场景里用Promise.all或Promise.allSettled。真正决定代码质量的不是那行写入调用而是你有没有先回答这四个问题写入是一次还是长期失败时要不要重试目录是否存在调用方是否必须等结果这四个问题想清楚了写法自然就选定了。