尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Node.js异步写入详解:回调、Promise与async/await在Express中的实践

Node.js异步写入详解:回调、Promise与async/await在Express中的实践 如果你已经在用 Express.js 写接口那你一定遇到过这个问题请求进来后想把日志、上传文件、临时数据写到磁盘但又不想阻塞后面的业务逻辑。Node.js 的异步写入看起来选择很多网上搜出来的写法有writeFile、writeFileSync、fs.promises.writeFile、createWriteStream新手经常分不清什么时候该用哪一种。这次我们就把“Node.js 异步写入”一次性讲透。本文是 Express.js 系列的第 11 篇重点对比三种异步写入写法回调、Promise、async/await然后带你在 Express 中间件里做一个真实可用的异步日志写入再看一下批量写入和接口场景里的坑。本文适合这几类读者刚接触 Node.js 的前端同学、被“回调地狱”困扰的初学者、想在 Express 项目里规范化写日志或落盘文件的开发者。直接进入正题。1. 核心能力速览在动手写代码之前先给一张速览表。后面所有演示都围绕这三种写法展开。能力项说明核心主题Node.js 文件异步写入的三种写法以及 Express.js 中的落地实践三种写法回调风格fs.writeFile、Promise 风格fs.promises.writeFile、async/await 风格运行环境Node.js 12 推荐最好使用 16 以上 LTS 版本依赖要求只需 Node.js 内置fs模块不需要第三方包实践场景Express 日志中间件、请求体落盘、批量文件写入核心结论不需要回调写法优先fs.promises配合 async/await适合人群Node.js 初学者、Express 接口开发者、日志模块维护者避坑重点同步写入会阻塞事件循环、回调未捕获异常会导致进程崩溃、并发写入需要排队或追加另外多说一句这里演示的代码主要基于fs模块。如果你在 Express 项目里只是写日志还可以直接用createWriteStream做流式追加本文第 7 节会做对比说明。2. 适用场景与使用边界异步写入到底适合什么场景先把边界划清楚后面写代码时心里才有底。适合的场景Express 中间件里写访问日志比如记录请求路径、耗时、状态码。接口收到上传文件后把文件内容异步保存到磁盘。批量导出报表把多行数据写入同一个文件。配合消息队列边接收数据边落盘。写缓存文件、临时文件、离线数据包。不适合的场景必须等待写入完成再响应的场景比如“保存成功”必须依赖文件真正写入成功。这种情况下可以异步写入但要配合 await 或回调逻辑不能直接“假成功”。对文件顺序要求极其严格、且并发量特别大的场景。直接使用fs.writeFile并发追加会出现错乱需要自己排队。每次启动时读取配置、读取密钥这种操作不能用异步写应该用同步读。使用边界上要注意三点第一文件系统操作涉及用户数据时要做好路径白名单校验避免把接口入参直接拼接成文件路径。比如用户传入文件名你要过滤掉../、/等危险字符。第二不要把未脱敏的隐私数据直接写入日志文件。涉及手机号、身份证、Token 的内容要先脱敏再落盘。第三本地测试时建议把写入目标限定在./tmp或系统临时目录避免污染项目目录。3. 环境准备与前置条件开始写代码前先确认本机环境。以下是一套通用检查清单版本号需要根据你的实际环境确认。3.1 Node.js 版本检查打开终端执行node -v npm -v如果你还没有安装 Node.js建议去官网下载当前 LTS 版本。如果本机同时装了多个版本可以用 nvm 管理nvm install 20 nvm use 20注意不同 Node.js 版本对fs.promises的支持程度不同。Node.js 12 以上支持fs.promises但如果你在用很老的项目先确认版本避免 API 不可用。3.2 初始化项目本文示例不需要复杂脚手架直接在空目录里初始化mkdir express-async-write-demo cd express-async-write-demo npm init -y如果要跑 Express 部分需要安装 Expressnpm install express如果只验证异步写入不依赖任何第三方包。文件写入和文件读取都走 Node.js 内置模块。3.3 准备写入目录建议在项目下创建logs、uploads、tmp三个目录分别用于存放日志、上传文件、临时文件mkdir logs uploads tmp如果你的代码里没有自动创建目录的逻辑记得先手动建好否则写入时会报ENOENT。4. 三种异步写入写法的完整实现下面用同一个需求来演示三种写法把一段字符串写入logs/demo.txt。我们先看最基础的回调写法。4.1 写法一回调风格 fs.writeFile这是 Node.js 最传统的写法const fs require(fs); const path require(path); const filePath path.join(__dirname, ../logs/demo.txt); const content hello, async write\n; fs.writeFile(filePath, content, utf8, (err) { if (err) { console.error(写入失败:, err); return; } console.log(写入成功); });回调写法的优点是兼容老版本 Node.js不需要任何语法糖。缺点是一旦多个异步操作串起来就会出现回调嵌套代码越写越深。如果只是追加一行日志可以用fs.appendFilefs.appendFile(filePath, append content\n, utf8, (err) { if (err) { console.error(追加失败:, err); return; } console.log(追加成功); });回调写法里最容易踩的坑是err回调里如果不做处理错误就直接丢了而且有可能触发进程崩溃。建议至少打印错误或者交给全局uncaughtException兜底。4.2 写法二Promise 风格 fs.promises.writeFile如果你不想嵌套回调直接用fs.promises的 APIconst fs require(fs); const path require(path); const filePath path.join(__dirname, ../logs/demo-promise.txt); const content hello, promise write\n; fs.promises.writeFile(filePath, content, utf8) .then(() { console.log(写入成功); }) .catch((err) { console.error(写入失败:, err); });追加一行则对应fs.promises.appendFile(filePath, append content\n, utf8) .then(() console.log(追加成功)) .catch((err) console.error(追加失败:, err));这种写法的好处是可以一路.then().catch()链式调用不用层层嵌套。但它也有问题——如果多个.then()连着写逻辑复杂时并不比回调好看多少。4.3 写法三async/await 风格这是目前最推荐、也是我日常用得最多的写法const fs require(fs); const path require(path); const filePath path.join(__dirname, ../logs/demo-async.txt); const content hello, async await write\n; async function writeDemo() { try { await fs.promises.writeFile(filePath, content, utf8); console.log(写入成功); } catch (err) { console.error(写入失败:, err); } } writeDemo();追加写法async function appendDemo() { try { await fs.promises.appendFile(filePath, append content\n, utf8); console.log(追加成功); } catch (err) { console.error(追加失败:, err); } }async/await 写法最大的优势是代码读起来和同步代码几乎一样但是不会阻塞事件循环。在 Express 接口里你可以直接把await放在路由处理函数中异常还能被try/catch统一捕获不会出现“回调忘了处理 err”的情况。4.4 三种写法对比小结对比维度回调Promiseasync/await代码可读性一般嵌套越多越差中等链式调用最好接近同步代码错误处理容易漏需要 catchtry/catch 直观推荐程度不推荐新代码使用可以接受推荐优先使用兼容性最老Node 10Node 8 支持12 稳定5. 在 Express.js 接口里做异步写入实践纸上谈兵没意思下面把异步写入放到 Express 中间件里做成一个真实的访问日志功能。5.1 创建一个最小 Express 服务先创建一个app.jsconst express require(express); const fs require(fs); const path require(path); const app express(); const PORT 3000; const LOG_FILE path.join(__dirname, logs, access.log); // 中间件把请求信息写入日志 app.use((req, res, next) { const logLine ${new Date().toISOString()} ${req.method} ${req.url}\n; fs.promises.appendFile(LOG_FILE, logLine, utf8) .catch((err) { console.error(日志写入失败:, err); }); next(); }); // 模拟一个业务接口 app.get(/api/user, (req, res) { res.json({ code: 0, data: { name: demo } }); }); app.listen(PORT, () { console.log(Server running at http://localhost:${PORT}); });运行node app.js然后打开浏览器访问http://localhost:3000/api/user再查看logs/access.log应该能看到类似内容2025-01-01T10:00:00.000Z GET /api/user这里有一个很关键的细节日志写入使用了fs.promises.appendFile并且用.catch兜底。为什么因为日志写入失败不应该影响主流程更不能让接口 500。如果我在中间件里使用await且没有 try/catch写文件失败时请求会直接报错。5.2 接口内等待写入完成的场景上面的日志是“写完就走”的场景。但如果你的业务要求“保存成功后再返回结果”那就必须在路由里awaitapp.post(/api/save, async (req, res) { const filePath path.join(__dirname, uploads, data-${Date.now()}.txt); try { await fs.promises.writeFile(filePath, JSON.stringify(req.body), utf8); res.json({ code: 0, message: 保存成功, filePath }); } catch (err) { res.status(500).json({ code: 500, message: 保存失败 }); } });注意这里要配合express.json()中间件解析请求体否则req.body是undefined。6. 批量异步写入与接口 API 场景实际项目中很少只写一个文件。你可能会遇到这些需求批量导出报表、把大量日志分批写入、把数组内容写入多个文件。这里给出两种常见的批量处理思路。6.1 使用 Promise.all 并发写入多个文件当你有多个独立文件要写入时可以用Promise.allconst fs require(fs); const path require(path); const tasks [ { name: a.txt, content: AAA\n }, { name: b.txt, content: BBB\n }, { name: c.txt, content: CCC\n }, ]; async function batchWrite(items) { await Promise.all(items.map((item) { const filePath path.join(__dirname, tmp, item.name); return fs.promises.writeFile(filePath, item.content, utf8); })); } batchWrite(tasks) .then(() console.log(批量写入完成)) .catch((err) console.error(批量写入失败:, err));这种方式适合文件数量不是特别大的场景。如果一次性写入几百上千个文件并发太大会导致文件描述符不够用需要限制并发数。6.2 批量追加到同一个文件如果需要把多行内容逐行追加到同一个文件最简单的做法是先把内容拼成一个字符串再一次性追加async function appendManyLines(lines) { const content lines.map((line) ${line}\n).join(); await fs.promises.appendFile( path.join(__dirname, logs, batch.log), content, utf8 ); }如果每行内容的生产是异步的那就需要控制顺序。一个简单的做法是for...of循环加awaitasync function appendSequentially(asyncLineTasks) { const logFile path.join(__dirname, logs, seq.log); for (const task of asyncLineTasks) { const line await task(); await fs.promises.appendFile(logFile, ${line}\n, utf8); } }这种串行方式会牺牲一些性能但能保证写入顺序。6.3 接口 API 场景里的批量任务如果你做了一个“批量导出”接口前端传一批 ID后端把每个 ID 对应的内容写入一个文件最后把所有文件路径返回给前端。推荐这样做app.post(/api/batch-export, async (req, res) { const ids req.body.ids || []; const results []; try { await Promise.all(ids.map(async (id) { const filePath path.join(__dirname, exports, ${id}-${Date.now()}.txt); // 这里模拟从数据库取数据 const content data for ${id}; await fs.promises.writeFile(filePath, content, utf8); results.push(filePath); })); res.json({ code: 0, results }); } catch (err) { res.status(500).json({ code: 500, message: 批量导出失败 }); } });注意如果ids数量很大要加并发限制不能直接Promise.all。下面是一个简单的并发控制示例async function mapLimit(items, limit, worker) { const results []; let index 0; async function next() { if (index items.length) return; const current items[index]; index 1; await worker(current); return next(); } const workers Array.from({ length: Math.min(limit, items.length) }, next); await Promise.all(workers); return results; }常见的第三方库p-limit也很好用但如果你不想引依赖上面的写法足够应对大多数场景。7. 性能观察为什么离不开异步写入很多初学者一开始会用fs.writeFileSync写文件因为它简单直接。但放在 Express 接口里同步写入会阻塞事件循环。7.1 同步写入的问题// 不推荐的写法 const fs require(fs); app.get(/api/sync-write, (req, res) { fs.writeFileSync( path.join(__dirname, logs, sync.log), content\n, utf8 ); res.json({ code: 0 }); });writeFileSync会阻塞当前进程。在并发请求多的场景下所有请求都会排队等待文件写入完成接口响应时间直线上升。7.2 如何观察性能表现你可以用 Node.js 自带的时间统计来做个简单对比const start Date.now(); fs.writeFileSync(path.join(__dirname, tmp, sync.txt), hello); console.log(同步写入耗时:, Date.now() - start, ms);再对比异步async function timing() { const start Date.now(); await fs.promises.writeFile(path.join(__dirname, tmp, async.txt), hello); console.log(异步写入耗时:, Date.now() - start, ms); }单次写入差距不大但高并发下差异明显。建议你用一个 100 行左右的循环分别跑同步和异步写入观察事件循环阻塞情况。7.3 写入量很大时改用流式写入fs.promises.writeFile适合中小文件。如果是超大文件、高频日志追加createWriteStream是更好的选择const fs require(fs); const path require(path); const stream fs.createWriteStream(path.join(__dirname, logs, stream.log), { flags: a }); function writeLog(line) { stream.write(${line}\n); } // Express 中使用 app.use((req, res, next) { writeLog(${req.method} ${req.url}); next(); });createWriteStream内部有缓冲区批量写入时性能更好。不过要注意进程退出前需要关闭流否则可能出现数据丢失。process.on(exit, () { stream.end(); });但单纯写日志还有一个更轻量的选择直接把日志打印到 stdout让外部日志系统收集减少应用内磁盘压力。这一点在容器部署场景里尤其重要。8. 常见问题与排查方法下面整理异步写入最常见的 5 类问题以及对应排查方式。问题现象可能原因排查方式解决方案写入时报 ENOENT目标目录不存在检查目录路径确认是否手动创建在写入前用fs.mkdir创建目录写入时报 EACCES没有目录写权限检查当前用户权限修改目录权限或换一个有权限的目录文件内容乱码编码参数不对检查writeFile第三个参数统一使用utf8请求返回 500 但日志没写中间件异步错误没捕获检查是否有.catch或 try/catch中间件里用.catch兜底多个接口并发写同一日志文件内容交错并发追加导致写入顺序混乱查看日志中相邻行是否有交叉用createWriteStream流式写入或在服务内部做队列8.1 写入前自动创建目录推荐在写入前加一段目录确保逻辑const fs require(fs); const path require(path); async function ensureDir(dirPath) { await fs.promises.mkdir(dirPath, { recursive: true }); } async function safeWrite(filePath, content) { const dir path.dirname(filePath); await ensureDir(dir); await fs.promises.writeFile(filePath, content, utf8); }{ recursive: true }允许递归创建多级目录不会因为目录已存在而报错。8.2 Node.js 版本相关问题如果你在安装 Node.js 或切换版本时遇到问题比如node -v显示版本过低安装依赖时提示node-gyp编译失败使用nvm install时目标版本不可用。先检查当前 npm 源和 Node 版本。不要强行使用过高或过低的版本推荐用当前 LTS。项目里可以用.nvmrc文件固定版本20然后执行nvm use这样就可以避免不同环境版本不一致。8.3 Promise 未捕获异常导致进程退出这是新手最容易踩的坑。下面这行代码如果写入失败且没有.catch可能直接抛出UnhandledPromiseRejectionfs.promises.writeFile(/dev/null/xxx, content);在某些 Node.js 版本中未处理的 Promise 拒绝会导致进程退出。所以每次异步写入都要保证有错误处理。9. 最佳实践与使用建议最后给出工程化建议下面的每一条都是从实际项目中总结出来的。第一新代码统一使用fs.promises配合 async/await。回调写法能不做就不做除非你在维护老项目。老项目的回调代码也不要大规模重写先在有问题的模块里局部替换。第二建立统一的文件写入工具模块。不要在路由里到处写fs.promises.writeFile而是封装成logger、fileStorage之类的模块。这样以后想切换成流式写入、加队列、接入云存储只需要改一个文件。第三写入路径必须校验。用户上传的文件名、接口传参不能直接拼接成路径。建议把文件存到一个固定的根目录下并使用path.basename或 UUID 重命名避免路径穿越风险。第四批量任务要加日志和失败重试。每一条写入任务都应该有独立的日志失败时区分“可重试错误”和“不可重试错误”例如ENOENT是目录问题EACCES是权限问题都应在重试前先排查根因。第五日志目录要轮转。如果用createWriteStream写日志文件会越来越大。建议使用logrotate或按天按大小拆分文件。没有轮转机制日志文件膨胀到几个 GB 后写入性能会肉眼可见下降。第六涉及用户数据、人脸、音频、文档等敏感内容时必须先确认授权再落盘。异步写入只是技术手段不代表可以随意处理用户文件。真实项目中需要做脱敏、加密、访问日志和定期清理。第七接口服务要限制访问范围。如果你把 Express 服务绑定到0.0.0.0:3000等于暴露所有接口。开发环境建议只监听127.0.0.1线上环境通过反向代理处理访问控制。第八发布或商用前要做效果复核。特别是批量导出、报表生成这类功能不能只看接口返回{ code: 0 }就认为成功要实际打开文件验证内容和完整性。10. 总结与下一步这次我们把 Node.js 异步写入的三种写法完整过了一遍并在 Express.js 里实现了访问日志中间件和批量写入接口。核心结论很明确新代码优先用fs.promises配合 async/await日志类需求推荐用createWriteStream流式写入所有异步写入都要有异常兜底。最值得先做的事是把第 5 节的日志中间件跑起来。这个例子虽然短但它覆盖了 Express 中间件、异步写入、异常捕获、不阻塞主流程这几个关键点。最容易踩的坑有两个一是中间件里异步写入失败导致进程崩溃二是大量并发写同一文件导致内容乱序。这两个问题在正式项目里几乎一定会遇到提前了解能少走很多弯路。下一步你可以继续深入的方向用p-limit或Bottleneck做并发控制优化批量写入把文件写入模块抽象出来接入数据库或云存储用morgan、winston等成熟日志库替换手写日志逻辑在 Express 路由里加上文件上传中间件multer配合异步写入做完整的上传落盘。建议收藏备用下次写 Node.js 文件操作时可以直接对照着看。
返回列表