在现代 Node.js 中,文件 I/O 的首选方案是基于 Promise 的 node:fs/promises 模块配合 async/await;当文件较大或大小未知时,应使用流(node:stream/promises 中的 pipeline);当需要字节级的位置控制时,则使用文件句柄(open/read/close)。fs 模块目前仍提供三套并行 API——同步、回调和 Promise——而大多数旧教程以回调 API 和 CommonJS require() 为主。这种写法已经过时:对于在当前运行时上编写的新代码,node:fs/promises 配合 ES 模块 import 才是正确的起点。
本文将展示如何使用这套现代 API 来读取、写入和处理文件,内容涵盖:编码行为、覆盖写入语义、基于 code 的错误处理、Promise.all 与 Promise.allSettled 的并发对比、导致 readFile 失败的内存限制、用于处理大文件的流与文件句柄、目录操作,以及路径遍历防护。示例面向 Node 24——即截至 2026 年的 Active LTS 版本(Node.js 发布时间表)。文中所有特性均远低于该基线要求,因此代码可在 Node 24 上不做任何修改地运行,且所有示例均使用带 node: 前缀的 ESM import 和顶层 await。
核心要点
- 以
node:fs/promises配合async/await作为文件 I/O 的默认方案;readFileSync等同步方法会阻塞事件循环,仅适用于一次性 CLI 脚本,绝不应出现在服务器代码中。 readFile会将整个文件缓冲到内存中,对于任何超过 2 GiB 的文件都会抛出ERR_FS_FILE_TOO_LARGE——这是 libuv 的固定 I/O 限制,与 Buffer 和字符串长度上限无关——因此文件超过几百 MiB 时就应该改用流处理。node:stream/promises中的pipeline()(自 Node 15 起稳定可用)可连接多个流,并自动处理错误传播和清理工作。- 当所有文件都必须成功时使用
Promise.all;当允许部分失败且希望处理成功结果时使用Promise.allSettled。 - 永远不要将未经验证的用户输入直接传递给
fs函数:应使用path.resolve解析路径,并验证其位于预期的基础目录内,以防止路径遍历攻击。
三套 fs API,以及为何 fs/promises 是默认选择
Node.js 通过三套 API 暴露相同的文件操作:同步(readFileSync)、回调(readFile(path, cb))和 Promise(node:fs/promises)。Promise API 在 Node 14.0.0 中正式稳定(Node.js 14 发布说明),是现代默认选择,因为它与 async/await 无缝集成,且永远不会阻塞事件循环。回调 API 属于遗留方案——它早于 Promise 出现,会导致深层嵌套代码——而同步 API 在整个 I/O 期间都会阻塞事件循环。新代码请使用 Promise API。
import { readFile } from 'node:fs/promises'
const data = await readFile('config.json', 'utf8')
console.log(data.length)
node: 前缀将该导入标记为 Node.js 内置模块,不会被同名 npm 包遮蔽;它明确表明该模块是 Node.js 核心模块。本示例还使用了顶层 await,自 Node 14.8.0 起,ES 模块中无需任何标志即可使用该特性(Node.js 14.8.0 发布说明)。在模块作用域中读取文件,不再需要将其包裹在 async IIFE 中。
应该使用哪套 API?
选择取决于文件大小、所需的访问类型,以及进程是否需要处理并发任务。以下表格可作为决策依据:
| 场景 | 推荐 API | 原因 |
|---|---|---|
| 小文件,大约 100 MB 以下 | node:fs/promises 的 readFile/writeFile | API 最简洁;整个文件可轻松放入内存,一个 await 即可完成 |
| 大文件或大小未知的文件 | 流:createReadStream/createWriteStream 配合 pipeline | 内存占用恒定,与文件大小无关,且完全避开 readFile 的 2 GiB 上限 |
| 字节级或位置访问(读写特定偏移量) | 文件句柄:open/read/close | 只有句柄 API 允许在指定位置读写特定字节范围 |
| 一次性 CLI 脚本和构建工具(无并发) | 同步方法可接受:readFileSync/writeFileSync | 当没有其他任务运行时,阻塞事件循环无害 |
| HTTP 服务器或任何并发代码 | 仅使用异步 Promise API,绝不使用同步方法 | 阻塞事件循环会同时挂起所有待处理请求 |
约 100 MB 是何时切换到流的经验法则,而非硬性限制;readFile 的硬性失败点是 libuv 的 2 GiB 上限。在 fs/promises 和流之间拿不定主意时,对于任何大小不受控制的文件,流都是更安全的默认选择。
使用 fs/promises 读取文件
Discover how at OpenReplay.com.
readFile() 将整个文件加载到内存中,返回字符串或 Buffer。传入 'utf8' 等编码可获得解码后的字符串;省略编码则以 Buffer 形式获得原始字节。对于配置文件和数据文件,以字符串形式读取后再解析:
import { readFile } from 'node:fs/promises'
const raw = await readFile('config.json', 'utf8')
const config = JSON.parse(raw)
console.log(config.port)
不指定编码时,返回值为 Buffer——这是处理图片、音频或任何非文本内容的正确选择:
import { readFile } from 'node:fs/promises'
const bytes = await readFile('logo.png') // Buffer
console.log(bytes.length, 'bytes')
JSON.parse 在输入格式错误时会抛出 SyntaxError,因此在解析外层包裹 try/catch 可同时处理 I/O 失败和 JSON 格式错误。完整的选项说明请参阅 fs.readFile 文档。
使用 fs/promises 写入文件
writeFile() 在文件不存在时创建文件,存在时则完全覆盖——从调用者的角度来看,一个 await 就能替换整个文件内容。若要追加内容而非替换,使用 appendFile(),它在文件不存在时也会创建文件。若要将文件截断到固定长度,使用 truncate()。
import { writeFile, appendFile } from 'node:fs/promises'
const user = { name: 'Ada', email: 'ada@example.com' }
// 创建 user.json 或完全替换其内容
await writeFile('user.json', JSON.stringify(user, null, 2), 'utf8')
// 追加一行而不重写文件;文件不存在时自动创建
await appendFile('events.log', `${new Date().toISOString()} user-created\n`, 'utf8')
truncate(path, n) 保留文件的前 n 个字节并丢弃其余内容——参数是要保留的字节数,而非要删除的字节数,这与函数名所暗示的含义相反。将 1234567890 截断为 5 后,剩余内容为 12345:
import { writeFile, truncate, readFile } from 'node:fs/promises'
await writeFile('data.txt', '1234567890')
await truncate('data.txt', 5)
console.log(await readFile('data.txt', 'utf8')) // '12345'
错误处理:通过 error.code 捕获错误
文件操作失败时会产生携带 code 属性的系统错误;应根据 error.code 进行分支处理,而不是解析错误消息。最常见的错误码记录在 Node.js 常见系统错误参考文档中:
error.code | 含义 |
|---|---|
ENOENT | 文件或目录不存在 |
EACCES | 权限被拒绝 |
EISDIR | 尝试将目录作为文件读取 |
ENOSPC | 设备空间不足(磁盘已满) |
EMFILE | 打开的文件描述符过多 |
一个 try/catch 即可同时覆盖 I/O 失败和 JSON 解析失败两种情况:
import { readFile } from 'node:fs/promises'
try {
const config = JSON.parse(await readFile('config.json', 'utf8'))
console.log('loaded', config)
} catch (error) {
if (error.code === 'ENOENT') {
console.error('config.json is missing; using defaults')
} else if (error.code === 'EACCES') {
console.error('no permission to read config.json')
} else {
throw error // 包括 JSON.parse 抛出的 SyntaxError 和未预期的错误码
}
}
对于未明确处理的错误码,应重新抛出而非静默吞掉——在生产环境中,静默捕获 ENOSPC 或 EMFILE 错误是一种常见的故障模式,会掩盖真实原因。
同步与异步:何时可以使用同步方法
同步 fs 方法(readFileSync、writeFileSync)在整个 I/O 操作期间会阻塞 Node.js 事件循环——期间不会执行任何其他 JavaScript 代码。在没有并发需求的一次性 CLI 脚本和构建工具中,这是可以接受的;但绝不能用于 HTTP 服务器或任何处理并发请求的代码,因为阻塞事件循环会同时挂起所有待处理请求(Node.js 事件循环指南)。
// 可接受:一次性脚本,运行后退出
import { readFileSync } from 'node:fs'
const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
console.log(pkg.version)
决策规则:如果进程同时处理多个任务,请使用异步 Promise API。
并发:Promise.all 与 Promise.allSettled
当所有文件读取都必须成功、任何一个失败都应中止整批操作时,使用 Promise.all;当允许部分失败、希望处理成功结果时,使用 Promise.allSettled。Promise.allSettled 始终会 resolve,返回一个数组,其中每个元素为 { status: 'fulfilled', value } 或 { status: 'rejected', reason } 之一。
在循环中将文件名映射为 Promise 而不在循环内 await,可实现并发读取:
import { readFile } from 'node:fs/promises'
const files = ['a.json', 'b.json', 'c.json']
// 全部成功或全部失败:任何一个文件缺失都会导致整批操作 reject
const all = await Promise.all(
files.map((f) => readFile(f, 'utf8').then(JSON.parse)),
)
如果希望加载所有存在的文件并报告失败情况,可检查 settled 结果:
import { readFile } from 'node:fs/promises'
const files = ['a.json', 'b.json', 'missing.json']
const results = await Promise.allSettled(
files.map((f) => readFile(f, 'utf8')),
)
const loaded = results.filter((r) => r.status === 'fulfilled').map((r) => r.value)
const failed = results
.filter((r) => r.status === 'rejected')
.map((r) => r.reason.code) // 例如 'ENOENT'
console.log(`loaded ${loaded.length}, failed:`, failed)
fs Promise 与大文件:导致 readFile 失败的内存限制
readFile 会将整个文件缓冲到内存中,对于任何超过 2 GiB 的文件都会抛出 ERR_FS_FILE_TOO_LARGE——这是 libuv 的固定 I/O 限制,而非 Buffer 大小限制(node#55864、ERR_FS_FILE_TOO_LARGE)。远在触及这个硬性上限之前,将数百兆字节加载到单个 buffer 中就会造成内存压力和响应变慢,因此文件超过几百 MiB 时就应该改用流处理。
以下三个不同的上限容易混淆,大多数教程也将它们混为一谈:
| 限制 | 值(64 位) | 适用范围 |
|---|---|---|
| libuv 文件读取上限 | 2 GiB | 任何文件的 readFile;抛出 ERR_FS_FILE_TOO_LARGE |
buffer.constants.MAX_STRING_LENGTH | 536,870,888 字节(约 512 MiB) | 字符串,即带编码的 readFile |
buffer.constants.MAX_LENGTH | 2⁵³−1 字节(约 8 PiB) | 最大 Buffer 分配量 |
当向 readFile 传入编码(返回字符串)时,实际生效的上限是 buffer.constants.MAX_STRING_LENGTH——在 64 位平台上为 536,870,888 字节,自 Node 14.4.0 起从约 1 GB 降低至此(node#33960)。超出此限制会抛出”Cannot create a string longer than…”错误,而非 ERR_FS_FILE_TOO_LARGE。buffer.constants.MAX_LENGTH 是最大 Buffer 分配量,是另一个独立的限制;它不约束 readFile,后者无论如何都会在 2 GiB 时失败。实践结论:不要从 Buffer 上限来推断 readFile 的行为——libuv 的 2 GiB 限制才是最先触发的那个。
流:createReadStream、createWriteStream 与 pipeline
流以分块方式处理文件数据,而非将整个文件缓冲到内存中,因此是处理大文件或大小未知文件的正确工具。createReadStream 和 createWriteStream 分别产生可读流和可写流;使用 node:stream/promises 中的 pipeline 将它们连接起来,该函数自 Node 15 起稳定可用。pipeline() 将可读流连接到可写流,并自动处理错误传播和流的清理工作。虽然 pipe() 已能管理背压,但 pipeline() 提供了更安全的错误处理和清理机制。
import { createReadStream, createWriteStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'
// 以恒定内存复制任意大小的文件
await pipeline(
createReadStream('huge-input.log'),
createWriteStream('huge-output.log'),
)
由于 pipeline() 返回一个 Promise,该 Promise 在任何流发生错误时 reject,并在失败时销毁相关流,因此一个 try/catch 就已足够——无需手动绑定 'error' 监听器。
背压是防止快速读取端压垮慢速写入端的机制:当可写流的内部缓冲区填满时,可读流会暂停,直到缓冲区排空。缓冲区阈值由 highWaterMark 决定。文件读取流的默认 highWaterMark 为 64 KiB,与通用 stream.Readable 的 16 KiB 默认值不同;fs 文档明确将 64 KiB 标注为文件流的默认值。当性能分析表明有必要时,可对其进行调整:
import { createReadStream } from 'node:fs'
const stream = createReadStream('data.bin', { highWaterMark: 128 * 1024 })
对于 CSV 等行分隔格式,应将读取流通过 csv-parser 等解析器处理,而非手动解析。
文件句柄:字节级和位置访问
当需要在特定偏移量处读写特定字节范围时——这是 readFile 和流都不支持的操作——应使用 open() 获取文件句柄。FileHandle 是底层资源:需要自行管理缓冲区和位置,且必须始终在 finally 块中通过 close() 释放句柄,否则描述符会泄漏(泄漏积累到一定程度会产生 EMFILE)。
import { open } from 'node:fs/promises'
async function readInChunks(path) {
let handle
try {
handle = await open(path, 'r')
const chunkSize = 64 * 1024
const buffer = Buffer.alloc(chunkSize)
let position = 0
while (true) {
const { bytesRead } = await handle.read(buffer, 0, chunkSize, position)
if (bytesRead === 0) break
// 处理 buffer.subarray(0, bytesRead)
position += bytesRead
}
} finally {
await handle?.close()
}
}
handle.read(buffer, offset, length, position) 从文件的指定 position 开始填充 buffer,并报告 bytesRead;手动追踪 position 正是实现位置访问的关键。对于大多数复制和转换操作,流是更好的工具——只有在真正需要字节级控制时,文件句柄的冗长写法才是值得的。
AbortSignal 受 readFile 和 fs.watch 支持,因此可以取消慢速读取——例如在请求超时时。被中止的读取会以 name 为 'AbortError' 的错误 reject:
import { readFile } from 'node:fs/promises'
const controller = new AbortController()
setTimeout(() => controller.abort(), 1000)
try {
await readFile('slow-source.bin', { signal: controller.signal })
} catch (error) {
if (error.name === 'AbortError') console.error('read cancelled')
else throw error
}
目录与路径
使用 mkdir({ recursive: true }) 创建嵌套目录(若已存在则不报错,类似 mkdir -p),使用 readdir({ withFileTypes: true }) 列出条目以获取 Dirent 对象,使用 rm({ recursive: true }) 删除目录树——以上均来自同一个 node:fs/promises 模块。使用 path.join 构建路径,以确保路径分隔符在各操作系统上均正确。
import { mkdir, readdir, rm } from 'node:fs/promises'
import { join } from 'node:path'
await mkdir('output/reports', { recursive: true })
const entries = await readdir('output', { withFileTypes: true })
for (const entry of entries) {
const full = join('output', entry.name)
if (entry.isFile()) console.log('file:', full)
else if (entry.isDirectory()) console.log('dir: ', full)
}
await rm('output/reports', { recursive: true, force: true })
遍历数组时使用 for...of,而非 for...in——对数组使用 for...in 会产生字符串索引而非值,这是旧版教程中的常见错误。rm(配合 recursive: true)是当前删除目录树的推荐方式;带 recursive 选项的 rmdir 已被废弃。
若要相对于当前模块而非进程工作目录来引用文件,使用 import.meta.dirname。该属性自 Node 20.11.0 起可用(import.meta.dirname),为 ESM 模块提供了与 CommonJS 中 __dirname 相同的当前目录引用:
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
const config = await readFile(join(import.meta.dirname, 'config.json'), 'utf8')
权限和所有权函数(chmod、chown)以及链接函数(symlink、link)在 Promise API 中也存在,但它们适用于类 Unix 系统,在 Windows 上行为异常或会报错;应将其视为平台特定功能。
安全:防止用户输入导致的路径遍历
永远不要将用户提供的字符串直接传递给 readFile、writeFile 或任何 fs 函数。在继续操作之前,应使用 path.resolve 规范化路径,并验证其以预期的基础目录开头——否则,原始的 ../../../etc/passwd 输入将从任何你试图约束的相对路径中向上遍历。简单的 join(baseDir, userInput) 无法提供保护,因为 .. 片段会向上解析。
import { resolve, sep } from 'node:path'
import { readFile } from 'node:fs/promises'
const baseDir = resolve('uploads')
async function readUserFile(userPath) {
const target = resolve(baseDir, userPath)
if (target !== baseDir && !target.startsWith(baseDir + sep)) {
throw new Error('path traversal blocked')
}
return readFile(target, 'utf8')
}
先解析路径,再对照 baseDir + sep 检查前缀,无论输入包含多少 .. 片段,都能将请求限制在允许的目录内。+ sep 的防护措施可防止 uploads-private 这样的同级目录通过简单的 startsWith('uploads') 检查。
结语
对于新的 Node.js 代码,从 node:fs/promises 和 async/await 开始;当文件超过几百兆字节或大小未知时,升级到配合 pipeline() 的流处理;只有在需要字节级位置访问时,才降级使用文件句柄。将每个操作与其实际约束相匹配——内存上限、并发性和不可信输入是最容易出问题的三个方面——需要了解完整选项时,请参阅官方 fs 文档。下一步是审查服务器中所有使用回调或同步 fs 的现有代码,并将其迁移到 Promise API。
常见问题
Node.js 中 fs 和 fs/promises 有什么区别?
两者指向相同的文件操作,但暴露的接口不同。基础 node:fs 模块提供同步方法(readFileSync)和基于回调的异步方法(带回调参数的 readFile),而 node:fs/promises 提供相同操作的 Promise 版本,可与 async/await 配合使用。Promise API 在 Node 14.0.0 中正式稳定,是新代码的推荐默认选择,因为它避免了回调嵌套,且永远不会阻塞事件循环。
使用 fs/promises 需要 ES 模块,还是也可以用 require?
两者均可。在 ES 模块中,写 import { readFile } from 'node:fs/promises'。在 CommonJS 中,写 const { readFile } = require('node:fs/promises')。Promise API 本身不要求使用 ESM;只有顶层 await 和 import.meta.dirname 需要 ES 模块上下文。node: 前缀在两种格式中均有效,可将导入标记为 Node.js 内置模块,防止被任何 npm 包遮蔽。
为什么 readFile 在达到 Buffer 大小限制之前就抛出 ERR_FS_FILE_TOO_LARGE?
ERR_FS_FILE_TOO_LARGE 是单次读取操作 2 GiB 的固定 libuv I/O 上限,与 Buffer 分配限制无关。即使 buffer.constants.MAX_LENGTH 远高于此,稍微超过 2 GiB 的文件也会失败,因为底层读取系统调用路径强制执行 2 GiB 上限,与 Buffer 能持有多少内存无关。若要处理更大的文件,请使用 pipeline 配合流,而非缓冲整个文件。
如何在特定字节偏移量处读取文件的一部分?
使用 node:fs/promises 中的 open() 打开文件以获取 FileHandle,然后调用 handle.read(buffer, offset, length, position),该方法从指定的文件位置开始填充 buffer,并报告 bytesRead。跨多次读取手动追踪 position,即可在文件中移动。务必在 finally 块中通过 handle.close() 释放句柄,否则描述符会泄漏,积累足够多的泄漏会产生 EMFILE。普通的 readFile 和流不支持位置访问。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k