鸿蒙 PC Markdown 编辑器十兆级大文档保护模式

仓库地址:https://gitcode.com/VON-/codex_md_oh

代码基线:大文档实现位于当前 Web 编辑器主线,精确十兆级回归提交为 941a1dc,状态同步提交为 6c9d88f

大文档能力首先是降级设计

桌面 Markdown 编辑器常把“能打开大文件”写成性能卖点,但真正困难的不是把字符串读进内存,而是在语法解析、自动换行、撤销历史、实时预览、公式、图表、图片、字数统计、恢复快照和原生桥接同时存在时仍保持可编辑。十兆级正文进入完整渲染链后,实际内存可能是源文件的数倍甚至数十倍。

OhMarkdown 没有用无限功能换取一张成功截图,而是给大文档建立保护模式:源码编辑与保存优先保留,语法扩展、自动换行、预览、专业渲染、导出、链接助手和周期恢复快照暂停。用户仍然能读取、修改并保存最重要的文本事实,产品则避免在已经超过资源预算时继续制造 DOM、SVG 和位图。

这是一种 PC 工具应有的失败策略。正常规模下提供丰富体验,越过明确阈值后主动收缩,状态栏给出可识别状态,所有入口遵守同一约束。比起某个按钮点击后长时间无响应,这种可预测降级更值得长期信任。

十兆字节与十兆字符不能混写

当前 Playwright 用例构造的是 10 * 1024 * 1024 个 JavaScript UTF-16 代码单元,严格说它验证十兆级编辑器文档长度,不等价于“文件恰好 10 MiB”。中文字符写成 UTF-8 时通常占三个字节,换行与 ASCII 又占一个字节,因此同一字符串落盘后的字节数会大于 10 MiB。

const targetLength = 10 * 1024 * 1024;
const line = '鸿蒙PC大文档\n';
const content = line
  .repeat(Math.ceil(targetLength / line.length))
  .slice(0, targetLength);

这一区分直接影响工程结论。Web 编辑器按 content.length 决定 CodeMirror 配置,所以代码单元是正确的 Web 边界;Core File Kit 的读取预算、磁盘吞吐和保存完整性则应该按 UTF-8 字节测量。文章把两类指标分开,避免把浏览器回归的 87 ms 写成完整 10 MiB 文件打开时间。

后续真机基准应同时记录:文件字节数、解码后代码单元数、Unicode 组成、行数、最长行、换行格式和图片引用数量。只有这样,英文日志、中文长文和单行压缩文本的结果才可比较。

阈值必须在 EditorState 创建前判断

大文件保护若在编辑器初始化完成后才打开,就已经太晚。CodeMirror 可能先加载 Markdown 语言扩展、构建语法状态、测量超长行并创建换行布局,然后应用才发现正文超过阈值。峰值成本已经支付,切回源码模式无法撤销初始化时的卡顿。

OhMarkdown 在 createEditorState 入口根据正文长度选扩展集合。超过五兆代码单元时使用 minimalSetup,不加载 Markdown 语言扩展和 lineWrapping;小文档仍使用 basicSetup 与完整 Markdown 编辑体验。

const LARGE_DOCUMENT_CHARACTER_THRESHOLD = 5 * 1024 * 1024;

function createEditorState(content: string): EditorState {
  const useLargeDocumentSetup =
    content.length >= LARGE_DOCUMENT_CHARACTER_THRESHOLD;
  const lineSeparator = content.includes('\r\n') ? '\r\n' : '\n';
  return EditorState.create({
    doc: content,
    extensions: [
      useLargeDocumentSetup ? minimalSetup : basicSetup,
      useLargeDocumentSetup ? [] : markdown({ base: markdownLanguage }),
      EditorState.lineSeparator.of(lineSeparator),
      localeCompartment.of(placeholder(getEditorMessages().editorPlaceholder)),
      useLargeDocumentSetup ? [] : EditorView.lineWrapping
    ]
  });
}

