在 Node.js 是怎样的 JavaScript 运行时? 中,我们知道 Node.js 为 JavaScript 提供了文件、网络和进程等宿主能力;在 Buffer 与字符编码 中,我们又知道文件读到 JavaScript 后首先表现为字节。

但下面这行代码把很多问题藏起来了:

const text = await readFile("./data/input.txt", "utf8");

这个路径相对于哪里?目标究竟是普通文件、目录还是符号链接?Node.js 什么时候打开文件?打开后由谁持有文件描述符?读取成功以前进程发生了错误,谁负责关闭它?

这篇文章沿着一条文件操作链回答这些问题:

path / file URL
  → 定位文件系统对象
  → open 得到 fd / FileHandle
  → read、write 或 Stream
  → complete / error / abort
  → close / destroy / cleanup

这篇文章把 Buffer 所表示的字节放回文件系统,并连接到 Stream 和 HTTP 中都会遇到的资源所有权问题。它补充文件系统的路径、句柄、写入一致性和资源清理,不重复 Buffer 的编码细节、Stream 的完整背压模型或 HTTP 的请求生命周期。

本文以 Node.js v24.16.0 的原生 ESM API 为基准。完整示例包含所需导入、输入和清理逻辑;用于解释单个 API 或生命周期边界的代码片段会说明依赖的前置条件。文中使用 ./data/input.txt 的片段假定该文件已经存在,示例不连接外部服务。

1. 同一个目标有四个层次,五种常见表现

先看一条最小关系:

"src/posts/nodejs/example.md"  ← 路径
  ↓ open()
FileHandle / fd               ← 当前打开实例
  ↓ read()
Buffer                         ← 字节容器
  ↓ decode / parse
字符串、Markdown 或业务对象   ← 应用数据

路径、文件对象、FileHandle、Buffer 与 Stream 的关系

这条链条可以归纳为四个层次;表格把其中常见的五种表现分别列出,最后一层既包括保存字节的 Buffer,也包括可能拥有底层句柄的 Stream。它们不能互换:

层次或表现 它表示什么 是否需要关闭
路径 path 文件系统命名空间中的定位信息 不需要
文件系统对象 文件、目录或符号链接等持久对象 不由 JavaScript 直接关闭
FileHandle / fd 当前进程的一次打开能力 由当前代码拥有时需要关闭
Buffer 一段内存中的字节 由垃圾回收管理内存
Stream 持续读取或写入的接口,可能拥有底层句柄 按所有权关闭或销毁

因此:

  • 路径存在,不代表文件仍然存在;
  • 文件存在,不代表当前程序已经打开它;
  • FileHandle 不等于文件本身;
  • 关闭句柄不等于删除文件;
  • Buffer 保存字节,但不携带“这些字节是什么文本”的结论;
  • Stream 的结束信号不一定等于底层资源已经关闭。

当前博客的文章读取代码可以作为真实例子。src/server/getPost.ts 使用 process.cwd() 找到 src/posts,递归读取目录,按文件名建立 slug 索引,最后使用 readFileSync(fullPath, "utf8") 读取 Markdown。

这个过程正好包含路径、目录项、文件内容和构建期资源边界,但它把打开和关闭封装在了同步 API 内部。

2. 路径:如何定位,而不是如何访问

2.1 process.cwd()、模块目录和项目目录

下面的路径示例统一使用这组目录:进程工作目录为 /workspace,模块文件为 /workspace/src/read-config.mjs。输出使用 POSIX 路径格式;Windows 会把分隔符显示为反斜杠。

路径本身通常只是字符串。下面的代码不会访问磁盘:

import path from "node:path";

const filePath = path.resolve("data/input.txt");
console.log(filePath);

输出为:

/workspace/data/input.txt

进程工作目录是启动命令的参照系。它取决于启动命令:

项目根目录启动
  → process.cwd() = /workspace

从 scripts 子目录启动
  → process.cwd() = /workspace/scripts

它不一定等于当前模块所在的目录。

在 ESM 中,资源相对于当前模块定位时,可以使用 import.meta.url:

import { fileURLToPath } from "node:url";
import path from "node:path";

const moduleFile = fileURLToPath(import.meta.url);
const moduleDirectory = path.dirname(moduleFile);
const configPath = path.join(moduleDirectory, "config.json");

console.log(import.meta.url);
console.log({ moduleFile, moduleDirectory, configPath });

