浏览器终于有了个真文件系统:OPFS 与硬盘上那个叫 00000000 的文件

做前端这些年,我对"浏览器存储"这个词的印象一直是四个柜子:localStorage 是个只能塞字符串的小信箱,IndexedDB 是个没有层级的仓库,Cache Storage 是给 Service Worker 配的快递柜,Cookies 则每次请求都跟着跑一趟。

它们都能存东西,但没有一个是文件系统。没有路径,没有目录树,没有偏移量,没有"打开—定位—读—关闭"这套动作。你想改一个 100MB 大文件中间的第 50MB,IndexedDB 的答案是:把整个值读出来,改,整个写回去。

直到前阵子我发现浏览器里其实早就有了第五个东西,而且它是真的往硬盘上写文件的。为了确认这不是某种抽象,我建了个 Chromium profile,让页面往里写一个 34 字节的文件,然后关掉浏览器,在磁盘上找到了它:

/tmp/opfsprofile/Default/File System/000/t/00/00000000

34 字节,cat 出来就是我写进去的那句话。文件名不在文件旁边,而在隔壁 Paths/ 目录的一个 LevelDB 里。

浏览器窗口悬浮在深色空间中,窗口内部是一棵发光的三维文件夹树状结构,蓝色节点与连线代表浏览器里真实存在的文件系统

下面所有数字都是我在 HeadlessChrome 149(Linux x86_64,30G 内存,NVMe,磁盘空闲 608 GiB)上真跑出来的,脚本和结果都能复现。

# 一、先把"存储"和"文件系统"分开

我先跑了四个最朴素的测试,想知道这几个柜子到底长什么样。

写 64MB,1MB 一块,IndexedDB 花了 33.5ms(约 1910 MB/s)。很快,快得有点离谱——因为它压根没往磁盘上落,事务完成时数据还在内存和写缓冲里。

localStorage 我试着写 40 个 256K 字符的字符串,写到第 20 个的时候炸了:

localStorage: 4.75 MB(19 × 256K 字符)写入后抛 QuotaExceededError

注意这里数的是字符数不是字节数:4.75M 个字符按 UTF-16 存就是 9.5MB,正好顶到那条线。所以"localStorage 有 5MB"这个说法里的 5MB,指的是 5M 个 UTF-16 码元,不是 5,242,880 个字节。而且它是整个 origin 共享的,你把 key 拆得再碎也没用。

所以真正的问题不是这些 API 快不快,而是它们的形状不对:

  • localStorage 是同步的 Map<string, string>,阻塞主线程,上限 5MB
  • IndexedDB 是异步键值库,能存结构化数据,但你只能整存整取
  • Cache Storage 的 key 是 Request,value 是 Response,为离线而生,不是给你存业务数据的
  • 三者都没有目录、没有偏移量、没有文件句柄这种概念

四种存储容器并排放在架子上:一个带锁孔的小信箱、一个文件柜、一个仓库木箱、以及一整套带嵌套文件夹的层级文件系统,等距 3D 插画

而"文件系统"的核心其实就一件事:给我一个句柄,我可以在任意偏移量上读任意长度,而且不用碰别的数据。数据库、视频编辑器、编译器,全都建立在这个动作上。

# 二、OPFS:借给你的一块私有地

OPFS 全称 Origin Private File System,入口就一行:

const root = await navigator.storage.getDirectory()

拿到的是一个 FileSystemDirectoryHandle,跟 File System Access API 里那个让用户选文件夹的句柄是同一个类型——区别在这个不需要用户选。root 直接就给你了,因为它是浏览器划给你这个 origin 的一块私有地。

"私有"到什么程度?我把 profile 目录翻了一遍,发现 Chromium 用两个 LevelDB 把这件事做得非常字面:

File System/Origins/000003.log   →  ORIGIN:http_localhost_8899
File System/000/t/Paths/000003.log  →  CHILD_OF:0:persist.bin  /  00/00000000  /  persist.bin
File System/000/t/00/00000000   →  (34 字节,真实内容)

Origins 那张表按 origin 分桶,000/t/ 就是我这个 origin 拿到的那块地。用户永远看不到它:不在下载目录里,不在文件管理器里,不能拖进去,也不能用 file:// 打开。这就是 "private" 的全部含义——它属于这个 origin,不属于这台电脑的用户。

顺便说一个我很喜欢的细节:文件名不在内容旁边。

数据文件叫 00000000,是个纯自增 ID;真正的名字 persist.bin 存在 Paths/ 的 LevelDB 里,靠一条 CHILD_OF:0:persist.bin 的 key 指过去。这跟 git 的做法几乎一模一样——blob 里只有内容,文件名在 tree 里。原因也一样:改名、移动、重命名都不需要动数据本身。

我顺手验证了一下持久化。在页面里写一行带时间戳的字符串,然后 page.reload(),再读回来:

content: "OPFS survives reload 1790967953769"
rootEntries: ["persist.bin"]

