BroadcastChannel + IndexedDB:多标签页文档同步实战

3 阅读6分钟

MarkView 是一个纯前端的 Markdown 实时预览工具(markview.art),文档全部存在浏览器 IndexedDB 里(架构设计见上一篇)。这带来一个绕不开的问题:用户在两个标签页里打开同一个站点怎么办?

两个标签页共享同一个 IndexedDB。如果互不感知,A 页的保存会静默覆盖 B 页的保存,谁后写谁赢,先写的编辑凭空消失——这是本地优先(local-first)应用最容易翻车的地方。

本文拆解 MarkView 的解法:用 BroadcastChannel 做通知、IndexedDB 做真相源,再加一个纯函数的状态协调器守住「正在编辑的内容绝不被覆盖」这条底线。

一、整体思路:通知 + 重载 + 协调

方案的骨架非常简单,三步:

  1. 写入方:任何标签页成功落盘后,通过 BroadcastChannel 广播一条「我写过了」;
  2. 接收方:收到广播后,从 IndexedDB 重新加载最新状态;
  3. 协调:把「DB 里的最新状态」和「本页内存状态」合并成一份新状态,有冲突时按规则裁决。

注意一个关键取舍:广播里不携带文档数据,只是一声通知。数据永远以 IndexedDB 为真相源(single source of truth)。这样不用考虑消息乱序、丢失后的补偿——大不了多读一次 DB,读到的一定是最新落盘状态。

二、写入方:落盘成功才广播

广播的时机挂在保存状态机的回调上——只有状态变为「已保存」(即 IndexedDB 事务成功提交)才通知别人:

const scheduler = createPersistenceScheduler({
  getSnapshot: () => snapshotState(state.value),
  save: saveDocumentState,
  onStatus: (status) => {
    saveStatus.value = status;
    // 落盘成功即通知其他标签页协调(自身不接收此消息)。
    if (status === SAVE_STATUS.SAVED) notifyOtherTabs();
  },
});

const notifyOtherTabs = () => {
  if (syncChannel) syncChannel.postMessage({ tabId: TAB_ID });
};

如果在「开始保存」时就广播,接收方可能赶在事务提交前去读 DB,读到旧数据。先落盘、后广播,时序天然正确。

消息里带上本页的 TAB_IDcrypto.randomUUID() 生成)。BroadcastChannel 规范上不会回送给自己,但这里仍做了双保险:

syncChannel.onmessage = (event) => {
  if (event.data?.tabId === TAB_ID) return;
  // ...
};

三、接收方:可见才协调,后台先攒着

收到通知后并不是无脑重载。一个常见浪费是:用户开了五个标签页,四个在后台,每次编辑都让四个隐藏页面跟着重新渲染。MarkViewvisibilityState 做了懒协调:

syncChannel.onmessage = (event) => {
  if (event.data?.tabId === TAB_ID) return;
  // 可见时立即协调;后台标签页先标记,回到前台再协调,避免隐藏页无谓渲染。
  if (document.visibilityState === "visible") reconcileFromStore();
  else pendingReconcile = true;
};

const handleVisibilityForSync = () => {
  if (document.visibilityState === "visible" && pendingReconcile) {
    pendingReconcile = false;
    reconcileFromStore();
  }
};

后台页只立一个 pendingReconcile 标记,等切回前台时一次性协调。中间无论错过多少次广播都无所谓——反正协调时读的是 DB 最新状态,天然幂等。

重载入口还有两个防御细节:

const reconcileFromStore = async () => {
  if (!canPersist || reconciling) return; // 防重入
  reconciling = true;
  try {
    const incoming = await loadDocumentState();
    if (!incoming) return; // DB 被清空:保留本页内存,不覆盖
    // ...协调...
  } finally {
    reconciling = false;
  }
};

reconciling 标志防止重入;DB 读出来是空(比如用户在别处清了站点数据)时直接返回,宁可保留本页内存也不清空用户正看着的内容。

四、核心:纯函数状态协调器

真正的裁决逻辑在 reconcileDocumentState——一个零 Vue、零 DOM 的纯函数,输入三样东西:

  • current:本页当前内存状态;
  • incoming:IndexedDB 里的最新状态(别的标签页刚写入的);
  • hasLocalPending:本页是否有未落盘的编辑(问保存调度器就知道)。

