Node.js 诞生十几年了,一直围绕 I/O 做文章。流、缓冲区、套接字、文件读写,这些能力从第一天起就是它的核心竞争力。但有一个缺口,一直没被补上:你没法虚拟化文件系统

什么意思呢?你不能 import 一个只存在于内存里的模块。你想把应用打包成单文件可执行程序(SEA),得先修改半个标准库。你想在多租户环境里做文件沙箱,得自己写一堆脆弱的路径校验逻辑。

3 月 16 日,Matteo Collina(Node.js 核心贡献者、Fastify 作者)宣布了两件事:一个即将合入 Node.js 核心的 node:vfs 模块(PR #61478,约 14000 行代码),以及一个现在就能用的用户态包 @platformatic/vfs

这篇文章带你看看这个虚拟文件系统到底解决了什么问题,怎么用,以及背后一些有意思的故事。

先说痛点:现有方案为什么不够用

社区其实早就有人在做类似的事情了。memfsunionfsmock-fs 这些包都试图提供内存文件系统的能力。但它们有一个共同的硬伤:只能 patch fs 模块,没法介入模块解析器

这意味着什么?如果你的代码用 fs.readFileSync() 读文件,这些库能拦截住。但如果你写了 import('./config.json') 或者 require('./utils'),它们完全无能为力,请求会直接走到真实的文件系统。

这不是这些库的 bug,是在运行时外面做这件事的根本限制。

具体来说有这么几个场景一直很头疼:

打包成单文件可执行程序时,你需要把配置文件、模板、静态资源都塞进去。Node.js 的 SEA 功能虽然能嵌入数据,但应用代码调用 fs.readFileSync() 时期待的是真实路径,最后要么文件重复,要么得注入一堆胶水代码。

跑测试时,你希望每个测试用例都有隔离的文件系统,不在磁盘上留下痕迹。用 memfs 可以模拟 fs,但模拟出来的文件没法被 import 或 require() 加载。

多租户沙箱场景,需要把每个租户限制在特定目录里,防止 ../ 路径逃逸。目前只能自己写路径验证,既脆弱又容易出错。

AI 代码生成场景,智能体生成的 JavaScript 代码需要被执行。现在的做法是写到临时文件再加载,既慢又不安全,还有清理问题。

这四个场景指向同一个需求:一个能同时接入 node:fs 和模块加载器的虚拟文件系统。

node:vfs 长什么样

先看一段最基本的用法:

import vfs from'node:vfs'
import fs from'node:fs'

const myVfs = vfs.create()
myVfs.mkdirSync('/app')
myVfs.writeFileSync('/app/module.mjs', 'export default "hello from VFS"')
myVfs.mount('/virtual')

// 标准 fs 操作可以正常读取
const data = fs.readFileSync('/virtual/app/module.mjs', 'utf8')

// import 也能用
const mod = awaitimport('/virtual/app/module.mjs')
console.log(mod.default) // "hello from VFS"

myVfs.unmount()

这不是模拟。调用 myVfs.mount('/virtual') 之后,VFS 会真正挂载到 fs 模块和模块解析器上。进程里任何代码读取 /virtual 路径下的内容,都会从 VFS 拿数据。第三方库不需要做任何适配,express.static('/virtual/public') 直接就能跑。

两种挂载模式

Mount 模式:VFS 只在指定的路径前缀下生效,和真实文件系统完全隔离。适合需要一个干净独立空间的场景。

Overlay 模式:VFS 覆盖在真实文件系统之上,优先检查虚拟路径,找不到再去真实文件系统。这对测试来说特别好用,你只需要覆盖几个配置文件,其他的都保持原样:

const myVfs = vfs.create({ overlay: true })
myVfs.writeFileSync('/etc/config.json', '{"mocked": true}')
myVfs.mount('/')

// /etc/config.json 来自 VFS
// /etc/hostname 来自真实文件系统

完整的 fs API 支持

VFS 不是一个 fs 的子集。同步、回调、Promise 三套 API 全部支持,涵盖读写、目录操作、符号链接、文件描述符、流、文件监视和 glob 匹配。错误代码也和 Node.js 保持一致,ENOENTENOTDIREISDIREEXIST 这些该有的都有。

为什么必须做进核心

你可能会问:既然 @platformatic/vfs 已经能用了,为什么还要费劲合入 Node.js 核心?

因为用户态实现永远是一种妥协。具体来说有五个绕不过去的问题:

模块解析逻辑重复。用户态包包含了 960 多行模块解析代码:遍历 node_modules 目录树、解析 package.json 的 exports 字段、处理条件导出等。这些逻辑在 Node.js 内部都已经有了,用户态只能重新实现一遍,还不一定能覆盖所有边界情况。

依赖私有 API。用户态包需要 patch Module._resolveFilename 和 Module._extensions,这些都是没有稳定性保证的私有 API。Node.js 的一个小版本升级就可能让它们失效。

全局 fs 修补的脆弱性。用户态包替换的是 fs.readFileSync 这些公开函数。但如果某些代码在 VFS 挂载之前就缓存了这些函数的引用,那这些引用会完全绕过 VFS。在核心里,拦截发生在公开 API 之下,缓存的引用照样能被拦截。

原生模块加载不了dlopen() 需要真实的文件路径,用户态没办法让原生模块加载器从内存读取 .node 文件。核心实现可以。

模块缓存清理不了。卸载 VFS 之后,通过 require() 加载的模块还留在 require.cache 里。用户态无法区分哪些模块来自 VFS、哪些来自真实文件系统。核心可以跟踪来源,卸载时自动清理。

现在就能用:@platformatic/vfs

不想等核心 PR 合并?@platformatic/vfs 已经发布到 npm,支持 Node.js 22+:

npm install @platformatic/vfs

API 和提议中的 node:vfs 保持一致,将来迁移只需要改一行 import:

// 现在
import { create } from '@platformatic/vfs'
// 将来
import { create } from 'node:vfs'

有意思的是,Vercel 的 CTO Malte Ubl 看到 Matteo 的 PR 后,独立地把相同的 API 提取成了用户态包 node-vfs-polyfill。两个团队各自独立构建出了一样的东西,这本身就说明了 API 设计是靠谱的。

额外的存储提供者

用户态包还提供了两个核心 PR 里没有的 Provider:

SqliteProvider:基于 node:sqlite 的持久化 VFS,文件在进程重启后依然保留。适合缓存编译产物或者在部署之间保存生成的代码。

import { create, SqliteProvider } from '@platformatic/vfs'

const disk = new SqliteProvider('/tmp/myfs.db')
const vfs = create(disk)
vfs.writeFileSync('/config.json', '{"saved": true}')
disk.close()

// 另一个进程里
const disk2 = new SqliteProvider('/tmp/myfs.db')
const vfs2 = create(disk2)
console.log(vfs2.readFileSync('/config.json', 'utf8')) // '{"saved": true}'

RealFSProvider:沙箱化的真实文件系统访问,内置路径遍历防护,不用再自己写 path.resolve() 校验了。

import { create, RealFSProvider } from '@platformatic/vfs'

const provider = new RealFSProvider('/tmp/sandbox')
const vfs = create(provider)
vfs.writeFileSync('/file.txt', 'sandboxed') // 写入 /tmp/sandbox/file.txt
vfs.readFileSync('/../../../etc/passwd')    // 抛出错误,无法逃逸

背后的故事:14000 行代码是怎么写出来的

Matteo 在推文里坦诚地聊了这件事的经过。这个 PR 有大约 14000 行代码,分布在 66 个文件里。按照正常节奏,这种规模的工作需要好几个月。

但他是在 2025 年圣诞假期期间完成的,借助了 Claude Code。他把 AI 用在了那些"枯燥但必须做"的部分:每个 fs 方法的同步、回调、Promise 三种变体的实现,测试覆盖,文档编写。而他自己则专注于架构设计、API 设计和逐行代码审查。

用他自己的话说:"如果没有 AI,这不可能是一个假期的副项目。它根本不会发生。"

目前这个 PR 已经在积极审查中。来自 Igalia 的 Joyee Cheung 对安全模型做了严格审查,James Snell 和 Paolo Insogna 已经批准,Stephen Belanger 提出了关于全局 mount() 劫持的安全隐患并建议与权限模型集成。社区的参与度相当高。

一些个人看法

Node.js 这些年在 I/O 层面的抽象其实已经做得很成熟了,但虚拟文件系统确实是一个缺了很久的拼图。从实际应用角度看,SEA 打包、测试隔离、AI 代码执行这几个场景都是真实的痛点,不是为了酷而做。

不过也要注意,这个功能目前还是实验阶段。一个需要接入模块加载系统和整个 fs 模块的特性,涉及面非常广。边界情况和与第三方库的兼容性问题肯定还会冒出来。

如果你正好有上面提到的那些需求,现在就可以用 @platformatic/vfs 试试。等 node:vfs 正式合入核心后,迁移成本很低,改一行 import 就行。

相关链接:

  • node:vfs PR:https://github.com/nodejs/node/pull/61478

  • @platformatic/vfs:https://github.com/platformatic/vfs

  • Vercel 的 polyfill:https://github.com/vercel-labs/node-vfs-polyfill

  • 博客:https://blog.platformatic.dev/why-nodejs-needs-a-virtual-file-system

热点推荐

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