/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

对于习惯在浏览器中随手记录生活思考的创作者来说,最令人抓狂的噩梦莫过于“草稿意外丢失”:有时候在某个标签页中洋洋洒洒写下了一千多字的秋日随笔,中途打开另一个标签页去查阅植物名称或历史照片;结果一不小心在第二个标签页里点开了同一篇草稿,由于两边状态脱节,第二个标签页带着旧内容按下了保存,或者随手关闭了浏览器窗口,原本苦心构思的文字便瞬间灰飞烟灭。
为了解决这种多标签页状态冲突,很多工业级富文本编辑方案动辄搬出庞大厚重的协同编辑框架(如 Yjs、Automerge 甚至私有 WebSocket 服务端)。然而,对于一个完全运行在本地、追求极简断网可用的个人手账单页应用来说,引入一套复杂的网络服务器与 CRDT 分布式算法,无疑是杀鸡用牛刀。
其实,现代 Web 浏览器早已为我们提供了一套极其轻巧、强大且完全运行在客户端本地的原生通信规范——BroadcastChannel API 与 Web Storage。借助这两项标准技术,无需启动任何外部后端服务,我们仅用几十行干净透明的原生 JavaScript,就能构建起一套兼具毫秒级多标签页双向状态同步、断网自愈与防覆盖特性的高可靠离线状态机。
架构蓝图:本地持久化与跨标签总线
在单机浏览器同源上下文中,多标签页协同的核心在于明确“持久化数据源”与“事件总线”的分工:
原生双向状态机完整实现
下面是一份完整的、零第三方依赖的水彩质感多标签页手账草稿编辑器实现:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>秋日手账离线协同草稿箱</title>
<style>
:root {
–bg-warm: #fcfbf9;
–card-bg: #ffffff;
–text-main: #333d29;
–text-muted: #7f8c8d;
–accent-green: #656d4a;
–accent-ochre: #ddb892;
–border-line: #ede8e1;
}
body {
margin: 0;
padding: 40px 20px;
background-color: var(–bg-warm);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Songti SC", serif;
color: var(–text-main);
display: flex;
justify-content: center;
}
.notebook-container {
width: 100%;
max-width: 680px;
background: var(–card-bg);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(101, 109, 74, 0.06);
border: 1px solid var(–border-line);
padding: 36px 40px;
box-sizing: border-box;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px dashed var(–border-line);
}
.title-input {
font-size: 1.5rem;
border: none;
outline: none;
width: 70%;
color: var(–text-main);
font-weight: 500;
background: transparent;
}
.sync-status {
font-size: 0.85rem;
color: var(–text-muted);
transition: color 0.3s ease;
}
.editor-textarea {
width: 100%;
min-height: 400px;
border: none;
outline: none;
resize: vertical;
font-size: 1.05rem;
line-height: 1.8;
color: var(–text-main);
background: transparent;
font-family: inherit;
box-sizing: border-box;
}
.editor-textarea::placeholder {
color: #b7b7a4;
}
</style>
</head>
<body>
<div class="notebook-container">
<div class="header-bar">
<input type="text" id="draftTitle" class="title-input" placeholder="写下一句秋日短题…" />
<div id="statusBadge" class="sync-status">🍃 本地就绪</div>
</div>
<textarea id="draftBody" class="editor-textarea" placeholder="随手记下当下的思绪与光影,多个标签页将自动保持同步…"></textarea>
</div>
<script>
const STORAGE_KEY = 'autumn_journal_draft_v1';
const CHANNEL_NAME = 'autumn_journal_bus';
const titleEl = document.getElementById('draftTitle');
const bodyEl = document.getElementById('draftBody');
const statusEl = document.getElementById('statusBadge');
// 1. 初始化跨标签广播通信通道
const syncChannel = new BroadcastChannel(CHANNEL_NAME);
// 内存中的本页面最后更新时间戳
let localTimestamp = 0;
// 2. 从本地持久化存储加载初版草稿
function loadInitialDraft() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const data = JSON.parse(saved);
titleEl.value = data.title || '';
bodyEl.value = data.body || '';
localTimestamp = data.timestamp || Date.now();
updateStatusBadge('已恢复历史草稿');
} catch (e) {
console.error('草稿解析失败:', e);
}
}
}
function updateStatusBadge(text) {
statusEl.textContent = `🍃 ${text}`;
statusEl.style.color = 'var(–accent-green)';
}
// 3. 页面主动输入:防抖写入本地存储并广播通知全站
let debounceTimer = null;
function handleUserEdit() {
clearTimeout(debounceTimer);
statusEl.textContent = '✏️ 正在落笔…';
statusEl.style.color = 'var(–accent-ochre)';
debounceTimer = setTimeout(() => {
localTimestamp = Date.now();
const payload = {
title: titleEl.value,
body: bodyEl.value,
timestamp: localTimestamp,
senderId: window.name || Math.random().toString(36).slice(2)
};
// 写入本地持久化
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
updateStatusBadge('已存入本地');
// 向同源所有标签页广播同步事件
syncChannel.postMessage(payload);
}, 300);
}
titleEl.addEventListener('input', handleUserEdit);
bodyEl.addEventListener('input', handleUserEdit);
// 4. 监听来自其他标签页的广播消息
syncChannel.onmessage = (event) => {
const remoteData = event.data;
if (!remoteData || !remoteData.timestamp) return;
// 冲突协调法则:只采纳更新更及时的草稿版本
if (remoteData.timestamp > localTimestamp) {
titleEl.value = remoteData.title || '';
bodyEl.value = remoteData.body || '';
localTimestamp = remoteData.timestamp;
updateStatusBadge('已从其他窗口无感对齐');
}
};
// 5. 页面关闭前的保底快照防护
window.addEventListener('beforeunload', () => {
const finalPayload = {
title: titleEl.value,
body: bodyEl.value,
timestamp: Date.now()
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(finalPayload));
});
// 启动初始加载
loadInitialDraft();
</script>
</body>
</html>
极度平滑的协同艺术
通过这套架构,我们在两个并排打开的浏览器标签页之间,实现了令人赏心悦目的无感同步:
- 零网络开销与毫秒对齐:当你在左侧窗口中输入“十月十七日夜,煮茶听雨”时,右侧窗口里的光标虽然处于失焦状态,但编辑框内的文字几乎在手指松开键盘的同时(300 毫秒后)瞬间对齐。数据完全在浏览器的跨进程管道内部流转,不发出哪怕一个外部网络 HTTP 请求。
- 天然的离线免疫力:即便此时拔掉网线、拔掉路由器插头,整个应用的状态保存与跨页面同步依然丝滑如常。网络状态的波动不会对你的写作心流造成任何一丝一毫的干扰。
- 极简主义的技术愉悦:没有臃肿的框架胶水代码,没有复杂的状态机状态树推导。依靠两项经过 W3C 严格标准化的现代 Web 原生能力,我们便以极其轻盈的姿态,彻底解决了多年来困扰无数写作者的草稿覆盖与丢失痛点。
把复杂的交给规范,把纯粹的留给创作。让代码隐匿于无形之中,文字才真正拥有了直抵人心的生命力。