真的在,跨 reload 存活。

# 三、两种句柄,两种活法

OPFS 最让人迷惑的地方是:同一个文件,有两套完全不同的 API。

我先在主线程上试了一把,看拿到手的 FileSystemFileHandle 上都有什么方法:

{
  hasCreateSyncAccessHandle: false,
  hasCreateWritable: true,
  hasGetFile: true
}

createSyncAccessHandle 根本不存在。不是调用报错,是这个方法压根没挂在主线程的原型上。因为它是同步 API,会阻塞,而主线程不许被阻塞。规范把它限制在 dedicated worker 里。

于是我把测试搬进了 Worker,两套 API 各跑一遍。

异步那条路(主线程和 Worker 都能用):

const fh = await root.getFileHandle('a.bin', { create: true })
const w = await fh.createWritable()      // FileSystemWritableFileStream
await w.write(buf)                        // 顺序追加
await w.close()

是流,是 await,写的位置由流自己维护。

同步那条路(只能在 dedicated worker):

const ah = await fh.createSyncAccessHandle()
ah.write(buf, { at: off })   // 注意这个 at
ah.read(buf, { at: off })
ah.truncate(n)
ah.getSize()
ah.flush()
ah.close()

没有 await。全部同步返回。而那个 { at: off } 是整套 API 的灵魂——偏移量。

工厂里一个后台工人操作高速传送带运送数据块,旁边另一条慢速队列在柜台前排队等待,工业隐喻表现同步与异步文件访问的区别

还有一个排他的坑,我实测过:

NoModificationAllowedError | Access Handles cannot be created if there is another
open Access Handle or Writable stream associated with the same file.

同一个文件不能同时挂着两个 Access Handle,而且异步的 Writable stream 也算——两者互斥。另外 access handle 不可转移:

self.postMessage({probe}, [ah])
→ DataCloneError: Value at index 0 does not have a transferable type.

但 FileSystemFileHandle 本身是可序列化的,可以 postMessage 到 Worker。所以正确姿势是:主线程拿 handle,传给 Worker,由 Worker 自己去开 sync access handle。

# 四、实测:能写多快(以及为什么这个数字不能信)

写 64MB,不同姿势对比:

方式 耗时 吞吐
sync handle,1MB/次,最后 flush 64.8 ms 987.7 MB/s
sync handle,1MB/次,每次 flush 49.7 ms 1287.7 MB/s
sync handle,4MB/次,最后 flush 36.2 ms 1768.0 MB/s
async createWritable,1MB/次 67.2 ms 952.4 MB/s
async createWritable,4MB/次 54.6 ms 1172.2 MB/s
IndexedDB,1MB/次 33.5 ms 1910.4 MB/s

再把文件放大到 1GB:

方式 耗时 吞吐
sync handle,4MB/次,最后 flush 822.1 ms 1245.6 MB/s
sync handle,4MB/次,每次 flush 595.9 ms 1718.4 MB/s

看到"每次 flush 反而更快"这一行,你就该意识到这些数字有问题了。

它们不是磁盘速度。 这台机器 30G 内存、buff/cache 有 24G,1GB 的文件整个躺在页缓存里,写入根本没摸到 NVMe。所以上表测出来的其实是"JS 到浏览器的拷贝 + 系统调用"开销,不是 IO 能力。真要测磁盘,得写远超内存的量、并且让 flush() 真的等到落盘——我这个环境里 flush() 明显没有付出 fsync 的代价。

随机读的结果也印证了这点:

测试 IOPS
4KB × 2000,顺序,64MB 文件 27211
4KB × 2000,随机,64MB 文件 26738
4KB × 5000,随机,1GB 文件 27427

随机和顺序几乎一样,1GB 和 64MB 也几乎一样。这不代表"OPFS 随机读和顺序读一样快",只能说明两次测量都命中了页缓存。别拿这个数字去说服任何人。

有一个数字倒是真能说明问题的:建 500 个 1KB 小文件(getFileHandle + createWritable + write + close),一共 166.1 ms,平均每个 0.33 ms。这个开销是实打实的往返成本,跟数据量无关——所以不要拿 OPFS 存一堆小文件。

# 五、SQLite 为什么盯上了它

理解了"偏移量"这件事,你就能理解为什么 SQLite 官方把 OPFS 当成浏览器里的数据库底座。

一个数据库引擎要的从来不是"能存",而是:

  1. 按偏移量随机读一小块——读 B-tree 的一个 page,4KB,位置随机
  2. 原地改一部分——改一行不该重写整个库
  3. 持久化的顺序保证——WAL 要求"这条日志落盘了,才能改主库"

IndexedDB 三条全不满足:它只能整值 put/get,没有偏移量,也没有 fsync 语义。所以早年的 SQLite-WASM 只能把整个数据库文件当一个 blob 塞进 IndexedDB,每次写都全量重写——能跑,但不能认真用。

