Express.js 异步文件写入:从回调到 async/await 的工程实践

发布时间:2026/8/29 10:05:03
Express.js 异步文件写入:从回调到 async/await 的工程实践 在 Express.js 里写文件最容易被忽略但后果最严重的一个决定就是选了同步写入还是异步写入。很多刚接触 Node.js 的同学会觉得“写个文件能有多慢”直到某个导出接口在并发上来后把整个服务拖慢排查半天才发现卡在fs.writeFileSync。这篇文章不会只给一段fs.writeFile的示例让你抄而是把 Node.js 异步写入的三种写法——回调函数、Promise、async/await——放到 Express.js 的真实场景里拆开讲清楚。你会明白为什么服务端落盘必须用异步也会拿到一份可以直接运行的完整 demo以后写日志、导文件、保存上传内容时都能直接套用。先说结论在 Node.js 里同步写入会把整个事件循环堵住而异步写入会把真正耗时的磁盘操作交给后台线程池处理。三种写法本质上是同一目标在不同时期的实现方式没有绝对谁对谁错但工程上一定有优先级。1. 这篇文章真正要解决的问题有两个常见场景几乎每个用 Express.js 的后端都会遇到。场景一接口里要把订单摘要、操作日志或者导出结果写入本地文件。低并发的时候fs.writeFileSync用得很顺请求量一上来响应时间突然从几十毫秒涨到十几秒数据库也查过、外部接口也查过最后发现是同步写文件在拖累事件循环。场景二你需要写日志。很多人第一反应是“加一行console.log不就行了”但线上日志往往要落盘、要按天切割、要追加上去而不是覆盖。如果每次请求都用同步方式写一次文件QPS 稍微高一点整个进程的表现都会变得很诡异。这篇文章要解决的就是这两个问题。读完你会知道为什么同步写入在 Express.js 接口里是定时炸弹异步写入的三种写法分别怎么写、怎么处理错误三种写法背后的演进逻辑是什么选哪种更合适如何在一份完整的 Express.js demo 里跑通三种写法并验证结果。适合正在学 Node.js 异步编程的初学者也适合写过一段时间 Express 但一直没把文件写入细节搞清楚的开发者。如果你已经在生产环境用过fs/promises这篇文章可以作为查漏补缺重点看后面的工程建议。2. 先把背景讲透文件写入与事件循环2.1 同步写入为什么不适合服务端先看一段反面教材。// 文件路径server.js反面示例仅用于演示 const fs require(fs); app.post(/export/sync, (req, res) { // 注意writeFileSync 会阻塞后续所有请求的处理 fs.writeFileSync(path.join(DATA_DIR, export.txt), bigContent); res.send(done); });这段代码在功能上没有问题文件确实会被写入。问题出在 Node.js 的执行模型上。Node.js 的 JavaScript 代码是单线程执行的所有请求、定时器、I/O 回调都跑在同一个“事件循环”里。当你调用writeFileSync时从调用开始到文件真正写完主线程一直停在原地等待。这个期间其他请求无法被处理事件循环像被按了暂停键。如果写入的是一个几千字节的小文件你几乎感觉不到。但如果写入几十 MB 的导出文件或者在同一个循环里连续写多个文件停顿就会变得非常明显。线上表现是接口响应时间曲线突然出现一个很长的平台同时在线请求越多体感越差。这里有个很常见的误区很多人觉得“写文件是很底层的小操作不至于阻塞”。但当磁盘压力大、文件变大、并发变高时同步写入的代价会立刻传导给所有请求。尤其是像订单导出、日志收集这种写量大、频率高的任务同步写入几乎一定会成为瓶颈。补充一个跨语言的现象后端日志框架在 C# 这类天生支持多线程的语言里也会强调异步写入、批量提交、避免锁竞争。原因和 Node.js 一样——写入磁盘这类 I/O 操作天然比内存操作慢几个数量级最好的做法是把它从请求处理链路里摘出去。2.2 异步写入到底是怎么发生的异步写入看起来只是一行fs.writeFile(file, data, callback)它和同步版本的区别是调用后代码不会原地等待而是立刻往下执行等写入完成后再通过回调通知结果。Node.js 在底层把文件操作交给 libuv 的线程池。线程池里的工作线程负责真正的磁盘读写主线程只负责接收请求、安排任务、处理结果。磁盘写完以后回调函数被放进事件队列等主线程空闲时再执行。可以这样理解你去餐厅点餐服务员记下你的需求后让你先回座位等着而不是站在柜台前盯着厨师做菜。线程池就是后厨事件循环就是服务员。点餐的人多了服务员依然能同时接待其他顾客而不是被一道菜卡住。这就是为什么异步写入的接口看起来“不阻塞”耗时操作不在主线程上执行主线程依然能响应新请求。2.3 三种写法是怎样的演进关系Node.js 从诞生起就以异步著称但对异步代码的组织方式一直在演进。回调函数风格是最原始的方式函数参数里的callback在操作完成后被调用第一个参数是错误对象。Promise 在 ES6 中正式成为标准把回调改成了链式调用用.then和.catch表达结果和错误。async/await 在 ES2017 中实装用接近同步代码的写法表达异步逻辑配合try/catch处理错误。三者在底层都是异步执行区别在于上层代码怎么组织。用同一个fs.writeFile任务对比可以直接看到演进的价值写法代码风格错误处理适用场景回调函数嵌套或回调参数判断第一个参数err兼容旧代码、理解历史项目Promise链式调用.then/.catch统一.catch需要多个异步任务组合时async/await类似同步代码风格try/catch新项目首选可读性最好三种写法会在后面逐一展开这里先记住一点工程上更推荐 async/await但理解回调是理解另外两种写法的基础。3. 环境准备与最小项目初始化3.1 环境要求本文示例基于 Node.js 的fs和fs/promises模块建议使用 Node.js 14 及以上版本。fs/promises在 Node.js 10 中开始引入到 Node.js 14 已经稳定可用所以用当前任意 LTS 版本都能跑通本文代码。如果还没有安装 Node.js推荐用 nvm 这类版本管理工具安装。安装完成后先确认版本node -v npm -v如果命令行提示找不到node优先检查是否在安装时把 Node.js 加入 PATH。用 nvm 切换版本时如果出现node.js v24.19.0 is not yet released or is not available之类的提示说明远程版本列表还没有同步到这个版本执行一下nvm ls-remote拉取最新列表再切换到已发布的版本即可。3.2 初始化项目创建项目目录并初始化package.jsonmkdir async-write-demo cd async-write-demo npm init -y最终项目目录结构如下async-write-demo/ ├── data/ // 存放写入的文件 ├── package.json └── server.js先手动创建data目录也可以让代码在启动时自动创建。接下来我们会先学三种写法然后再统一用一个 Express.js 项目把它们串起来。4. 写法一回调函数风格回调函数是 Node.js 最传统、最底层的异步写法。fs.writeFile接受一个回调函数作为最后一个参数文件写入完成后回调会被调用。回调的第一个参数是错误对象如果为null说明写入成功。// 文件路径write-callback.js const fs require(fs); fs.writeFile(message.txt, Hello, callback style, (err) { if (err) { console.error(写入失败:, err); return; } console.log(写入成功); });解释一下关键点第一个参数message.txt是文件路径第二个参数是写入内容字符串会按 utf8 编码写入第三个参数是回调函数回调里必须处理err如果写入失败不处理错误会留下隐患。回调风格最容易踩的坑是“回调地狱”。如果要在写入后继续做下一个异步操作容易写成嵌套结构fs.writeFile(a.txt, A, (err) { if (err) return; fs.writeFile(b.txt, B, (err) { if (err) return; fs.writeFile(c.txt, C, (err) { if (err) return; console.log(全部写完了); }); }); });这个代码能跑但层级越来越深可读性和维护性都不好。这也是 Promise 要解决的问题。在 Express 路由里使用回调风格时通常配合res返回结果。代码如下const fs require(fs); app.post(/write/callback, (req, res) { const filePath path.join(DATA_DIR, callback-${Date.now()}.txt); fs.writeFile(filePath, req.body.content, (err) { if (err) { console.error(写入失败:, err); return res.status(500).json({ code: 500, message: 写入失败 }); } res.json({ code: 0, message: 写入成功, file: filePath }); }); });注意每一层err都要判断尤其注意不要在回调里直接throw err。在异步回调里throw不会让外层捕获反而可能直接导致进程崩溃。正确做法是把错误交给res或日志系统。5. 写法二Promise 风格Promise 是 ES6 引入的标准。Node.js 提供fs/promises模块从fs转换成返回 Promise 的版本。fs.promises.writeFile会返回一个 Promise 对象写入成功后 resolve失败后 reject。// 文件路径write-promise.js const fs require(fs/promises); fs.writeFile(message.txt, Hello, promise style) .then(() { console.log(写入成功); }) .catch((err) { console.error(写入失败:, err); });相比于回调风格Promise 最大的变化是错误处理集中到了.catch不再需要每个回调里手动判断err。同时多个写入可以通过Promise.all并行执行const fs require(fs/promises); Promise.all([ fs.writeFile(a.txt, A), fs.writeFile(b.txt, B), fs.writeFile(c.txt, C), ]) .then(() { console.log(三个文件都写完了); }) .catch((err) { console.error(至少有一个文件写入失败:, err); });在 Express 路由里用 Promise 风格写接口的代码比回调版本更短也更不容易遗漏错误分支const fsp require(fs/promises); app.post(/write/promise, (req, res) { const filePath path.join(DATA_DIR, promise-${Date.now()}.txt); fsp.writeFile(filePath, req.body.content) .then(() { res.json({ code: 0, message: 写入成功, file: filePath }); }) .catch((err) { console.error(写入失败:, err); res.status(500).json({ code: 500, message: 写入失败 }); }); });用 Promise 风格时“忘记写.catch”是新手容易犯的问题。一旦 Promise 被 reject 且没有捕获控制台会打印 UnhandledPromiseRejection甚至可能导致进程退出。所以无论代码多简单只要有异步操作最后一定要接.catch。6. 写法三async/await 风格async/await 是 ES2017 的标准本质上是 Promise 的语法糖。它让异步代码看起来像是同步代码没有回调嵌套也不用满屏.then。// 文件路径write-async-await.js const fs require(fs/promises); async function writeMessage() { try { await fs.writeFile(message.txt, Hello, async/await style); console.log(写入成功); } catch (err) { console.error(写入失败:, err); } } writeMessage();关键点有三个函数用async修饰内部才能使用awaitawait会等待 Promise 完成但不阻塞主线程错误处理用try/catch结构和对同步代码一样。在 Express 路由中使用 async/await 是非常自然的选择。甚至很多开发者直接用 async 中间件包一层const fs require(fs/promises); app.post(/write/async, async (req, res) { const filePath path.join(DATA_DIR, async-${Date.now()}.txt); try { await fs.writeFile(filePath, req.body.content); res.json({ code: 0, message: 写入成功, file: filePath }); } catch (err) { console.error(写入失败:, err); res.status(500).json({ code: 500, message: 写入失败 }); } });推荐新项目优先使用 async/await原因是可读性最好。后续如果需要把文件写入放到一个函数里复用逻辑也更清晰async function writeToFile(filePath, content) { const fs require(fs/promises); await fs.writeFile(filePath, content, { encoding: utf8 }); }需要提醒的是用了async关键字后函数自动返回一个 Promise。如果在 Express 路由中使用了 async 函数一旦内部抛出的异常没有被try/catch捕获Express 4 默认不会自动把错误转发到错误处理中间件需要自己处理。这一点在第七节的完整示例中会更明显。7. 完整示例在 Express 路由里跑通三种写法前面三种写法分开看都很简单现在把它们放进同一个 Express.js 项目用三个接口分别演示。这样可以看到完整的请求链路和代码组织形式。7.1 安装依赖在项目目录下安装 Expressnpm install expresspackage.json大致如下{ name: async-write-demo, version: 1.0.0, description: Express.js 异步写入示例, main: server.js, scripts: { start: node server.js }, dependencies: { express: ^4.19.2 } }7.2 编写完整 server.js// 文件路径server.js const express require(express); const fs require(fs); const fsp require(fs/promises); const path require(path); const app express(); const PORT 3000; const DATA_DIR path.join(__dirname, data); // 解析 JSON 请求体 app.use(express.json()); // 确保 data 目录存在 if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } // 接口1回调函数风格写入 app.post(/write/callback, (req, res) { const content req.body.content || 默认内容callback; const filePath path.join(DATA_DIR, callback-${Date.now()}.txt); fs.writeFile(filePath, content, utf8, (err) { if (err) { console.error(写入失败:, err); return res.status(500).json({ code: 500, message: 写入失败, error: err.message }); } res.json({ code: 0, message: 写入成功, file: filePath }); }); }); // 接口2Promise 风格写入 app.post(/write/promise, (req, res) { const content req.body.content || 默认内容promise; const filePath path.join(DATA_DIR, promise-${Date.now()}.txt); fsp.writeFile(filePath, content, utf8) .then(() { res.json({ code: 0, message: 写入成功, file: filePath }); }) .catch((err) { console.error(写入失败:, err); res.status(500).json({ code: 500, message: 写入失败, error: err.message }); }); }); // 接口3async/await 风格写入 app.post(/write/async, async (req, res) { const content req.body.content || 默认内容async/await; const filePath path.join(DATA_DIR, async-${Date.now()}.txt); try { await fsp.writeFile(filePath, content, utf8); res.json({ code: 0, message: 写入成功, file: filePath }); } catch (err) { console.error(写入失败:, err); res.status(500).json({ code: 500, message: 写入失败, error: err.message }); } }); app.listen(PORT, () { console.log(服务已启动: http://localhost:${PORT}); });这段代码里有几个细节值得解释fs.existsSync配合fs.mkdirSync在启动时创建data目录避免写文件时因为目录不存在而报错每次请求用Date.now()生成唯一文件名避免多个请求写入同一个文件互相覆盖第三个参数utf8显式指定编码减少歧义三个接口的错误都返回了error字段方便联调时快速定位。如果你希望启动时目录已经存在也可以手动创建data文件夹效果一样。8. 运行结果与效果验证8.1 启动服务npm start看到如下输出说明服务启动成功服务已启动: http://localhost:30008.2 调用三个接口用 curl 分别测试三种风格curl -X POST http://localhost:3000/write/callback \ -H Content-Type: application/json \ -d {content:这是回调写法的内容}预期返回{code:0,message:写入成功,file:/path/to/async-write-demo/data/callback-1718083200000.txt}第二个接口curl -X POST http://localhost:3000/write/promise \ -H Content-Type: application/json \ -d {content:这是 Promise 写法的内容}第三个接口curl -X POST http://localhost:3000/write/async \ -H Content-Type: application/json \ -d {content:这是 async/await 写法的内容}8.3 检查文件内容查看 data 目录下新生成的文件ls -l data/ cat data/callback-*.txt cat data/promise-*.txt cat data/async-*.txt如果文件内容与请求体中的content一致说明写入成功。8.4 验证失败场景尝试写入但不传content三个接口会分别写入默认内容。如果强行触发错误比如把DATA_DIR改成不可写路径接口会返回{code:500,message:写入失败,error:EACCES: permission denied, open /xxx/xxx.txt}看到code: 500说明错误分支生效。在整个验证过程中最直接的判断依据是文件存在、内容正确、接口返回成功。9. 常见问题与排查思路问题现象可能原因排查方式解决方案写入的文件不存在请求还没处理完成或路径错误查看服务端日志是否报错检查filePath的完整路径确认请求真触发了路由文件内容为空请求体里没有传content查看请求体 JSON 格式增加默认内容或做参数校验多个请求写同一个文件内容互相覆盖文件名固定没有唯一标识查看data目录文件名用Date.now()或随机数生成文件名回调接口里throw err导致进程崩溃错误处理方式错误查看进程退出日志改成res.status(500)返回不要 throw使用fs/promises报模块不存在Node.js 版本过低执行node -v查看版本升级到 Node.js 14同步写入把接口拖慢使用了writeFileSync检查代码中是否有 Sync 方法改为异步写入安装 Node.js 时提示缺少 Visual C 运行库Windows 缺少依赖查看安装日志先安装对应 Visual C 运行库nvm 切换版本提示版本不可用nvm 版本列表未同步执行nvm ls-remote刷新远程版本列表后重试排查时第一原则是先看错误日志而不是盲改代码。接口返回 500 时先确认服务端打印的error内容再定位是路径问题、权限问题还是编码问题。10. 最佳实践与工程建议10.1 新代码优先使用 async/await三种写法都能完成异步写入但工程上更推荐 async/await。它没有回调嵌套也没有.then链式层级配合try/catch后代码阅读顺序和执行顺序一致排错成本最低。回调风格主要用于兼容老项目Promise 适合处理多个异步任务的组合但新的 Express 项目首选 async/await。10.2 写日志不要用同步方式日志是最容易出现同步写入问题的场景。每个请求写一次日志如果使用同步写入QPS 越高事件循环被占用的时间越多。日志写入要满足“快”和“不阻塞”两个要求如果不想引入日志库至少用异步appendFile并考虑批量合并写入。生产环境建议直接使用成熟的日志库例如 winston、pino它们内部已经处理了流式写入、按天切割、异步落盘等问题不需要重复造轮子。10.3 大文件用流式写入如果写入的内容很大例如几十 MB 的导出文件一次性writeFile会把整个内容放在内存里还可能加剧事件循环压力。大文件使用fs.createWriteStream流式写入更合适可以边读边写控制内存占用。不过“大文件”没有一个严格的阈值实操中超过几 MB 就值得考虑改成流式。10.4 并发写入注意文件冲突多个请求同时写同一个文件时会产生写覆盖或内容错乱。最简单的做法是让每个请求写入独立的文件名例如用Date.now()、随机数或uuid。如果需要多个异步写操作按顺序执行不要无脑在循环里并发写入可以用Promise.all控制并数量或者引入队列保证顺序。10.5 错误不能静默吞掉三种写法中最需要注意的错误处理是回调风格判断err不能偷懒。Promise 风格一定要接.catchasync/await 一定要配try/catch。写入失败时客户端需要知道失败原因服务端需要记录错误现场否则线上问题很难排查。10.6 路径拼接用 path.join不要手动用字符串拼接文件路径容易在 Windows 和 Linux 上踩路径分隔符的坑。统一使用path.join处理路径代码跨平台更安全。10.7 文件路径与权限最小化如果服务要写入的是用户上传文件或临时文件尽量把写入目录限制在项目内的独立文件夹并确认该目录的权限足够。生产环境中写入目录一旦设置成整个项目根目录容易误覆盖代码文件或配置文件风险较高。11. 总结与后续学习方向如果这篇文章只能记住一句话那就是在 Express.js 里做文件写入无论选择哪种写法都不要用同步版本阻塞事件循环。三种写法——回调、Promise、async/await——底层都是异步执行真正的差异在代码组织方式。理解回调才能理解 Node.js 的异步基础理解 Promise 才知道怎么组合多个异步任务理解 async/await 才能写出符合现代工程习惯的代码。学完三种写法后建议做两个练习一是把本文的 Promise 接口改写为 async/await 版本二是把自己的日志模块从同步写入改成异步appendFile或流式写入。下一步可以继续学习fs.createReadStream与fs.createWriteStream的配合使用、winston 日志库的异步写入机制以及 Express 的错误处理中间件。文件写入只是 Node.js 异步世界的一个入口理解了事件循环后面再看网络请求、数据库连接、消息队列都会轻松很多。建议收藏备用。