鸿蒙 PC Markdown 编辑器规范兼容与性能预算工程
鸿蒙 PC Markdown 编辑器规范兼容与性能预算工程
Markdown 编辑器很容易在演示阶段显得“已经完成”:输入标题能高亮,粘贴表格能预览,打开长文章也没有立即崩溃。但面向鸿蒙 PC 的桌面产品真正进入候选发布阶段后,问题会迅速变得具体。所谓兼容,到底兼容哪一版规范、哪一种运行配置、哪些扩展?所谓大文档,按 JavaScript 字符、Unicode 码点还是磁盘上的 UTF-8 字节计量?所谓性能通过,是一次无头浏览器的偶然快照,还是具有固定语料、固定预算和失败断言的持续门禁?
本文围绕一个本地优先的鸿蒙 PC Markdown 编辑器,完整讨论从规范语料冻结、GFM 删除线实现、UTF-8 字节保护、CodeMirror 动态降级,到多标签、多窗口、专业渲染和焦点竞态回归的工程方法。文章所对应的实际应用为“芯笺 Markdown”,公开仓库地址是:
https://gitcode.com/VON-/codex_md_oh
本文涉及的功能基线提交为 501f3f1。代码、测试数字和限制均来自该提交及其验证记录。需要先说明边界:文中的 Chromium 数字用于防止代码回退,不等于 HarmonyOS PC Release 真机性能;模拟器可以验证 ArkTS、ArkWeb、沙箱文件和窗口会话的真实组合,但不能替代物理键盘、触控板、多显示器、休眠唤醒和真实 GPU 合成测试。