这段代码位于 /workspace/src/read-config.mjs 时,输出为:

file:///workspace/src/read-config.mjs
{
  moduleFile: '/workspace/src/read-config.mjs',
  moduleDirectory: '/workspace/src',
  configPath: '/workspace/src/config.json'
}

import.meta.url 是模块加载时由 Node.js 写入的模块元数据,值是字符串;process.cwd() 是进程当前工作目录,每次读取时反映进程状态。两者都属于运行时概念,但参照系不同。执行 process.chdir() 后,工作目录会改变,模块 URL 保持不变:

因此,import.meta.url 不是“编译时路径”,process.cwd() 也不是唯一的“运行时路径”。前者绑定模块加载结果,后者绑定进程当前状态。打包器可以在构建阶段改写 import.meta.url,那属于打包工具的实现。

console.log({
  cwd: process.cwd(),
  moduleUrl: import.meta.url,
});

process.chdir("/tmp");

console.log({
  cwd: process.cwd(),
  moduleUrl: import.meta.url,
});

在 /workspace 启动这段代码,输出为:

{ cwd: '/workspace', moduleUrl: 'file:///workspace/src/read-config.mjs' }
{ cwd: '/tmp', moduleUrl: 'file:///workspace/src/read-config.mjs' }

也可以保留 file: URL:

const configUrl = new URL("./config.json", import.meta.url);
console.log(configUrl.href);

new URL() 的第一个参数是相对资源,第二个参数是基准 URL;./config.json 表示当前模块目录下的同名文件。configUrl 是 URL 对象,可以直接传给支持 file: URL 的文件 API。

此时 configUrl.href 为:

file:///workspace/src/config.json

Node.js 的许多文件 API 可以接受 file: URL。路径字符串和 URL 之间转换时使用 pathToFileURL() 与 fileURLToPath();手工拼接 URL 会在空格、中文、#、? 和 Windows 盘符出现时产生错误。

fileURLToPath() 适用于 file: URL。模块由 data: URL 或自定义 loader 提供其他协议时,import.meta.url 仍然是模块 URL,但它不再代表本地文件路径。

例如:

import { pathToFileURL } from "node:url";

console.log(pathToFileURL("/workspace/src/my file.txt").href);

输出为:

file:///workspace/src/my%20file.txt

2.2 join()、resolve() 和 normalize() 做的事情不同

import path from "node:path";

console.log(path.join("data", "posts", "a.md"));
console.log(path.resolve("data", "posts", "a.md"));
console.log(path.normalize("data/./posts/../posts/a.md"));
console.log(path.relative("/app", "/app/data/a.md"));
console.log(path.parse("/app/data/a.md"));

在 /workspace 作为当前工作目录时,输出为:

data/posts/a.md
/workspace/data/posts/a.md
data/posts/a.md
data/a.md
{
  root: '/',
  dir: '/app/data',
  base: 'a.md',
  ext: '.md',
  name: 'a'
}

可以这样理解:

API 作用
join() 拼接多个路径片段并规范化
resolve() 从右向左计算绝对路径
normalize() 清理 .、.. 和重复分隔符
relative() 计算两个位置之间的相对关系
parse() / format() 在字符串和结构化路径对象之间转换

resolve() 遇到绝对路径片段时会重置之前的片段:

console.log(path.resolve("/app", "data", "/tmp", "a.txt"));

输出为:

/tmp/a.txt

这不是字符串拼接。

这些函数都主要进行词法计算:

path.resolve(...)
  ≠ 文件存在
  ≠ 当前进程有权限
  ≠ 路径没有符号链接
  ≠ 路径一定位于安全目录

realpath() 不同,它会访问文件系统并解析符号链接:

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

const actualPath = await realpath("./data/input.txt");
console.log(actualPath);

当 /workspace/data/input.txt 存在且没有符号链接时,输出为:

/workspace/data/input.txt

相对路径、当前工作目录、模块目录和 file URL 的定位基准

2.3 POSIX、Windows 和 file: URL

路径分隔符、盘符、UNC 路径和大小写规则都可能随平台变化。需要处理明确平台格式时,可以使用:

import path from "node:path";

console.log(JSON.stringify(path.posix.join("a", "b")));
console.log(JSON.stringify(path.win32.join("a", "b")));

在 POSIX 系统上运行时,输出为:

"a/b"
"a\\b"

