看到“5分钟学编程”这个标题,我知道你在期待什么:不想看长篇大论,要的是“快点讲完、马上能跑、最好还能避开坑”。
这篇是《5分钟学编程 · Express.js篇》系列的第 11 篇,主题是 Node.js 里最不起眼、却最容易写错的一类操作——异步写入文件。
先给结论:在 Express 项目里写日志、导出数据、落盘临时文件,首选fs/promises搭配async/await的写法。它不是性能上最快的,却是可读性、错误处理、工程维护成本综合起来最稳的。如果你正在用fs.writeFileSync在接口里写日志,那这篇文章就是写给你的。读完你会搞明白三件事:为什么同步写入在 Node.js 里很危险;回调、Promise、async/await 三种异步写法到底有什么区别;以及在一个真实的 Express 请求日志场景里,怎样把代码写得既正确又容易维护。
1. 为什么“写文件”值得单独写一篇
很多新手第一次用 Express 写业务接口时,会遇到类似的场景:用户请求某个接口,服务端要把请求时间、路径、状态码记录下来,方便后面排查问题。
第一反应往往是:
const fs = require('fs'); fs.writeFileSync('access.log', 'some content', 'utf8');这段代码在本地跑一次,一切正常,日志也写进去了。于是你觉得文件写入就是这么简单。
问题出在什么时候?出在流量变大以后。
Node.js 是单线程事件循环模型。JavaScript 代码在同一个线程上排队执行,而writeFileSync是同步阻塞操作。只要它开始写文件,事件循环就会被卡住,后续所有到达的请求都要排队等它写完。
在普通性能的笔记本硬盘或者容器环境里,单个文件写入耗时可能从几毫秒到几十毫秒不等。看起来不多,但几十个请求同时触发日志写入时,累积的阻塞会让接口响应时间显著上升,甚至出现请求超时。
所以,Node.js 里写文件这种 I/O 操作,应该优先走异步路径。异步写入的本质是:发起写入请求后,不占用当前执行线程,等系统完成写入后再通过回调、Promise 或者 async/await 的方式收到结果。这样,事件循环就不会被磁盘操作拖住,服务端就能继续处理其他请求。
这也是为什么“写入”值得单独讲一篇。不是因为它复杂,而是因为它夹在“看似简单”和“一不留神就写错”之间。
2. 先理清:回调、Promise、async/await 到底在解决什么
在 Node.js 中,异步编程经历了三个阶段,对应三种主流写法。它们底层都是同一套异步 I/O 机制,差别在于代码组织和错误处理的方式。
2.1 回调风格(Callback)
这是 Node.js 早期最原始的异步写法。fs.writeFile、fs.appendFile都支持这种风格:最后一个参数传入一个函数,文件操作完成后,Node 会调用这个函数,并通过第一个参数传递错误对象。
const fs = require('fs'); fs.appendFile('access.log', 'hello\n', 'utf8', (err) => { if (err) { console.error('写入失败:', err); return; } console.log('写入成功'); });回调风格的问题在于:一旦多个异步操作有依赖关系,就要在回调里嵌套回调,代码层级越来越深,形成“回调地狱”。比如“先创建目录,再创建文件,再写入内容”,三层嵌套就已经很难读了。
2.2 Promise 风格(fs.promises)
从 Node.js 10 开始,fs模块提供了fs.promisesAPI,把异步操作封装成 Promise 对象。写法变成了链式调用:
const fs = require('fs/promises'); fs.appendFile('access.log', 'hello\n', 'utf8') .then(() => console.log('写入成功')) .catch((err) => console.error('写入失败:', err));Promise 解决了两件事:一是通过.then()和.catch()让错误处理不再依赖回调参数;二是配合Promise.all、Promise.race可以组合多个异步操作。但它依然不是最自然的阅读方式,尤其当逻辑分支变多时,链式调用也会变得冗长。
2.3 async/await 风格
async/await是 Promise 的语法糖,让异步代码写起来像同步代码:
const fs = require('fs/promises'); async function writeLog() { try { await fs.appendFile('access.log', 'hello\n', 'utf8'); console.log('写入成功'); } catch (err) { console.error('写入失败:', err); } }这个写法的好处是:没有嵌套、没有链式调用,代码的执行顺序就是书写顺序。try/catch和同步代码的错误处理方式一致,心智负担最低。
回到文件写入的场景,结论很明确:新代码优先使用fs/promises加async/await。回调风格在维护老项目时还需要看懂,但新项目没必要再写。
我整理了一个简单的对比表,方便你做技术选型时参考:
| 写法 | 代码结构 | 错误处理 | 嵌套深度 | 适合场景 |
|---|---|---|---|---|
| 回调 | 函数参数传入回调 | 回调的第一个参数是 error | 深,容易形成回调地狱 | 老代码、短小的单次操作 |
| Promise | 链式调用.then().catch() | .catch()统一捕获 | 中等,可组合多个操作 | 中等复杂度异步流程 |
| async/await | 同步代码风格 | try/catch | 浅,接近同步代码 | 新项目首选,复杂流程首选 |
你不需要把三种写法都背下来,但至少要能读懂老代码里的回调,然后知道用 async/await 重写它。
3. 环境准备与项目初始化
动手前先把环境准备好。到这里,假设你已经安装了 Node.js 和 npm。如果还没装,建议优先使用官方安装包或 nvm 这类版本管理工具安装 LTS 版本,这样既能避免 PATH 配置问题,后期也能在多个 Node.js 版本之间快速切换。
版本方面没有太严格的要求。fs/promises从 Node.js 14 开始已经稳定可用,本文示例在 Node.js 18+ 环境下运行都没有问题。如果你用的版本低于 14,请先升级环境。
先创建项目目录并初始化:
mkdir express-async-write-demo cd express-async-write-demo npm init -y接着安装 Express:
npm install express安装完成后,目录结构大概是这样的:
express-async-write-demo/ ├── package.json ├── node_modules/ ├── server.js ├── callback-demo.js ├── promise-demo.js ├── sync-demo.js └── logs/后面我会逐个创建server.js、callback-demo.js、promise-demo.js和sync-demo.js。如果你只是想看核心写法,直接看第 4 节;如果你想在一个完整的 Express 场景里验证效果,直接看第 5 节。
4. 三种异步写入写法与核心代码
这一节是最核心的部分。我们统一使用appendFile来演示,因为它会追加内容到文件末尾,不会覆盖已有内容,这个行为更符合日志写入场景。
注意:fs.writeFile默认会覆盖整个文件,如果你用它写日志,第二次请求就会把第一次记录冲掉。后面“常见问题”里我会详细讲这个坑。
4.1 写法一:回调风格
文件路径:callback-demo.js
const fs = require('fs'); const path = require('path'); const logPath = path.join(__dirname, 'logs', 'access-callback.log'); function appendLogCallback(line, callback) { fs.appendFile(logPath, line + '\n', 'utf8', (err) => { if (err) { console.error('写入日志失败:', err); return callback(err); } callback(null); }); } // 调用示例 appendLogCallback('Hello Callback Log', (err) => { if (err) { console.error('调用失败:', err); return; } console.log('回调风格写入完成'); });这段代码里有一个习惯需要保持:回调函数的第一参数永远约定为err。没有错误时,err是null,否则就是 Error 对象。这是 Node.js 早期 API 的通用约定,你在很多老项目中都会看到这种写法。
4.2 写法二:Promise 链式风格
文件路径:promise-demo.js
const fs = require('fs/promises'); const path = require('path'); const logPath = path.join(__dirname, 'logs', 'access-promise.log'); function appendLogPromise(line) { return fs.appendFile(logPath, line + '\n', 'utf8') .then(() => { console.log('Promise 风格写入完成'); }) .catch((err) => { console.error('写入日志失败:', err); }); } // 调用示例 appendLogPromise('Hello Promise Log');这种写法比回调容易读一些:then里的代码代表成功路径,catch里的代码代表失败路径,不需要再自己判断err是否为null。
如果你要在多个异步操作之间做组合,Promise 的优势会更明显。比如同时向两个日志文件写入:
Promise.all([ fs.appendFile(logPathA, '内容A\n', 'utf8'), fs.appendFile(logPathB, '内容B\n', 'utf8') ]).then(() => { console.log('两个文件都写完了'); }).catch((err) => { console.error('至少有一个文件写入失败:', err); });4.3 写法三:async/await 风格(推荐)
文件路径:async-demo.js,或者直接写在 Express 的server.js里。
const fs = require('fs/promises'); const path = require('path'); const logPath = path.join(__dirname, 'logs', 'access-async.log'); async function appendLogAsync(line) { try { await fs.appendFile(logPath, line + '\n', 'utf8'); } catch (err) { console.error('写入日志失败:', err); } } // 调用示例 appendLogAsync('Hello Async Log');如果你认真对比一下就会发现,async/await 版本在逻辑上就是“同步代码 +await+try/catch”。它没有增加新的概念,只是把 Promise 的.then()和.catch()变成了更自然的try/catch结构。
对于大多数 Express 项目来说,这是最推荐的方式。因为它足够直观,团队里任何人接手这段代码,不需要额外学习就能理解。
4.4 顺手对比:同步写入的问题在哪里
为了讲清楚为什么不用writeFileSync,我再贴一段同步写入的示例,仅用来做对比,不推荐在真实项目里使用。
文件路径:sync-demo.js
const fs = require('fs'); const path = require('path'); const logPath = path.join(__dirname, 'logs', 'access-sync.log'); function appendLogSync(line) { // 注意:这里会阻塞事件循环 fs.appendFileSync(logPath, line + '\n', 'utf8'); } appendLogSync('Hello Sync Log'); console.log('同步写入完成');在低并发写一两行日志时,这个版本的代码和异步版本没有肉眼可见的差别。但一旦接口的并发量上来,appendFileSync会让所有请求在事件循环里排队等待磁盘操作,后果是整个服务所有接口的整体响应时间被拉长。
所以,在 Express 服务端代码里,写文件操作要尽量走异步。如果页面能渲染出来,底层流程是同步还是异步,用户无感知;但服务能不能扛住并发,差别就在这里。
5. 一个完整的 Express 请求日志写入示例
下面我们把上面的写法放到一个真实的 Express 场景里:每次请求到达/api/hello,中间件负责记录请求方法、路径、状态码和处理耗时,最后异步追加到logs/access.log文件。
文件路径:server.js
const express = require('express'); const fs = require('fs/promises'); const path = require('path'); const app = express(); const PORT = 3000; const LOG_DIR = path.join(__dirname, 'logs'); const LOG_FILE = path.join(LOG_DIR, 'access.log'); // 确保日志目录存在 async function ensureLogDir() { try { await fs.mkdir(LOG_DIR, { recursive: true }); } catch (err) { console.error('创建日志目录失败:', err); } } // 异步追加写入日志 async function appendLog(line) { try { await fs.appendFile(LOG_FILE, line + '\n', 'utf8'); } catch (err) { console.error('写入日志失败:', err); } } // 请求日志中间件 app.use((req, res, next) => { const start = Date.now(); res.on('finish', () => { const duration = Date.now() - start; const line = `${new Date().toISOString()} ${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`; // 注意:这里不 await,避免阻塞响应 appendLog(line); }); next(); }); app.get('/api/hello', (req, res) => { res.json({ message: 'Hello Express' }); }); app.listen(PORT, async () => { await ensureLogDir(); console.log(`Server is running at http://localhost:${PORT}`); });代码拆开看也很清晰:
ensureLogDir负责在服务启动前创建logs目录,recursive: true允许递归创建多级目录,目录已存在时也不会报错。appendLog封装了文件写入逻辑,内部用try/catch兜住错误,外层调用方不需要关心失败细节。- 中间件里通过
res.on('finish')捕获响应结束事件,在这个时机记录日志,能拿到真实的状态码和耗时。 - 中间件里没有对
appendLog使用await,这是刻意为之。日志写入不应阻塞响应返回,异步让它在后台完成即可。
如果你担心日志写入失败后没人处理,可以在appendLog内部更激进一点,比如把错误上报到监控系统或写入备用错误日志:
async function appendLog(line) { try { await fs.appendFile(LOG_FILE, line + '\n', 'utf8'); } catch (err) { console.error('写入日志失败:', err); try { await fs.appendFile(path.join(LOG_DIR, 'error.log'), err.stack + '\n', 'utf8'); } catch (err2) { console.error('写入错误日志也失败了:', err2); } } }6. 运行结果与功能验证
现在启动服务并验证写入是否正常。
终端一,启动服务:
node server.js预期输出:
Server is running at http://localhost:3000终端二,发起几个请求:
curl http://localhost:3000/api/hello curl http://localhost:3000/api/hello curl http://localhost:3000/api/hello然后查看日志文件:
cat logs/access.log预期输出类似下面这样(时间戳和耗时会随你的运行时间变化):
2025-06-10T10:15:30.123Z GET /api/hello 200 12ms 2025-06-10T10:15:31.456Z GET /api/hello 200 8ms 2025-06-10T10:15:32.789Z GET /api/hello 200 10ms每一行代表一次请求。如果你能看到三行日志,说明整个流程已经跑通了。
如果想单独验证三种写法,也可以分别运行:
node callback-demo.js node promise-demo.js node sync-demo.js然后去logs目录查看对应的日志文件是否生成、内容是否正确。
关于“如何判断写入成功”,最直接的办法就是查看文件内容和文件修改时间。如果文件存在且内容是你期望的,就说明写入成功。如果文件没生成,先确认logs目录是否存在,再确认代码执行过程中有没有抛出异常。
7. 常见问题与排查思路
异步写入在本地跑通很容易,真正麻烦的是遇到问题时的排查。我整理了几个最常见的坑:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 第二次写入后,第一次的内容被覆盖 | 使用了fs.writeFile,默认 flag 是w,会覆盖文件 | 查看文件内容是否只剩最后一次写入 | 改用fs.appendFile,或传入flag: 'a' |
| 日志内容顺序错乱 | 多个请求并发写入,没有保证执行顺序 | 观察是否偶发错行 | 使用单线程队列串行写入,或选用 pino/winston 等日志库 |
| 调用接口后日志文件没有生成 | 日志目录不存在,或写入抛错未被发现 | 先检查logs目录,再看控制台错误输出 | 调用fs.mkdir创建目录,并检查try/catch |
| 写入错误被静默吞掉 | 异步错误没有捕获 | 在appendLog的catch中打印错误 | 统一错误处理,或上报监控系统 |
安装 Node.js 后执行node -v报 not found | PATH 未生效或版本管理器未切换 | 检查which node/node -v | 重启终端,或用 nvm 指定默认版本 |
| Windows 上安装依赖时报 VC++ 相关错误 | 缺少 Visual C++ Redistributable 运行库 | 查看安装日志 | 安装对应版本的 VC++ Redistributable 后重试 |
排第一的坑最常见:writeFile覆盖写。它在单一请求场景下根本暴露不出来,等到你写“第二个请求的日志”时才发现之前的内容全没了。
另外,fs.appendFile也不是完全没有竞争问题。在高并发的极端场景下,如果多个请求同时向同一个文件追加内容,会有顺序错乱的风险。从工程角度,日志文件写入更推荐借助专门的日志库来管理轮转和格式,而不是长期手写。
8. 工程建议:把日志写入做得更稳
写日志这件事,从“能写入”到“能稳定地写入”,中间还差几步。
第一,不要把日志文件直接放在项目根目录。尤其是在多服务部署、容器化运行的环境里,根目录可能没有写权限,或者目录会随版本发布被覆盖。更安全的方式是单独规划日志目录,并通过环境变量配置路径,让运维同学可以调整:
const LOG_DIR = process.env.LOG_DIR || path.join(__dirname, 'logs');第二,日志文件建议按天滚动。一个access.log跑几个月,文件会变得巨大,排查问题、清理归档都会很痛苦。可以按日期生成文件名:
const dateStr = new Date().toISOString().slice(0, 10); const LOG_FILE = path.join(LOG_DIR, `access-${dateStr}.log`);第三,批量写入比逐条写入更高效。如果你的业务需要一次性记录多条日志,不要一条条appendFile,先拼成一个大字符串,再一次性写入:
const lines = []; lines.push(requestLog1); lines.push(requestLog2); lines.push(requestLog3); await fs.appendFile(LOG_FILE, lines.join('\n') + '\n', 'utf8');这样能显著减少系统调用次数,在高频场景下对性能更友好。
第四,不要在请求热路径上同步等待日志写完。即使你用了 async/await,如果业务逻辑必须等日志落盘再返回,那么接口的响应时间就包含了磁盘写入时间。更合理的做法是:让日志异步在后台写入,甚至在内存队列里攒一批再批量落盘。
第五,生产环境优先考虑成熟的日志库。这听起来像废话,但真的很重要。Node.js 生态里的pino、winston已经处理好了日志级别、滚动、格式化、多传输目的地等问题。手写日志逻辑适合学习、适合小型项目,但在生产环境里,直接用这些库能省掉很多维护成本。
第六,注意日志脱敏。如果你把请求路径、查询参数、请求体原样写入文件,用户手机号、身份证号、Token 这类敏感信息就可能以明文形式留在服务器上。写入前要做字段过滤或脱敏,这是安全底线。
9. 总结:这节真正留下的三个结论
回到最初的问题:Node.js 异步写入文件,到底应该怎么写?
第一个结论是,服务端文件写入要优先用异步。writeFileSync在低并发时看着没问题,在高并发时会把事件循环卡死,这是 Node.js 单线程模型的硬约束,不是编码习惯问题。
第二个结论是,三种异步写法里,新项目首选fs/promises加async/await。回调风格是历史包袱,需要能读懂,但不需要在新代码里继续使用;Promise 链式风格用于组合多个异步操作很合适;而日常工作里,async/await的代码可读性和维护性最好。
第三个结论是,写入日志这类操作,要尽早考虑工程化。目录规划、滚动文件名、批量写入、错误处理、脱敏,这些细节决定了你写的日志代码能不能扛住生产环境,而不只是本地跑通一次。
顺着这个方向,下一步值得研究的话题是 Node.js 的Stream模块。文件日志、大文件上传下载、数据处理管道,底层都会用到 Stream,理解了它,你对 Node.js I/O 的理解才算真正补齐。这篇先到这里,建议你把示例代码跑一遍,收藏备用。