Express.js异步写入文件:回调、Promise与async/await实践
2026/9/12 11:36:12 网站建设 项目流程

在 Express.js 项目中,异步写入是绕不开的操作:写请求日志、保存上传文件、导出统计结果,都会落到磁盘 I/O 上。很多 Node.js 初学者会把fs.writeFileSync直接放进路由,本地跑只有一两个请求时看不出问题,一旦并发上来,整条请求链路都会被同步阻塞。本文围绕一个具体的 Express.js 场景——把每个 POST 请求的 body 写入本地文件,讲清楚 Node.js 异步写入的三种写法:Callback 回调、Promise 链式、async/await。你会看到三种写法各自的代码形态、错误处理方式、适用场景,以及生产环境里最容易踩的坑。学完后,你能在 Express 路由里正确选型、正确排错,也能够把写入逻辑封装成可维护的模块。

1. 为什么在 Express 里写文件必须用异步方式

1.1 从 Node.js 事件循环看写入的两种路径

Node.js 是单线程事件循环模型,JavaScript 代码运行在一个主线程上,但写文件这类 I/O 操作不会全部压在主线程上。fs.writeFileSync会让主线程等待系统调用返回后才继续执行下一行;fs.writeFile则会把文件写入交给 libuv 底层线程池,执行完再通过事件循环通知应用代码。在 Express 中,主线程同时负责接收新请求、解析 body、响应结果,如果某个路由用同步写文件,它停留多少毫秒,后续请求就得排队等多少毫秒。请求一多,超时、内存占用上升、接口整体变慢就随之而来。

这个模型也解释了异步回调为什么“看起来不按顺序执行”。文件写入完成后,回调不是立刻执行,而是先进入事件循环的待处理队列,等主线程空闲后再被取出来执行。因此,异步写文件只适合用在“不需要马上得到结果”的场景,或者说不应该依赖写入完成后的状态来继续执行和写入无关的逻辑。

1.2 同步 API 什么时候才可用

这并不代表同步 API 要完全戒掉。程序启动阶段执行一次性检查,比如判断目录是否存在、创建日志目录,用fs.existsSyncfs.mkdirSync是没问题的,因为它们只发生一次,不会长期占住主线程。但在请求处理函数里,不建议出现fs.writeFileSyncfs.readFileSync。demo 里写在路由里看似简洁,实际是把每次请求的耗时都变成阻塞。

如果一个路由里同时有数据库查询、远程接口调用、文件写入,同步写法会把所有耗时累加在同一个请求线程上。换成异步写法之后,每一段等待都会把事件循环让出去,其他请求才有机会被处理。这也是判别一个 Node.js 服务是否健壮的基础:看它在等待外部 I/O 时,是不是继续响应用户请求。

1.3 三种写法不是三套引擎

Callback、Promise、async/await 本质上是同一层异步 I/O 的不同表达方式。底层仍然是 Node.js 的fs模块和 libuv,最终调用操作系统的写文件能力。区别在于:回调靠函数参数通知结果,Promise 把异步状态封装成对象,async/await 是 Promise 的语法糖。理解了这一点,就不会把“异步写入”当成魔法,也不会认为换一种写法就一定能提升性能。写法只会影响代码可读性和错误处理方式,不会改变磁盘 I/O 本身的成本。

2. 准备一个可运行的最小 Express 项目

2.1 环境要求和依赖版本

建议使用 Node.js 14 以上的版本。因为require('fs/promises')从 Node.js 14 开始稳定可用;如果使用更早版本,可以改成require('fs').promises。Express 示例基于 4.x,通过 npm 安装会得到当前可用的 4.x 版本。如果项目已经用了 Express 5,正文路由代码基本适用,但异步路由的异常处理行为有差异,这一点会在后文单独说明。

依赖版本建议说明
Node.js14+需要fs/promises支持
express4.x示例基于 Express 4,Express 5 基本兼容
包管理器npm使用 npm 初始化项目

2.2 初始化项目目录

先创建一个目录,再通过 npm 初始化。命令如下:

