ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

File System Access API 实战:让网页真正读写本地文件

File System Access API 实战:让网页真正读写本地文件 MarkViewhttps://markview.arthttps://github.com/acheding/markview一个纯前端的 Markdown 编辑器最尴尬的地方是它读不到你电脑上的文件——只能「导入一份副本」编辑完再「导出下载」源文件纹丝不动。File System Access API 改变了这件事showOpenFilePicker拿到的是一个文件句柄可以直接createWritable()写回原文件句柄还能存进 IndexedDB下次打开网页时恢复。MarkView 用它实现了CtrlO打开、CtrlS写回以及安装成 PWA 后双击.md文件直接打开。但真正的工作量不在调 API而在它带来的一整套状态问题权限会过期、文件会在别处被改、文件会被删掉、浏览器可能压根不支持。本文讲这些。一、基础三件套封装层是纯逻辑无 Vue、无应用状态// src/core/documents/fileAccess.jsconstMARKDOWN_PICKER_TYPES[{description:Markdown,accept:{text/markdown:[.md,.markdown]}}]// 用户取消AbortError返回空数组/null调用方静默即可其余异常照常抛出。constisPickerCancel(error)error?.nameAbortErrorexportconstpickOpenFilesasync(){try{returnawaitwindow.showOpenFilePicker({multiple:true,types:MARKDOWN_PICKER_TYPES})}catch(error){if(isPickerCancel(error))return[]throwerror}}第一个要处理的就是取消不是错误。用户按 Esc 关掉选择器浏览器抛AbortError如果不拦会弹一个「打开失败」的 toast非常无厘头。这里把它归一化成假值打开返回[]另存返回null调用方直接静默返回。写回是标准的三步但最后一步容易漏// 写回并返回写后的磁盘 mtime作为下次外部修改检测的基准。exportconstwriteFileHandleasync(handle,content){constwritableawaithandle.createWritable()awaitwritable.write(content)awaitwritable.close()constfileawaithandle.getFile()return{lastModified:file.lastModified}}close()之后再取一次lastModified——这个值是后面整套外部修改检测的锚点。少了它你自己写回的操作下一次就会被误判成「别人改了文件」。还有一个轻量版只取 mtime 不读内容// 只取 mtime 不读内容写回前的外部修改快检、focus 时的轻量轮询。exportconststatFileHandleasync(handle){constfileawaithandle.getFile()return{name:file.name,lastModified:file.lastModified}}二、句柄持久化能存但权限不能FileSystemFileHandle可以结构化克隆意味着能直接塞进 IndexedDB。MarkView 为此单开了一张表// v2新增 fileHandles 表持久化文档关联的 FileSystemFileHandle结构化克隆存储// 与文档主表分离——句柄无法参与内容签名比较不能混进增量写入的 documents 表。存取就是普通的put/getAll值直接是句柄对象。但权限不会跟着一起恢复。下次打开网页句柄还在queryPermission()返回prompt——你得重新要一次。而requestPermission()有个硬约束// 必须在用户手势keydown/click 等内调用否则浏览器直接拒绝。exportconstrequestFilePermissionasync(handle){if(typeofhandle?.requestPermission!function)returngrantedreturnhandle.requestPermission({mode:readwrite})}所以启动装载时只能query不能request那时没有用户手势真正要权限的时机有两个CtrlS 的 keydown 里和用户点击头部状态标签时。// CtrlS 的 keydown 是用户手势可直接请求权限跨会话恢复的句柄默认 prompt。if(link.permission!granted){constpermissionawaitrequestFilePermission(handle).catch(()denied)updateLink(docId,{permission})if(permission!granted){toast.show(未获得文件写入权限可用「另存为本地文件」保存,{tone:warning,duration:3000})returndenied}}UI 上「需要重新授权」这个状态被做成了可点击的状态标签——用户看到「重新授权文件访问」点一下就是一次合法的用户手势。这是把 API 约束翻译成交互设计的典型例子。顺带一提句柄不进响应式状态consthandlesnewMap()// docId → FileSystemFileHandle句柄不可比较内容不进响应式状态constlinksref({})// docId → link meta整体替换以触发响应驱动 UI 的是一份可序列化的元信息句柄本身只是一个不参与比较的副本。三、外部修改检测双基线这是全篇最有意思的部分。场景你在 MarkView 里打开了README.md切到 VS Code 改了几行再切回浏览器。这时候应该发生什么答案取决于两边各自改了没有于是需要两个基线// link meta 语义// savedSignature —— 上次与磁盘对齐时文档内容的签名null 基线未知跨会话恢复后尚未与磁盘核对。// lastModified —— 上次读/写时磁盘文件的 mtimenull 尚未核对。两者配合区分「本地脏」与「磁盘变了」。签名用 FNV-1a 加长度前缀够快够短// 内容签名FNV-1a 长度判断「当前内容是否与上次写回磁盘时一致」。// 只用于同一会话内的脏检查不做跨端一致性保证碰撞概率可忽略。exportconstcontentSignature(value){consttextString(value)lethash2166136261for(letindex0;indextext.length;index1){hash^text.charCodeAt(index)hashMath.imul(hash,16777619)}return${text.length}:${(hash0).toString(36)}}检测时机是三个事件没有轮询窗口focus、visibilitychange回到前台、切换文档。判定规则如下磁盘 mtime磁盘内容 vs 当前文档本地是否有未写回的编辑行为未变——直接返回不读内容变了相同—静默对齐基线变了不同没有静默重载磁盘版本 提示变了不同有弹窗让用户决定对应的代码constdiskNormalizednormalizeLineEndings(content)if(diskNormalizeddoc.content){// 内容一致mtime 变化来自外部 touch 或基线核对对齐基线即可。updateLink(docId,{savedSignature:contentSignature(doc.content),lastModified,name,/* … */})return}constlocalCleanlink.savedSignature!nullcontentSignature(doc.content)link.savedSignatureif(localClean){// 本地自上次同步后没动过安全地跟进磁盘版本。reloadFromDisk(docId,content,{name,lastModified})toast.show(已加载「${name}」的最新内容,{tone:success})return}// 双方都有变化或基线未知且内容不一致让用户决策绝不静默丢弃任何一方。constloadDiskawaitconfirm.ask({/* … */tone:danger})几个不那么显然的处理快路径不读内容。mtime 没变就直接返回连text()都不调——每次 focus 都全文读盘对大文件是浪费。「保留当前内容」也要对齐 mtime。// 对齐 mtime这次外部修改已知悉后续 CtrlS 直接覆盖、不再重复弹窗。updateLink(docId,{lastModified,name,missing:false,missingPrompted:false})否则用户选了「保留我的版本」下次 focus 又弹一遍同样的窗非常烦人。基线未知savedSignature null走「让用户决定」分支。跨会话恢复的句柄没有基线——上次会话结束后谁更新过无从判断。与其猜不如把这个场景收敛进已有的冲突分支而不是加一条特殊路径。重载时要丢编辑器缓存。constreloadFromDisk(docId,diskContent,{name,lastModified}){constnormalizednormalizeLineEndings(diskContent)documents.replaceContent(docId,normalized)if(documents.activeFileId.valuedocId)getEditor()?.setDoc(normalized)elsegetEditor()?.forgetDocument(docId)// …}活动文档直接setDoc后台文档则丢掉 CodeMirror 缓存的 EditorState否则切回去还是旧内容加一条污染的撤销栈。行尾归一化是签名一致性的前提。Windows 上的 CRLF 文件如果读进来不归一化每次比对都会「不一致」每次 focus 都误报外部修改// 统一行尾为 LF预设README 磁盘文件/导入文件可能带 CRLF而 marked 在词法分析时// 会把 token.raw 归一化成 LF源行标注data-source-line以 indexOf(token.raw) 回定位// 若 content 仍是 CRLF 则跨行 token 匹配失败滚动同步与搜索定位全部错位。故入库即归一化。写回前还有一次快检——因为 focus 检测和用户按 CtrlS 之间仍有时间窗// 写回前快检磁盘在别处被修改过则先确认避免静默覆盖外部编辑。以及一个容易忽略的时序细节updateLink(docId,{writing:true})// 确认框是异步的内容以落笔瞬间为准。constcontentgetDocById(docId)?.content??确认框弹出期间用户还能继续打字所以内容必须在确认之后才取。四、文件被删了、改名了、移走了这三种情况浏览器统一抛NotFoundError。处置逻辑最值得说的是三态确认框// 文件句柄因磁盘文件被移动、重命名或删除而失效时提示用户处置// 确认 另存到新位置转移关联取消按钮 取消关联、仅保留在 MarkView// Esc / 点遮罩 未做选择——保持丢失标记稍后可点击头部「本地文件已丢失」标签再处理。// 自动检查每次丢失只提示一次避免 focus/visibilitychange 反复打扰显式 CtrlS / 点标签可强制再次提示。confirm.ask返回true/false/null三种值null按 Esc不等于点了取消按钮if(choicetrue){if(documents.activeFileId.value!docId)returnfailedreturnsaveActiveAs()}// 关掉对话框Esc / 点遮罩暂不处置保留失效关联不静默改变文档归属。if(choice!false)returnfailed按 Esc 应该是「我先不处理」而不是「解除关联」。这个区分在测试里被单独钉死了一条用例。另外missingPrompted标志保证自动检查只弹一次窗而显式入口CtrlS、点状态标签传forcePrompt: true绕过它。文件如果「复活」了mtime 快路径命中标记自动清除。整套状态对外收敛成一个五值枚举exportconstFILE_LINK_STATUS{SYNCED:synced,// 内容与上次写回磁盘时一致DIRTY:dirty,// 有未写回磁盘的编辑WRITING:writing,// 正在写入磁盘PERMISSION:permission,// 跨会话恢复的句柄待重新授权MISSING:missing// 磁盘文件已被移动/删除}优先级是writing permission missing synced/dirty而 IndexedDB 保存失败永远优先于文件状态——「IndexedDB 保存失败是数据安全信号始终优先露出」。DOMException 到处置的映射整理成表错误名含义处置AbortError用户取消选择器静默NotAllowedError/SecurityError权限被撤销状态转PERMISSION标签可点重授权NotFoundError文件被删/移动/重命名走丢失处置流程其他未知记日志 error toast五、不支持的浏览器怎么办Firefox 和 Safari 目前都没有这套 API。MarkView 的降级思路是「打开」和「导入」本来就是两个并存的功能不支持时只是「打开」消失。// 「打开」与「导入」是两个并存的入口不做二选一// 打开 保留句柄、CtrlS 写回原文件仅支持 File System Access 时存在不支持的浏览器// 无按钮、不进面板、CtrlO 键位归还「导入」见 shortcuts.js// 导入 拷贝一份进工作区、与磁盘断开始终可用。快捷键的处理很巧妙——CtrlO在不支持的浏览器上「归还」给导入...(isFileSystemAccessSupported()?{open:{key:o},import:{key:o,alt:true}}:{import:[{key:o,alt:true},{key:o}]}),用户不必知道自己的浏览器缺什么CtrlO永远能打开点什么。能力检测分两级这点值得注意// 「打开/另存为」选择器需要 window.show*FilePickerlaunchQueue 句柄的写回只需 createWritable。exportconstisFileSystemAccessSupported()typeofwindow!undefinedtypeofwindow.showOpenFilePickerfunctiontypeofwindow.showSaveFilePickerfunction// launchQueue / 拖拽等外部来源的句柄是否可写回防御非 Chromium 实现给出只读句柄。exportconstisWritableFileHandle(handle)Boolean(handlehandle.kindfiletypeofhandle.createWritablefunction)全局 API 存在不代表每一个拿到的句柄都可写——外部来源launchQueue、其他实现可能给只读句柄所以建立关联前单独检测一次不可写就降级为普通导入副本。CtrlShiftS另存为也有降级不支持时直接走 Blob 下载。而导入始终用动态创建的input typefile// 每次新建实例重复选择同一文件也能触发 change。constopenImportPicker(){constinputdocument.createElement(input)input.typefileinput.accept.md,.markdown,text/markdown,text/plaininput.multipletrueinput.addEventListener(change,()importFiles(input.files),{once:true})input.click()}「每次新建实例」是为了绕开同一个 input 重复选同一文件不触发 change 的老坑。六、双击 .md 文件打开网页装成 PWA 后manifest 里注册文件处理器// 注册为 .md / .markdown 的系统文件处理器安装后双击这类文件即用 MarkView 打开// 应用侧由 launchQueue 消费者接收文件并导入为新文档见 createWorkspace。file_handlers:[{action:base,accept:{text/markdown:[.md,.markdown]}}],// 打开文件时优先复用已有窗口走 launchQueue而非每次新开一个实例。launch_handler:{client_mode:[focus-existing,auto]}应用侧接收constconsumeLaunchFilesasync(launchParams){consthandleslaunchParams?.filesif(!handles?.length)returnawaitwhenDocumentsReady()if(disk?.isSupported){awaitdisk.openViaHandles(handles)return}// …否则解析成 File 导入副本}constsetupFileHandling(){if(typeofwindowundefined||!(launchQueueinwindow))returnwindow.launchQueue.setConsumer(consumeLaunchFiles)}时序有讲究setConsumer必须尽早注册挂在onMounted里同步调用否则 launchParams 会丢但 consumer 内部要await whenDocumentsReady()——「避免导入被随后到达的初始状态覆盖」。注册要早动手要晚。同一个文件重复双击不该开出两份副本去重靠isSameEntry// 两个句柄是否指向磁盘上同一文件同文件去重实现缺失时按不同文件处理。exportconstisSameFileEntryasync(a,b){if(!a||!b||typeofa.isSameEntry!function)returnfalsetry{returnawaita.isSameEntry(b)}catch{returnfalse}}打开流程里还藏着一条顺序约束注释解释得很清楚// 先建关联再命名关联后名字被锁定setDocumentName 直接沿用磁盘文件名// 不参与重名加序号可与普通文档重名只读句柄降级为普通导入副本// 不建关联命名走普通文档的去重老规则。普通文档重名会自动加序号但关联了磁盘文件的文档必须跟磁盘同名——如果先命名后关联锁还没生效README.md会被改成README-1.md刷新后连磁盘文件名都跟着变。测试里专门有一条用例叫「建立关联先于命名」。七、这些浏览器 API 怎么测这套逻辑几乎全是异步 浏览器 API看起来很难测但实际上一个假句柄就够了——关键是让假句柄内建一份「虚拟磁盘」// —— 假句柄内建磁盘状态内容 / mtime / 权限行为与 FileSystemFileHandle 对齐 ——constmakeHandle(name,{content,lastModified1,permissiongranted}{}){constdiskState{name,content,lastModified,permission}consthandle{kind:file,diskState,queryPermission:vi.fn(async()diskState.permission),getFile:vi.fn(async()({name:diskState.name,lastModified:diskState.lastModified,text:async()diskState.content})),createWritable:vi.fn(async(){letpendingreturn{write:async(data){pendingdata},close:async(){diskState.contentpending diskState.lastModified1}}})}handle.isSameEntryvi.fn(async(other)otherhandle)returnhandle}close()才真正提交内容并让 mtime 自增——语义和真实 API 一致。于是模拟「别的程序改了文件」handle.diskState.content # 外部编辑; handle.diskState.lastModified 99模拟「文件被删了」让getFile抛NotFoundError模拟「权限被撤销」改diskState.permissionlaunchQueue也一样捕获 consumer 就能直接驱动// 捕获 launchQueue consumer模拟系统双击 .md 文件把句柄交给应用。conststubLaunchQueue(){letconsumernullvi.stubGlobal(window,{launchQueue:{setConsumer:(fn)(consumerfn)}})return()consumer}这里有个前提条件fileAccess.js里所有调用都写成window.showOpenFilePicker(...)而不是解构出来用才能整体vi.stubGlobal(window, …)替换掉。写法上多一个window.前缀换来的是整个模块可测。结语三条经验API 的约束会渗透到交互设计里——requestPermission必须在用户手势内于是「待授权」这个状态就得做成一个可点击的按钮这不是妥协是把技术约束翻译成了合理的 UI。冲突检测要双基线——只看 mtime 分不清「谁改的」只看内容签名不知道「磁盘动没动」两个基线加上一个「未知」态null四种场景就都收敛了。给假对象一份内部状态——测这类 API与其一个个 mock 返回值不如让假句柄自带虚拟磁盘close()提交内容、mtime 自增测试用例读起来就跟真实操作一样。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进