阈值名称使用 CHARACTER 而不是 BYTE,与实现事实保持一致。这个细节能阻止未来维护者误把文件大小直接传入,或者以为中文内容与英文内容有相同的字节边界。

为什么阈值是五兆而测试是十兆

五兆是保护模式触发点,十兆是阶段验收样本。只在阈值附近测试,可以证明条件表达式工作,却不能证明进入保护模式后还能承载更高一级规模。十兆样本相当于给降级路径留出一倍距离,能发现 setDocumentgetDocument、桥接状态和模式切换中仍然存在的全文复制。

这不意味着支持上限就是十兆。当前产品没有把十兆写成硬拒绝线,也没有证明一百兆仍安全。一个严谨的能力声明应写成:十兆级样本在当前 Web 回归中能于三秒预算内进入保护模式并保持源码区可见;更大规模、真机内存和持续编辑仍需专项数据。

阈值未来若调整,必须同时评估功能体验和风险。过低会让普通长文过早失去预览,过高则会在内存不足设备上触发冻结。合理做法是在固定真机矩阵上采集峰值内存、输入延迟和首次可交互时间,再通过配置或设备能力档位演进。

模式请求必须被保护状态拒绝

大文档加载完成后,外部工具栏、快捷键、右键菜单或恢复的旧会话都可能请求 splitpreview。仅在加载时设置 source 不够,所有后续模式入口都必须检查保护状态。

function updateLargeDocumentMode(documentLength: number): void {
  largeDocumentMode =
    documentLength >= LARGE_DOCUMENT_CHARACTER_THRESHOLD;
  if (largeDocumentMode && currentMode !== 'source') {
    currentMode = 'source';
    workspace.dataset.mode = 'source';
  }
}

function setMode(mode: ViewMode): void {
  if (largeDocumentMode && mode !== 'source') return;
  currentMode = mode;
  workspace.dataset.mode = mode;
}

命令面板还会把预览、分栏、HTML、PNG 和打印标记为不可用,编辑器右键中的插入链接也遵守同一状态。入口层禁用负责用户反馈,执行层再校验负责安全;两层同时存在,才能防止原生桥接或未来快捷键绕过界面禁用。

字数统计为什么返回负一

普通文档每次变更都会计算字数并通知原生状态栏。对十兆级正文执行全量词数扫描,会把每次输入都变成 O(n) 工作,还可能产生临时字符串。大文档模式将 wordCount 设为 -1,由原生界面显示保护状态,而不是一个看似精确但持续拖慢输入的数字。

function flushToNative(content?: string): void {
  window.clearTimeout(bridgeTimer);
  if (pendingNativeChange) {
    const wordCount = largeDocumentMode
      ? -1
      : countWords(content ?? editor.state.sliceDoc());
    window.ohMarkdownBridge?.onChange(wordCount, pendingDirty);
    pendingNativeChange = false;
  }
}

负一是跨 ArkWeb 与 ArkUI 的协议值,不应直接展示给用户。协议需要集中解释,否则状态栏可能出现“-1 字”。未来若要提供大文件统计,可以改为后台分块计算和“约 N 字”,但不能重新把同步全量扫描放回每次输入路径。

恢复快照为什么暂停

周期恢复通常是编辑器的优势,但对十兆级正文每 1.5 秒把全文从 CodeMirror 切出、跨桥传给 ArkTS、序列化并原子写盘,会持续制造内存和存储压力。保护模式暂停周期快照,避免安全功能本身成为卡顿或崩溃原因。

function scheduleRecoverySnapshot(): void {
  if (!pendingDirty || largeDocumentMode) {
    window.clearTimeout(recoveryTimer);
    recoveryTimer = undefined;
    return;
  }
  if (recoveryTimer !== undefined) return;
  recoveryTimer = window.setTimeout(
    flushRecoverySnapshot,
    RECOVERY_SNAPSHOT_INTERVAL_MILLISECONDS
  );
}

