三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

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

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

MarkView(https://markview.art,https://github.com/acheding/markview),一个纯前端的 Markdown 编辑器,最尴尬的地方是它读不到你电脑上的文件——只能「导入一份副本」,编辑完再「导出下载」,源文件纹丝不动。

File System Access API 改变了这件事:showOpenFilePicker拿到的是一个文件句柄,可以直接createWritable()写回原文件;句柄还能存进 IndexedDB,下次打开网页时恢复。MarkView 用它实现了Ctrl+O打开、Ctrl+S写回,以及安装成 PWA 后双击.md文件直接打开。

但真正的工作量不在调 API,而在它带来的一整套状态问题:权限会过期、文件会在别处被改、文件会被删掉、浏览器可能压根不支持。本文讲这些。

一、基础三件套

封装层是纯逻辑,无 Vue、无应用状态:

// src/core/documents/fileAccess.jsconstMARKDOWN_PICKER_TYPES=[{description:'Markdown',accept:{'text/markdown':['.md','.markdown']}}]// 用户取消(AbortError)返回空数组/null,调用方静默即可;其余异常照常抛出。constisPickerCancel=(error)=>error?.name==='AbortError'exportconstpickOpenFiles=async()=>{try{returnawaitwindow.showOpenFilePicker({multiple:true,types:MARKDOWN_PICKER_TYPES})}catch(error){if(isPickerCancel(error))return[]throwerror}}

第一个要处理的就是取消不是错误。用户按 Esc 关掉选择器,浏览器抛AbortError;如果不拦,会弹一个「打开失败」的 toast,非常无厘头。这里把它归一化成假值(打开返回[],另存返回null),调用方直接静默返回。

写回是标准的三步,但最后一步容易漏:

// 写回并返回写后的磁盘 mtime(作为下次外部修改检测的基准)。exportconstwriteFileHandle=async(handle,content)=>{constwritable=awaithandle.createWritable()awaitwritable.write(content)awaitwritable.close()constfile=awaithandle.getFile()return{lastModified:file.lastModified}}

close()之后再取一次lastModified——这个值是后面整套外部修改检测的锚点。少了它,你自己写回的操作下一次就会被误判成「别人改了文件」。

还有一个轻量版,只取 mtime 不读内容:

// 只取 mtime 不读内容:写回前的外部修改快检、focus 时的轻量轮询。exportconststatFileHandle=async(handle)=>{constfile=awaithandle.getFile()return{name:file.name,lastModified:file.lastModified}}

二、句柄持久化:能存,但权限不能

FileSystemFileHandle可以结构化克隆,意味着能直接塞进 IndexedDB。MarkView 为此单开了一张表:

// v2:新增 fileHandles 表,持久化文档关联的 FileSystemFileHandle(结构化克隆存储),// 与文档主表分离——句柄无法参与内容签名比较,不能混进增量写入的 documents 表。

存取就是普通的put/getAll,值直接是句柄对象。

权限不会跟着一起恢复。下次打开网页,句柄还在,queryPermission()返回'prompt'——你得重新要一次。而requestPermission()有个硬约束:

// 必须在用户手势(keydown/click 等)内调用,否则浏览器直接拒绝。exportconstrequestFilePermission=async(handle)=>{if(typeofhandle?.requestPermission!=='function')return'granted'returnhandle.requestPermission({mode:'readwrite'})}

所以启动装载时只能query不能request(那时没有用户手势),真正要权限的时机有两个:Ctrl+S 的 keydown 里,和用户点击头部状态标签时

// Ctrl+S 的 keydown 是用户手势,可直接请求权限(跨会话恢复的句柄默认 prompt)。if(link.permission!=='granted'){constpermission=awaitrequestFilePermission(handle).catch(()=>'denied')updateLink(docId,{permission})if(permission!=='granted'){toast.show('未获得文件写入权限,可用「另存为本地文件」保存',{tone:'warning',duration:3000})return'denied'}}

UI 上,「需要重新授权」这个状态被做成了可点击的状态标签——用户看到「重新授权文件访问」,点一下就是一次合法的用户手势。这是把 API 约束翻译成交互设计的典型例子。

顺带一提,句柄不进响应式状态

consthandles=newMap()// docId → FileSystemFileHandle(句柄不可比较内容,不进响应式状态)constlinks=ref({})// docId → link meta(整体替换以触发响应)

驱动 UI 的是一份可序列化的元信息,句柄本身只是一个不参与比较的副本。

三、外部修改检测:双基线

这是全篇最有意思的部分。

场景:你在 MarkView 里打开了README.md,切到 VS Code 改了几行,再切回浏览器。这时候应该发生什么?

答案取决于两边各自改了没有,于是需要两个基线:

// link meta 语义:// savedSignature —— 上次与磁盘对齐时文档内容的签名;null = 基线未知(跨会话恢复后尚未与磁盘核对)。// lastModified —— 上次读/写时磁盘文件的 mtime;null = 尚未核对。两者配合区分「本地脏」与「磁盘变了」。

签名用 FNV-1a 加长度前缀,够快够短:

// 内容签名(FNV-1a + 长度):判断「当前内容是否与上次写回磁盘时一致」。// 只用于同一会话内的脏检查,不做跨端一致性保证,碰撞概率可忽略。exportconstcontentSignature=(value='')=>{consttext=String(value)lethash=2166136261for(letindex=0;index<text.length;index+=1){hash^=text.charCodeAt(index)hash=Math.imul(hash,16777619)}return`${text.length}:${(hash>>>0).toString(36)}`}

检测时机是三个事件,没有轮询:窗口focusvisibilitychange回到前台、切换文档。

判定规则如下:

磁盘 mtime磁盘内容 vs 当前文档本地是否有未写回的编辑行为
未变直接返回,不读内容
变了相同静默对齐基线
变了不同没有静默重载磁盘版本+ 提示
变了不同弹窗让用户决定

对应的代码:

constdiskNormalized=normalizeLineEndings(content)if(diskNormalized===doc.content){// 内容一致(mtime 变化来自外部 touch 或基线核对):对齐基线即可。updateLink(docId,{savedSignature:contentSignature(doc.content),lastModified,name,/* … */})return}constlocalClean=link.savedSignature!==null&&contentSignature(doc.content)===link.savedSignatureif(localClean){// 本地自上次同步后没动过:安全地跟进磁盘版本。reloadFromDisk(docId,content,{name,lastModified})toast.show(`已加载「${name}」的最新内容`,{tone:'success'})return}// 双方都有变化(或基线未知且内容不一致):让用户决策,绝不静默丢弃任何一方。constloadDisk=awaitconfirm.ask({/* … */tone:'danger'})

几个不那么显然的处理:

快路径不读内容。mtime 没变就直接返回,连text()都不调——每次 focus 都全文读盘对大文件是浪费。

「保留当前内容」也要对齐 mtime。

// 对齐 mtime:这次外部修改已知悉,后续 Ctrl+S 直接覆盖、不再重复弹窗。updateLink(docId,{lastModified,name,missing:false,missingPrompted:false})

否则用户选了「保留我的版本」,下次 focus 又弹一遍同样的窗,非常烦人。

基线未知(savedSignature === null)走「让用户决定」分支。跨会话恢复的句柄没有基线——上次会话结束后谁更新过,无从判断。与其猜,不如把这个场景收敛进已有的冲突分支,而不是加一条特殊路径。

重载时要丢编辑器缓存。

constreloadFromDisk=(docId,diskContent,{name,lastModified})=>{constnormalized=normalizeLineEndings(diskContent)documents.replaceContent(docId,normalized)if(documents.activeFileId.value===docId)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 检测和用户按 Ctrl+S 之间仍有时间窗:

// 写回前快检:磁盘在别处被修改过则先确认,避免静默覆盖外部编辑。

以及一个容易忽略的时序细节:

updateLink(docId,{writing:true})// 确认框是异步的,内容以落笔瞬间为准。constcontent=getDocById(docId)?.content??''

确认框弹出期间用户还能继续打字,所以内容必须在确认之后才取。

四、文件被删了、改名了、移走了

这三种情况浏览器统一抛NotFoundError。处置逻辑最值得说的是三态确认框

// 文件句柄因磁盘文件被移动、重命名或删除而失效时,提示用户处置:// 确认 = 另存到新位置(转移关联);取消按钮 = 取消关联、仅保留在 MarkView;// Esc / 点遮罩 = 未做选择——保持丢失标记,稍后可点击头部「本地文件已丢失」标签再处理。// 自动检查每次丢失只提示一次,避免 focus/visibilitychange 反复打扰;显式 Ctrl+S / 点标签可强制再次提示。

confirm.ask返回true/false/null三种值,null(按 Esc)不等于点了取消按钮

if(choice===true){if(documents.activeFileId.value!==docId)return'failed'returnsaveActiveAs()}// 关掉对话框(Esc / 点遮罩)=暂不处置:保留失效关联,不静默改变文档归属。if(choice!==false)return'failed'

按 Esc 应该是「我先不处理」,而不是「解除关联」。这个区分在测试里被单独钉死了一条用例。

另外missingPrompted标志保证自动检查只弹一次窗,而显式入口(Ctrl+S、点状态标签)传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 的降级思路是:「打开」和「导入」本来就是两个并存的功能,不支持时只是「打开」消失。

// 「打开」与「导入」是两个并存的入口,不做二选一:// 打开 = 保留句柄、Ctrl+S 写回原文件,仅支持 File System Access 时存在(不支持的浏览器// 无按钮、不进面板、Ctrl+O 键位归还「导入」,见 shortcuts.js);// 导入 = 拷贝一份进工作区、与磁盘断开,始终可用。

快捷键的处理很巧妙——Ctrl+O在不支持的浏览器上「归还」给导入:

...(isFileSystemAccessSupported()?{open:{key:'o'},import:{key:'o',alt:true}}:{import:[{key:'o',alt:true},{key:'o'}]}),

用户不必知道自己的浏览器缺什么,Ctrl+O永远能打开点什么。

能力检测分两级,这点值得注意:

// 「打开/另存为」选择器需要 window.show*FilePicker;launchQueue 句柄的写回只需 createWritable。exportconstisFileSystemAccessSupported=()=>typeofwindow!=='undefined'&&typeofwindow.showOpenFilePicker==='function'&&typeofwindow.showSaveFilePicker==='function'// launchQueue / 拖拽等外部来源的句柄是否可写回(防御非 Chromium 实现给出只读句柄)。exportconstisWritableFileHandle=(handle)=>Boolean(handle&&handle.kind==='file'&&typeofhandle.createWritable==='function')

全局 API 存在,不代表每一个拿到的句柄都可写——外部来源(launchQueue、其他实现)可能给只读句柄,所以建立关联前单独检测一次,不可写就降级为普通导入副本。

Ctrl+Shift+S另存为也有降级:不支持时直接走 Blob 下载。而导入始终用动态创建的<input type="file">

// 每次新建实例,重复选择同一文件也能触发 change。constopenImportPicker=()=>{constinput=document.createElement('input')input.type='file'input.accept='.md,.markdown,text/markdown,text/plain'input.multiple=trueinput.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']}

应用侧接收:

constconsumeLaunchFiles=async(launchParams)=>{consthandles=launchParams?.filesif(!handles?.length)returnawaitwhenDocumentsReady()if(disk?.isSupported){awaitdisk.openViaHandles(handles)return}// …否则解析成 File 导入副本}constsetupFileHandling=()=>{if(typeofwindow==='undefined'||!('launchQueue'inwindow))returnwindow.launchQueue.setConsumer(consumeLaunchFiles)}

时序有讲究:setConsumer必须尽早注册(挂在onMounted里同步调用),否则 launchParams 会丢;但 consumer 内部要await whenDocumentsReady()——「避免导入被随后到达的初始状态覆盖」。注册要早,动手要晚。

同一个文件重复双击不该开出两份副本,去重靠isSameEntry

// 两个句柄是否指向磁盘上同一文件(同文件去重);实现缺失时按不同文件处理。exportconstisSameFileEntry=async(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='',lastModified=1,permission='granted'}={})=>{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()=>{letpending=''return{write:async(data)=>{pending=data},close:async()=>{diskState.content=pending diskState.lastModified+=1}}})}handle.isSameEntry=vi.fn(async(other)=>other===handle)returnhandle}

close()才真正提交内容并让 mtime 自增——语义和真实 API 一致。于是:

  • 模拟「别的程序改了文件」:handle.diskState.content = '# 外部编辑'; handle.diskState.lastModified = 99
  • 模拟「文件被删了」:让getFileNotFoundError
  • 模拟「权限被撤销」:改diskState.permission

launchQueue也一样,捕获 consumer 就能直接驱动:

// 捕获 launchQueue consumer,模拟系统双击 .md 文件把句柄交给应用。conststubLaunchQueue=()=>{letconsumer=nullvi.stubGlobal('window',{launchQueue:{setConsumer:(fn)=>(consumer=fn)}})return()=>consumer}

这里有个前提条件:fileAccess.js里所有调用都写成window.showOpenFilePicker(...)而不是解构出来用,才能整体vi.stubGlobal('window', …)替换掉。写法上多一个window.前缀,换来的是整个模块可测。

结语

三条经验:

  1. API 的约束会渗透到交互设计里——requestPermission必须在用户手势内,于是「待授权」这个状态就得做成一个可点击的按钮;这不是妥协,是把技术约束翻译成了合理的 UI。
  2. 冲突检测要双基线——只看 mtime 分不清「谁改的」,只看内容签名不知道「磁盘动没动」;两个基线加上一个「未知」态(null),四种场景就都收敛了。
  3. 给假对象一份内部状态——测这类 API,与其一个个 mock 返回值,不如让假句柄自带虚拟磁盘:close()提交内容、mtime 自增,测试用例读起来就跟真实操作一样。
← 返回列表