浏览器终于有了个真文件系统:OPFS 与硬盘上那个叫 00000000 的文件
- 作者:Bougie
- 创建于:2026-10-03
做前端这些年,我对"浏览器存储"这个词的印象一直是四个柜子: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>,阻塞主线程,上限 5MBIndexedDB是异步键值库,能存结构化数据,但你只能整存整取Cache Storage的 key 是Request,value 是Response,为离线而生,不是给你存业务数据的- 三者都没有目录、没有偏移量、没有文件句柄这种概念

而"文件系统"的核心其实就一件事:给我一个句柄,我可以在任意偏移量上读任意长度,而且不用碰别的数据。数据库、视频编辑器、编译器,全都建立在这个动作上。
# 二、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 当成浏览器里的数据库底座。
一个数据库引擎要的从来不是"能存",而是:
- 按偏移量随机读一小块——读 B-tree 的一个 page,4KB,位置随机
- 原地改一部分——改一行不该重写整个库
- 持久化的顺序保证——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 的固定开销,攒起来很疼
几个会踩的坑:
createSyncAccessHandle()在主线程不存在,必须进 dedicated worker- 同一个文件不能同时开两个 access handle,异步 writable 也算一个
- access handle 不能
postMessage转移,但FileSystemFileHandle可以 - OPFS 里没有符号链接、没有权限位、没有可写的 mtime,
getFile()拿到的File对象只是快照 - 默认 best-effort,数据可能被驱逐
# 最后
我一开始只是想知道"浏览器能不能存个真文件",结果一路追到了 Default/File System/000/t/00/00000000。
最有意思的不是性能,而是那个目录结构:数据按自增 ID 存成裸文件,名字放在 LevelDB 里,按 origin 分桶隔离。这套设计跟 git 的对象库、跟任何一个正经的存储引擎,思路都是相通的——内容和名字分离,才能廉价地改名、移动、去重。
浏览器这些年补齐的东西——WebGPU、WASI、OPFS——越来越像是"把操作系统的能力,在不交出钥匙的前提下,借给网页"。文件系统这件迟到的事情,现在终于也交出来了。
只不过钥匙还在浏览器手里:文件在你磁盘上,但你打不开那个目录,它也随时可能收回去。