OPFS 的 sync access handle 恰好补上了这三个:read(buf, {at}) / write(buf, {at}) 就是 pread / pwrite,flush() 就是持久化点。于是 sqlite.org 官方的 sqlite3.wasm 里有了 OPFS VFS,让 SQLite 在浏览器里以接近原生的方式跑,WAL 也正常工作。

(顺带一提,官方后来又加了个 opfs-sahpool VFS:预先申请一批 sync access handle 存着复用。因为 Safari 上的支持情况和多标签页争抢同一个文件时,现开现关的开销太高。这部分我没实测,只是说明它存在的原因。)

发光的数据库圆柱体带着堆叠磁盘盘片,运行在一个半透明玻璃浏览器窗口内,蓝色全息光晕,数据行流入其中

一个实用的判断标准:如果你的数据是"一个大的、要随机访问的东西",OPFS 是这几年浏览器里最重要的新能力之一;如果你只是存点小配置,它反而是过度设计。

# 六、你的文件什么时候会消失

这是用 OPFS 之前必须搞清楚的事。

navigator.storage.estimate() 给了我这个 origin 的账本:

{ quota: 6442450944, usage: 0 }        // 临时 profile:6.00 GiB
{ quota: 10737418442, usage: 202 }     // 持久 profile:10.00 GiB

而我这块盘还剩 608 GiB 空闲。所以配额不是"磁盘的百分之多少"这么简单——同一台机器、同一个 origin,换了个 profile 就从 6 GiB 变成 10 GiB,都是整数 GiB。具体怎么算的我没有去挖,但有一点是确定的:别自己猜配额,要问 estimate()。

还有个好玩的细节:我写完数据后再查一次,quota 从 6442450944 涨到了 6442617688,正好多了 usage 的量(166744)。配额是跟着用量走的。

estimate() 还会把 OPFS 单列出来记账:

{ quota: ..., usage: 202, usageDetails: { fileSystem: 202 } }

usageDetails.fileSystem 就是 OPFS 占的那部分,和 IndexedDB、Cache 分开统计。

接下来是真正要紧的。默认情况下:

await navigator.storage.persisted()   // → false

false 意味着这块地是 best-effort(尽力而为)的。磁盘紧张时,浏览器可以按 LRU 把最久没访问的 origin 的数据清掉,你的 OPFS 文件会静静地消失,没有回调,没有通知。

想变成 persistent,得调 navigator.storage.persist()。Chrome 会根据站点活跃度(加了书签、装了 PWA、有通知权限之类)决定是静默通过还是弹权限提示,而且这个判断我没法在这台无头的机器上验证。

还有两条:用户手动"清除浏览数据"会连 OPFS 一起清;无痕窗口里的 OPFS 在窗口关掉就没了。

一个大型透明水箱压力表显示存储配额正在上升,顶部有红色危险线,旁边一个盾牌锁图标代表持久化存储权限

所以结论很直白:OPFS 是"缓存增强版",不是"永久存储"。 任何不能重新生成的东西,服务器上得有一份。

# 七、什么时候该用,什么时候别用

该用:

  • 大文件:视频编辑的素材、端侧模型的权重、几百 MB 的日志
  • 需要随机访问的结构:数据库、索引、可寻址的资源包
  • 离线编辑器:改哪儿写哪儿,不用整存整取

别用:

  • 小配置、小状态——localStorage / IndexedDB 更简单,也不用在 Worker 里绕一圈
  • 用户需要"找到"的文件——OPFS 用户看不见。该给用户的文件走 showSaveFilePicker()(真文件系统)或者直接触发下载
  • 一堆小文件——实测每个 0.33ms 的固定开销,攒起来很疼

几个会踩的坑:

  1. createSyncAccessHandle() 在主线程不存在,必须进 dedicated worker
  2. 同一个文件不能同时开两个 access handle,异步 writable 也算一个
  3. access handle 不能 postMessage 转移,但 FileSystemFileHandle 可以
  4. OPFS 里没有符号链接、没有权限位、没有可写的 mtime,getFile() 拿到的 File 对象只是快照
  5. 默认 best-effort,数据可能被驱逐

# 最后

我一开始只是想知道"浏览器能不能存个真文件",结果一路追到了 Default/File System/000/t/00/00000000。

最有意思的不是性能,而是那个目录结构:数据按自增 ID 存成裸文件,名字放在 LevelDB 里,按 origin 分桶隔离。这套设计跟 git 的对象库、跟任何一个正经的存储引擎,思路都是相通的——内容和名字分离,才能廉价地改名、移动、去重。

浏览器这些年补齐的东西——WebGPU、WASI、OPFS——越来越像是"把操作系统的能力,在不交出钥匙的前提下,借给网页"。文件系统这件迟到的事情,现在终于也交出来了。

只不过钥匙还在浏览器手里:文件在你磁盘上,但你打不开那个目录,它也随时可能收回去。