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

📅 2026/8/15 13:28:27
BroadcastChannel + IndexedDB:多标签页文档同步实战
MarkView 是一个纯前端的 Markdown 实时预览工具https://markview.arthttps://github.com/acheding/markview文档全部存在浏览器 IndexedDB 里架构设计见上一篇。这带来一个绕不开的问题用户在两个标签页里打开同一个站点怎么办两个标签页共享同一个 IndexedDB。如果互不感知A 页的保存会静默覆盖 B 页的保存谁后写谁赢先写的编辑凭空消失——这是本地优先local-first应用最容易翻车的地方。本文拆解 MarkView 的解法用 BroadcastChannel 做通知、IndexedDB 做真相源再加一个纯函数的状态协调器守住「正在编辑的内容绝不被覆盖」这条底线。一、整体思路通知 重载 协调方案的骨架非常简单三步写入方任何标签页成功落盘后通过 BroadcastChannel 广播一条「我写过了」接收方收到广播后从 IndexedDB 重新加载最新状态协调把「DB 里的最新状态」和「本页内存状态」合并成一份新状态有冲突时按规则裁决。注意一个关键取舍广播里不携带文档数据只是一声通知。数据永远以 IndexedDB 为真相源single source of truth。这样不用考虑消息乱序、丢失后的补偿——大不了多读一次 DB读到的一定是最新落盘状态。二、写入方落盘成功才广播广播的时机挂在保存状态机的回调上——只有状态变为「已保存」即 IndexedDB 事务成功提交才通知别人constschedulercreatePersistenceScheduler({getSnapshot:()snapshotState(state.value),save:saveDocumentState,onStatus:(status){saveStatus.valuestatus;// 落盘成功即通知其他标签页协调自身不接收此消息。if(statusSAVE_STATUS.SAVED)notifyOtherTabs();},});constnotifyOtherTabs(){if(syncChannel)syncChannel.postMessage({tabId:TAB_ID});};如果在「开始保存」时就广播接收方可能赶在事务提交前去读 DB读到旧数据。先落盘、后广播时序天然正确。消息里带上本页的TAB_IDcrypto.randomUUID()生成。BroadcastChannel 规范上不会回送给自己但这里仍做了双保险syncChannel.onmessage(event){if(event.data?.tabIdTAB_ID)return;// ...};三、接收方可见才协调后台先攒着收到通知后并不是无脑重载。一个常见浪费是用户开了五个标签页四个在后台每次编辑都让四个隐藏页面跟着重新渲染。MarkView 用visibilityState做了懒协调syncChannel.onmessage(event){if(event.data?.tabIdTAB_ID)return;// 可见时立即协调后台标签页先标记回到前台再协调避免隐藏页无谓渲染。if(document.visibilityStatevisible)reconcileFromStore();elsependingReconciletrue;};consthandleVisibilityForSync(){if(document.visibilityStatevisiblependingReconcile){pendingReconcilefalse;reconcileFromStore();}};后台页只立一个pendingReconcile标记等切回前台时一次性协调。中间无论错过多少次广播都无所谓——反正协调时读的是 DB 最新状态天然幂等。重载入口还有两个防御细节constreconcileFromStoreasync(){if(!canPersist||reconciling)return;// 防重入reconcilingtrue;try{constincomingawaitloadDocumentState();if(!incoming)return;// DB 被清空保留本页内存不覆盖// ...协调...}finally{reconcilingfalse;}};reconciling标志防止重入DB 读出来是空比如用户在别处清了站点数据时直接返回宁可保留本页内存也不清空用户正看着的内容。四、核心纯函数状态协调器真正的裁决逻辑在reconcileDocumentState——一个零 Vue、零 DOM 的纯函数输入三样东西current本页当前内存状态incomingIndexedDB 里的最新状态别的标签页刚写入的;hasLocalPending本页是否有未落盘的编辑问保存调度器就知道。协调规则只有三条优先级从高到低文档集合与内容以 incoming持久化真相为准数据安全底线——本页活动文档若有未落盘编辑保留本页版本保存时以本页为准last-write-wins绝不被别处的写入静默覆盖本页的活动选择activeId尽量保持不变仅当活动文档在别处被删、且本页无未存改动时才改选。代码主干exportconstreconcileDocumentState({current,incoming,hasLocalPending}){// ...建索引...letfilesincomingFiles.map((file)({...file}))letactiveIdliveIdletactiveContentnullif(incomingActive){constcontentDiffersBoolean(localActive)localActive.content!incomingActive.contentif(hasLocalPendingcontentDiffers){// 保留本页正在编辑、尚未落盘的活动文档filesfiles.map((file)(file.idliveId?{...localActive}:file))}elseif(contentDiffers){// 空闲页采纳别处的最新内容稍后推回编辑器activeContentincomingActive.content}}elseif(hasLocalPendinglocalActive){// 活动文档在别处被删但本页有未落盘编辑保住它重新并入并置顶files[{...localActive},...files]}else{// 活动文档在别处被删且本页无改动跟随改选activeId/* incoming 的活动文档或第一篇 */}// ...return{state:{activeId,files},activeContent,forgetIds}}逐个场景过一遍场景本页有未存编辑结果活动文档被别处改了有保留本页版本下次保存以本页为准活动文档被别处改了没有采纳别处内容推回编辑器活动文档被别处删了有把本页版本重新并入文档列表并置顶——「我正在写的东西不能没了」活动文档被别处删了没有跟随改选到别处的活动文档非活动文档被改/增/删—一律以 incoming 为准这张表的每一行在reconcile.test.js里都是一个直接跑纯函数的单测——不需要开两个真实标签页来验证并发逻辑这是把协调器做成纯函数最大的红利。五、别忘了编辑器缓存forgetIds还有一个隐蔽的坑。MarkView 里每个文档有独立的 CodeMirrorEditorState缓存保住各自的撤销历史。协调把state.files换新了但某个后台文档的编辑器缓存态还是旧内容——用户切过去看到的就是过期文档。所以协调器还返回两样东西交给上层推回编辑器activeContent活动文档内容被外部更新时需要就地替换进当前编辑器的新内容forgetIds内容已被外部更新的文档 id 列表让EditorController丢弃它们的缓存态下次切入时按新内容重建。const{state:nextState,activeContent,forgetIds}reconcileDocumentState({...})state.valuenextState onExternalUpdate?.({activeContent,forgetIds})状态同步不只是「数据对了」还要把所有衍生缓存一并失效——这一步漏掉的话bug 会在很久之后以「切换文档内容不对」的形态出现极难排查。六、方案边界诚实地说清楚这套方案的适用范围它不是 CRDT。两个标签页同时编辑同一篇文档时走的是 last-write-wins后保存的覆盖先保存的。它保证的是「你正盯着编辑的内容不会被静默覆盖」而不是字符级合并——对单人多标签页的场景这个保证已经足够复杂度却低一个数量级BroadcastChannel 只在同源标签页间工作正好匹配「同一浏览器开多页」的场景不支持的环境极老浏览器自动跳过同步退回单页行为功能无损广播只是加速器。就算消息全丢数据也不会坏——因为真相永远在 IndexedDB下一次协调总能收敛到一致。结语回看这套设计值得带走的经验有三条通知与数据分离广播只说「有变化」数据永远从真相源读天然免疫消息乱序与丢失冲突裁决做成纯函数并发场景难以手工复现但纯函数协调器可以把每种交错情形写成毫秒级单测同步状态时记得失效衍生缓存编辑器状态、渲染缓存这些「第二真相」不清理同步就只做对了一半。