04 fs 回调、Promise 与同步 API

1. 三种风格

回调:

import { readFile } from "node:fs";

readFile("package.json", "utf8", (error, content) => {
  if (error !== null) {
    console.error(error);
    return;
  }

  console.log(content);
});

Promise:

import { readFile } from "node:fs/promises";

const content = await readFile("package.json", "utf8");

同步:

import { readFileSync } from "node:fs";

const content = readFileSync("package.json", "utf8");

2. 项目中的选择

请求处理和后台任务优先 Promise/Stream。同步 API 会阻塞当前 Node.js 事件循环:读取尚未结束时,这个进程不能继续处理其他 JavaScript 请求。

同步 API 可谨慎用于程序启动阶段的小型、可信配置,但仍要处理异常。不要在 Express 路由中使用 readFileSync() 读取上传文件。

3. Buffer 与字符串

不传编码时:

const bytes: Buffer = await readFile(filePath);

传入 UTF-8 时:

const text: string = await readFile(filePath, "utf8");

ZIP、图片、PDF 是二进制数据,不应该先转成 UTF-8 字符串。转换可能产生替换字符并破坏原始字节。

4. 错误处理

async function loadText(filePath: string): Promise<string> {
  try {
    return await readFile(filePath, "utf8");
  } catch (error: unknown) {
    if (
      typeof error === "object" &&
      error !== null &&
      "code" in error &&
      error.code === "ENOENT"
    ) {
      throw new Error("指定文本不存在", { cause: error });
    }

    throw error;
  }
}

不要捕获后返回空字符串:

catch {
  return "";
}

它会把“文件不存在”伪装成“空文件”。

5. readFile() 的边界

readFile() 会尝试把完整文件读入内存,适合有明确大小上限的小文本。例如单个 Markdown 已限制为 2 MiB。它不适合数 GB 的 FASTQ、BAM 或 ZIP。

读取前 stat() 可以提供早期拒绝,但文件可能在 stat() 后变化,因此仍需限制上游上传大小、处理并发和实际读取错误。

实操

编写三个版本读取同一文件,并在读取期间用另一个 HTTP 请求观察同步版本的阻塞。然后读取 ZIP:一次以 Buffer,一次错误地使用 UTF-8,对比长度和是否还能被 ZIP 工具识别。

练习题

  1. 什么场景可以接受同步读取?
  2. 为什么 Buffer.toString("utf8") 不适合任意二进制文件?
  3. readFile() 已经异步,为什么仍可能撑爆内存?
  4. 为什么不能把所有读取错误都转换成空字符串?