Node.js 虚拟文件系统(VFS)之我见
Node.js 与真实文件系统耦合过紧。引入虚拟文件系统层,可以更好地支持测试、沙箱隔离和边缘部署。

试着在没有真实文件系统的环境里运行一个 Node.js 应用,你很快就会发现它会全面崩溃。require() 会读文件,fs.readFile() 会读文件,模板引擎会读文件,配置加载器会读文件,日志库会写文件。整个 Node.js 生态都默认存在一个类 POSIX 的文件系统,而且它可写,并且是访问代码和数据的主要方式。
这在 2010 年、Node.js 跑在本地磁盘服务器上时还没什么问题。但现在我们想把 JavaScript 运行到那些没有文件系统、文件系统只读或根本不可信的地方,这个假设就越来越成问题了:边缘运行时、WebAssembly 沙箱、Serverless 函数,以及基于浏览器的开发环境。
文件系统假设失效的场景
边缘运行时。 Cloudflare Workers、Deno Deploy 和 Vercel Edge Functions 在边缘节点上运行 JavaScript,机器离用户很近,基础设施也很精简。这些环境往往不提供可写的文件系统,或者只提供一个有限的内存文件系统,而且请求之间无法持久化。调用 fs.writeFileSync 的 Node.js 模块会直接崩溃;用 fs.existsSync 检查配置文件的模块,行为也变得不可预测。
WebAssembly。 要在 WebAssembly 沙箱里运行 Node.js,就得把文件系统操作映射到 Wasm 运行时的虚拟文件系统上(通常是 WASI)。这种映射并不完美:文件权限、符号链接和平台相关的路径都无法干净地转换。VFS 抽象可以让这种映射变得显式,而不是靠临时拼凑。
测试。 对读取配置文件、加载模板或写日志的代码做单元测试,要么 mock fs 模块(很脆弱,因为不同库用的 fs API 不一样),要么创建临时目录和测试夹具(又慢、又不稳定,还会在磁盘上留下垃圾文件)。虚拟文件系统可以让测试完全在内存中运行,结果确定,也不会碰真实的磁盘。
安全沙箱。 运行不可信代码时(插件、用户脚本、CI/CD 构建步骤),你需要控制文件系统访问。目前这通常要靠操作系统级别的沙箱(容器、namespace),或者对 fs 模块做猴子补丁。VFS 则可以实现细粒度的文件系统策略:这段代码可以读取 /app 但不能读取 /etc,可以写入 /tmp 但不能写入 /app。
VFS 大概长什么样
虚拟文件系统并不是什么新概念。操作系统从 20 世纪 80 年代就开始使用 VFS 层了,比如 Linux 的 VFS 就让 ext4、NFS 和 procfs 能在同一套 API 后面共存。Node.js 的思路也一样:把文件系统接口(fs.readFile、require() 等)和文件系统的具体实现解耦。
// Hypothetical VFS API
import { createVFS, MemoryFS, ReadOnlyFS, OverlayFS } from 'node:vfs';
// In-memory filesystem for testing
const testFS = new MemoryFS({
'/app/config.json': '{"port": 3000}',
'/app/templates/index.html': '<h1>Hello</h1>',
});
// Read-only view of the real filesystem
const readOnly = new ReadOnlyFS('/');
// Overlay: reads from real FS, writes go to memory
const sandbox = new OverlayFS(readOnly, new MemoryFS());
// Run code with a specific filesystem
const vfs = createVFS(sandbox);
vfs.run(() => {
// Inside this context:
// fs.readFileSync('/etc/hosts') → reads real file
// fs.writeFileSync('/tmp/log.txt') → writes to memory overlay
// require('./module') → resolves against the VFS
const app = require('./app');
app.start();
});
关键在于,VFS 会拦截所有的文件系统访问,不只是显式的 fs 调用,还包括 require()、import()、__dirname 和 process.cwd()。这才是它真正有用的地方:无需修改应用本身,就能重定向整个应用的文件系统访问。
模块解析问题
Node.js 与文件系统之间最深的耦合在于模块解析。执行 require('express') 时,Node.js 会逐级向上遍历目录树,查找 node_modules/express 目录,读取 package.json,解析符号链接,并跟随 main 或 exports 字段。仅仅一次 require 就会触发几十次文件系统调用。
Yarn PnP(Plug'n'Play)在一定程度上解决了这个问题:它用一个清单文件替代 node_modules,清单把模块名映射到 zip 压缩包。这种方式更快(不需要遍历目录),也更确定(不会有提升(hoisting)带来的意外),但它需要对 Node 的模块解析打猴子补丁,结果破坏了那些假定 node_modules 存在的工具。
一个成熟的 VFS 可以把 Yarn PnP 的思路变成一等功能。模块解析会经过 VFS,VFS 可以实现任意映射:zip 压缩包、内存模块、远程 URL,或者传统的 node_modules 目录遍历。不同环境使用不同策略,但应用代码面对的是同一套 API。
已有的先例
其他运行时已经解决了这个问题的各种变体。
- Deno 默认从 URL 加载模块,并把它们缓存到一个受管理的目录里。它没有
node_modules,也没有基于文件系统的模块解析。运行时决定模块存放的位置和方式。 - Bun 使用带硬链接的全局模块缓存,减少了文件系统开销。与 Node.js 相比,它的模块解析经过了大量优化。
- Go 的
io/fs接口(Go 1.16 引入)定义了一套文件系统接口,可由操作系统文件系统、嵌入文件(embed.FS)、zip 压缩包和内存文件系统实现。标准库函数接受fs.FS接口,因此不依赖真实文件也能测试。 - Java 的 NIO
FileSystem抽象支持可插拔的文件系统提供者,包括用于测试的内存实现和基于 zip 的文件系统。 - .NET 的
IFileSystem模式在 .NET 应用中被广泛用于提升可测试性,不过它是社区约定,而不是运行时特性。
Go 的做法尤其有启发性。它定义了一个很小的接口(Open、Read、Stat),并在整个标准库中使用它,这让文件系统抽象变得轻而易举,还没有破坏现有代码。Node.js 也可以这样做:先定义一个 VFS 接口,再逐步把标准库和模块加载器迁移过去。
兼容性挑战
实现 Node.js VFS 最大的障碍不在实现本身,而在生态。npm 上有超过两百万个包,其中相当一部分在访问文件系统时,都假定了 POSIX 语义、真实路径和可写目录。
如果一个 VFS 会破坏现有的包,那它一出生就死了。它必须是可选的,并且向后兼容:如果你没有显式创建 VFS,一切都和现在完全一样。而当你创建了 VFS 时,它应该透明地拦截文件系统调用,让行为良好的包无需修改就能正常工作。
“行为良好”是关键的限定词。那些调用 cp 或 rm 命令、使用原生插件直接调用 libc 的 open(),或依赖 /proc 及其他操作系统特有文件系统的包,在 VFS 下都跑不通。这是一条硬边界:只要代码越过抽象直接触碰底层系统,任何抽象都会泄漏。
实际提出的方案
Node.js 的 VFS 讨论并非纸上谈兵,Node.js 的 issue 追踪器里已经有具体的提案。目前的方向包括以下几个关键想法。
第一,一个 FileSystemProvider 接口,fs 模块把工作委托给它。默认提供者是真实的文件系统。自定义提供者实现同一接口,用于内存、只读、叠加(overlay)或远程文件系统。
第二,与模块加载器集成。require() 和 import() 将通过 VFS 解析模块,从而支持不依赖 node_modules 目录的模块解析策略。
第三,策略层。VFS 可以执行访问控制,阻止代码读写限定范围之外的路径。这将让 Node.js 内置类似 Deno 的 --allow-read 和 --allow-write 标志的沙箱能力。
它最终会进入 Node.js 24、26,还是永远不会发布,取决于 Node.js 团队的精力,以及社区对文件系统访问方式这一破坏性变更的接受程度。但压力是真实存在的:边缘运行时在增长,WebAssembly 的部署目标越来越多,而“JavaScript 无处不在”与“仅限于带真实文件系统的服务器上的 Node.js”之间的差距,正在变成一种竞争劣势。
今天你能做什么
你不必等运行时提供 VFS 才能享受文件系统抽象带来的好处。对于新代码,把文件系统访问包装在一个接口后面。不要直接调用 fs.readFile,而是创建一个 FileStore 接口,让你的代码依赖它。生产环境用真实文件系统实现它,测试时用内存 Map 实现它。这就是标准的依赖倒置:不起眼,但很有效,而且兼容任何运行时。
对于已有代码,memfs 和 unionfs 这类库提供了内存和叠加文件系统的实现,并对 Node 的 fs 模块打补丁。它们并不完美:原生插件和子进程会绕过它们。但对于纯 JavaScript 代码,它们确实有效,并且能大幅降低测试难度。
文件系统是 Node.js 基础设施中最后一块还没有清晰抽象边界的主要部分。Stream 有接口,HTTP 有接口,甚至模块加载器也有钩子。当文件系统也得到同样的对待,Node.js 就会真正成为一个可移植的运行时,而不只是服务器专用的运行时。这一点值得推动。


