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