MarkView 是一个纯前端的 Markdown 实时预览工具(markview.art),文档全部存在浏览器 IndexedDB 里(架构设计见上一篇)。这带来一个绕不开的问题:用户在两个标签页里打开同一个站点怎么办?
两个标签页共享同一个 IndexedDB。如果互不感知,A 页的保存会静默覆盖 B 页的保存,谁后写谁赢,先写的编辑凭空消失——这是本地优先(local-first)应用最容易翻车的地方。
本文拆解 MarkView 的解法:用 BroadcastChannel 做通知、IndexedDB 做真相源,再加一个纯函数的状态协调器守住「正在编辑的内容绝不被覆盖」这条底线。
一、整体思路:通知 + 重载 + 协调
方案的骨架非常简单,三步:
- 写入方:任何标签页成功落盘后,通过 BroadcastChannel 广播一条「我写过了」;
- 接收方:收到广播后,从 IndexedDB 重新加载最新状态;
- 协调:把「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_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();
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:本页是否有未落盘的编辑(问保存调度器就知道)。
协调规则只有三条,优先级从高到低:
- 文档集合与内容以 incoming(持久化真相)为准;
- 数据安全底线——本页活动文档若有未落盘编辑,保留本页版本,保存时以本页为准(last-write-wins),绝不被别处的写入静默覆盖;
- 本页的活动选择(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,下一次协调总能收敛到一致。
结语
回看这套设计,值得带走的经验有三条:
- 通知与数据分离:广播只说「有变化」,数据永远从真相源读,天然免疫消息乱序与丢失;
- 冲突裁决做成纯函数:并发场景难以手工复现,但纯函数协调器可以把每种交错情形写成毫秒级单测;
- 同步状态时记得失效衍生缓存:编辑器状态、渲染缓存这些「第二真相」不清理,同步就只做对了一半。