鸿蒙 PC Markdown 编辑器内核隔离:CodeMirror EditorState 多会话切换
鸿蒙 PC Markdown 编辑器内核隔离:CodeMirror EditorState 多会话切换
多标签编辑器最隐蔽的数据错误,不是标签标题显示错,而是撤销历史、保存基线或恢复计时器跨文档串联。用户在文档甲输入一段,切到文档乙按撤销,如果乙退回甲的内容,编辑器已经失去可信度。只在原生层保存每个标签的正文字符串,无法解决 CodeMirror 内部状态归属。
本文基于鸿蒙 PC Markdown 编辑器 OhMarkdown,说明如何为每个 session保存完整 EditorState,如何同时隔离撤销栈、基线、revision 与恢复版本,以及切换时为什么必须清理延迟任务。完整代码位于 https://gitcode.com/VON-/codex_md_oh,本文对应提交 3a9146e。
EditorState 才是编辑会话
CodeMirror 6 将正文、选择区、扩展状态和撤销历史组织在 EditorState 中,EditorView 负责把状态渲染到 DOM。多标签可以为每个文档创建一个独立 View,但多个 DOM 编辑器会增加内存和布局成本。OhMarkdown 复用单个 EditorView,切换其 state。
如果切换时只调用 setDocument(content) 创建新 state,正文虽然正确,旧标签的光标、选择和撤销历史会丢失。切回后只能得到一份新编辑器。真正会话快照需要保留:
interface EditorSessionSnapshot {
state: EditorState;
baselineDocument: Text;
forcedDirty: boolean;
pendingDirty: boolean;
documentRevision: number;
lastRecoveryRevision: number;
}
const editorSessions = new Map<
string,
EditorSessionSnapshot
>();
state 包含 CodeMirror 文档与历史;其余字段是应用建立在编辑器之上的业务状态。它们必须与 state 同时切换,不能继续作为全局状态留在上一文档。
Map 的键是原生层提供的稳定 sessionId。不能用文件名,因为两个未命名文档都叫 Untitled.md;不能用正文哈希,因为两份文件内容可能相同;不能用 URI,因为新文档还没有 URI。sessionId 表达的是编辑会话身份,与外部文件身份分开。
为什么保存的是 EditorState 引用
会话持久化函数直接保存当前 state:
function persistActiveSession(): void {
editorSessions.set(activeSessionId, {
state: editor.state,
baselineDocument,
forcedDirty,
pendingDirty,
documentRevision,
lastRecoveryRevision
});
}
CodeMirror state 是不可变值。一次 transaction 不会原地修改旧 state,而是产生新 state 并由 view持有。Map 中保存旧会话 state 后,切到其他会话继续编辑不会修改它。切回时可以直接 editor.setState(storedSession.state)。
不可变结构还使用持久化文本树,保存 state 引用不等于每次复制全文字符串。历史中不同版本可以共享未变化结构,比手工深拷贝正文和选区更适合编辑器。不过撤销历史仍会占用内存,十二标签上限是当前保护措施。长期需要通过真实文档监控每会话历史成本。
Map 只在 Web 进程内有效,不是跨进程恢复。系统回收 ArkWeb 后,原生层可以用 session.content 重建正文,但光标和撤销历史会丢失。跨进程会话恢复需要显式序列化可恢复字段,不能把内存 Map误称为持久化。
保存基线不能只用 dirty 布尔值
编辑器需要判断当前正文是否等于最近保存版本。OhMarkdown 保留 CodeMirror Text 基线:
let baselineDocument: Text;
EditorView.updateListener.of((update) => {
if (!update.docChanged) {
return;
}
documentRevision += 1;
pendingDirty = forcedDirty ||
!update.state.doc.eq(baselineDocument);
// 省略预览、通知和恢复
})
用户输入后 dirty 为 true;撤销回与保存基线完全相同的 Text 时,eq 返回 true,dirty 自动恢复为 false。若只在第一次输入时把布尔值设成 true,撤销回原文后标签仍带星号,关闭还会多余询问。
每个会话必须保存自己的 baseline。甲保存后基线是甲正文,乙可能从未保存。切换只恢复 EditorState、不恢复 baseline,乙的内容会与甲基线比较,脏状态随机变化。
Text.eq 比把两边 sliceDoc() 成完整字符串后比较更符合 CodeMirror 数据结构,也避免每次按键都生成两份大字符串。大文档性能尤其依赖这种结构化比较。
forcedDirty 处理恢复文档
崩溃恢复内容可能恰好与创建 state 时的正文相同,但它并未写回用户文件,必须显示为未保存。重置函数设置:
baselineDocument = editor.state.doc;
forcedDirty = recovered;
pendingDirty = recovered;
普通打开文档 recovered=false,创建后的正文就是磁盘基线。恢复记录 recovered=true,虽然 editor.state.doc.eq(baselineDocument) 为 true,forcedDirty 仍让 dirty 保持 true,直到显式保存。
这是“内容相等”与“持久化事实”分离的例子。恢复正文与某个内存基线相等,不代表外部文件已经包含它。forcedDirty 只在保存成功后清除,也要随会话快照切换。否则从恢复标签切到普通标签再返回,星号可能消失。
revision 与恢复版本属于会话
每次文档 transaction 递增 documentRevision:
documentRevision += 1;
pendingDirty = forcedDirty ||
!update.state.doc.eq(baselineDocument);
scheduleRecoverySnapshot();
恢复快照只在 dirty、非大文档且 revision 与上次快照不同的情况下发送:
function flushRecoverySnapshot(): void {
window.clearTimeout(recoveryTimer);
recoveryTimer = undefined;
if (!pendingDirty ||
largeDocumentMode ||
documentRevision === lastRecoveryRevision) {
return;
}
window.ohMarkdownBridge?.onSnapshot(
editor.state.sliceDoc(),
documentRevision
);
lastRecoveryRevision = documentRevision;
}
甲可能 revision 为 15,乙为 2。若切换只换正文而不恢复计数,乙的下一次快照可能错误继承 15,或者与甲的 lastRecoveryRevision 相等而被跳过。两个数字必须进入 snapshot。
revision 目前是会话内单调计数,不跨进程。恢复记录带 revision主要用于当前会话写入排序和保存竞争,不是全局文档版本。未来多会话恢复需要把 sessionId、URI 和 revision一起作为记录身份。
创建新 state 必须隔离旧历史
打开一个完全不同的文档时,程序使用 createEditorState(content),而不是对当前 state dispatch 一个“替换全文”事务:
function resetEditorDocument(
content: string,
recovered: boolean
): void {
window.clearTimeout(bridgeTimer);
window.clearTimeout(recoveryTimer);
recoveryTimer = undefined;
pendingNativeChange = false;
isApplyingNativeDocument = true;
editor.setState(createEditorState(content));
isApplyingNativeDocument = false;
// 初始化基线和版本
}
若把新文件作为一次全文 replacement dispatch,撤销可能恢复上一份文件。即使两个文件内容恰好相同,旧重做历史也可能在新文件中生效。新建 EditorState 从结构上切断历史,是文档身份变更应有的语义。
自动化专门验证了两种情况:加载不同内容后,第一次撤销只撤销新文件内刚输入的内容,第二次撤销返回 false;加载相同内容的新文件后,旧文档遗留的重做也必须返回 false。相同字符串不能让编辑器误以为还是同一会话。
isApplyingNativeDocument 抑制程序设置 state引发的业务通知。虽然 setState 本身不是普通用户 transaction,扩展和后续演进仍可能触发监听;明确来源标记可以防止切换被计入 revision 或 dirty。
活动会话切换顺序
切换函数首先持久化当前快照:
function activateSession(
sessionId: string,
content: string,
recovered: boolean = false
): void {
if (sessionId === activeSessionId) {
return;
}
persistActiveSession();
window.clearTimeout(bridgeTimer);
window.clearTimeout(recoveryTimer);
recoveryTimer = undefined;
pendingNativeChange = false;
activeSessionId = sessionId;
const storedSession = editorSessions.get(sessionId);
if (!storedSession) {
resetEditorDocument(content, recovered);
return;
}
// 恢复快照
}
先保存、后改变 activeSessionId 是关键。如果先改 id,persistActiveSession 会把甲 state写到乙键下,两个会话立即混乱。
清理 bridgeTimer 防止甲延迟的 onChange 在乙激活后发给原生层。清理 recoveryTimer 防止甲快照以乙身份写入。pendingNativeChange=false 丢弃旧会话尚未发出的状态通知;甲的正文已经由原生切换前捕获,并且完整 state保存在 Map,不需要把过期通知发送给当前页面。
如果目标没有 Web 快照,说明它是原生新建、刚打开或 ArkWeb 状态已丢失,使用传入 content 建立 state。已有快照则恢复:
isApplyingNativeDocument = true;
editor.setState(storedSession.state);
isApplyingNativeDocument = false;
baselineDocument = storedSession.baselineDocument;
forcedDirty = storedSession.forcedDirty;
pendingDirty = storedSession.pendingDirty;
documentRevision = storedSession.documentRevision;
lastRecoveryRevision = storedSession.lastRecoveryRevision;
pendingSaveDocument = undefined;
pendingSaveDocument 不随会话恢复。它只属于当前正在进行的保存命令快照,而原生层通过操作互斥防止保存期间切换。恢复一个旧 pending 保存引用可能把另一个会话基线标记错误,因此明确清空。
恢复后主动同步原生状态
设置 state后,Web 重新计算大文档模式、预览和字数,并主动通知原生:
const wordCount = largeDocumentMode
? -1
: countWords(editor.state.sliceDoc());
window.ohMarkdownBridge?.onState(wordCount);
window.ohMarkdownBridge?.onChange(
wordCount,
pendingDirty
);
if (pendingDirty) {
scheduleRecoverySnapshot();
}
原生标签栏和状态栏不能只靠之前缓存。恢复通知让当前 wordCount、dirty 与实际 EditorState 对齐。如果目标会话为脏,重新安排它自己的恢复快照;干净会话不写。
大文档用 -1 表示跳过字数统计,原生状态栏显示保护模式。每个会话的内容长度不同,切换后必须重新计算,不能把大文档模式当全局开关。
预览也根据当前模式更新。纯源码只标记 dirty,分栏或预览立即渲染目标正文。若不处理,标签已切换,右侧还可能短暂显示上一文档。
保存基线与异步输入
用户请求保存时,Web 先保存当前 Text引用:
requestCommand: (command) => {
flushToNative();
if (command === 'save') {
flushRecoverySnapshot();
pendingSaveDocument = editor.state.doc;
}
const content = command === 'save'
? editor.state.sliceDoc()
: '';
window.ohMarkdownBridge?.onCommand(command, content);
}
原生写入成功后调用 markSaved:
markSaved: () => {
baselineDocument =
pendingSaveDocument ?? editor.state.doc;
pendingSaveDocument = undefined;
forcedDirty = false;
pendingDirty = !editor.state.doc.eq(
baselineDocument
);
window.clearTimeout(recoveryTimer);
recoveryTimer = undefined;
persistActiveSession();
}
基线使用保存请求时的 Text,而不是完成时的当前 state。如果磁盘写入期间用户继续输入,新内容不等于 pendingSaveDocument,dirty 继续为 true。若用完成时 editor.state.doc 作为基线,会错误认为尚未写入的新输入已保存。
清除 forcedDirty 表示恢复内容已经成功写入。重新比较当前 state后决定是否仍脏,再把结果持久化到会话 Map。保存与切换交错由原生互斥阻止,但 Web 仍保持版本正确性。
关闭会话的内存清理
关闭非活动 Web 会话时删除 Map 项:
function closeSession(sessionId: string): void {
if (sessionId !== activeSessionId) {
editorSessions.delete(sessionId);
}
}
活动会话不能在仍由 EditorView 使用时直接从逻辑上清空;原生层关闭活动标签时先激活后继,再调用旧 id 的 closeSession,此时旧会话已不活动,可以删除。
如果关闭后不删除,十二标签虽然限制可见会话,反复打开关闭仍会让 Map无限增长,历史和 Text结构无法释放。内存生命周期必须与标签生命周期一致。
最后一个标签关闭后,原生层创建新空会话并激活,再清理旧会话。Web始终有一个活动 id,不进入无 state 特例。
鸿蒙 PC 模拟器中的隔离结果
下图来自 MateBook Pro 2in1 模拟器。两个未命名标签拥有独立正文,活动会话显示 Session-B,另一个标签保持自己的状态。切换和撤销测试确认两个 EditorState历史不串联。