上图由 MateBook Pro 2in1 模拟器直接截取。前景窗口运行最终重新安装的 HAP,预览中展示了 CommonMark 652/652 记录以及单、双波浪线删除线;背景保留第二个系统窗口。它不是网页原型截图,而是 ArkUI 工作台承载本地 ArkWeb 编辑器后的应用内部画面。
先把“兼容”写成可以证伪的命题
如果产品文档只写“支持 CommonMark 和 GFM”,工程团队实际上无法判断什么时候完成。CommonMark 有明确版本和数百条示例;GFM 又在 CommonMark 上定义表格、删除线、自动链接、任务列表和标签过滤等扩展。解析器还会因为配置不同产生不同输出,例如原始 HTML 是否允许、换行是否转换、链接是否自动识别。一个不可证伪的宣传语,会同时掩盖兼容缺口和安全取舍。
更可靠的声明应拆成三层:
- 语法层:使用 CommonMark 0.31.2 官方 652 条示例验证 conforming 配置。
- 产品层:生产预览明确支持表格、删除线、自动链接和任务列表等声明子集。
- 安全层:生产环境继续关闭原始 HTML,并对输出执行 DOMPurify 净化,不为追求逐字 HTML 等价开放活动内容。
这里最重要的不是得到一个漂亮的通过率,而是保证测试结论和产品行为没有偷换概念。CommonMark 官方示例会包含原始 HTML,解析器的规范配置应按规范验证;真正进入 ArkWeb 的生产配置则可以更严格。两者使用不同目的的测试,结论也必须分别书写。
冻结上游语料,而不是复制几个顺手样例
手写五六条 Markdown 样例无法构成兼容性工程。它们通常只覆盖作者熟悉的正常路径,对列表缩进、转义、实体、代码跨度、链接目的地和边界空白几乎没有约束。本项目将 CommonMark 0.31.2 官方 JSON 和 cmark-gfm 0.29.0.gfm.13 扩展示例冻结到测试语料目录,同时记录来源、许可、版本和 SHA-256。
哈希检查不是形式工作。上游规范示例中包含刻意设计的尾随空格、制表符和空行,一次编辑器自动清理就可能改变语义。如果测试只看文件还在不在,语料被悄悄改写后仍会得到“通过”。验证脚本先校验完整字节哈希,再运行解析断言:
const COMMONMARK_SHA256 =
'd431b29d97b6f73e69d547109cf5081578fac931e72afe95639ebe766c1b2a20';
const GFM_EXTENSIONS_SHA256 =
'a2a45e98be9fca95f564f927265a0f63beea6cae5369d1cf4bde44caa51b2a3a';
function sha256(content) {
return createHash('sha256').update(content).digest('hex');
}
const commonMarkSource = readFileSync(commonMarkPath);
if (sha256(commonMarkSource) !== COMMONMARK_SHA256) {
throw new Error('CommonMark 0.31.2 fixture hash does not match the frozen upstream artifact.');
}
CommonMark JSON 可以直接逐例比较 Markdown 输入和期望 HTML。比较时只归一化解析器在空引用块上的一个等价序列化差异,不用大范围字符串清洗掩盖错误:
function normalizeEquivalentHtml(value) {
return value.replace(/<blockquote>\n<\/blockquote>/gu, '<blockquote></blockquote>');
}
const commonMark = new MarkdownIt('commonmark');
const failures = commonMarkExamples.filter((example) =>
normalizeEquivalentHtml(commonMark.render(example.markdown)) !==
normalizeEquivalentHtml(example.html));
if (failures.length > 0) {
throw new Error(`CommonMark 0.31.2 failed ${failures.length} examples`);
}
最终 conforming 配置为 652/652。这意味着被锁定的解析器和配置在这 652 个输入上产生规范期望结果,不意味着产品已经实现所有 Markdown 方言,更不意味着可以关闭安全过滤。
GFM 上游文本没有采用项目自己的 JSON 结构,因此验证脚本使用规范中的 32 个反引号围栏解析示例,并锁定关键章节数量:表格 16 条、删除线 2 条、自动链接 3 条、HTML 标签过滤 1 条、任务列表 3 条。整个冻结文件含 30 个示例,其中还存在产品没有声明的能力。工程报告因此只说“语料完整且声明子集通过”,没有把 30 条结构检查写成“完整 GFM 等价”。
为什么 GFM 删除线需要独立处理
常见的 markdown-it 删除线规则只接受双波浪线,很多编辑器也只测试 ~~text~~。但冻结的 GFM 版本允许一对或两对匹配波浪线。若应用声称 GFM,却把 ~one~ 原样显示,这就是可复现的兼容缺口。
直接用正则替换波浪线并不可靠。删除线受左右可开闭条件、嵌套、转义和段落边界影响,还要与解析器的 delimiter 栈协作。实际实现沿用 markdown-it 的 scanDelims,为单波浪线和双波浪线使用不同 marker,在第二阶段把配对 token 改写为 del:
const SINGLE_TILDE_MARKER = 0x7E;
const DOUBLE_TILDE_MARKER = 0x1007E;
function tokenizeGfmStrikethrough(state: StateInline, silent: boolean): boolean {
if (silent || state.src.charCodeAt(state.pos) !== SINGLE_TILDE_MARKER) {
return false;
}
const scanned = state.scanDelims(state.pos, true);
if (scanned.length < 1) {
return false;
}
if (scanned.length > 2) {
const literal = state.push('text', '', 0);
literal.content = '~'.repeat(scanned.length);
state.pos += scanned.length;
return true;
}
const token = state.push('text', '', 0);
token.content = '~'.repeat(scanned.length);
state.delimiters.push({
marker: scanned.length === 1 ? SINGLE_TILDE_MARKER : DOUBLE_TILDE_MARKER,
length: 0,
token: state.tokens.length - 1,
end: -1,
open: scanned.can_open,
close: scanned.can_close
});
state.pos += scanned.length;
return true;
}
三个及更多波浪线被整体保留为文本,而不是从中截取一对制造部分删除线。这条规则专门防止 ~~~~~one~~~~~ 被解析成难以解释的混合结构。后处理同时遍历顶层 delimiter 和嵌套 token metadata,保证强调等内联结构内部也能正确配对。测试覆盖单波浪线、双波浪线、超过两个、未闭合和跨段落边界。
大文档阈值必须回答“一个 MiB 是什么”
早期实现把五 MiB 阈值写成 content.length >= 5 * 1024 * 1024。这在只用 ASCII 语料时看起来正确,因为 ASCII 一个 UTF-16 code unit 恰好也是一个 UTF-8 字节。但中文通常占三个 UTF-8 字节,Emoji 可能由代理对和多个码点构成。一个 220 万中文字符的文档,JavaScript length 尚未达到五 MiB,磁盘和 Bridge 传输却已经超过六 MiB。
这不仅是统计误差。阈值决定编辑器是否启用完整 Markdown 语言包、结构化编辑、即时 Decoration、专业预览和恢复快照。误判意味着最需要保护的大量中文内容反而继续走高成本路径,直接违背鸿蒙 PC 中文写作场景。
项目将统一口径定义为 UTF-8 字节:
const UTF8_ENCODER = new TextEncoder();
const LARGE_DOCUMENT_BYTE_THRESHOLD = 5 * 1024 * 1024;
function getUtf8ByteLength(content: string): number {
return UTF8_ENCODER.encode(content).byteLength;
}
ArkTS 原生侧也不能继续用字符串长度。打开文件、窗口会话恢复和崩溃快照必须与 Web 使用同一规则,通过 buffer.from(content, 'utf-8').length 得到字节数。否则 Web 已经进入保护模式,原生侧仍可能把大正文写入周期恢复记录,造成 Bridge 和沙箱 I/O 压力。
每次按键重算全文同样不可接受
把口径改成 TextEncoder 后,如果每次输入都对十 MiB 正文重新编码,正确性提高了,输入延迟却会被全文 O(n) 扫描拖累。CodeMirror 的 transaction 已经包含删除区间和插入片段,字节数可以增量维护:
update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted): void => {
const removedContent = update.startState.doc.sliceString(
fromA, toA, update.startState.lineBreak);
const insertedContent = inserted.sliceString(
0, undefined, update.state.lineBreak);
documentUtf8Bytes -= getUtf8ByteLength(removedContent);
documentUtf8Bytes += getUtf8ByteLength(insertedContent);
});
updateLargeDocumentMode(documentUtf8Bytes);
这段实现有一个容易遗漏的细节:sliceString 必须传入相应状态的 lineBreak。CodeMirror 内部文档以逻辑行组织,如果原文使用 CRLF,省略分隔符可能把每个换行按一个字节计算,持续编辑后累计字节数就会漂移。删除片段使用 startState.lineBreak,插入片段使用新状态的 lineBreak,才能让 CRLF 的两个字节稳定进入计数。
增量计数还需要会话边界。产品最多支持十二个标签,每个标签都应记录自己的 UTF-8 字节数;切换标签时不能继承上一个标签的大文档状态。测试因此不只验证打开一个大文件,还反复切换十二个不同正文的会话,检查内容、脏状态和性能配置没有串扰。
跨过阈值时要真正卸载高成本扩展
另一类隐藏问题是“只在创建 EditorState 时判断”。文档初始为 4.99 MiB,继续粘贴后超过五 MiB,如果编辑器只把预览隐藏却保留完整语法分析和结构化扩展,保护模式只是视觉标签,不是资源治理。
CodeMirror 的 Compartment 适合动态重配置扩展。项目把性能相关能力统一装入 compartment:
const performanceCompartment = new Compartment();
function createPerformanceExtensions(useLargeDocumentSetup: boolean) {
if (useLargeDocumentSetup) {
return [minimalSetup];
}
return [fullMarkdownSetup, structuredEditingExtensions];
}
function createEditorState(content: string): EditorState {
documentUtf8Bytes = getUtf8ByteLength(content);
const useLargeDocumentSetup =
documentUtf8Bytes >= LARGE_DOCUMENT_BYTE_THRESHOLD;
return EditorState.create({
doc: content,
extensions: [
performanceCompartment.of(
createPerformanceExtensions(useLargeDocumentSetup))
]
});
}
编辑增量导致模式变化时,用 microtask 延后 reconfigure,避免在当前 update listener 内递归 dispatch;同时捕获当前 session id,防止用户快速切换标签后把旧会话的模式应用到新标签:
if (wasLargeDocument !== largeDocumentMode) {
const changedSessionId = activeSessionId;
window.queueMicrotask(() => {
if (activeSessionId === changedSessionId) {
editor.dispatch({
effects: performanceCompartment.reconfigure(
createPerformanceExtensions(largeDocumentMode))
});
}
});
}
超过阈值后还要停止完整预览、取消尚未完成的 Mermaid/KaTeX 增强、关闭即时 Decoration、暂停大正文恢复快照,并把字数状态改为不可计算标记。保护模式的意义是主动减少工作,而不只是弹出“大文件”提示。
性能预算要覆盖任务,而不只是文件大小
十 MiB 打开速度当然重要,但它只代表一种压力。桌面编辑器的真实负载还包括频繁标签切换、公式和图表异步渲染、大量图片占位、多个窗口恢复,以及后台工作区搜索。G4-08 建立了五组浏览器防回退预算:
| 场景 | 实测结果 | 自动化预算 |
|---|---|---|
| 200 KiB 全量预览更新 | 45 ms | 小于 750 ms |
| 1 MiB UTF-8 文档加载 | 165 ms | 小于 1000 ms |
| 10 MiB UTF-8 文档加载 | 55 ms | 小于 3000 ms |
| 十二标签执行 120 次切换 | 1553 ms | 小于 3000 ms |
| 120 图片、120 公式、26 图表、80 代码块稳定 | 1144 ms | 小于 15000 ms |
十 MiB比一 MiB更快并不表示文件越大越快,而是十 MiB会立即进入轻量源码保护模式,一 MiB仍初始化完整编辑能力。性能数据必须和执行路径一起解释,否则很容易得出错误优化结论。
专业渲染压力也不只断言“最后出现 HTML”。Mermaid 有明确的单次文档上限,测试输入 26 个图表,前 24 个完成渲染,额外两个显示局部错误,并保证公式、代码和正文不被阻塞。这样的失败预算比无限制并发更适合桌面端:异常内容的影响被限制在局部,用户仍能编辑和导出其他部分。
多窗口压力应验证持久化不变量
模拟器很难提供可靠的 GPU 帧率结论,但非常适合验证 ArkTS 沙箱和窗口会话不变量。原生 ohosTest 构造八个窗口,每个窗口十二个标签,每个标签带独立标记和正文,然后读取恢复清单:
for (let windowIndex: number = 0; windowIndex < 8; windowIndex += 1) {
const windowSessionId = windowIndex === 0 ? 'primary' :
`window-${(2000000000100 + windowIndex).toString()}-${
windowIndex.toString(16).padStart(8, '0')}`;
const documents: WindowDocumentSessionRecord[] = [];
for (let documentIndex: number = 1; documentIndex <= 12; documentIndex += 1) {
const marker = `# 窗口 ${windowIndex} 标签 ${documentIndex}\n\n`;
documents.push({
sessionId: `document-session-${documentIndex}`,
content: `${marker}${'x'.repeat(8 * 1024)}`
});
}
await sessionService.saveWindowSession({
windowSessionId,
activeDocumentSessionId: 'document-session-12',
documents
});
}
设计约束不是“八个窗口全部自动重开”。为避免启动风暴,产品只自动恢复 primary 加最近七个次窗口;仍在运行的记录不会为了满足静态上限被静默删除。测试验证最新窗口的第十二个标签仍以预期标题开头。该压力用例耗时 122 ms,完整模拟器 ohosTest 为 16/16,总耗时 2258 ms;既有一千文件工作区压力为 1748 ms,且后续链接和大纲用例继续通过,说明后台任务没有污染后续状态。
全量回归为什么会发现焦点竞态
性能阶段的价值不只在数字。把新增压力测试与旧功能一起顺序执行时,命令面板偶发出现查询文字写入正文的问题。根因是某个原生命令执行后安排了 requestAnimationFrame(editor.focus);在下一帧到来前,用户已经打开命令面板,旧回调仍把焦点抢回 CodeMirror。
单独运行命令面板用例往往不会暴露它,因为时序过于稳定。完整回归、页面初始化和压力任务改变了帧调度,竞态才变得可见。修复方法不是延长测试等待,而是把所有延迟聚焦收束为带状态守卫的函数:当前处于预览、命令面板、快捷键设置、链接助手、历史比较或右键菜单时,都不得让旧回调重新聚焦编辑器。
测试又暴露出一个自动化设计问题:过去通过“列表恰好只有一个元素”推断命令身份。筛选和异步状态改变后,这个偶然条件会失效。命令项改用稳定的 data-command-id,测试精确选择白名单命令。修复后目标焦点路径连续执行 10/10,全套 Playwright 最终 66/66。
这个案例说明,性能测试不能与交互正确性隔离。帧调度、异步渲染、Bridge 回调和焦点路由共享同一个事件循环,压力变化经常最先揭露潜伏的状态竞态。
浏览器、模拟器和真机必须分账
桌面混合架构至少存在三类环境:
- Chromium:适合精确构造字节语料、统计脚本耗时、验证 DOM、CodeMirror 和渲染逻辑。
- HarmonyOS 2in1 模拟器:适合验证 ArkUI、ArkWeb、Bridge、沙箱文件、窗口会话、HAP 资源和原生服务组合。
- HarmonyOS PC Release 真机:才适合形成输入 P95、滚动帧率、完整进程 PSS、物理键盘、触控板、显示器、休眠恢复和系统分享结论。
三者不是高低级替代关系,而是不同测量工具。把 Chromium 的 55 ms 写成“鸿蒙 PC 十 MiB 打开 55 ms”是错误的;把模拟器窗口恢复 122 ms写成“八窗口渲染性能”同样错误。正确报告应标明运行环境、构建类型、语料大小、计时边界和不覆盖的部分。
当前工程的构建证据包括:生产单 HTML 7,691,815 字节,SHA-256 为 937dffb5cb62db836aac7f747a4f0f9bb59ba34630882a7b6b256d37be06b95d;Debug HAP 8,720,939 字节,SHA-256 为 27658344a6df6d3e65e5e4d1aac8c32dc8e82992c8f2cdce92ad5910e75e4ad2;ohosTest HAP 9,395,112 字节,SHA-256 为 a86cbaf916428d4744fe444f92d64fad4dbf8585922024116510475d04292aa1。这些哈希让截图、报告和产物可以相互追溯。
规范兼容不能突破安全边界
兼容性工程最危险的误区,是为了让官方 HTML 示例逐字相同而放宽生产配置。一个本地优先编辑器仍然会打开来自邮件、聊天、仓库和网络下载的 Markdown,原始 <script>、事件属性、危险 URL 和内嵌活动内容不能因为“本地软件”而默认可信。
芯笺 Markdown 的生产预览保持 html: false,生成结果还要经过 DOMPurify;CSP 禁止网络连接,ArkWeb 不开放任意文件访问;图片资源通过受限相对路径和原生 Bridge 分块读取;Bridge 命令采用固定白名单。CommonMark conforming 测试用于证明解析器基线,安全生产配置则通过恶意内容用例证明危险能力没有被打开。
这会产生一种合理差异:规范要求原始 HTML 原样进入输出,而产品把它当文本或过滤。工程报告应明确这是安全策略,不把生产输出的差异混进语法缺陷,也不虚构 652/652 等于生产配置的逐字等价。
对产品优势的真实含义
官方语料、字节保护和压力预算不会直接让工具栏多一个按钮,却会形成更难复制的产品优势。中文长文档不再因为 UTF-16 误判落入高成本路径;用户在 4.9 MiB 文档中继续粘贴时,编辑器能够在不中断正文的情况下动态降级;十二标签切换和八窗口恢复有确定边界;GFM 单波浪线不会在跨工具协作时悄悄改变含义;模态面板不会被上一帧遗留的聚焦动作夺走输入。
这些优势的共同点是“失败可解释”。超过能力预算时,产品退回源码模式,不冻结或丢弃正文;图表超过上限时,局部显示错误,不阻塞其余预览;未声明的 GFM 能力不会因为上游文件存在就被宣传;没有真机时,分数不会因 Chromium 数据自动提升到“领先”。
真正可持续的竞争力不是用一次顺利演示证明所有问题都不存在,而是把规范版本、资源预算、降级规则、失败路径和证据边界写进工程系统。这样后续升级 markdown-it、CodeMirror、ArkWeb 或 HarmonyOS SDK 时,团队能立即知道哪里发生了行为漂移。
还没有完成的验证
G4-08 完成的是性能与兼容性工程基线,不是公开发布的全部性能承诺。仍需在具备正式签名和 Release 构建的鸿蒙 PC 真机上补齐:一 MiB与十 MiB文件打开、持续输入 P95、长文档滚动帧率、完整进程内存、中文输入法组合文本、物理键盘与触控板、多显示器、休眠唤醒和真实文件管理器关联。同一设备还要使用冻结版本的竞品和相同语料执行同边界计时,才能讨论是否领先。
全局 256 MiB 版本历史长期清理、千次异常终止恢复、实际 PDF、真实第三方分享、独立安全复核和真实用户连续试用也仍是候选发布硬门槛。模拟器已经证明实现可以继续推进,但不能替这些外部条件签字。
可复用的实施清单
准备为鸿蒙 PC 或其他桌面平台建立 Markdown 性能兼容基线时,可以按以下顺序执行:
- 冻结带版本、来源、许可和 SHA-256 的官方语料。
- 分开定义规范解析配置、生产安全配置和产品声明子集。
- 用官方示例做解析结果比较,用产品语料做安全和交互回归。
- 把文件预算统一为实际持久化编码字节,不使用语言运行时的字符长度代替。
- 利用编辑事务增量维护字节数,特别验证 CRLF、中文、Emoji 和组合字符。
- 在文档编辑跨阈值时动态卸载高成本扩展,而不是只在打开时判断。
- 为预览、多标签、专业渲染、多窗口和后台任务分别设置预算。
- 把正常、异常、安全和降级路径放入同一全量回归,观察事件循环竞态。
- 分开记录浏览器、模拟器、Debug HAP 与 Release 真机结论。
- 对所有未完成外部验证保留明确门槛,不从较弱环境换算结论。
结语
Markdown 编辑器做到“能解析”并不难,难的是让规范、性能和安全三者长期相容。规范语料要求实现不要随意漂移,安全策略要求生产环境不能盲从原始 HTML,性能预算又要求大文档及时放弃昂贵能力。三者看似互相拉扯,实际上可以通过分层配置、统一字节口径、动态 Compartment、可复现语料和环境分账建立稳定边界。
对于鸿蒙 PC 编辑器,这套方法尤其重要。ArkUI、ArkWeb、Bridge、文件沙箱和多窗口会话共同参与一条写作链路,任何一层用模糊指标自我证明,都可能把压力转移到下一层。把结论写成可证伪的测试,把降级做成保持正文的产品行为,把模拟器和真机严格分账,才是从“可以运行”走向专业桌面工具的可靠路径。
更多推荐



所有评论(0)