mkdir express-async-write-demo cd express-async-write-demo npm init -y npm install express@4

预期会生成package.jsonnode_modules目录。接下来手动创建app.js,并让项目启动后自动创建logs目录。目录结构如下:

express-async-write-demo/ ├── app.js ├── logs/ │ └── request.log └── package.json

2.3 创建基础 app.js

app.js中创建 Express 应用,同时准备好日志文件路径。这里的关键点是把目录创建工作放在启动阶段完成,避免真正写入文件时因为目录不存在而报ENOENT

const express = require('express'); const fs = require('fs'); const path = require('path'); const fsPromises = require('fs/promises'); const app = express(); app.use(express.json()); const LOG_DIR = path.join(__dirname, 'logs'); const LOG_FILE = path.join(LOG_DIR, 'request.log'); if (!fs.existsSync(LOG_DIR)) { fs.mkdirSync(LOG_DIR, { recursive: true }); } app.get('/health', (req, res) => { res.json({ status: 'up' }); }); // 后续小节会为 /api/echo/callback、/api/echo/promise、/api/echo/async 添加路由 app.listen(3000, () => { console.log('server: http://localhost:3000'); });

运行npm start后,用curl验证服务是否正常:

curl http://localhost:3000/health

正常响应为:

{"status":"up"}

这里有几个细节需要注意:fs.mkdirSync(LOG_DIR, { recursive: true })会在目录不存在时自动创建,不会因为目录已存在而报错。path.join(__dirname, 'logs')使用绝对路径,避免运行时因为工作目录不同而找不到文件。日志文件本身没有提前创建,第一次写入时由fs.writeFile自动创建。

3. 异步写入的第一种写法:Callback 回调风格

3.1 用 fs.writeFile 发起异步写入

Callback 是 Node.js 最早的异步风格。fs.writeFile接收路径、数据、可选的 options 和回调函数。写入完成后,回调函数会被调用。为了在 Express 路由中演示,我添加一个/api/echo/callback接口,把请求 body 序列化后追加到request.log

app.post('/api/echo/callback', (req, res) => { const payload = { time: new Date().toISOString(), body: req.body || {} }; const line = JSON.stringify(payload) + '\n'; fs.writeFile(LOG_FILE, line, { encoding: 'utf8', flag: 'a' }, (err) => { if (err) { console.error('write failed:', err); return res.status(500).json({ error: 'write_failed', detail: err.message }); } res.json({ received: true, timestamp: payload.time }); }); });

注意fs.writeFile默认的flag'w',含义是打开文件写入并截断原内容。如果多个请求都写同一个文件,后面的请求会把前面内容覆盖掉。这里显式传入flag: 'a',表示追加写入,更适合记录日志。

3.2 回调参数和错误处理

Node.js 回调风格有一个约定:回调的第一个参数是错误对象err,没有错误时它是nullundefinedfs.writeFile的回调没有成功结果参数,因为写入完成后不需要返回数据,你只需要知道“写成功了”还是“写失败了”。

很多新手会写成下面这样:

fs.writeFile(LOG_FILE, line, { flag: 'a' }, () => { res.json({ received: true }); });

这个写法的问题是完全没有检查err。如果磁盘满了、目录被删除、权限不足,接口仍然响应成功,但数据其实没有写入。生产环境里这种错误最隐蔽,因为它不会让服务崩溃,只会让日志或者文件数据静默丢失。正确的做法是写入失败时记录错误,并返回给客户端一个明确的失败状态。

3.3 回调地狱与它的代价

如果一次请求需要处理多个写入步骤,比如先写一个临时文件,再写一个索引文件,回调写法会层层嵌套:

