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