截图只能展示正文与标签,撤销历史必须用行为验证。自动化在 session-a输入“修改”,session-b输入“新增”,切回 a后撤销,预期只得到“文档甲”;再切回 b,必须仍为“文档乙新增”。
host.OhMarkdownEditor.activateSession(
'session-a',
'文档甲'
);
// 输入“修改”
host.OhMarkdownEditor.activateSession(
'session-b',
'文档乙'
);
// 输入“新增”
host.OhMarkdownEditor.activateSession(
'session-a',
'文档甲'
);
expect(host.OhMarkdownEditor.undo()).toBe(true);
expect(host.OhMarkdownEditor.getDocument())
.toBe('文档甲');
还应增加选区、滚动、重做、搜索选择、保存基线和恢复 revision 的逐项隔离断言。当前 EditorState天然保留选区与历史,但产品验收不能只依赖库设计推断。
内存与性能边界
单 View多 state避免同时渲染多个 CodeMirror DOM,但所有打开会话的 Text和历史仍驻留内存。十二个十兆文档即使结构高效,也可能超过桌面模拟器和真机可接受范围。当前单文档五兆以上进入最小扩展模式,不代表多标签总量已有控制。
后续可以统计每会话内容长度,限制总打开字节;后台会话可以截断撤销历史或序列化正文后释放 EditorState,激活时重建;最近使用的少量会话保留完整状态。冻结策略必须告知用户撤销历史是否会丢,并在后台脏会话释放前完成恢复快照。
切换性能也要测量:editor.setState、预览渲染、字数统计和 Bridge全文捕获都会随文档变大。Map 查找是常数时间,不代表完整切换是常数成本。真实指标应从点击标签到光标可输入,并分别记录源码、分栏和大文档模式。
当前边界
会话隔离目前存在于进程内。应用重启后不会恢复全部标签、光标、选区和撤销历史;崩溃恢复重点仍是活动文档内容。固定标签、标签排序、跨窗口和会话持久化需要更高层模型。
CodeMirror state与原生 DocumentSession是两份互补状态,也存在一致性成本。正文切换前由原生主动捕获,Web切换后主动回报 dirty和字数。未来可以给 Bridge消息增加 sessionId,避免所有回调隐含作用于当前活动会话,进一步降低异步误归属。
结语
多标签编辑内核的正确单位不是字符串,而是完整会话。OhMarkdown 为每个 session保存 CodeMirror EditorState、保存基线、恢复脏标记、revision和快照版本;切换前保存当前状态并取消旧定时器,切换后恢复目标 state并重新同步原生;保存使用请求时 Text作为基线,关闭删除不再使用的快照。
这些细节让正文、撤销、恢复和保存都归属于正确标签。鸿蒙 PC 编辑器要承担长期写作,用户不应感知 ArkUI 与 ArkWeb 的边界,更不应承担跨边界状态串联的后果。EditorState 会话隔离就是把这种技术复杂度留在应用内部的基础。
更多推荐




所有评论(0)