通用库不能假定:

  • 路径一定使用斜杠;
  • 根目录一定是斜杠;
  • 文件名一定区分大小写;
  • Windows 路径一定可以直接当 URL;
  • path.normalize() 已经完成安全检查。

Linux/macOS 上观察到的符号链接、删除和重命名行为,也不能自动推广到 Windows。

2.4 用户路径与目录边界

路径来自用户输入时,直接拼接会把未验证的输入带入目标路径:

import path from "node:path";

const uploadRoot = "/srv/uploads";
const userInput = "example.txt";
const target = path.join(uploadRoot, userInput);
console.log(target);

输出为:

/srv/uploads/example.txt

用户输入可能包含:

../secret.txt
绝对路径
编码后的 ..
符号链接
Windows 驱动器或 UNC 路径

基本的目录约束通常包含:

  1. 解析允许的根目录;
  2. 解析候选路径;
  3. 检查候选路径是否仍在根目录内;
  4. 对符号链接和不存在的目标分别处理;
  5. 对创建操作使用排他 flags,减少检查与使用之间的竞态。

normalize() 只能整理字符串,不能单独构成安全沙箱。真实路径检查也只是某个时刻的观察,仍要考虑 TOCTOU 和符号链接竞态。

3. 文件系统对象:文件、目录、链接和元数据

3.1 stat() 是快照,lstat() 关注链接本身

import { lstat, stat } from "node:fs/promises";

const followed = await stat("./data/link");
const link = await lstat("./data/link");

console.log("stat:", followed.isFile(), followed.isDirectory());
console.log("lstat:", link.isSymbolicLink());

如果 link 是符号链接:

  • stat() 通常跟随链接,报告目标对象;
  • lstat() 把符号链接本身当作对象;
  • 已经打开文件后,可以通过 FileHandle.stat() 查询当前句柄指向的对象;在回调式 node:fs API 中,也可以使用 fs.fstat(fd, callback)。

元数据包括:

  • 类型;
  • 大小;
  • 权限;
  • 所有者;
  • 修改时间;
  • 访问时间;
  • 某些系统中的设备和 inode 信息。

但元数据只是某一时刻的快照:

stat 成功
  → 其他进程替换文件
  → 后续操作面对的是新状态

所以不能把 stat() 成功理解成“后续操作已经获得永久保证”。

3.2 目录项和文件对象不是同一个概念

目录中保存的是名称到对象的映射。重命名主要改变这个映射:

old-name ─┐
          ├── 文件对象
new-name ─┘

删除路径通常先删除目录项。POSIX 系统中,已经打开的句柄可能仍然可以继续访问原对象;Windows 的共享和删除规则不同,同一操作可能得到不同结果。

因此要分开理解:

操作 作用
rename() 改变目录中的名称关系
unlink() 删除一个文件目录项
rm() 删除文件或递归删除目录
close() 释放当前打开实例
sync() 请求把句柄相关数据推进到更稳定的状态

这些操作相互之间没有自动替代关系。

3.3 目录遍历也是资源管理

一次性读取目录:

下面两段代码使用同一个 directory,展示一次性读取和逐项遍历的资源边界。

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

const directory = "./data";
const entries = await readdir(directory, {
  withFileTypes: true,
});

逐项遍历目录:

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

const directory = "./data";
const directoryHandle = await opendir(directory);
for await (const entry of directoryHandle) {
  console.log(entry.name, entry.isFile());
}

opendir() 返回的 fs.Dir 也有生命周期,但 Node.js 会在异步迭代器退出后自动关闭它。改用 directoryHandle.read() 手动读取时,关闭动作由调用方执行;异步迭代结束后再次关闭同一个目录句柄会产生重复清理。

当前项目的 getPost.ts 使用同步的 readdirSync(),调用返回后没有暴露长期目录句柄;测试则使用 mkdtempSync() 创建目录,并在 finally 中使用 rmSync() 清理。这是构建期和测试环境中的确定性资源管理。

4. 从路径到 fd 和 FileHandle

4.1 fd 是什么

fd 是 file descriptor 的缩写,中文叫文件描述符。

它是当前进程中代表一个已打开资源的数字。资源可以是:

  • 普通文件;
  • 目录;
  • 管道;
  • Socket;
  • 终端;
  • 设备;
  • 标准输入、标准输出和标准错误。

Node.js Promise API 通常返回 FileHandle:

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

const fileHandle = await open("./data/input.txt", "r");

console.log(fileHandle.fd);

