读:File System Access API,浏览器原生读写本地文件
File System Access API 是一组 Web API,让网页通过 JavaScript 直接读写用户本地文件。以前网页只能用 <input type="file"> 让用户选文件上传,选中后只能读内容,不能写回去。File System Access API 把这个能力补齐了,读和写都能做,还能列目录、删文件。不过目前只有 Chrome 系桌面浏览器支持,Firefox 和 Safari 都没有跟进,详见后文。
CSS-Tricks 上有一篇 入门教程 按读、写、目录、删除四块讲核心 API。本文按原文结构梳理,顺便补上原文没展开的设计细节和 2026 年的浏览器支持现状。
先拿一个能跑的页面
不想一段段看代码的,可以先把下面这个 HTML 存成文件,起个本地服务器直接点按钮试。把代码存成 demo.html=,然后在同目录下执行 =python3 -m http.server 8000=,浏览器打开 =http://localhost:8000/demo.html 就行。
为什么要起服务器?因为 File System Access API 只在安全上下文(HTTPS 或 localhost)里可用,直接双击 .html 文件走的是 file:// 协议,API 会变成 =undefined=。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>File System Access API 试试看</title> <style> body { font-family: sans-serif; max-width: 700px; margin: 40px auto; } button { margin: 4px; padding: 8px 16px; cursor: pointer; font-size: 14px; } pre { background: #f5f5f5; padding: 12px; white-space: pre-wrap; word-break: break-all; min-height: 100px; } </style> </head> <body> <h1>File System Access API 试试看</h1> <p>需要 Chrome 或 Edge 105+,页面必须跑在 localhost 或 HTTPS 上(不能直接双击打开)。</p> <div> <button class="pick-file">读单个文件</button> <button class="pick-multi">读多个文件</button> <button class="pick-image">只选图片</button> <button class="save-file">写新文件</button> <button class="edit-file">改已有文件(覆盖)</button> <button class="read-dir">列出目录</button> <button class="del-file">删除文件</button> <button class="del-entry">删除目录内条目</button> </div> <h3>输出</h3> <pre id="output">点上面的按钮试试</pre> <script> const out = document.getElementById("output"); const log = (msg) => { out.textContent = msg; }; let fileHandle; // 读单个文件 document.querySelector(".pick-file").onclick = async () => { try { [fileHandle] = await window.showOpenFilePicker(); } catch (err) { log("取消了选择"); return; } const file = await fileHandle.getFile(); const content = await file.text(); log( "文件名: " + file.name + "\n大小: " + file.size + " 字节" + "\n类型: " + file.type + "\n最后修改: " + new Date(file.lastModified).toLocaleString() + "\n\n--- 内容 ---\n" + content ); }; // 读多个文件 document.querySelector(".pick-multi").onclick = async () => { try { const handles = await window.showOpenFilePicker({ multiple: true }); const results = await Promise.all( handles.map(async (h) => { const f = await h.getFile(); return "[" + f.name + "]\n" + await f.text(); }) ); log(results.join("\n\n---\n\n")); } catch (err) { log("取消了选择"); } }; // 只选图片(限定文件类型) document.querySelector(".pick-image").onclick = async () => { try { const handles = await window.showOpenFilePicker({ multiple: true, types: [{ description: "图片", accept: { "image/jpeg": [".jpeg"], "image/png": [".png"] }, }], excludeAcceptAllOption: true, }); const names = handles.map(async (h) => (await h.getFile()).name); log("选中的图片:\n" + (await Promise.all(names)).join("\n")); } catch (err) { log("取消了选择"); } }; // 写新文件 document.querySelector(".save-file").onclick = async () => { try { const handle = await window.showSaveFilePicker({ types: [{ description: "文本文件", accept: { "text/plain": [".txt"] } }], }); const writable = await handle.createWritable(); await writable.write("Hello World\n这是用 File System Access API 写入的内容。"); await writable.close(); log("已保存到: " + handle.name); } catch (err) { log("取消了保存"); } }; // 改已有文件(覆盖) document.querySelector(".edit-file").onclick = async () => { try { [fileHandle] = await window.showOpenFilePicker(); const writable = await fileHandle.createWritable(); await writable.write("文件内容已被替换。\n" + new Date().toLocaleString()); await writable.close(); log("已覆盖写入: " + fileHandle.name); } catch (err) { log("取消了选择"); } }; // 列出目录 document.querySelector(".read-dir").onclick = async () => { try { const dirHandle = await window.showDirectoryPicker(); const entries = []; for await (const entry of dirHandle.values()) { entries.push(entry.kind + "\t" + entry.name); } log("目录: " + dirHandle.name + "\n\n" + entries.join("\n")); } catch (err) { log("取消了选择"); } }; // 删除文件 document.querySelector(".del-file").onclick = async () => { try { const [handle] = await window.showOpenFilePicker(); await handle.remove(); log("已删除: " + handle.name); } catch (err) { log("取消了选择"); } }; // 删除目录内条目 document.querySelector(".del-entry").onclick = async () => { try { const dirHandle = await window.showDirectoryPicker(); const name = prompt("输入要删除的文件或目录名:"); if (!name) { log("没输入名字"); return; } try { await dirHandle.removeEntry(name); log("已删除: " + name); } catch (e) { // 可能是目录且有内容,尝试递归删 if (e.name === "InvalidModificationError") { await dirHandle.removeEntry(name, { recursive: true }); log("已递归删除目录: " + name); } else { throw e; } } } catch (err) { log("取消了选择或文件不存在: " + err.message); } }; </script> </body> </html>
下面逐个讲每个操作背后的代码。
动手之前得满足两个条件
调用这组 API 有两个硬性前提。一是必须在安全上下文中,也就是 HTTPS 页面。localhost 是例外,浏览器把它当作安全的,方便本地开发调试。二是必须由用户主动触发,比如点击按钮。不能在页面加载时自动弹出文件选择框,也不能在定时器回调里偷偷调。
这个限制是故意的,防止网页在用户不知情的情况下操作本地文件。下文所有代码示例都假设挂在一个按钮的 click 事件上,且需要在 Chrome 或 Edge 105+ 浏览器中执行(其他浏览器不支持,原因见后文)。
读文件
读单个文件
不到十行代码就能读完一个文件。
let fileHandle; document.querySelector(".pick-file").onclick = async () => { try { [fileHandle] = await window.showOpenFilePicker(); } catch (err) { // 用户点了取消,什么都不用做 return; } const file = await fileHandle.getFile(); const content = await file.text(); console.log(content); };
window.showOpenFilePicker() 弹出系统的文件选择框。注意等号左边的中括号,这个方法返回的是一个数组(哪怕只选了一个文件),这里用解构语法取第一个元素。
拿到的 fileHandle 是一个 FileSystemFileHandle 对象,长这样。
FileSystemFileHandle {kind: 'file', name: 'data.txt'}
kind 要么是 file 要么是 directory=,=name 是文件名。这个 handle 不直接包含文件内容,只是文件的一个引用。要拿内容,得先调 getFile() 得到 File 对象,再调 text() 读文本。 getFile() 返回的 File 对象还带几个元数据属性: name (文件名)、 size (字节数)、 type (MIME 类型)、 lastModified (最后修改时间的毫秒时间戳)。
这里的设计是把 handle(引用)和 file(内容快照)分开了。 getFile() 每次调用都返回当前最新的文件状态,不会缓存第一次选中时的版本。用户要是在网页打开期间用其他程序改了文件,可以通过再调一次 getFile() 来拿到新内容。
另外,handle 不会随页面刷新自动保留。想跨会话复用同一个文件引用,需要手动把 handle 存进 IndexedDB。
读多个文件
传一个 multiple: true 选项就能选多个文件。
const options = { multiple: true, }; document.querySelector(".pick-file").onclick = async () => { const fileHandles = await window.showOpenFilePicker(options); const allContent = await Promise.all( fileHandles.map(async (handle) => { const file = await handle.getFile(); return file.text(); }) ); console.log(allContent); };
还可以限定能选的文件类型。比如只接受 JPEG 图片。
const options = { types: [ { description: "Images", accept: { "image/jpeg": [".jpeg"], }, }, ], excludeAcceptAllOption: true, };
excludeAcceptAllOption: true 会去掉选择框里那个「所有文件」的选项,强制用户只能选指定类型。
写文件
写新文件
document.querySelector(".save-file").onclick = async () => { const options = { types: [ { description: "Test files", accept: { "text/plain": [".txt"], }, }, ], }; const handle = await window.showSaveFilePicker(options); const writable = await handle.createWritable(); await writable.write("Hello World"); await writable.close(); };
showSaveFilePicker() 弹出的是另存为对话框,让用户选保存位置和文件名。拿到 handle 后,调 createWritable() 创建一个可写流,再用 write() 写内容,最后 close() 关闭流并落盘。
close() 这一步不能漏。忘了调的话,内容可能还留在内存缓冲区里,没真正写到磁盘上。
改已有文件
把读和写拼起来就是改文件。
let fileHandle; document.querySelector(".pick-file").onclick = async () => { [fileHandle] = await window.showOpenFilePicker(); const writable = await fileHandle.createWritable(); await writable.write("This is a new line"); await writable.close(); };
这里有个原文没强调的坑。=write()= 是覆盖整个文件,不追加。上面的代码执行后,原文件的内容会被 "This is a new line" 完全替换掉。想追加的话,得先读出原内容,自己拼接后再写回去。
目录和删除
列出目录内容
document.querySelector(".read-dir").onclick = async () => { const directoryHandle = await window.showDirectoryPicker(); for await (const entry of directoryHandle.values()) { console.log(entry.kind, entry.name); } };
showDirectoryPicker() 让用户选一个目录,返回的 directoryHandle 可以用 for await...of 遍历里面的条目。每个 entry 也有 kind 和 name ,输出大致是这样的。
file data.txt file image.jpeg directory subdir
注意只列出顶层条目,不会递归到子目录。要递归得自己写遍历逻辑。
删除文件和目录
删选中的文件或目录,拿到 handle 后直接调 remove()=。文件用 =showOpenFilePicker() 拿 handle,目录用 =showDirectoryPicker()=,删除调用一样。
// 删文件 const [fileHandle] = await window.showOpenFilePicker(); await fileHandle.remove(); // 删整个目录(连同里面所有内容) const directoryHandle = await window.showDirectoryPicker(); await directoryHandle.remove();
删目录里的某个指定条目(而不是整个目录),用 =removeEntry()=。删子目录时如果里面有内容,必须加 ={ recursive: true }=,否则报错。
const directoryHandle = await window.showDirectoryPicker(); // 删目录里的某个文件 await directoryHandle.removeEntry("data.txt"); // 递归删一个子目录(里面有内容时必须加 recursive: true) await directoryHandle.removeEntry('data', { recursive: true });
2026 年的浏览器支持现状
原文的浏览器支持表没标日期。2026 年的真实情况是这样的。
先看桌面端。Chrome 105+ 和 Edge 105+ 支持,Opera 也行(同为 Chromium 内核)。Firefox 从 2 到最新版全不支持,Mozilla 的官方立场把这个 API 标为 harmful,理由是让网页直接操作本地文件对用户安全风险太大。Safari 同样全版本不支持。
移动端更惨,Chrome for Android、Safari on iOS、Samsung Internet、Firefox for Android 全不支持,手机上没有任何浏览器能用这组 API。
数据来源:https://caniuse.com/native-filesystem-api(2026-06-25 查询)
这个支持现状直接决定了 File System Access API 目前不适合用于面向公众的生产环境。网页应用要是依赖这组 API,Firefox 用户、Safari 用户和所有手机用户都用不了。
不过浏览器其实另开了一条路,Origin Private File System(OPFS)。OPFS 让网页在一个沙盒内的虚拟文件系统中读写,不碰用户的真实文件系统。这个能力支持范围广得多(Firefox 111+、Safari 15.2+),因为不涉及访问用户真实文件,安全风险低得多。如果只是想在浏览器里存点数据,OPFS 比 File System Access API 更合适。
我的评价
这组 API 的设计本身是合理的。handle 和 file 分层、强制用户手势、强制 HTTPS、读写权限不随页面刷新自动保留,每个设计都在收紧网页碰本地文件这个危险操作的风险面。 getFile() 每次返回最新快照,不会缓存首次选择时的版本,这个细节也考虑到了文件被外部修改的场景。
支持不全这个现实摆在这里。原文推荐了一个叫 browser-fs-access 的 ponyfill(ponyfill 和 polyfill 的区别在于,polyfill 会往全局对象上补方法,ponyfill 只导出自己用的方法,不改全局环境),在不支持的浏览器里自动降级到传统的 <input type"file">= 上传和 <a download> 下载。真要在项目里用这组 API,建议直接上这个库做兼容层,别裸调原生 API。