暂停快照并不代表可以忽略数据安全。大文档仍需清楚提示保护状态,保存命令必须可用,关闭窗口仍要走未保存确认。未来可以设计增量日志或基于 CodeMirror transaction 的恢复格式,但在没有证明增量方案可靠之前,宁可明确限制,不使用高频全文副本冒充安全。

延迟桥接减少跨进程复制

正文超过一兆后,Web 到原生的变更通知防抖从 160 ms 延长到 600 ms。通知本身不总是传全文,但保存、冲突与会话同步可能触发内容读取。更长防抖减少连续输入中重复的边界穿越,让编辑器先处理用户输入,再合并状态更新。

这不是把延迟藏起来。状态栏和保存状态允许数百毫秒最终一致,光标与文本回显则必须即时。把不同交互按响应要求分层,是 PC 编辑器性能设计的重要方法:输入路径最优先,统计和预览可以稍后,磁盘持久化按明确策略完成。

自动化用例验证了什么

新增 Playwright 用例在页面内部精确构造十兆级字符串,记录 setDocument 与请求预览的合计时间,随后验证三个事实:取回的文档长度完全一致;模式仍为 source;耗时小于三秒。界面层还断言编辑器可见、预览隐藏。

test('十兆字节文档在三秒内进入保护模式并保持源码可读',
  async ({ page }) => {
    const result = await page.evaluate(() => {
      const host = window as unknown as EditorTestWindow;
      const targetLength = 10 * 1024 * 1024;
      const line = '鸿蒙PC大文档\n';
      const content = line.repeat(
        Math.ceil(targetLength / line.length)
      ).slice(0, targetLength);
      const startedAt = performance.now();
      host.OhMarkdownEditor.setDocument(content);
      host.OhMarkdownEditor.setMode('preview');
      return {
        elapsedMilliseconds: performance.now() - startedAt,
        documentLength: host.OhMarkdownEditor.getDocument().length,
        mode: document.querySelector('#workspace')
          ?.getAttribute('data-mode')
      };
    });
    expect(result.documentLength).toBe(10 * 1024 * 1024);
    expect(result.mode).toBe('source');
    expect(result.elapsedMilliseconds).toBeLessThan(3000);
  });

当前 Chromium 回归记录 87 ms,Playwright 全量为 44/44。三秒是退化门禁,不是把本机 87 ms 变成所有设备的承诺。自动化能稳定发现模式绕过和数量级退化,但不能覆盖 Core File Kit 读取、ArkTS/ArkWeb 首次装载、真机调度和磁盘温度。

第一次失败暴露了语料错误

用例初版按人工估计的中文行长度计算重复次数,最终只有 9,320,680 个代码单元,长度断言失败。修复后改用 line.length 作为生成公式的事实来源,再通过 slice 精确截断。

这个失败说明性能测试也需要先验证样本。文件名叫 10mb.md、循环写了一百万次、日志打印“十兆”都不能证明语料正确。测试应在计时之外断言长度和字节数,否则会出现样本逐渐缩小、成绩却越来越好的假优化。

对中文语料尤其如此:JavaScript 长度、Unicode 码点、UTF-8 字节和用户看到的字形不是同一单位。工程报告必须注明测量单位,不能把数字便利放在事实之前。

应用内部的大文件状态

下图来自 OhMarkdown 应用内部的大文件保护界面。源码区保持可用,预览能力退出,状态栏以大文件状态替代昂贵的实时字数统计。

在这里插入图片描述

图片用于证明产品确实有可见的保护状态和源码工作区,不用于证明 87 ms 或十兆级长度。性能数字来自自动化日志,视觉状态来自应用截图,文件整链结果来自模拟器或真机测试。把三类证据分开,可以避免一张界面图承担它不能证明的性能结论。

为什么预览和导出要一起关闭