try {
  const buffer = Buffer.alloc(32);
  const result = await fileHandle.read(
    buffer,
    0,
    buffer.length,
    0,
  );

  console.log(result.bytesRead);
  console.log(buffer.subarray(0, result.bytesRead).toString("utf8"));
} finally {
  await fileHandle.close();
}

关系是:

"./data/input.txt" → 打开时使用的路径
fileHandle          → Node.js 包装对象
fileHandle.fd       → 底层数字文件描述符

fd 的数值没有固定意义。一次运行可能得到 17,下一次可能得到 18,不能通过数字反推路径。

并不是每个 fd 都由当前代码创建。标准输入、标准输出、标准错误或调用方传入的描述符可能属于其他所有者(owner);借用它们时,关闭责任仍属于原所有者,除非接口明确转移了所有权。

4.2 readFile() 隐藏了什么

这行代码:

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

const filePath = "./data/input.txt";
const text = await readFile(filePath, "utf8");

可以从应用层近似理解为:

根据 path 定位
  → 打开
  → 读取全部内容
  → 按 utf8 解码
  → 关闭
  → 返回字符串

而下面的代码把所有权交给调用者。filePath 代表调用方传入的目标路径:

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

const filePath = "./data/input.txt";
let handle;
let primaryError;

try {
  handle = await open(filePath, "r");
  const text = await handle.readFile({ encoding: "utf8" });
  console.log(text);
} catch (error) {
  primaryError = error;
  throw error;
} finally {
  try {
    await handle?.close();
  } catch (cleanupError) {
    if (primaryError) {
      console.error("关闭失败,保留原始错误", cleanupError);
    } else {
      throw cleanupError;
    }
  }
}

使用 open() 时,生命周期包含这些责任:

  • 谁创建句柄;
  • 谁负责关闭;
  • 出错后是否仍然关闭;
  • 句柄是否交给了 Stream;
  • 是否允许多个操作共享它。

这是一个概念片段:假定 fileHandle 已由前面的 open() 示例创建,filePath 指向同一个文件。

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

await fileHandle.close(); // 释放当前打开实例
await unlink(filePath);   // 删除目录项

关闭句柄不会删除文件。删除目录项也不等于所有已经打开的句柄立刻失效。

这也是为什么“文件已经不存在”与“程序仍然可以读到已经打开的内容”在某些平台上可以同时出现。POSIX 与 Windows 的句柄共享规则不同,同一段代码的结果可能不同。

5. 生命周期与资源所有权

5.1 生命周期状态图

资源生命周期可以统一表示为:

未持有
  ↓ acquire / open
已打开
  ↓ read / write / stream
使用中 ───────────────┐
  ↓ complete           │ error / abort
已完成                 ↓
  ↓ flush / close   destroy / close / cleanup
已释放  ←──────────────┘

文件句柄从获取到使用、完成、错误取消和释放的状态关系

资源生命周期可以拆成五个问题:

  1. 谁创建它?
  2. 谁拥有关闭责任?
  3. 哪个信号表示正常完成?
  4. 错误或取消如何传播?
  5. 清理是否可以安全地重复执行?

5.2 try/finally 是基本结构

finally 能保证清理动作执行,但清理本身也可能失败。若业务操作和清理同时失败,直接在 finally 中抛出关闭错误会覆盖原始错误。下面保留主错误,并把清理错误记录下来:

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

const filePath = "./data/input.txt";
const consume = async (handle) => {
  // 省略解析、校验或业务处理
};

let handle;
let primaryError;

try {
  handle = await open(filePath, "r");
  await consume(handle);
} catch (error) {
  primaryError = error;
  console.error("读取失败", error);
} finally {
  try {
    await handle?.close();
  } catch (cleanupError) {
    if (primaryError) {
      console.error("关闭失败,保留原始错误", cleanupError);
    } else {
      throw cleanupError;
    }
  }
}

if (primaryError) {
  throw primaryError;
}

即使 consume() 在解析、校验或业务处理中失败,finally 仍然会执行。

对于临时资源,生命周期通常嵌套在一起:

创建临时目录
  → 创建临时文件
    → 打开 FileHandle
      → 写入
      → 关闭
    → 重命名或删除临时文件
  → 删除临时目录

清理代码通常做成幂等操作。例如使用 rm(..., { force: true }) 清理可能已经不存在的临时文件;清理失败时保留真正的业务错误。

