如果你做过任何需要和本地文件打交道的 Web 应用一定体会过那种憋屈浏览器只能下载、只能上传想要真正意义上“改一个本地文件”基本靠用户手动搬运。File System Access API 的诞生把这件事彻底改变了——它让网页能直接读取、编辑、保存用户本地文件甚至能读取整个目录。这篇文章我不会讲那种“能读文件但没法写回去”的玩具方案而是把从原理到踩坑一次说清楚适合正在开发在线编辑器、报表工具、本地优先类应用的开发者参考。1. 从“下载再上传”到“直接读写”它到底解决了什么问题1.1 传统文件操作的沙箱困局在没有这个 API 之前浏览器对本地文件的访问能力基本可以用一句话概括只能把文件“拿进来”不能把文件“写回去”。input typefile负责读取用户选中的文件a download负责诱导浏览器下载一段数据。但这两个功能之间是断裂的——你想改一个本地文件流程只能是“下载 → 用本地工具改 → 再拖回网页上传”。这带来几个很实际的痛点。第一文件内容经过多次复制传递用户根本无法确定页面上展示的到底是“原文件”还是“某个副本”。第二下载到本地后原文件的历史版本、目录结构、文件关联全部丢失。第三涉及批量处理的场景比如批量改图片、批量整理文档传统方案基本做不了因为页面拿不到目录只能一个文件一个文件地选。我印象很深的一次经历帮朋友做一个内部用的批量重命名工具传统方案只能让用户逐个选择文件一次选一百多个文件再一批处理操作路径长到让人崩溃。当时就想如果浏览器能直接拿到一个目录句柄把这些文件当作一个集合来遍历效率会高一个量级。这个 API 的意义就在于此——它把浏览器从一个“临时容器”变成了真正能操作本地文件的运行时。1.2 两个核心概念句柄与权限File System Access API 的设计核心围绕两个词展开句柄Handle与权限Permission。句柄可以理解成“文件的身份证遥控器”。它不存储文件内容而是保存了一个指向真实文件条目的引用。拿到文件句柄你可以随时getFile()读取最新的内容也可以随时createWritable()打开一个写入流把改动写回去。关键点是句柄是有身份的——同一个文件无论你重读多少次句柄相同时它指向的都是同一个真实文件不会因为页面刷新而自动失联。权限则是“遥控器的保险开关”。浏览器再激进也不会允许一个网页无感修改本地文件。任何涉及读写的操作都必须经过用户显式授权。这个授权是分粒度的分为只读read和读写readwrite两种模式并且权限可以持久化到当前浏览器会话中让用户在“信任这个站点”之后减少重复弹窗的打扰。把这两个概念放在一起就能理解整套 API 的设计哲学能力是强大的但每一步都踩在用户同意的基础之上。它不是让网页拥有任意读写磁盘的权限而是让用户有能力决定“这个网页可以动哪些文件”。1.3 适用场景与边界在哪里这个 API 最适合三类场景。第一类是生产力工具典型代表是在线代码编辑器、在线 Office、笔记软件、Markdown 编辑器。用户打开一个本地文档直接在网页里编辑Ctrl-S 就能保存回原文件体验接近原生应用。第二类是批量文件处理工具比如图片压缩器、格式转换器、批量重命名软件。通过目录句柄网页能一次读取整个文件夹的结构维护一个“工作目录”的上下文。第三类是本地优先Local-first应用。这类应用把数据文件放在用户本地网页只负责展示和编辑天然离线可用数据归属清晰。但它的边界也很明显任何一次文件访问都必须由用户手势触发并显式授权不可能做到“网页一打开自动扫描某个目录”。如果你要做的是类似网盘、云端同步类的应用那这个问题不应该靠这个 API 解决它是给“本地文件优先”的场景准备的不是给“云端自动同步”场景准备的。2. 核心细节解析句柄、权限与写入流2.1 打开文件showOpenFilePicker 的三个关键参数入口方法只有三个打开文件用showOpenFilePicker()保存文件用showSaveFilePicker()打开目录用showDirectoryPicker()。先说最常用的打开文件。const [handle] await window.showOpenFilePicker({ types: [ { description: 文本文件, accept: { text/plain: [.txt, .md, .markdown], text/markdown: [.md], }, }, ], multiple: false, excludeAcceptAllOption: true, });需要注意几个细节。返回值是一个数组即使multiple: false也返回数组。types用来限定文件类型accept对象里键名是 MIME 类型值是扩展名数组。excludeAcceptAllOption: true表示不提供“所有文件”这个兜底选项如果你希望用户可以选任意格式就保持为 false。还有一个非常容易被忽视的要求这个调用必须在用户手势的调用栈里执行也就是必须由用户点击按钮触发不能异步延迟太久之后再调用。我试过在setTimeout里调 picker结果直接被拦截报了一个安全错误。原因是这个 API 依赖“瞬态用户激活”transient activation一旦事件处理函数结束激活状态就过期了。2.2 权限模型queryPermission 和 requestPermission 的组合拿到句柄之后接下来做的事情几乎都是“先查权限再要权限拿到权限再操作”。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只负责询问不会弹窗requestPermission会让浏览器弹出权限确认框。第二requestPermission也必须由用户手势触发不能暗中请求。第三权限是绑定在句柄上的不是绑定在域名上的。也就是说你可以有十个句柄其中五个有写权限另外五个只有读权限。还有一个实际体验上的规律通过showOpenFilePicker获取的句柄如果在 picker 时授予了读写权限通常返回的句柄权限就是 granted。但通过拖拽获取的句柄后面会讲默认只有只读权限必须再走一次requestPermission才能写入。权限能不能永久保留答案是“视情况而定”。浏览器会记录站点的文件访问权限用户每次授权之后浏览器会弹一个“是否记住”的选项如果用户选了记住下次访问时权限仍然有效。但在隐私模式下或者用户主动清理了站点权限之后所有的授权都会失效。所以代码里不能假设权限一定有效每次会话启动或者每次操作前都应该重新校验。2.3 createWritable写入流里的三个陷阱拿到读权限之后真正写文件靠的是createWritable()。这个方法返回一个FileSystemWritableFileStream它的写入模型和普通 Node.js 流不太一样更像是一个“可直接 seek 的文件游标”。const writable await handle.createWritable(); await writable.write(第一段内容); await writable.write(new Blob([第二段内容], { type: text/plain })); await writable.write({ type: write, data: 插入的内容, position: 5 }); await writable.close();write方法有三种调用形式直接写字符串、写 Blob、写一个带position的偏移指令。第三种是进阶玩法可以在指定位置插入内容而不整体覆盖。这里必须提醒三个坑。第一个坑写完一定要close()。我一开始以为createWritable()之后直接 write 就算完成了结果发现刷新页面后文件没变化。后来查了规范才知道close()才真正触发数据落盘不调用它流的缓存可能会被丢弃。中间如果出异常应该主动调用abort()避免留下半写入状态。第二个坑write()本身是异步的但多个连续write()不保证顺序。如果写的是大文件分块数据最好await每一次 write或者用await write()逐个串行否则可能出现数据交错的诡异问题。第三个坑写入不是“原地修改”的实现方式。浏览器实际上是在事务性模型下工作的——它会把改动写入到一个临时交换文件中只有close()成功后才会替换原始文件。这是故意的为了防止写入半途而废把源文件弄坏。所以如果你在写的过程中查看原文件会发现它一直是旧的直到关闭写入流才变化。2.4 句柄持久化把把手存在 IndexedDB 里页面的刷新或关闭会丢掉内存中的所有句柄。但句柄是可以被序列化和恢复的浏览器提供了indexedDB来存储句柄对象。// 保存句柄 const db await openDatabase(); const tx db.transaction(handles, readwrite); tx.objectStore(handles).put(fileHandle, last-opened-file); // 恢复句柄 const storedHandle await getObject(last-opened-file); if (storedHandle) { const permission await storedHandle.queryPermission({ mode: readwrite }); if (permission granted) { // 直接可以继续使用 } }这里的原理是句柄本身是一个可结构化克隆structured clone的对象可以放进 IndexedDB、postMessage 甚至保存到本地。恢复出来之后原先的授权记录也一并恢复了——前提是用户之前授权过并且权限仍然有效。这就是所谓的“最近打开文件”功能的实现基础。对本地优先类应用来说这个能力非常关键。用户关闭网页再打开应用能自动恢复上一次的文件句柄省去重新从文件选择器里翻找的流程。体验上已经非常接近原生桌面应用。3. 实战从零实现一个本地文本编辑工具3.1 环境准备与兼容性判断先明确一个问题当前这代 API 只在基于 Chromium 的浏览器里完整可用Safari 和 Firefox 至今仍未实现或者只实现了部分能力。所以动手之前必须做特性检测。if (!(showOpenFilePicker in window)) { // 提示用户使用支持该特性的浏览器或者降级到传统上传方案 }我在一个实际项目中用的降级方案是支持新 API 的浏览器走“打开并编辑”流程不支持的浏览器退回input typefile配合手动下载保存。虽然体验差了但功能至少不缺失。另外还要注意该 API 要求安全上下文——本地开发用http://localhost没问题线上必须 HTTPS否则window.showOpenFilePicker是 undefined。3.2 读取文件流程读取流程比较直接打开 picker → 拿句柄 →getFile()→ 解析内容。async function openFileFromDisk() { const [handle] await window.showOpenFilePicker({ types: [{ description: 文本, accept: { text/plain: [.txt, .md] } }], }); const file await handle.getFile(); const content await file.text(); currentHandle handle; document.querySelector(#editor).value content; }getFile()每次返回的都是这个文件当前状态的快照对象它不是实时变化的。如果你在写操作之后重新getFile()拿到的才是新内容。如果用户用其他程序修改了文件getFile()拿到的也是磁盘上最新的状态所以它可以充当一个轻量的“外部变更检测”手段。3.3 写入流程与保存快捷键写入流程是上面的反向操作先确认权限再createWritable()写入关闭。async function saveFileToDisk() { if (!currentHandle) return; const granted await ensurePermission(currentHandle, readwrite); if (!granted) { alert(没有写权限); return; } const writable await currentHandle.createWritable(); await writable.write(document.querySelector(#editor).value); await writable.close(); }再把保存快捷键接上体验就完整了。document.addEventListener(keydown, (e) { if ((e.ctrlKey || e.metaKey) e.key s) { e.preventDefault(); saveFileToDisk(); } });这里有一个小经验Ctrl-S 和 Ctrl-Shift-S 建议区分行为。普通 Ctrl-S 直接静默保存到原文件Ctrl-Shift-S 调用showSaveFilePicker()另存为一个新文件。这样既照顾了快速保存又保留了“另存为”的灵活性。另存为的实现本质上没有区别区别只在于showSaveFilePicker()返回的是一个新句柄async function saveAsFile() { const newHandle await window.showSaveFilePicker({ suggestedName: untitled.txt, types: [{ description: 文本, accept: { text/plain: [.txt] } }], }); // 然后把 newHandle 当作 currentHandle 继续写入 }3.4 目录操作一个被低估的能力showDirectoryPicker()可能是整个 API 里最被低估的方法。它让你获取的是一个FileSystemDirectoryHandle这个对象可以像遍历一个懒加载的 Map 一样遍历目录内容。const dirHandle await window.showDirectoryPicker(); async function walkDirectory(dirHandle) { for await (const [name, handle] of dirHandle.entries()) { if (handle.kind file) { console.log(文件:, name); } else if (handle.kind directory) { console.log(目录:, name); await walkDirectory(handle); } } }我个人做过的批量重命名工具就依赖这个能力拿到目录句柄之后遍历所有文件名按规则生成新名再用dirHandle.getFileHandle(oldName)取到旧文件句柄最后newName是调用dirHandle.getFileHandle(newName, { create: true })生成的。整个操作不需要用户逐个选文件体验完全不一样。目录句柄还可以继续向下深入也可以向上回到父目录dirHandle.getParent()。注意getParent()在根目录会返回 null需要做空值判断。3.5 拖拽导入文件句柄除了文件选择器拖拽也是获取句柄的重要途径。HTML 拖放 API 提供的数据传输对象里可以直接获取文件系统句柄document.addEventListener(dragover, (e) e.preventDefault()); document.addEventListener(drop, async (e) { e.preventDefault(); const items e.dataTransfer.items; const handles []; for (const item of items) { if (item.kind file) { const handle await item.getAsFileSystemHandle(); if (handle) handles.push(handle); } } // 这里的 handle 有只读权限若要写入需要再申请 for (const handle of handles) { const permission await handle.requestPermission({ mode: readwrite }); if (permission granted) { // 可以正常读写了 } } });拖拽进来的句柄和 picker 返回的句柄在能力上没有区别但是权限状态有差异——拖拽句柄默认是只读的需要显式再要一次写权限。这一点如果不注意很容易在拖拽导入后直接调用createWritable()然后收到异常。3.6 一个完整的组合示例把上面的逻辑拼起来就是一个最小可用的本地文件编辑器let currentHandle null; async function openFile() { const [handle] await window.showOpenFilePicker(); currentHandle handle; const file await handle.getFile(); editor.value await file.text(); } async function saveFile() { if (!currentHandle) return; if (!(await ensurePermission(currentHandle, readwrite))) return; const writable await currentHandle.createWritable(); await writable.write(editor.value); await writable.close(); } async function renameFile(newName) { const parent await currentHandle.getParent(); if (!parent) return; const oldName currentHandle.name; const newHandle await parent.getFileHandle(newName, { create: true }); const writable await newHandle.createWritable(); await writable.write(editor.value); await writable.close(); // 删除旧文件 await parent.removeEntry(oldName); currentHandle newHandle; }这个示例里有一个值得注意的技巧getParent()getFileHandle(newName, { create: true })实际上就实现了“重命名保存”的效果。原理是先在新文件名下创建一个文件句柄把内容写进去然后删除旧文件。听着有点笨但这是这个 API 当前实现“重命名”的可行方式之一因为句柄本身没有 rename 方法。4. 常见问题与排查技巧实录4.1 API 不存在或 undefined这是最常见的问题通常有三个原因浏览器不支持、非安全上下文、代码在 iframe 里且 iframe 没有对应权限。排查顺序建议是先console.log(showOpenFilePicker in window)结果为 false 就看地址栏是不是 HTTPS 或 localhost如果是 iframe需要给 iframe 添加allowfile-system-access属性。这一条在嵌入第三方应用时特别容易踩到。4.2 权限明明授权过却仍抛出 NotAllowedError我在实际项目里遇到过好几次用户第一次用没问题关掉页面再回来恢复的句柄在调用createWritable()时直接报权限错误。原因通常是两种。第一用户在上次授权时没有勾选“记住”所以浏览会话结束后授权就失效了。第二站点权限被清理、浏览器设置变更、隐私模式切换。解决思路是每次恢复句柄之后先queryPermission如果是prompt状态主动触发一次requestPermission而不是等到真正写文件的时候才报错。async function restoreHandle(storedHandle) { const permission await storedHandle.queryPermission({ mode: readwrite }); if (permission granted) { return storedHandle; // 直接用 } if (permission prompt) { // 需要用户手势触发的弹窗这里应该在点击事件里调用 const result await storedHandle.requestPermission({ mode: readwrite }); if (result granted) return storedHandle; } // 如果是 denied只能让用户重新打开文件 return null; }经验法则是永远不要在“需要写数据的那一刻”才去要权限而是每次准备好的时候先把权限确认好。这样即使弹窗出现用户也是在“准备操作”的语境下授权而不是在“数据已经编辑完临时卡住”的语境下体验会好很多。4.3 写入大文件时页面卡顿或内存暴涨FileSystemWritableFileStream本身支持分块写入但很多人习惯把所有内容一次性拼成一个字符串或 Blob 再写。大文件几百 MB场景下这一下就爆了。正确的做法是用流式写入把一个可读流管道连接pipe到 writable 流边读边写内存占用保持在低位。const response await fetch(large-data.bin); const readStream response.body; const writable await handle.createWritable(); await readStream.pipeTo(writable);pipeTo会把读取端的数据自动转发到写入端结束之后自动关闭写入流。这个方案在文件下载后写入本地时有奇效。注意pipeTo会同时关闭目标流所以这里不需要手动close()写出来的代码也更简洁。如果中途出错pipeTo会尝试中止但保险起见可以包一层 try/catch在异常时调用writable.abort()。4.4 文件被外部程序占用或删除当你持有句柄时用户可能在系统文件管理器里把这个文件删掉了或者用另一个程序打开并锁定了文件。此时createWritable()或者write()可能失败具体表现是异步方法抛异常错误信息通常是 “A requested file or directory could not be found at the time an operation was processed” 一类。我的处理方法是执行写入的操作不要裸写一定要 try/catch捕获到NotFoundError时提示用户文件可能已被移动或删除并给一个“重新打开”的入口。千万别吞异常否则用户会以为保存成功了实际上文件丢了。4.5 常见问题速查表现象可能原因处理方式showOpenFilePicker是 undefined浏览器不支持 / 非安全上下文 / iframe 缺权限特性检测提示换用 Chromium 系浏览器检查 HTTPScreateWritable()抛 NotAllowedError写权限未授予或被撤销先 queryPermission再 requestPermission写完后刷新页面内容没变没调用close()确保 close() 成功后再提示保存完成写入大文件卡死一次性 write 整个大对象使用管道写入或分块写入保存提示成功但文件找不到文件被外部移动/删除捕获 NotFoundError提示用户重新打开恢复句柄后写权限丢失授权未持久化或权限被清除启动时重新校验权限必要时重新打开拖拽导入后无法写入拖拽句柄默认只读显式 requestPermission({ mode: readwrite })4.6 调试工具与技巧Chromium 开发者工具里可以在“Application”面板的 “Storage → File System Access” 下看到所有已授权的文件句柄列表可以手动撤销任意固定的授权这样就能快速测试权限失效的流程。另外控制台的await可以直接在全局环境中调用 picker省去不断点击页面的麻烦。但注意控制台里调用 picker 不会自动获得用户激活状态有时会失败解决方案是在页面监听一个自定义事件或者临时加一个按钮来触发。5. 一些值得后续拓展的方向这个 API 和几个周边技术组合起来潜力更大。与 Origin Private File SystemOPFS结合可以做到“本地文件私有沙箱文件”双轨并行。OPFS 是浏览器管理的独立文件系统不需要用户授权适合存缓存、数据库和临时文件。而 File System Access API 负责的是用户看得见的真实文件。两者的分工很像“应用缓存”与“用户文档”的关系。与 Web Worker 结合可以把文件读取、内容解析、格式转换这些重活丢到后台线程。句柄是可以传递给 Worker 的毕竟它支持结构化克隆。这样即使处理很大的文件UI 也不会卡顿。另外如果你在开发跨端应用可以把这套 API 封装成抽象层桌面浏览器走 File System Access API不支持的地方走上传下载。使用统一接口之后上层业务完全不用关心底层差别。我自己在几个项目里就是这样的写法虽然后期维护成本低很多但最初的接口设计需要多花一些心思。// 一个简单的封装示意 class LocalFileSystemAdapter { async open() { if (showOpenFilePicker in window) { return this.#openWithNativePicker(); } return this.#openWithClassicUpload(); } }我还真的在某个在线画图工具里见过更激进的用法把整个应用的数据文件设计成一个可导出的本地文件用户打开后所有画布数据直接读写到一个本地.mindmap文件里。保存时用 API 写回自动备份时复制一份到 OPFS再配合文件系统同步工具做云备份。这套架构做下来用户对本地的掌控感完全不一样。总的来说这个 API 最大的价值不是“能写文件”这一个能力点而是它把浏览器从“被动接收数据”的角色转变成“主动管理本地文件”的角色。一旦把这种能力释放给开发者能迸发出的产品形态远超我上面提到的几个方向。前提始终是用户的授权和感知永远放在第一位技术只是实现的手段。我在做了两个实际项目之后最深的体会是File System Access API 本身不难代码量也不大难的是把权限模型、句柄生命周期和异步写入机制这些概念真正融入产品设计。从用户点击“打开”开始到之后每一次“保存”整个链路都要把授权状态考虑进去。只要把这些细节处理好Web 应用离原生体验的差距就又被抹平了一大截。
阅读完成 · 觉得有帮助?