File System Access API 从原理到实战:让网页读写本地文件不再受限
要说清楚 File System Access API得先聊聊浏览器过去几十年里最拧巴的一件事明明开发网页应用的人越来越多网页能做的事也越来越复杂但只要碰到本地文件四个字所有人都得像回避某些历史遗留问题一样绕路走。你想让网页应用直接改掉磁盘上某个文本文件不行。你想批量重命名某个文件夹里的照片也不行。浏览器安全模型从诞生那天起就把这些能力锁死了网页只能通过下载、上传、拖拽这种间接方式碰文件体验离原生应用差了一大截。File System Access API 就是冲着这个缺口来的。它让 Chromium 系浏览器里的网页第一次能弹出系统级文件对话框拿到真实文件的句柄读内容、写内容、建目录、遍历目录甚至把句柄存起来跨会话复用。这篇文章不打算把 MDN 上的文档搬一遍我想从原理层面拆一拆这套 API 为什么能突破浏览器多年的限制然后带你把读写文件、目录操作、权限持久化这些核心链路完整跑一遍最后聊几个真正上生产才会踩到的坑。对想做浏览器本地工具、离线编辑器、无后端小应用的人来说这是目前最贴近原生体验的一条路。1. 为什么浏览器不能写文件以及被困住的那些年先回到问题本身。浏览器不是故意跟开发者过不去它从第一天起就建立了一套安全边界核心原则是网页代码不能无感访问用户磁盘。这个原则后来发展成沙箱概念——网页进程跑在一个受限环境里什么能碰、什么不能碰全由浏览器说了算。如果把操作系统比作一栋大楼网页应用就是个只能在自己房间里活动的访客想进其他房间必须有人带路、刷卡、登记。过去几十年网页碰文件的路径只有三条而且全是间接的input typefile用户主动选择一个或多个文件浏览器把内容以 File 对象的形式交给页面。能读不能写。拖拽文件进页面底层其实是 DataTransfer 里的 File 列表同样是只读。下载把 Blob 变成下载链接浏览器把它存到用户下载目录页面从此失去控制。这套体系带来的问题很明显。首先网页应用只能读一次。你想做文本编辑器用户每次保存都要重新触发下载根本无法原地修改原文件。其次下载的本质是复制不是写入——你把文件存到下载目录原文件没动用户需要自己管理副本体验破碎。最后所有文件操作都必须由用户手动介入自动化批量处理完全没有空间。所以过去想做真正能读写本地文件的东西只有两条路要么装原生应用要么用 ActiveX、Flash 这类插件的私有接口。前者违背网页零安装的初衷后者的安全记录太差而且早已退出历史舞台。转折点在 2019 年前后。Chromium 团队开始实现 File System Access API把文件句柄FileSystemHandle作为桥接网页与本地文件系统的核心概念引入浏览器。这套设计既没有打破沙箱——所有访问仍然要经过用户手势授权也没有牺牲能力——句柄一旦拿到读、写、遍历都成了可能。Chrome 85 首发支持Edge 跟着同步后来规范整合进了 W3C 的 File System Standard也就是现在 MDN 上说的 File System API。严格区分的话File System Access API 是早期叫法但它仍然是最流行的搜索关键词本文就沿用这个叫法。我自己的体验是第一次用 showOpenFilePicker 打开真实系统对话框、选中一个本地文件并直接改掉它的内容时确实有种浏览器终于松口了的感觉。但这套 API 远不是表面看那么简单它背后藏着句柄的权限模型和浏览器进程架构这两层很关键的设计搞懂它们你写代码时才不容易踩权限丢失和兼容性的坑。2. 句柄、权限、浏览器进程File System Access API 的三层底层逻辑这一节是全文最重要的一部分。如果你只想知道怎么调用 API直接跳到第三节也行但遇到权限过期、刷新后句柄失效、跨浏览器行为不一致这类问题时回来看看这一节你会更快找到根因。2.1 句柄不是路径而是凭证传统桌面开发里我们习惯用路径去定位文件比如/Users/me/notes/readme.md。浏览器里不能这么做因为路径会暴露系统结构而且用户完全有权限移动、改名甚至删除文件路径在网页端根本不可靠。File System Access API 的答案是不给你路径给你一个句柄handle。句柄本质上是浏览器内部维护的一个引用对象它绑定的是这个文件的实体不是这个文件当时的路径。文件被移动后如果用旧的路径去找早找不到了但句柄依然有效因为它指向的是文件系统层面那个真实存在的节点。从开发者角度看你拿到的FileSystemFileHandle有name、kind这些属性也可以调用getFile()、createWritable()这些方法完全不关心文件到底住在哪个目录。这有点像你住酒店时拿到的房卡。房卡不写酒店三楼走廊尽头那间写的是房间实体的授权信息。你把房卡带在身上只要酒店还在经营这间房无论内部怎么调整你都能刷开。FileSystemHandle 就是浏览器发给你的一张房卡。2.2 权限模型用户手势、显式授权、可撤销拿到了句柄不等于可以无限访问。浏览器在句柄上叠加了一套权限机制核心是三个约束。第一个约束是用户手势。所有文件选取、目录选取的 API比如showOpenFilePicker()、showSaveFilePicker()、showDirectoryPicker()都必须由用户的点击、键盘操作等用户激活事件触发。你在页面加载完成后的setTimeout里调它是无效的浏览器会直接拒绝。这条和弹窗拦截是一个道理目的是保证任何文件访问都有用户的明确意图。第二个约束是粒度权限。句柄有queryPermission()和requestPermission()两个方法都接受{ mode: read }或{ mode: readwrite }。前者查一下当前会话里有没有权限后者向用户弹一个权限确认框让用户明确同意。读取权限和读写权限是分开算的你不能拿了一个目录的读取权限就去写权限升级必须再次征求用户同意。第三个约束是权限的可撤销性。用户可以通过浏览器地址栏的站点设置面板随时撤销某个站点对文件的访问权限。也就是说你的应用不能把用户授权过一次当成永久授权。每次会话开始时检查一下权限状态比等到真正读文件时被NotAllowedError打断要体面得多。2.3 浏览器进程在中间充当信使很多人第一次接触这个 API 时会有一个疑问网页是 JS 直接发系统调用去读写磁盘吗如果真是这样任何一个被攻破的网页都能在用户磁盘上撒野沙箱岂不是形同虚设实际上不是。Chromium 的多进程架构在这里起了决定性作用。渲染进程跑 JS 的地方是沙箱化的权限很低浏览器进程负责 UI、网络、文件系统交互权限较高但只有它能和系统文件服务通信。当你调用getFile()或createWritable()时渲染进程把这请求通过 IPC 发给浏览器进程由浏览器进程拿着系统级的文件描述符去执行真正的读写再把结果传回来。这个设计让沙箱原则没有被破坏网页代码依然没有直接操作磁盘的能力但可以借浏览器进程之手完成复杂文件操作。代价是每次读写都有 IPC 开销所以这个 API 不太适合做那种每秒钟写几万次小数据的场景它更擅长的是打开文件、批量读写、关闭这类应用级操作。2.4 OPFS 与用户文件是两条路线还要区分一个关键概念File System Access API 实际覆盖了两类文件系统。一类是用户可见文件就是你通过选择器拿到的、存在用户磁盘任意位置的文件另一类是源私有文件系统Origin Private File System简称 OPFS是浏览器给每个站点分配的一块隐藏存储区通过navigator.storage.getDirectory()直接拿根目录句柄不需要任何用户授权。OPFS 的好处是快、不需要弹窗、有配额管理但用户看不到也摸不着。一般网页游戏存档、缓存数据放这里很合适。想做能操作真实文件的应用必须用选择器那套。这篇文章的重点在用户可见文件但后面讲到兼容性时OPFS 的地位会很重要先埋个伏笔。3. 读文件实战从文件选择器到可读流的完整链路原理讲完开始写代码。读取本地文件是整套 API 最基础、也最常用的一环流程是先让用户选文件拿到FileSystemFileHandle再调用getFile()拿到File对象最后按需读取内容。3.1 showOpenFilePicker 的参数细节showOpenFilePicker()是浏览器的原生方法必须在用户手势里调用返回一个 Promiseresolve 为一个FileSystemFileHandle数组即使你只选了一个文件它也是数组。常用参数如下const handles await window.showOpenFilePicker({ multiple: true, excludeAcceptAllOption: false, types: [ { description: 文本文件, accept: { text/plain: [.txt, .md], application/json: [.json] } } ], id: my-editor });multiple是否允许多选默认为 false。types限定可选取的文件类型。注意这里既有 MIME 类型也有扩展名列表两者以对象形式映射。excludeAcceptAllOption设为 true 时系统对话框里不会显示所有文件选项用户只能选你限定的类型。id这个参数很关键。浏览器会用 id 记住上次打开的目录下次再调用同一个 id 的选择器时对话框会优先定位到上次的位置。这能明显提升用户连续处理文件时的体验但要注意它本质是个字符串标识别用随机值否则每次都会重置。有个异常处理必须写上用户点了取消这里不会走普通的 reject 路径而是抛出一个名为AbortError的 DOMException。如果不捕获控制台一片红是小事更麻烦的是你会误以为程序出 bug 了。let handles; try { handles await window.showOpenFilePicker({ multiple: true }); } catch (err) { if (err.name AbortError) { console.log(用户取消了选择); return; } throw err; }3.2 getFile()、File 对象与三种读取姿势拿到句柄之后下一步就是调getFile()。这一步返回的是标准的File对象也就是你熟悉的那个继承自Blob的东西。从这开始之前所有 Blob/File 的知识都能直接复用比如file.name、file.size、file.lastModified、URL.createObjectURL(file)等。读取内容有三种姿势按场景选// 小文件一次性拿全文 const text await file.text(); // 中等文件拿二进制适合处理图片、音频等 const buffer await file.arrayBuffer(); // 大文件流式读取边读边处理 const stream file.stream(); const reader stream.getReader(); const decoder new TextDecoder(); let result ; while (true) { const { done, value } await reader.read(); if (done) break; result decoder.decode(value, { stream: true }); }这里最需要记住的教训是text()和arrayBuffer()看起来很爽但它们是一把梭把整个文件加载进内存。文件一上来几个 GB页面直接卡死甚至崩溃。所以判断多大多小要诚实几 MB 的文本你用text()没问题几百 MB 的日志文件请老老实实走流。另外file.text()默认按 UTF-8 解码遇到 GBK 编码的纯文本会乱码自己用TextDecoder(gbk)处理流式读取更可控。3.3 用 FileSystemFileHandle 直接做文件预览读文件不一定要读内容。有个非常实用的技巧拿到句柄后你其实可以先不读让用户看到这是什么文件。比如做一个文件管理工具用户选中一批文件你想立即展示缩略图只需要const file await handle.getFile(); if (file.type.startsWith(image/)) { const url URL.createObjectURL(file); img.src url; }这里的File对象是真实文件在浏览器里的影子它不需要arrayBuffer()就能被img、video、audio消费。你甚至可以把它交给FileReader、Web Worker或IndexedDB存储因为本质上它就是一个 Blob。这比旧方案先读 arrayBuffer 再转 Blob简单了一个数量级。我实测下来getFile()每次调用都会从磁盘重新取一次当前内容而不是返回一个缓存快照。所以如果用户在外部改了文件你再调一次getFile()能拿到最新内容。这个行为对不同需求各有利弊做编辑器可能要留意并发冲突做预览正好是想要的。4. 写文件的三种姿势以及 createWritable 背后那个保命设计读文件解决了看得见的问题写文件才是让浏览器脱胎换骨的关键。值得庆幸的是写入 API 的设计比读取更讲究它没有简单粗暴地给你一个JS 直接写磁盘的通道而是引入了FileSystemWritableFileStream和临时文件 原子替换的机制。4.1 姿势一原地覆写已有文件这是最核心的能力也是旧网页永远做不到的事。拿到文件句柄后调用createWritable()得到可写流写入内容关闭流const fileHandle await window.showOpenFilePicker({ types: [{ description: 文本, accept: { text/plain: [.txt, .md] } }] }).then((handles) handles[0]); const writable await fileHandle.createWritable(); await writable.write(第一行内容\n); await writable.write(第二行内容\n); await writable.close();write()可以接收字符串、Blob、BufferSource也就是ArrayBuffer、Uint8Array这一族作为参数。这意味着你可以直接写入二进制内容图片处理、文件格式转换都能做。close()是提交的终点不调用它数据不会真正落盘。中途想放弃怎么办调abort()或者直接让 writable 流被垃圾回收。这里有个小细节createWritable()默认模式是截断后写入也就是从文件开头清空重写。如果你只想改文件末尾、增加一段内容不要傻乎乎地先读整个文件再拼字符串直接用流式 API 的定位特性// seek 到文件末尾再写等效于追加 await writable.seek(file.size); await writable.write(追加的内容); await writable.close();seek(position)把写入光标移动到指定字节偏移truncate(size)把文件裁剪到指定长度。从头拼接大文件是性能灾难这些 API 就是给你做局部编辑用的。4.2 姿势二用 showSaveFilePicker 另存为新文件如果你不想动原文件或者用户想导出一份新文件用showSaveFilePicker()。它会弹出一个保存对话框用户可以选择保存位置、起文件名const saveHandle await window.showSaveFilePicker({ suggestedName: untitled.txt, types: [{ description: 文本, accept: { text/plain: [.txt] } }] }); const writable await saveHandle.createWritable(); await writable.write(content); await writable.close();suggestedName是默认文件名系统对话框允许用户改。注意你没法通过这个 API 直接强行覆盖一个用户不想覆盖的文件——即使用户选择了已存在的文件浏览器也会先弹确认框这是写入安全模型的一部分。4.3 姿势三createWritable 的底层设计为什么值得庆幸很多人第一次用createWritable()时会想这不就是打开了一个文件流吗底层有什么特别的特别就特别在写入不是直接落在原文件上的。Chromium 的实现是你先拿到一个可写流这个流在写入阶段实际操作的是同目录下的一个临时文件只有当close()被调用时浏览器再把临时文件内容原子地替换原文件。如果写入过程中页面崩溃、断网、代码抛异常原文件大概率完好无损最多残留一个临时文件。这其实是把桌面编辑器常用的保存时写临时文件 rename 替换策略搬到了浏览器里。为什么不能直接写原文件因为直接写的窗口期太长一旦写到一半断电原文件可能变成一半新一半旧的残废状态。有了临时文件机制要么完全不改要么整体替换这种事务感对文件安全太重要了。对用户来说这个机制还有一个隐藏好处你在write()之后、close()之前其实是可以继续读原文件的因为原文件还没被碰过。做Diff 预览、写前校验都来得及。4.4 边生成边写的流式写入处理大文件不慌FileSystemWritableFileStream本质是个流所以它能和任意可读流管道对接。如果你在生成一个很大的文件比如导出数据库、处理视频完全不需要先把完整内容堆进内存const writable await fileHandle.createWritable(); const reader hugeBlob.stream().getReader(); while (true) { const { done, value } await reader.read(); if (done) break; await writable.write(value); // 分块写入 } await writable.close();wirte({ type: write, position, data })这种对象形式也可以指定位置写入。它适合做断点续写、随机修改之类的高级操作普通场景用不到但知道存在总比遇到问题再搜好。5. 目录句柄、权限保持与多会话持久化的工程实践单个文件读写搞定后真正拉开差距的是目录操作。能遍历目录句柄你就能批量处理、递归搜索、创建文件夹结构让网页工具变得像桌面应用一样有组织能力。这一节同时要解决一个实战痛感很强的问题页面刷新后句柄丢了怎么办。5.1 showDirectoryPicker 与目录遍历showDirectoryPicker()让用户选择一个文件夹返回FileSystemDirectoryHandle。拿到它你就能看这个目录里有什么const dirHandle await window.showDirectoryPicker(); // 遍历目录下所有条目 for await (const entry of dirHandle.values()) { console.log(entry.name, entry.kind); // entry.kind file 或 directory } // 也可以只拿 key即名字 for await (const name of dirHandle.keys()) { console.log(name); }values()和keys()是现在标准的异步迭代器接口。以前文档里常见的getEntries()是早期实现现在别用了兼容性也一般。递归遍历是顺手的事。目录句柄里套着目录句柄用递归函数就能做全量扫描async function walk(dirHandle, prefix ) { for await (const entry of dirHandle.values()) { const path prefix ? ${prefix}/${entry.name} : entry.name; if (entry.kind directory) { await walk(entry, path); } else { console.log(path); } } }这里有个重要细节目录句柄不是一个快照你遍历它时看到的是实时状态。用户在选完目录之后新建了文件你去values()遍历时能看到它。这一点非常有用做浏览器里的文件管理器就靠它。创建和删除条目也很直接。getFileHandle(name, { create: true })创建新文件getDirectoryHandle(subDir, { create: true })创建子目录removeEntry(name, { recursive: true })删除目录树。注意删除目录必须传recursive否则目录非空时会抛错。5.2 权限查询与请求的正确姿势拿到目录或文件句柄后权限是按需的。你刚通过选择器拿到句柄时读取权限通常已经有了但读写权限不一定。严谨的做法是先查再请async function ensurePermission(handle, mode readwrite) { const options { mode }; if ((await handle.queryPermission(options)) granted) { return true; } if ((await handle.requestPermission(options)) granted) { return true; } return false; }queryPermission返回granted | denied | prompt。requestPermission必须在用户手势里调用否则浏览器不弹确认框。这个函数在每次操作前跑一次能避免很多 NotAllowedError 的尴尬。5.3 把句柄存进 IndexedDB跨页面、跨刷新保活FileSystemHandle 有一个非常重要的特性它是可结构化克隆的对象。这意味着它可以直接放进 IndexedDB 存储下次再打开页面时取出来继续用。这一下就解决了网页刷新后一切归零的老大难问题。存句柄async function saveHandle(key, handle) { const db await openDB(); // 自己封装一个 IndexedDB 打开逻辑 const tx db.transaction(handles, readwrite); tx.store.put(handle, key); // handle 直接存入无需序列化 await tx.done; }取句柄async function loadHandle(key) { const db await openDB(); const tx db.transaction(handles, readonly); const handle await tx.store.get(key); return handle; }取出来之后的动作很关键不能直接信任这个句柄仍然有权限。浏览器重启、用户撤销授权、权限策略变化等都会导致它失效所以每次恢复后先跑一遍ensurePermissionconst handle await loadHandle(last-file); if (handle (await ensurePermission(handle, readwrite))) { // 继续工作 } else { // 权限丢了引导用户重新选文件 }实测下来这个组合是浏览器本地工具的标准套路用户首次选择授权 → 句柄存 IndexedDB → 后续访问直接恢复并确认权限。它让网页应用第一次有了记住上次打开的文件这个原生应用基础能力。还要注意有些场景中句柄不持久化也能用。比如你在同一个页面会话里多次调用showOpenFilePicker上次的句柄当然还在。但页面一刷新普通变量清空不存 IndexedDB 等于没存过。目录句柄也一样想做一个记住我上次打开的文件夹同样要走持久化。6. 兼容性矩阵、性能陷阱和安全边界上线前必须知道的六件事这部分内容不是从文档里抄的是踩坑之后总结的。六个问题每一个都可能让你在演示时翻车。6.1 兼容性不是全部浏览器支持File System Access API 的完整实现主要落在 Chromium 系Chrome 86、Edge 86。Safari 是 15.2Firefox 是 111但这两家只实现了 OPFS也就是说showOpenFilePicker、showDirectoryPicker这套用户可见文件选择器是没有的。这让产品的跨浏览器策略变得现实又骨感能力Chrome/EdgeSafariFirefoxshowOpenFilePicker / showSaveFilePicker支持不支持不支持showDirectoryPicker支持不支持不支持FileSystemHandle 结构化克隆支持受限受限OPFS支持支持支持所以遇到一个只支持 OPFS 的浏览器你想读用户磁盘文件只能用回input typefile做降级想写用户磁盘文件基本没有替代方案只能生成下载。我的建议是核心功能优先保证 Chromium 系体验同时对其他浏览器给出能读不能写或只能导出下载的降级路径而不是直接报错。6.2 showOpenFilePicker 必须绑定用户手势这一点前面提过但值得单独警告一次。很多人会写出这种代码// 错误示例app 初始化时尝试直接弹出选择器 window.addEventListener(load, async () { const handle await window.showOpenFilePicker(); // 被浏览器拒绝 });浏览器会拒绝这个调用因为 load 事件不是用户手势触发的。解决办法是把文件选择动作绑定到按钮的 click、键盘激活这些事件上。如果你确实想在页面初始时恢复上次文件用 IndexedDB 里存的句柄再请求权限而不是直接弹选择器。6.3 性能别用一把梭 API 处理大文件前面说过text()和arrayBuffer()的内存问题这里再补一个更隐蔽的性能陷阱——写入大文件时createWritable()内部要做临时文件的复制和替换磁盘占用大约是原文件的两倍。如果用户机器磁盘空间紧张大文件保存可能失败。你的应用应该捕获空间不足类错误给用户明确提示而不是让他看到一个莫名其妙的QuotaExceededError。另外IPC 开销决定了高频小写入不是一个好主意。如果你要写大量小数据攒一批再write()比一次一次来回 IPC 强得多。一次大块写入的性能通常能接受但和原生应用比还是有差距。6.4 错误处理要细化不同的 name 对应不同的用户话术文件系统操作的错误不像网络请求那么简单它会抛各种各样让你摸不着头脑的 DOMException。最常见的几个错误 name含义建议话术AbortError用户取消选择静默处理即可NotAllowedError权限不足/被拒绝引导用户授权NotFoundError文件被删除或移动提示重新选择NotReadableError文件被占用/锁住提示关闭占用程序SecurityError浏览器策略或沙箱限制说明环境受限我的习惯是封装一个handleFileError(err)函数把每个错误 name 映射成用户友好文案同时保留原始错误到日志。否则用户看到控制台里的DOMException: A requested file or directory could not be found...只会觉得你做的工具很原始。6.5 隐私模式、企业策略与 iframe 限制File System Access API 在隐私模式下的行为不统一有些版本会直接禁用选择器有些允许但权限不持久化。企业浏览器策略也可能主动屏蔽这套 API。所以任何调用都应该用 try/catch 包住并对API 不存在和API 存在但调用失败两种情况分别处理。更隐蔽的是 iframe 限制。跨域 iframe 里默认没有allowfile-system-access权限调用 API 会被拦截。如果你的工具要嵌入别的站点记得在 iframe 标签上显式声明权限策略。6.6 监听文件变化目前做不到这个 API 没有提供 watch 文件的机制。虽然有一个 File System Observer 的草案但离普及还远。如果你需要感知文件被外部修改目前最朴素的办法是轮询file.lastModifiedlet lastModified file.lastModified; setInterval(async () { const handle userSelectedHandle; const freshFile await handle.getFile(); if (freshFile.lastModified lastModified) { lastModified freshFile.lastModified; // 文件外部被改动了刷新视图 } }, 2000);这个方法不优雅但可靠。做 Markdown 编辑器实时预览、日志监控这种场景时轮询是唯一无需用户手动刷新的方案。7. File System Access API 适合做什么几个我从实操里验证过的方向最后聊应用场景。我见过很多人一开始就把这个 API 想得过于宏大比如用它替代 Electron 全家桶结果被兼容性和权限折腾到放弃。我的看法是它最舒服的场景不是替代桌面应用而是在网页应用已经存在、但缺一个本地文件能力的缝隙里把体验补上。7.1 浏览器版批量文件工具批量重命名、批量转换格式、批量整理目录结构这类工具过去必须安装客户端。现在用 File System Access API 做一个网页版用户选一个目录应用遍历所有文件在页面上展示修改前后对比用户确认后批量写入。整个过程用户始终控制着访问范围授权粒度精确到目录安全性反而比很多动辄全盘扫描的桌面工具好得多。我自己做过一个图片压缩小工具选一个文件夹网页把所有图片显示出来用户可以勾选要压缩哪些压缩后直接覆写或另存。全程没有上传任何数据图片在本地被处理完隐私性无懈可击。这种本地优先的 Web 应用是这个 API 最有说服力的用途。7.2 本地优先的文档编辑器Markdown 笔记、代码片段管理、配置文件的图形化编辑这些都很适合。核心套路是第一次打开时让用户选文件句柄存入 IndexedDB之后每次自动恢复。保存时createWritable()直接覆写原文件不需要任何导出步骤和桌面编辑器的手感已经非常接近。这类应用还有先天优势天然离线。文件内容存在本地网页应用本身可以做成 PWA用户打开应用、恢复句柄、编辑、保存全程不需要服务器参与。对追求数据自主的用户来说这是个很大的卖点。7.3 无服务器的数据处理流水线还有一个很有潜力的方向是把浏览器当成本地数据处理引擎。比如读取 CSV 文件用table展示用户配置清洗规则再把结果写回新文件或者处理音频文件的元数据、拆分、转码配合 Web Audio API。这些操作如果做成服务器应用需要考虑上传下载、存储配额、隐私合规全链路复杂得多。而放在本地文件不离开用户磁盘计算在本地完成整个过程的工程量和成本都低了很多。7.4 暂不建议硬扛的方向同时我也要泼点冷水。第一别用它做需要长期后台运行的文件同步器因为权限随时可能被撤销且网页不能在后台一直活着。第二别在只支持 OPFS 的浏览器里假装能操作真实文件体验会严重降级。第三别把句柄轻易传给不可信的第三方脚本——句柄一旦被拿走第三方可以持续请求权限这是安全责任不是功能细节。8. 最后分享一个让句柄恢复更稳的小技巧前面讲权限持久化时我只说把句柄存进 IndexedDB实际开发里还有一个细节值得注意恢复句柄后不要急着getFile()或createWritable()先按目录层级把句柄的name显示给用户看一眼。因为用户可能会疑惑你怎么知道我之前打开的是这个文件我在自己的工具里做了个上次会话恢复卡片把句柄的name、lastModified、kind展示出来用户确认后才继续。这既是对用户知情权的尊重也避免了自动恢复后直接写了一个用户已经忘了的文件这种尴尬。另一个技巧是当你把FileSystemHandle存进 IndexedDB 时顺便存一份name和kind的副本。恢复时先读这些字段做 UI 展示再判断句柄是否还能用。万一句柄在存储过程中出了问题至少 UI 上不会出现空白还能引导用户重新授权。现在的浏览器确实越来越像一个轻量操作系统了。File System Access API 这种能力放在十年前是想都不敢想的但能力变强意味着责任变多。写文件、删目录、持久化权限每一步都要带着敬畏心去设计这不是什么正确的说教——是因为我确实见过有人把removeEntry({ recursive: true })放在一个没有二次确认的按钮上然后看着用户的整个文件夹消失在回收站里。如果你准备在前端项目里用这套 API先把权限确认、错误映射、降级方案做好再谈那些花哨的功能。