目录、流和排他创建也沿用这个原则:业务错误和清理错误同时发生时,保留主错误并单独记录清理失败。

5.3 垃圾回收不等于关闭资源

V8 可以回收不可达的 JavaScript 对象,但这不是文件句柄、socket、watcher 或数据库连接的确定性关闭协议。

JavaScript 对象不可达
  ≠ FileHandle 已关闭
  ≠ Socket 已断开
  ≠ FSWatcher 已停止

Node.js 24 提供了 Symbol.asyncDispose 和 await using 等版本相关能力,可以在支持的环境中辅助自动清理。基础示例仍使用 try/finally,因为它直接表达了所有权,也更容易迁移到其他 Node.js 版本。

5.4 取消不会自动回滚

AbortSignal 只能取消支持它的操作,也不一定撤销已经完成的写入或业务副作用:

下面的代码展示取消信号如何沿着一次读取传播:

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

const filePath = "./data/input.txt";
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1000);

try {
  await readFile(filePath, {
    encoding: "utf8",
    signal: controller.signal,
  });
} catch (error) {
  if (error.name === "AbortError") {
    console.log("读取被取消");
  } else {
    throw error;
  }
} finally {
  clearTimeout(timeout);
}

如果取消发生在写入中间,程序仍需要决定:

  • 是否删除部分文件;
  • 是否保留失败结果;
  • 是否回滚业务状态;
  • 是否通知上游;
  • 是否允许重试。

取消是生命周期的一条分支,不是自动事务。

6. 文件流:把句柄交给谁

6.1 文件流的最小模型

大文件不适合一次性读入内存:

import {
  createReadStream,
  createWriteStream,
} from "node:fs";
import { pipeline } from "node:stream/promises";

const sourcePath = "./data/input.bin";
const targetPath = "./data/output.bin";

await pipeline(
  createReadStream(sourcePath),
  createWriteStream(targetPath, { flags: "wx" }),
);

这里发生了:

创建 ReadStream
  → 读取文件块
  → 交给 Writable
  → 写入目标文件
  → 正常结束或错误销毁

完整的背压机制会在后续关于 Stream 与背压的讨论中展开。本文只关注资源所有权:ReadStream 和 WriteStream 是否拥有底层 fd,以及谁负责关闭。

6.2 end、finish、close 和 destroy()

信号 含义
Readable 的 end 没有更多可读数据
Writable 的 finish end() 已调用,流协议中的写入已处理完
error 某个阶段发生失败
destroy() 提前终止流
close 流或底层资源已经关闭

finish 不是 close,end 也不是业务确认。文件写入流报告完成后,应用仍要根据需要处理关闭、持久化和正式文件替换。

6.3 autoClose 与所有权

文件流通常默认在结束或出错后关闭自己拥有的文件描述符。应用把已有 fd 交给 Stream 时,autoClose 决定关闭责任:

下面的所有权示例从调用方已经打开的 fileHandle 开始,consume 表示业务消费函数。 Node.js v24 可以直接从 FileHandle 创建 Stream;这里显式设置 autoClose: false,让调用方继续保留关闭责任。

async function consumeFile(fileHandle) {
  const stream = fileHandle.createReadStream({
    autoClose: false,
  });
  const consume = async (chunk) => {
    // 省略实际业务处理
  };
  let primaryError;

  try {
    for await (const chunk of stream) {
      await consume(chunk);
    }
  } catch (error) {
    primaryError = error;
    throw error;
  } finally {
    try {
      await fileHandle.close();
    } catch (cleanupError) {
      if (primaryError) {
        console.error("关闭句柄失败,保留原始错误", cleanupError);
      } else {
        throw cleanupError;
      }
    }
  }
}

这里调用方保留关闭责任。如果设置为 autoClose: true,则需要确认 Stream 和外部调用方不会同时管理同一个句柄。

资源所有权最好只有一个明确负责人:

路径创建的 Stream 自己 open
  → Stream autoClose

调用方先 open FileHandle
  → 调用方明确 close

事件名称只表示协议状态;资源是否已经释放由当前 Node.js 版本和 API 选项决定。

7. 写入一致性与 TOCTOU

7.1 先检查再使用存在竞态

下面这段代码看起来合理,但把 access() 和 readFile() 放在同一段代码中,正好暴露出检查与使用之间的时间窗口;access() 的成功不代表后续读取一定成功。

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