协调规则只有三条,优先级从高到低:

  1. 文档集合与内容以 incoming(持久化真相)为准;
  2. 数据安全底线——本页活动文档若有未落盘编辑,保留本页版本,保存时以本页为准(last-write-wins),绝不被别处的写入静默覆盖;
  3. 本页的活动选择(activeId)尽量保持不变;仅当活动文档在别处被删、且本页无未存改动时才改选。

代码主干:

export const reconcileDocumentState = ({ current, incoming, hasLocalPending }) => {
    // ...建索引...
    let files = incomingFiles.map((file) => ({ ...file }))
    let activeId = liveId
    let activeContent = null

    if (incomingActive) {
        const contentDiffers = Boolean(localActive) && localActive.content !== incomingActive.content
        if (hasLocalPending && contentDiffers) {
            // 保留本页正在编辑、尚未落盘的活动文档
            files = files.map((file) => (file.id === liveId ? { ...localActive } : file))
        } else if (contentDiffers) {
            // 空闲页:采纳别处的最新内容,稍后推回编辑器
            activeContent = incomingActive.content
        }
    } else if (hasLocalPending && localActive) {
        // 活动文档在别处被删,但本页有未落盘编辑:保住它(重新并入并置顶)
        files = [{ ...localActive }, ...files]
    } else {
        // 活动文档在别处被删且本页无改动:跟随改选
        activeId = /* incoming 的活动文档,或第一篇 */
    }
    // ...
    return { state: { activeId, files }, activeContent, forgetIds }
}

逐个场景过一遍:

场景本页有未存编辑?结果
活动文档被别处改了保留本页版本,下次保存以本页为准
活动文档被别处改了没有采纳别处内容,推回编辑器
活动文档被别处删了把本页版本重新并入文档列表并置顶——「我正在写的东西不能没了」
活动文档被别处删了没有跟随改选到别处的活动文档
非活动文档被改/增/删一律以 incoming 为准

这张表的每一行,在 reconcile.test.js 里都是一个直接跑纯函数的单测——不需要开两个真实标签页来验证并发逻辑,这是把协调器做成纯函数最大的红利。

五、别忘了编辑器缓存:forgetIds

还有一个隐蔽的坑。MarkView 里每个文档有独立的 CodeMirror EditorState 缓存(保住各自的撤销历史)。协调把 state.files 换新了,但某个后台文档的编辑器缓存态还是旧内容——用户切过去看到的就是过期文档。

所以协调器还返回两样东西,交给上层推回编辑器:

  • activeContent:活动文档内容被外部更新时,需要就地替换进当前编辑器的新内容;
  • forgetIds:内容已被外部更新的文档 id 列表,让 EditorController 丢弃它们的缓存态,下次切入时按新内容重建。
const { state: nextState, activeContent, forgetIds } = reconcileDocumentState({ ... })
state.value = nextState
onExternalUpdate?.({ activeContent, forgetIds })

状态同步不只是「数据对了」,还要把所有衍生缓存一并失效——这一步漏掉的话,bug 会在很久之后以「切换文档内容不对」的形态出现,极难排查。

六、方案边界

诚实地说清楚这套方案的适用范围:

  • 它不是 CRDT。两个标签页同时编辑同一篇文档时,走的是 last-write-wins,后保存的覆盖先保存的。它保证的是「你正盯着编辑的内容不会被静默覆盖」,而不是字符级合并——对单人多标签页的场景,这个保证已经足够,复杂度却低一个数量级;
  • BroadcastChannel 只在同源标签页间工作,正好匹配「同一浏览器开多页」的场景;不支持的环境(极老浏览器)自动跳过同步,退回单页行为,功能无损;
  • 广播只是加速器。就算消息全丢,数据也不会坏——因为真相永远在 IndexedDB,下一次协调总能收敛到一致。

结语

回看这套设计,值得带走的经验有三条:

  1. 通知与数据分离:广播只说「有变化」,数据永远从真相源读,天然免疫消息乱序与丢失;
  2. 冲突裁决做成纯函数:并发场景难以手工复现,但纯函数协调器可以把每种交错情形写成毫秒级单测;
  3. 同步状态时记得失效衍生缓存:编辑器状态、渲染缓存这些「第二真相」不清理,同步就只做对了一半。