当前位置: 首页 > news >正文

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

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

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

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

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

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

  1. 写入方:任何标签页成功落盘后,通过 BroadcastChannel 广播一条「我写过了」;
  2. 接收方:收到广播后,从 IndexedDB 重新加载最新状态;
  3. 协调:把「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_IDcrypto.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:本页是否有未落盘的编辑(问保存调度器就知道)。

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

  1. 文档集合与内容以 incoming(持久化真相)为准;
  2. 数据安全底线——本页活动文档若有未落盘编辑,保留本页版本,保存时以本页为准(last-write-wins),绝不被别处的写入静默覆盖;
  3. 本页的活动选择(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,下一次协调总能收敛到一致。

结语

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

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

相关文章:

  • 番茄小说下载器离线阅读手册:一本书五种导出格式,三条启动路径一次讲透
  • OpenProject容器化部署完整实操:排掉三个高频坑,半小时把项目管理平台跑起来
  • 2026年湖北恩施宜昌文旅合规出行服务推介|本地持证导游行程定制说明 - 纯玩旅游分享
  • 一个文件装齐 2005-2022 全部 Visual C++ 运行库:5 分钟根治 MSVCP140.dll 与 0xc000007b 报错
  • Diablo Edit2:暗黑破坏神II角色编辑器的终极上手全攻略
  • 华为OD机考双机位C卷:寻找密码算法与Java实现
  • 2026秦皇岛高价回收缪缪包包的靠谱商家 毓典奢品汇13103017712 高价回收专业靠谱 - 毓典奢侈品回收
  • 支付宝沙箱环境配置与支付回调全流程实战指南
  • 智能表单(SmartForms)核心技术解析:从JSON Schema到动态渲染的实战指南
  • 游戏角色动画中的JiggleBone技术:从弹簧质点模型到实战调优
  • 告别联想全家桶:Lenovo Legion Toolkit 从安装到进阶的完整实战指南
  • FanControl 实测上手:三步配置半小时见效,让机箱风扇学会安静
  • 2026年新消息:清远管路穿墙护筒厂家地址从裁切焊接到成品质检,用心做好每一件预埋构件-辉高管件制造 - 行业甄选汇
  • 下棋别总靠感觉:基于YOLOv5的中国象棋连线工具完整上手指南
  • Linux下UDP聊天服务器开发实战
  • 【知律|07】HarmonyOS ArkTS 学习统计实战:汇总学习天数、正确率和薄弱分类
  • 别急着重装系统:VisualCppRedist AIO 运行库一键修复指南,3个场景告别闪退与报错
  • 联想笔记本高性能模式开启与优化全指南
  • 液压行业中间商未来五年发展机遇与挑战分析
  • Prism Launcher离线版实测:不用正版账户,3步解锁Minecraft全版本离线畅玩
  • 视频下载别再求人了:资源嗅探工具 res-downloader,把视频号、抖音、小红书一键存进硬盘
  • 2026 年石家庄工业螺杆机组、螺杆超低温机组怎么选购? - LYL仔仔
  • Excel核心函数实战指南:从VLOOKUP到动态数组,提升数据处理效率
  • Prompt 改坏了怎么查:把模板、样本和解析器一起版本化
  • 2026 年石家庄速冻机组、工业冷水机组采购要注意什么? - LYL仔仔
  • 支付合规与分账系统设计:从二清风险到技术解决方案
  • 还在被 Markdown 乱码折磨?5 分钟上手 Markdown Viewer,让浏览器秒变专业阅读器
  • 用 Pulover‘s Macro Creator 把重复点击变成可维护的 AutoHotkey 脚本
  • Sunshine 游戏串流零基础实战:1 小时搭好你的私人云游戏平台
  • 2026年宁波市漏水检测行业优质服务商 - 全域品牌推荐