const filePath = "./data/input.txt";
await access(filePath); // 成功时 resolve undefined,只表示检查发生在此刻
await readFile(filePath);

但 access() 和 readFile() 之间有时间窗口:

T1: access() 判断存在
T2: 其他进程删除、替换或重命名
T3: readFile() 开始读取

这叫 TOCTOU:

Time Of Check To Time Of Use

更可靠的读取方式是直接执行并处理错误:

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

async function readIfPresent(filePath) {
  try {
    return await readFile(filePath, "utf8");
  } catch (error) {
    if (error.code === "ENOENT") {
      return null;
    }
    throw error;
  }
}

“文件不存在时才创建”使用底层排他标志:

这里的 open 接收 filePath,wx 将判断和创建交给文件系统的一次操作。

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

const filePath = "./data/input.txt";
const handle = await open(filePath, "wx");
let primaryError;

try {
  await handle.writeFile("created once", "utf8");
} catch (error) {
  primaryError = error;
  throw error;
} finally {
  try {
    await handle.close();
  } catch (cleanupError) {
    if (primaryError) {
      console.error("关闭句柄失败,保留原始错误", cleanupError);
    } else {
      throw cleanupError;
    }
  }
}

wx 让判断和创建由文件系统在一次操作中完成,避免应用层先检查再创建。

检查与使用之间的替换竞态,以及使用 wx 创建临时文件后原子替换正式文件

7.2 临时文件和原子替换

需要避免正式文件出现半写状态时,可以采用:

同一目录创建临时文件
  → 独占写入
  → sync
  → close
  → rename 到正式路径
  → 清理临时目录

示例:

import path from "node:path";
import { mkdtemp, open, rename, rm } from "node:fs/promises";

async function replaceFile(targetPath, content) {
  const directory = path.dirname(targetPath);
  const tempDirectory = await mkdtemp(
    path.join(directory, ".replace-"),
  );
  const tempPath = path.join(tempDirectory, "content");
  let handle;
  let operationError;
  let cleanupError;
  let committed = false;

  try {
    handle = await open(tempPath, "wx");
    await handle.writeFile(content, "utf8");
    await handle.sync();
    await handle.close();
    handle = undefined;

    await rename(tempPath, targetPath);
    committed = true;
  } catch (error) {
    operationError = error;
    throw error;
  } finally {
    try {
      await handle?.close();
    } catch (error) {
      cleanupError ??= error;
      if (operationError) {
        console.error("关闭临时句柄失败,保留原始错误", error);
      }
    }

    try {
      await rm(tempDirectory, {
        recursive: true,
        force: true,
      });
    } catch (error) {
      cleanupError ??= error;
      if (operationError) {
        console.error("清理临时目录失败,保留原始错误", error);
      }
    }

    if (!operationError && cleanupError) {
      if (committed) {
        console.error("文件已经替换成功,但清理临时目录失败", cleanupError);
      } else {
        throw cleanupError;
      }
    }
  }
}

这个流程改善了观察者看到的文件版本:

  • 正式路径在替换前仍然指向旧文件;
  • 替换成功后,正式路径指向完整的新文件;
  • 临时文件和目标文件在同一文件系统中时,rename() 通常具有原子可见性。

但它不自动保证:

  • 断电后所有数据已经持久化;
  • 目录项变化已经完成持久化;
  • 多个进程不会同时替换;
  • 业务数据库和文件系统同时提交。

更严格的耐久性设计还需要目录同步、版本校验、恢复策略和业务层协调。Redis 持久化文章已经详细讨论写入、页缓存和刷盘边界,这里只保留文件 API 的基本模型。

8. fs.watch():长期资源和变化提示

前面的读取操作通常在一次任务中完成,而 fs.watch() 代表另一类资源:它会在创建后持续占用一个观察者,直到显式 close()。它更像一个长期运行的事件源,而不是一次性的文件读取函数。

文件观察者从创建、接收变化到关闭的长期生命周期

8.1 fs.watch() 不等于文件内容

观察者报告的是“某个路径附近可能发生了变化”,不等于它已经提供了新的完整内容。一次事件可能只告诉你 change 或 rename,事件可能合并、重复、延迟,也可能没有可靠的 filename。收到事件后,应用通常还要重新读取目录或目标文件,并处理目标已经被删除、替换或移动的情况。