fs.writeFile(file1, data1, () => { fs.writeFile(file2, data2, () => { fs.writeFile(file3, data3, () => { // 越来越深 }); }); });

这种结构被称为回调地狱。它的问题不只是缩进变深,更严重的是错误处理会变得混乱:哪一层出错、错误由谁捕获、如何中断后续步骤,都要靠开发者手工维护。于是 Node.js 引入了 Promise,用来把这套流程改成更接近线性的表达。

4. 异步写入的第二种写法:Promise 链式风格

4.1 使用 fs/promises 的 writeFile

fs/promises模块提供了 Promise 版本的文件操作 API。fsPromises.writeFile的行为和fs.writeFile一致,但它返回一个 Promise,不再接收回调函数。用then处理成功,用catch处理失败。

app.post('/api/echo/promise', (req, res) => { const payload = { time: new Date().toISOString(), body: req.body || {} }; const line = JSON.stringify(payload) + '\n'; fsPromises.writeFile(LOG_FILE, line, { encoding: 'utf8', flag: 'a' }) .then(() => { res.json({ received: true, timestamp: payload.time }); }) .catch((err) => { console.error('write failed:', err); res.status(500).json({ error: 'write_failed', detail: err.message }); }); });

这段代码和回调版本最大的区别是,错误处理被收拢到了catch里。只要writeFile内部抛出异常,无论是文件不存在、权限问题还是编码问题,都会被同一个catch捕获。这对普通写入场景已经够用了。

4.2 多个写入操作如何编排

Promise 带来的第二个好处是可以编排多个异步操作。如果多个不同文件之间没有依赖关系,可以用Promise.all并行写入:

const writeTask = fsPromises.writeFile(fileA, dataA); const readTask2 = fsPromises.writeFile(fileB, dataB); Promise.all([writeTask, readTask2]) .then(() => { res.json({ received: true }); }) .catch((err) => { console.error('batch write failed:', err); res.status(500).json({ error: 'write_failed' }); });

如果多个写入步骤之间存在先后依赖,可以直接在then里返回下一个 Promise,形成链式调用。这样虽然没有完全消除缩进,但比回调嵌套更容易理解,错误也会传递到最后的catch。建议只有当需要同时写多个独立文件时才使用并行写入;如果多个写操作指向同一个日志文件,并行写入反而可能造成内容交错。

4.3 Promise 写法的剩余问题

Promise 解决了回调地狱的一部分问题,但长时间使用.then().catch()会让代码显得琐碎。遇到多层await、条件分支、循环写入时,链式表达读起来仍然不够自然。于是 async/await 在 Promise 之上提供了更接近同步代码的写法。

5. 异步写入的第三种写法:async/await 风格

5.1 把写入逻辑改造成同步阅读体验

async/await是 Promise 的语法糖。函数前面加上async,函数内部就可以使用await等待一个 Promise 完成。等待期间事件循环不会被阻塞,代码却看起来像同步写法。

app.post('/api/echo/async', async (req, res) => { try { const payload = { time: new Date().toISOString(), body: req.body || {} }; const line = JSON.stringify(payload) + '\n'; await fsPromises.writeFile(LOG_FILE, line, { encoding: 'utf8', flag: 'a' }); res.json({ received: true, timestamp: payload.time }); } catch (err) { console.error('write failed:', err); res.status(500).json({ error: 'write_failed', detail: err.message }); } });

这里await fsPromises.writeFile(...)会暂停当前 async 函数的执行,直到文件写完成。如果写入失败,异常会被catch捕获。和 Promise 链式写法相比,缩进更少,逻辑顺序和阅读顺序一致,适合放在 Express 路由中。

5.2 Express 4 与 Express 5 的异步错误处理差异

使用 async 路由时,一个容易忽略的坑是 Express 4 不会自动捕获异步路由中产生的 Promise rejection。也就是说,如果 async 函数里没有try/catch,写入失败时 Express 4 不会把它送到错误中间件,而是可能出现UnhandledPromiseRejection,客户端请求也会一直挂起直到超时。

// 反例:Express 4 下不要这样写 app.post('/api/echo/async', async (req, res) => { await fsPromises.writeFile(LOG_FILE, line, { flag: 'a' }); res.json({ received: true }); });

Express 5 对异步错误处理做了改进,会把 async 路由中抛出的异常自动传给错误中间件。但当前大量项目仍基于 Express 4,稳妥的做法是显式使用try/catch,不要依赖框架自动兜底。这既适用于写入操作,也适用于任何数据库、缓存、外部接口调用。

5.3 将写入封装成独立模块

当写入逻辑变复杂后,不要把fsPromises.writeFile散落在每个路由里。建议封装成一个独立模块,例如logger.js

const fs = require('fs'); const path = require('path'); const fsPromises = require('fs/promises'); const LOG_DIR = path.join(__dirname, 'logs'); const LOG_FILE = path.join(LOG_DIR, 'app.log'); if (!fs.existsSync(LOG_DIR)) { fs.mkdirSync(LOG_DIR, { recursive: true }); } async function writeLog(payload) { const line = JSON.stringify({ time: new Date().toISOString(), payload }) + '\n'; await fsPromises.writeFile(LOG_FILE, line, { encoding: 'utf8', flag: 'a' }); } module.exports = { writeLog };

路由里的调用就变得很干净:

const { writeLog } = require('./logger'); app.post('/api/echo/async', async (req, res) => { try { await writeLog(req.body || {}); res.json({ received: true }); } catch (err) { console.error('write failed:', err); res.status(500).json({ error: 'write_failed' }); } });

封装之后,后续如果需要增加日志轮转、不同日志级别、写入队列,只需要改动logger.js,路由代码不受影响。

6. 三种写法对比与选型

6.1 三种写法横向对比

写法典型 API错误处理方式适用场景
Callbackfs.writeFile(path, data, opts, cb)回调函数第一个参数err旧代码、简单一次性写入
PromisefsPromises.writeFile(...).then().catch().catch收尾需要编排多个异步操作
async/awaitawait fsPromises.writeFile(...)try/catchExpress 路由、复杂业务逻辑

从维护性角度看,新项目建议优先使用async/await。它最接近同步代码的阅读习惯,错误处理也更集中。回调风格仅用于兼容旧代码,不要在新增业务里继续写深嵌套回调。

6.2 写文件参数速查

fs.writeFilefsPromises.writeFile的参数基本一致,常用的options字段如下:

参数默认值含义
encoding'utf8'字符串写入时使用的字符编码
mode0o666文件权限,会受系统umask影响
flag'w'文件打开模式,'w'表示覆盖,'a'表示追加
signal传入AbortSignal可以取消写入

其中最容易出错的是flag。默认的'w'会在写入前截断文件,如果用于日志场景,第二次请求就会把第一次请求覆盖掉。推荐日志写入明确指定flag: 'a'。如果你希望更直观,也可以使用fs.appendFilefsPromises.appendFile,它等价于以追加模式写入。

6.3 并发写入同一个文件的坑

即使使用了异步写入,也不能完全避免并发问题。多个请求同时调用fsPromises.writeFile写同一个文件,即使指定的flag: 'a',也不能保证大段数据之间不会交错。对于一行一行的日志,内容较短时通常问题不明显,但一旦数据量变大或写入频率增加,就可能出现半行、乱序、内容交错。

因此,在需要高并发写同一文件时,不要裸写文件。更稳妥的做法是引入写入队列,或者使用pinowinston等成熟日志库,它们内部已经处理了批量写入、轮转和背压问题。

7. 常见问题:写不进、被覆盖、乱码、异步异常

7.1 ENOENT:目录不存在或路径错误

写入时报ENOENT: no such file or directory, open '...',最常见原因是日志目录没有创建。fs.writeFile只能创建文件,不能递归创建不存在的父目录。解决方式是在应用启动阶段调用:

fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });

也可以把LOG_FILE设计成绝对路径,避免因为工作目录变化导致找不到文件。排查时先看路径字符串本身,再确认目录是否真实存在。

7.2 文件内容始终只剩最后一条

如果每次请求后打开日志文件,发现里面只有最后一次请求的记录,基本可以断定使用了默认flag: 'w'。覆盖写入对配置文件生成可能没问题,但对日志和追加场景是错误的选择。

解决方案有两种:

// 显式使用追加 flag fsPromises.writeFile(LOG_FILE, line, { flag: 'a' }); // 或者使用专门的追加 API fsPromises.appendFile(LOG_FILE, line);

推荐优先使用appendFile,语义更明确,也避免每次传flag出错。

7.3 中文内容乱码

写入中文后,用编辑器打开文件看到乱码,通常不是 Node.js 写错了,而是写入编码和读取编码不一致。默认utf8写入,但某些 Windows 编辑器默认用 ANSI 或 GBK 打开文件,就会显示乱码。

处理方式:写入时显式指定encoding: 'utf8',编辑器打开时也选择 UTF-8。还可以在文件开头写入一个不可见的 BOM 标记,但日志文件通常不需要,这里不推荐。如果你不是在写字符串而是写 Buffer,要确保 Buffer 内容本身已经完成了正确的编码转换。

7.4 async 路由抛异常但客户端一直转圈

客户端请求一直没有响应,最典型的原因是 async 路由里发生了异常,但没有被try/catch捕获,并且 Express 4 又不能自动处理 Promise rejection。此时终端往往会打印未处理的 rejection,但请求没有返回。

检查顺序如下:

  1. 路由是否声明了async关键字。
  2. 函数内是否有try/catch包裹await操作。
  3. 是否有全局错误中间件。
  4. Express 版本是 4 还是 5。

修复方式很简单:把await写入包进try/catch,失败时返回 500。如果项目统一使用 Express 5,可以依赖错误中间件,但不要在 Express 4 里赌这个行为。

8. 生产环境写日志:别在请求里裸写文件

8.1 从 Demo 到生产还差什么

Demo 演示的三种写法只解决了“能不能写”的问题,生产环境还需要考虑:

  • 日志按天或按大小轮转,避免单个文件无限增长。
  • 写入频率高时增加批量写入或队列机制。
  • 每条日志带上请求 ID、用户 ID、耗时等上下文信息。
  • 文件权限、目录权限按部署用户最小化设置。
  • 写入失败时要有监控和告警,而不是只打印到终端。
  • 不要把日志路径硬编码在代码里,通过环境变量注入。

对于小型项目,可以先把logger.js封装好,再加入pino这类日志库。对于大型项目,建议直接接入集中式日志服务,应用本身只负责输出结构化日志,分析组件负责采集和检索。

8.2 一个简单的串行写入队列

如果不想引入额外依赖,又要解决并发写同一文件时的交错问题,可以维护一个 Promise 队列,让所有写入操作串行执行。下面是一个最小示例:

const fsPromises = require('fs/promises'); let writeQueue = Promise.resolve(); function appendLine(file, line) { const task = writeQueue.then(() => fsPromises.writeFile(file, line, { encoding: 'utf8', flag: 'a' }) ); writeQueue = task.catch(() => {}); return task; }

appendLine的每次调用都会在前一个写入任务完成后才开始新的写入,从应用层面避免了并发写同一文件。注意这里单独给writeQueue挂了catch,防止某次写入失败导致整个队列后续任务永远不执行。这个写法适合中低频率写入,高频场景仍建议使用专门的日志库。

8.3 发布前检查清单

每次写完文件相关的功能,发布前可以按下面清单检查:

  • [ ] 日志目录是否在启动阶段创建,路径是否为绝对路径。
  • [ ] 写操作是否使用异步 API,路由里没有writeFileSync
  • [ ] 写入模式明确,日志场景使用追加模式。
  • [ ] 异步路由有try/catch,或明确依赖 Express 5 的错误处理。
  • [ ] 错误日志是否包含时间、请求 ID、错误堆栈等可排查信息。
  • [ ] 并发写入同一文件是否有队列或日志库兜底。
  • [ ] 日志文件是否有轮转策略,磁盘占用是否可控。
  • [ ] 是否预留了通过环境变量覆盖日志路径和级别的能力。

这三种异步写入写法本身并不复杂,真正决定项目质量的,是是否理解了事件循环模型,是否在路由里错误使用了同步 API,以及是否在并发和错误处理上做了提前设计。建议从async/await开始写新代码,用回调风格阅读旧

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询