做前端这些年,最容易被问倒的一个问题就是:网页到底能不能直接写文件?传统浏览器的态度一直是“铁桶策略”——页面能读文件、能触发下载,但你想把内容写回用户磁盘上某个指定文件,难如登天。File System Access API 把这个局面彻底改了:页面脚本在用户明确授权后,可以打开本地文件、修改内容并保存回去,甚至能遍历用户选择的目录、创建新文件。简单说,以前网页像个只能进不能出的沙箱,现在这个沙箱开了一扇由用户亲自把守的门。
这篇文章我会从原理入手,把三个核心入口方法、文件读写、目录遍历、权限持久化全部过一遍,最后给一个完整的编辑器示例和一份避坑清单。适合正在做浏览器端编辑器、文件管理工具、图像批处理工具,或者对 Web 平台新能力感兴趣的前端开发者。不需要太深的底层基础,只要写过 JavaScript 就能跟上。
1. 这 API 到底解决了什么问题
1.1 网页读写文件的演进
在 File System Access API 出现之前,网页操作文件的路子基本只有两条。
第一条是<input type="file">上传。这个方案能读文件,但拿到的是一个被剥离了路径的File对象,浏览器不会告诉你文件在磁盘哪里,更不让你把它写回去。第二条是下载。如果你想让用户“保存配置”,通常得生成一个 Blob、创建一个临时的a标签、用download属性触发下载。用户看到的是一堆从下载目录里蹦出来的新文件,要手动挪到目标位置。这个体验有多割裂,做过内部管理系统的人都知道。
问题的根源不在权限,而在浏览器根本没有暴露“文件系统句柄”这个概念。你说的“写文件”,在浏览器看来等价于“让用户手工完成一次下载和一次移动”,整个过程没有连续性。
1.2 工作原理一句话说清
File System Access API 的核心思路,是把真实磁盘上的文件、目录变成 JavaScript 里的句柄对象,让页面可以反复使用这个句柄来读取、修改、删除,直到用户主动收回授权。
整个过程分三步:
- 用户通过选择器(picker)主动授权,浏览器返回
FileSystemFileHandle或FileSystemDirectoryHandle。 - 页面拿着句柄,调用
getFile()读内容,调用createWritable()拿写入流。 - 句柄可以被持久化存储。下次打开页面,只要用户授权仍然有效,页面可以再次恢复句柄并直接读取或写入。
这里最关键的一点是:所有对真实磁盘的访问都必须经过用户手势。你没法在页面加载时偷偷打开文件,也没法从任意路径读取。浏览器把选择权完全交给了用户。
1.3 能干什么、不该干什么
我用实际体验告诉你,这个 API 最适合的三类场景:
- 浏览器端编辑器,比如 Markdown 编辑器、代码编辑器、画图工具,用户习惯性按
Ctrl+S保存。 - 文件批处理工具,比如批量重命名、图片压缩、格式转换,选一个目录后直接操作其中的文件。
- 本地优先类应用,比如笔记软件、离线数据管理工具,文件直接落在用户磁盘上而不是某个云盘。
但它不是万能的。比如它不能让网页任意读取磁盘所有文件,权限模型里没有“全盘访问”这个选项;也不能替代本地上传组件去读文件,因为上传场景往往不需要保留句柄,用传统input更轻。另外,浏览器标签页、后台脚本、Service Worker 都拿不到这个 API 的完整能力,一定要在页面上下文和用户交互中使用。
2. 核心入口:三个 Picker 方法
2.1 showOpenFilePicker:打开文件
使用showOpenFilePicker时,浏览器会弹出一个文件选择窗口。用户选完文件后,你拿到一个FileSystemFileHandle数组。
const [handle] = await window.showOpenFilePicker({ types: [ { description: '文本文件', accept: { 'text/plain': ['.txt', '.md', '.markdown'] } } ], multiple: false, });注意三个细节。
第一,types里的accept是 MIME 类型到扩展名数组的映射,主要影响系统文件选择器里默认显示哪些文件。如果你不传types,选择器会显示所有文件。
第二,multiple默认是false。设为true时,用户可以多选,你得到的是一个数组,每个元素都是独立的句柄。
第三,选择器还支持id和startIn参数。id用于记住用户上次打开的目录,比如你传id: 'edit-project',下次再打开时选择器会优先定位到上次的位置。startIn可以直接传'desktop'、'documents'、'downloads'这类常用目录名,也可以传一个目录句柄,让选择器从指定目录开始。
如果你支持这类功能,但选择器本身是浏览器弹窗,开发者没法直接控制它的 UI,所以不要试图“定制选择器”。能做的只是在用户点按钮时调用对应方法,然后处理结果。
2.2 showSaveFilePicker:保存文件
保存文件同样要先弹选择器。用户可以选择覆盖一个已有文件,也可以输入一个新文件名。
const handle = await window.showSaveFilePicker({ suggestedName: 'untitled.md', types: [ { description: 'Markdown 文档', accept: { 'text/markdown': ['.md'] } } ] });showSaveFilePicker返回的是一个句柄,不是给你一张“空文件”。它对应磁盘上用户指定的位置,页面后续在这个句柄上写内容即可。
这里有个容易忽略的点:suggestedName只是建议名字,用户完全可以在弹窗里改掉,所以你写文件的时候不要硬编码判断文件名和suggestedName一致。保存逻辑应该始终以返回的句柄为准。
2.3 showDirectoryPicker:选择目录
选择目录后,你得到的是FileSystemDirectoryHandle,这个句柄代表整个目录,你可以在上面列出文件、创建文件、删除子项。
const dirHandle = await window.showDirectoryPicker({ id: 'work-dir', mode: 'readwrite' // 只读模式是 'read' });看到mode参数了吧。选目录时可以指定只读或读写模式。默认行为其实比较保守,但既然选择器都让用户选了目录,绝大多数业务都需要写操作,所以我会直接传'readwrite',避免后续写入时报权限不足。
showDirectoryPicker的返回句柄上没有直接获取目录内文件内容的方法,你得先调用entries()或values()遍历,再对每个子项处理。这正好引出第三章的内容。
3. 拿到句柄后:读取、写入、目录遍历
3.1 读取文件内容
从FileSystemFileHandle上获取文件内容,方法名非常直白:getFile()。它返回一个标准的File对象,之后的操作就跟处理普通上传文件完全一致了。
const file = await handle.getFile(); const text = await file.text(); const arrayBuffer = await file.arrayBuffer();这里我要提醒一个性能细节:getFile()返回的File对象是在调用时刻对文件内容快照的封装。如果你在外部用其他程序改了文件,你再调用getFile()一般会拿到新内容,但如果你一直持有同一个File对象不重新获取,它是不会自己变的。
所以,无论编辑器还是批处理工具,每次真正的读取动作都应该重新执行getFile(),而不是缓存File实例。我自己踩过这个坑:在代码编辑器里监听文件变化时,我以为句柄是“活的”,结果数据一直不更新,排查半天才发现问题出在缓存上。
另外File对象上有个lastModified属性,保存着文件最后的修改时间戳。你可以拿它做冲突检测,比如用户打开文件后,外部程序修改了它,你可以在保存前比对这个时间戳,提示用户是否覆盖。
3.2 写入文件的正确姿势
写入文件的方法和传统文件系统不太一样,不提供writeFile(handle, data)这种一次性方法,而是要求你先调用createWritable()创建一个写流,写入完成后再调close()关闭流。
const writable = await handle.createWritable({ keepExistingData: false }); await writable.write('新的内容'); await writable.close();createWritable()的行为值得展开讲。默认情况下它会把文件原内容清空,相当于“截断重写”。如果你只想在文件末尾追加,或想修改部分内容,必须处理keepExistingData参数。
keepExistingData: false(默认):清空文件,从零开始写。keepExistingData: true:保留原内容,你可以用write方法的第二个参数指定写入位置。
示例:在文件开头插入一行。
const writable = await handle.createWritable({ keepExistingData: true }); await writable.write({ type: 'write', position: 0, data: '# 标题\n' }); await writable.close();write方法还有两个兄弟函数:truncate(size)和seek(position)。seek用于移动写入指针,truncate用来截断文件。大部分普通应用用不到这两个,但做底层编辑器时很有用。
写入流的close()不能省。数据不一定在每次write调用后就落盘,而是先进入内部缓冲,直到close()才真正提交并释放写锁。如果你忘了close(),数据很可能丢,而且文件会被锁定一段时间,外部程序也无法修改。我在做自动保存功能时,一度为了省事没有及时 close,结果整文件内容在用户切走浏览器标签页时意外丢失,后续加了自动保存队列才解决。
3.3 目录遍历与创建子项
拿到目录句柄后,第一个任务是知道里面有什么。
for await (const entry of dirHandle.values()) { console.log(entry.name, entry.kind); }这里有几个遍历方法:
values():遍历目录下的直接子项句柄。keys():只返回子项名称。entries():返回[name, handle]对。
注意values()返回的是异步迭代器,直接const entries = await dirHandle.values()是不行的,必须用for await...of或者手动调用next()。
在目录里新建文件或子目录:
const fileHandle = await dirHandle.getFileHandle('notes.md', { create: true }); const subDir = await dirHandle.getDirectoryHandle('images', { create: true });getFileHandle和getDirectoryHandle的第一个参数是名称,第二个参数的create决定“不存在时是否创建”。如果create为false且文件不存在,会返回一个错误。常见报错是NotFoundError,配合try...catch处理即可。
删除目录子项的代码:
await dirHandle.removeEntry('old-file.txt'); await dirHandle.removeEntry('old-dir', { recursive: true });删除目录时,如果目录非空,必须传{ recursive: true },否则会拒绝删除。这不是偷偷加的逻辑,是浏览器基于安全性强制要求——类似用命令行删除非空目录也要加-r一样。我在批量清理工具里经常遇到这个报错,后来形成习惯:凡是在目录句柄上执行removeEntry,只要目标是目录,无条件带上recursive。
4. 权限持久化:保存句柄与重新授权
4.1 句柄的存储
文件句柄对象是复数对象,不是简单的字符串。它能在页面会话中传递,也能被持久化存储。标准的持久化存储位置是 IndexedDB。我见过有人试图把句柄塞进localStorage,这行不通,因为localStorage只能存字符串,而句柄需要结构化克隆。
给你一个最小示例,把句柄存进 IndexedDB。
async function saveHandle(key, handle) { const db = await openDB(); const tx = db.transaction('handles', 'readwrite'); tx.objectStore('handles').put(handle, key); await tx.done; } async function loadHandle(key) { const db = await openDB(); const tx = db.transaction('handles', 'readonly'); return tx.objectStore('handles').get(key); }这里不展开openDB()的完整代码,你用原生 IndexedDB 写一个健壮的封装即可。需要注意的是,某些环境下的 IndexedDB 对句柄的存储行为有差异,最保险的用法是把句柄放进一个对象里一起存,而不是单独塞一个裸句柄字段。
存储之后,页面重新加载时句柄自身是“休眠”的。它能被反序列化出来,但并不能直接当成已经授权来用。你必须在需要访问前检查权限,必要时请求新的授权。
4.2 重新授权:queryPermission 与 requestPermission
每个句柄都有两个和权限相关的方法:queryPermission()和requestPermission()。
const options = { mode: 'readwrite' }; if (await handle.queryPermission(options) !== 'granted') { if (await handle.requestPermission(options) !== 'granted') { // 用户拒绝授权,降级处理 return; } } // 到这里才有权限继续读写queryPermission()不会弹窗,只返回当前状态。requestPermission()会弹出授权框,提示用户是否允许页面访问这个文件或目录。
requestPermission()有一个硬性要求:必须在用户手势的调用栈里调用。比如按钮点击事件、键盘事件回调里。如果页面刚加载完就自动调requestPermission(),浏览器会直接拒绝或静默忽略。我试过在setTimeout里模拟点击后的延迟调用,照样被拒,所以别琢磨绕过手势限制,老老实实在用户触发的事件处理函数里调用。
权限状态不管是'granted'还是'denied',都建议缓存到内存变量里,避免每次操作都重复queryPermission,造成不必要的频次开销。
4.3 免授权的私有空间 OPFS
如果你不想让用户每次打开页面都授权一次,但业务又不要求文件能出现在用户磁盘上,有第二个选择:Origin Private File System,缩写为 OPFS。
const root = await navigator.storage.getDirectory(); const fileHandle = await root.getFileHandle('local-data.json', { create: true });OPFS 也是 File System Access API 的一部分,但它返回的目录句柄不需要任何弹窗授权。缺点是这个文件系统完全“私有”——用户不会在你的下载目录、桌面、文档里看到它,它只属于当前站点源在自己的存储配额里分配的一块空间。不同源之间无法访问彼此的 OPFS 内容,普通用户更没办法通过系统文件管理器直接看到这些文件。
OPFS 用来缓存草稿、离线数据、大文件分块下载后的临时存储都很合适。但如果你想要用户亲手拿到一个文件,给它改名字、放进某个文件夹、发给同事,那还得走前面三个 picker 方法。
5. 完整实战:做一个记住上次会话的编辑器
5.1 整体设计
这部分我做一个极简的 Markdown 编辑器,重点不是 UI,而是完整串一遍整个 API 的流程:
- 用户点击“打开文件”,选择文件后读内容。
- 按下
Ctrl+S,内容写回当前文件;如果还没有文件,弹保存选择器。 - 页面加载时,从 IndexedDB 恢复上次打开的句柄,自动重新授权。
- 标题栏显示文件名,状态区显示权限状态。
这是一个非常典型的 File System Access API 应用骨架,你完全可以拿它当模板去套其他工具。
5.2 关键代码实现
先写打开动作。
let currentHandle = null; let currentFile = null; async function openFile() { const [handle] = await window.showOpenFilePicker({ types: [{ description: 'Markdown', accept: { 'text/markdown': ['.md'] } }] }); currentHandle = handle; await loadFromHandle(handle); await saveHandleToIDB(handle); } async function loadFromHandle(handle) { const file = await handle.getFile(); currentFile = file; editor.value = await file.text(); title.textContent = file.name; }保存动作。
async function saveFile() { if (!currentHandle) { currentHandle = await window.showSaveFilePicker({ suggestedName: 'untitled.md', types: [{ description: 'Markdown', accept: { 'text/markdown': ['.md'] } }] }); await saveHandleToIDB(currentHandle); } if (await verifyPermission(currentHandle, 'readwrite') !== 'granted') { return; } const writable = await currentHandle.createWritable(); await writable.write(editor.value); await writable.close(); }页面加载时恢复上次会话。
async function restoreLastSession() { const savedHandle = await loadHandleFromIDB('last-file'); if (!savedHandle) return; currentHandle = savedHandle; if (await verifyPermission(savedHandle, 'readwrite') !== 'granted') { statusText.textContent = '需要重新授权'; return; } await loadFromHandle(savedHandle); }这里有一点很重要:恢复句柄后不要立刻调用requestPermission(),因为页面加载阶段不属于用户手势触发,会被拒绝。安全做法是先queryPermission(),如果状态是'granted',直接读取;否则在界面上放一个“恢复上次文件”按钮,让用户点击时再请求授权。
我真正做项目时体会最深的就是这个边界:很多用户不理解为什么第二次打开页面时文件打不开,不是句柄丢了,而是浏览器不允许脚本在无人操作时自动弹授权框。你必须把“恢复会话”做成一个显式的用户动作。
5.3 拖拽打开与兼容降级
File System Access API 还有一个配套方法,能从拖拽事件里直接提取句柄,省掉文件选择器:
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); }); dropZone.addEventListener('drop', async (e) => { e.preventDefault(); const items = e.dataTransfer.items; for (const item of items) { if (item.kind === 'file') { const handle = await item.getAsFileSystemHandle(); if (handle.kind === 'file') { currentHandle = handle; await loadFromHandle(handle); } } } });getAsFileSystemHandle()也不是所有浏览器都支持。在兼容性评估时,我的习惯是写一个特征检测,能力不足就回退到input[type=file]。
const supportsFSA = 'showOpenFilePicker' in window;不支持的时候,openFile函数直接走老路线:创建一个隐藏的input,触发click(),拿File对象读取。保存就更简单了,只能生成下载链接,告诉用户手动保存。
这套降级逻辑意味着:你在 UI 和状态管理里最好把“文件句柄”和“普通 File 对象”抽象成同一种接口,打开文件后统一调用loadFromHandle。如果拿不到句柄,就用一个假的handle包装File对象,这样页面其他逻辑不用写两份。
6. 踩坑实录:常见问题与排查速查
6.1 权限与操作报错速查表
我在不同项目里重复踩过的坑,整理成一张速查表,每一条都对应一个真实报错场景。
| 错误或现象 | 常见原因 | 解决方式 |
|---|---|---|
| 用户点击打开后选择器一闪而过,没有文件列表 | types配置不对,或扩展名与 MIME 不匹配 | 检查accept映射,确认扩展名带点且正确 |
requestPermission()不弹窗、直接返回非 granted | 不在用户手势调用栈里 | 把调用移到按钮、快捷键等用户事件回调中 |
createWritable()一直报SecurityError | 句柄权限是只读,或未请求读写权限 | 确保queryPermission/requestPermission使用的是readwrite模式 |
| 保存后文件内容丢了 | createWritable()默认清空文件,且写入异常未处理 | 明确传keepExistingData: true,用try...finally保证 close |
removeEntry删不掉非空目录 | 没加recursive: true | 删除目录时无条件加{ recursive: true } |
| 跨标签页无法共享保存的句柄 | 句柄存储在 IndexedDB,但不同站点的存储隔离,同站点不同标签一般可以共享 | 确认是同一站点,检查 IDB 的读写事务是否正确 |
拖拽进来没有getAsFileSystemHandle()方法 | 浏览器不支持或拖拽物品不是文件类型 | 检测item.getAsFileSystemHandle是否存在,无则走传统上传 |
| 保存大量数据时页面卡死 | 一次性 write 超大 Blob | 分片写入,配合await writable.write()多次操作 |
每个问题都不是孤立的技术点,很多都来自浏览器安全模型。理解它们的共同逻辑会省很多时间:浏览器对磁盘操作持保守态度,任何看起来“过于自动”的行为都可能被阻止。
6.2 一些容易忽略的坑
文件外部修改冲突是我最后想重点讲的一个坑。用户用你的编辑器打开文件后,去系统文件管理器里改了同一个文件,然后回来按Ctrl+S,编辑器会把它的内存内容原样写回去,覆盖掉外部修改。这是个很危险的数据丢失路径。
缓解办法很简单:打开文件时记录lastModified,保存时比较:
async function isFileChanged(handle, lastModified) { const file = await handle.getFile(); return file.lastModified !== lastModified; }如果检测到变化,弹一个确认框“文件已被其他程序修改,是否覆盖?”。这个兜底逻辑在我做笔记工具时救过不少数据。
另一个容易被忽略的是createWritable()之后文件被锁定。写入流打开期间,系统内的其他进程无法修改这个文件。如果你一边写一边等用户做其他操作,非常容易触发冲突。所以写入流生命周期要短,除非你在做分片上传,否则保持“打开流-写入-立刻关闭”的节奏最稳。
最后一个提醒:所有打开选择器的方法,用户取消时都会抛一个AbortError。这不是真的错误,但你不能假装没看见,否则控制台一堆红色报错,用户看到也心慌。
try { const [handle] = await window.showOpenFilePicker(); } catch (err) { if (err.name === 'AbortError') return; throw err; }把AbortError单独归类处理,剩下的错误再走统一错误提示,这是我对每个使用 File System Access API 的代码库都会做的第一件事。
我个人在实际项目中用下来,这个 API 最值得投资的地方其实不是某个炫技功能,而是把网页从“临时任务”变成了“日常工具”。以前用户对网页里编辑文件这件事没有信任感,现在有了句柄、有了持久授权、有了真实的磁盘文件,很多之前要在桌面端软件里才能完成的工作流,浏览器已经能扛了。如果你正在做编辑类或者工具类的 Web 应用,建议尽早把你的上传和下载逻辑替换成句柄方案,用户体验的差距会非常明显。