fs.watch() 还存在平台边界:不同平台的事件行为并不完全一致;NFS、SMB 和某些虚拟化文件系统上可能不可靠;Linux/macOS 主要观察 inode,删除后重新创建同一路径可能不会继续报告新对象的事件。因此,watcher 适合作为重新扫描的提示,不能当作跨平台、无遗漏的变更日志。

因此,常见的可靠流程是:

创建 watcher
  → 收到 change/rename 提示
  → 合并短时间内的重复事件
  → 重新扫描或读取
  → 应用最新状态
  → close watcher

下面的例子把“提示”和“事实读取”分成两步,并用短暂防抖避免一次保存触发多次扫描。关闭时先标记状态,避免已经开始的异步读取在 watcher 关闭后继续回调:

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

function watchJsonFile(filePath, { onValue, onError }) {
  let timer;
  let closed = false;
  const watcher = watch(filePath, (eventType) => {
    if (closed) {
      return;
    }
    clearTimeout(timer);
    timer = setTimeout(() => {
      void readAndPublish().catch((error) => {
        if (closed) {
          return;
        }
        try {
          onError(error);
        } catch (callbackError) {
          console.error("watcher 回调失败", callbackError);
        }
      });
    }, 50);

    console.log("filesystem hint:", eventType);
  });

  watcher.on("error", (error) => {
    if (closed) {
      return;
    }
    try {
      onError(error);
    } catch (callbackError) {
      console.error("watcher 回调失败", callbackError);
    }
  });

  async function readAndPublish() {
    let value;
    try {
      const text = await readFile(filePath, "utf8");
      value = JSON.parse(text);
    } catch (error) {
      if (error.code === "ENOENT") {
        value = undefined;
      } else {
        throw error;
      }
    }

    if (!closed) {
      onValue(value);
    }
  }

  function close() {
    if (closed) {
      return;
    }
    closed = true;
    clearTimeout(timer);
    watcher.close();
  }

  return close;
}

这里返回的是清理函数。组件卸载、开发服务器重载或进程关闭时调用它;否则每次重新加载都可能多注册一个观察者。

8.2 用 AbortSignal 结束异步观察

node:fs/promises 的 watch() 返回异步可迭代对象,适合在一个任务中持续消费事件。AbortSignal 可以结束这个循环,但它只负责发出取消信号;循环退出后,finally 会释放相关状态:

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

function consumeChanges(directory) {
  // 每个并行观察任务都拥有自己的 controller。
  const controller = new AbortController();
  const task = (async () => {
    const changes = watch(directory, {
      signal: controller.signal,
      recursive: false,
    });

    try {
      for await (const event of changes) {
        console.log(event.eventType, event.filename);
      }
    } catch (error) {
      if (error.name !== "AbortError") {
        throw error;
      }
    } finally {
      controller.abort();
    }
  })();

  return {
    task,
    close: () => controller.abort(),
  };
}

const { task, close } = consumeChanges("./content");

// 例如收到服务关闭信号时:
close();
await task;

AbortSignal 解决的是“停止等待和消费事件”,不会撤销已经发生的文件写入,也不会替调用方关闭其他独立的 FileHandle、Socket 或数据库连接。取消传播需要沿着任务边界显式设计。

9. 错误、性能与测试边界

9.1 先按错误语义分类

文件与流相关的错误不是一个可以统一重试的类别。错误代码通常说明失败发生在哪个资源或阶段:

错误 常见含义 处理方向
ENOENT 路径中的目标或父目录不存在 检查路径来源,或按业务决定是否创建
ENOTDIR 路径中间的一段不是目录 修正拼接逻辑,避免把文件当目录继续解析
EISDIR 对目录执行只适用于普通文件的操作 先确认资源类型
EACCES / EPERM 权限或系统策略拒绝 检查运行用户、权限和平台约束
EEXIST 使用 wx 等排他创建时目标已存在 把它作为并发或重复请求的正常分支
EMFILE / ENFILE 当前进程或系统文件描述符耗尽 关闭泄漏的句柄,限制并发,检查资源池
ENOSPC 空间或 inode 不足 清理空间,停止无效重试,向上层报告
EXDEV rename() 跨文件系统 改用同一文件系统的临时目录,或采用复制加校验方案
ERR_STREAM_PREMATURE_CLOSE 流在预期完成前关闭 检查上游错误、取消信号和销毁顺序

是否重试要结合操作是否幂等。读取一个暂时忙碌的资源可以有限重试;创建、追加或替换文件则需要确认重复执行不会产生重复内容或覆盖错误版本。

