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

MarkViewhttps://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)}`
}

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

判定规则如下:

磁盘 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
  • 模拟「文件被删了」:让 getFileNotFoundError
  • 模拟「权限被撤销」:改 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. 前缀,换来的是整个模块可测。

结语

三条经验:

  1. API 的约束会渗透到交互设计里 ------requestPermission 必须在用户手势内,于是「待授权」这个状态就得做成一个可点击的按钮;这不是妥协,是把技术约束翻译成了合理的 UI。
  2. 冲突检测要双基线 ------只看 mtime 分不清「谁改的」,只看内容签名不知道「磁盘动没动」;两个基线加上一个「未知」态(null),四种场景就都收敛了。
  3. 给假对象一份内部状态 ------测这类 API,与其一个个 mock 返回值,不如让假句柄自带虚拟磁盘:close() 提交内容、mtime 自增,测试用例读起来就跟真实操作一样。
相关推荐
何时梦醒8 小时前
⚛️ React 19 + TypeScript 深度学习笔记 —— 从组件化思维到 WebGPU 端侧 AI 落地
前端·javascript·人工智能
橘子星8 小时前
在浏览器里跑大模型!用 WebGPU 零成本部署 DeepSeek-R1
前端·typescript
用户938515635078 小时前
从 Vite 脚手架到 WebGPU 推理:手写一个 DeepSeek-R1 浏览器端大模型 Demo
javascript·人工智能·全栈
两只羊ovo8 小时前
Vite+React+TS+Tailwind搭建WebGPU本地大模型项目,拆解前端核心知识点
前端·react.js
acheding8 小时前
把 CodeMirror 6 调教成 Markdown 编辑器:扩展、装饰与门面
javascript·vue.js·编辑器·markdown
hoLzwEge8 小时前
团队协作的隐藏利器:.vscode 完全指南
前端·前端框架
labixiong8 小时前
TypeScript 7.0 编译器用 Go 重写,速度暴增10倍——背后到底做了什么?
前端·javascript·go
WaywardOne8 小时前
Flutter组件化方案(AI总结)
前端·flutter·ai编程
玉宇夕落8 小时前
原子化编程”:Tailwind CSS 核心原理
前端