Markdown 预览不是一次简单字符串替换。专业渲染包含 MarkdownIt、DOMPurify、KaTeX、Mermaid、代码高亮、本地图片读取和 Blob 生命周期。HTML 导出还会内联字体和图片,PNG 导出会创建全页位图,PDF 会进入打印布局。十兆正文若继续走这些路径,峰值很难用单一阈值控制。

因此产品没有保留一个“仍然试试”的入口。命令面板与右键菜单显示禁用,底层 prepareOutput 再拒绝。用户可以先拆分文档或保存后用批处理工具处理。对专业 PC 软件而言,明确的能力边界比不可预测的偶尔成功更专业。

超长单行是另一类风险

总字符数相同,十万行短文本与一行十兆文本的布局成本不同。关闭 lineWrapping 能减少超长行的视觉测量,但横向滚动、选择、查找和行列定位仍需要专项测试。当前十兆语料包含短行,不能代表压缩 JSON、日志和生成代码。

后续性能矩阵至少要加入:全中文短行、英文日志、单个超长行、CRLF、混合换行、BOM、稀疏超长链接以及接近阈值的频繁编辑。每种语料记录首次可交互、输入 P95、滚动帧、撤销耗时和保存时长。一个平均样本不足以定义“大文件”。

真机验收应测什么

真机测试需要从文件选择器开始,而不是在已加载页面中调用 setDocument。计时边界应分成选择完成到源码首屏、源码首屏到可输入、一次输入到字符显示、保存发起到 fsync 完成。还应记录 ArkUI 主进程与 ArkWeb 进程组 PSS,而不是只看单进程内存。

设备矩阵至少覆盖低内存配置与目标鸿蒙 PC,输入法包含中文拼音和英文键盘,窗口包含停靠侧栏与窄窗口。冷启动、热启动、冷文件缓存和热缓存分别统计 P50/P95。测试完成后校验源文件哈希或字节内容,不能以“窗口没崩”代替数据正确。

恢复能力的后续方向

大文档模式当前暂停周期全文恢复,这是诚实但仍可改进的边界。更适合的方向是增量操作日志:记录基线文件指纹、文档修订号和有限 transaction,在恢复时重放,并定期压缩检查点。任何方案都必须处理外部修改、换行格式、撤销历史和异常中断。

增量恢复不能只在正常退出路径测试。需要模拟写到一半、检查点损坏、基线文件被外部替换、日志重复和空间不足。直到这些失败路径证明可靠,现有策略仍应优先保留源码编辑、主动保存和关闭确认,不提前承诺“十兆文档也有完整秒级恢复”。

产品优势来自稳定而不是强行全功能

大文档场景中,用户通常更看重能打开、能搜索局部、能修改和能保存,而不是公式是否即时渲染。OhMarkdown 的保护模式把核心任务排在增强任务之前,避免渲染插件拖垮文件工具。这种优先级与本地优先、安全保存和冲突处理共同构成可靠性优势。

优势还来自指标诚实。当前 87 ms 是 Web 页面内测量,历史模拟器整链约 345 ms 也不能替代 Release 真机 P95;十兆代码单元不是十兆 UTF-8 字节;自动化通过不代表无限文件支持。明确这些边界,让后续优化有真实基线,也防止产品宣传透支工程信誉。

结论

鸿蒙 PC Markdown 编辑器的大文档能力不是让所有增强功能在十兆正文上继续运行,而是在资源预算被突破时保住源码和保存。OhMarkdown 在 EditorState 创建之前选择 minimalSetup,统一拒绝预览与导出,停止高成本字数和恢复快照,并用精确十兆级代码单元样本验证三秒内进入保护模式。

当前 Playwright 44/44 通过,目标用例记录 87 ms,界面保持源码可见、预览隐藏。这个结果建立了 Web 保护路径基线,但完整 UTF-8 文件读取、超长单行、中文输入、真机 P95、进程组内存和大文档增量恢复仍需继续验证。可靠的产品优势,来自知道哪些能力必须保住,也来自清楚说明哪些能力尚未证明。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