9.2 性能优化不能牺牲所有权

readFile() 适合内容较小、需要一次拿到完整 Buffer 或字符串的场景。大文件、持续传输或需要限制内存占用时,使用流并通过 pipeline() 连接上下游。同步 API 会阻塞事件循环,适合启动阶段少量配置读取,不适合请求处理路径中的大文件操作。

异步文件系统操作通常会进入 libuv 的 Worker Pool。提高并发不等于提高吞吐:过多并发可能耗尽文件描述符、增加磁盘寻道和排队时间,甚至让应用同时面对 EMFILE 和内存压力。并发上限取决于文件大小、磁盘类型、并发请求和失败恢复成本。

写入流还需要处理背压。write() 返回 false 后继续无条件写入,会让数据堆积在用户态内存;等待 drain,或使用 pipeline(),可以把背压、错误和结束传播交给统一的生命周期管理。

长期 watcher、FileHandle 和流都属于需要明确所有者的资源。性能指标除了吞吐和延迟,还包括打开句柄数量、未关闭 watcher 数量、临时文件数量和异常清理次数。

9.3 用临时目录覆盖成功和失败路径

文件测试不依赖项目目录里的固定文件,也不把测试运行顺序当作清理机制。常见模式是为每个测试创建临时目录,在 finally 中无条件清理:

import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";

const directory = await mkdtemp(path.join(os.tmpdir(), "file-test-"));

try {
  const filePath = path.join(directory, "value.txt");
  await writeFile(filePath, "hello", "utf8");
  const value = await readFile(filePath, "utf8");
  console.assert(value === "hello");
} finally {
  await rm(directory, { recursive: true, force: true });
}

测试同时覆盖目标不存在、目标是目录、排他创建冲突、写入中途失败、取消信号触发和 close() 重复调用等边界。测试的重点不是重复 Node.js 的实现,而是验证自己的代码在每种结果下都释放自己拥有的资源。

10. 回到 web-knowledge 项目

这个博客的文章读取器把本章的概念串在一条真实链路上。src/server/getPost.ts 先用 process.cwd() 定位 src/posts,再递归读取目录项,用 basename()、extname() 和 relative() 建立文件名与诊断信息,最后用 readFileSync() 读取正文。

页面的 getStaticProps 再调用 markdownToArticle() 解析 Markdown。路径、目录项、文件内容和构建期资源边界分别落在这些步骤中;同步 API 的打开和关闭仍由 Node.js 封装。

这条链路中可以看到四个边界:

  1. 路径字符串只负责定位候选文件,不代表文件一定存在或类型正确;
  2. Dirent、stat() 和 readFile() 分别回答目录项、元数据和内容问题;
  3. 同步 API 简化了构建期代码,但它仍然会阻塞当前线程;
  4. 递归扫描中的读取错误、重复 slug 和无法解析的 front matter 可能在构建阶段暴露。字段类型和日期问题由读取器或下游生成器分别处理,浏览器请求时不再承担目录扫描。

本篇只把文件、路径和资源所有权讲到可以支撑项目维护的程度。相关背景可以接到已有文章:

后续关于 Stream 与背压的讨论会展开流的完成、背压、pipeline() 和销毁语义;关于 HTTP 请求生命周期的讨论会把同样的所有权和取消问题延伸到 request、response 与 socket。

要理解“写入成功”和“数据已经持久化”之间的差别,可以接着阅读 Redis 持久化原理:RDB、AOF、数据恢复与生产实践。

这些边界最后都可以归结为三件事:路径只负责定位,句柄代表一次打开,清理责任必须有明确的所有者。下面这张表把常见混淆集中列出:

看到的对象 先确认的问题 常见错误
路径字符串 它相对哪个基准?输入是否可信? 以为 join() 会验证目标存在
URL / file URL 它如何转换为平台路径? 手工截取 URL 字符串
Dirent / stat 结果 我需要类型、大小还是时间戳? 用元数据(metadata)代替真正读取
FileHandle / fd 谁拥有它?何时关闭? 交给流后又由两方重复关闭
Buffer 数据是字节还是已解码文本? 未确认 encoding 就拼接字符串
Stream 谁负责完成、背压和错误传播? 只监听 data,忽略 error 和 close
watcher 事件是提示还是最终事实? 收到一次 change 就直接假设内容已稳定
临时文件 替换是否原子?断电后是否耐久? 把 rename() 当成完整事务提交

参考资料