鸿蒙 PC Markdown 编辑器跨运行时通信:ArkWeb 返回值的 JSON 解码陷阱

ArkUI 调用 ArkWeb JavaScript 时,最容易被忽略的问题不是脚本能否执行,而是返回值到底是什么格式。一个函数在浏览器控制台返回字符串,不代表原生 runJavaScript Promise 得到的就是未经编码的原字符串。正文包含换行、引号或反斜杠时,这个差异会直接污染多标签内容和 HTML 导出。

本文基于鸿蒙 PC Markdown 编辑器 OhMarkdown 的真实缺陷与修复,说明如何区分字符串、数字和布尔返回值,为什么字符串结果要经过 JSON 解码,为什么脚本参数也必须结构化编码,以及如何把 Bridge 设计成可审查的窄接口。完整代码位于 https://gitcode.com/VON-/codex_md_oh,本文对应提交 3a9146e

问题为何会在多标签中暴露

单文档编辑器大部分变化通过 JavaScript Proxy 主动回调原生层,例如 onChangeonSnapshotonCommand。多标签切换前,原生层需要立即捕获 CodeMirror 当前全文,不能等待节流回调,因此调用:

const result = await this.editorController.runJavaScript(
  'window.OhMarkdownEditor?.getDocument() ?? ""'
);

Web API 的实现很直接:

window.OhMarkdownEditor = {
  getDocument: () => editor.state.sliceDoc()
};

最初代码把 result 直接当正文。只输入 Session-B 时,问题不一定明显;当正文是多行 Markdown,原生层可能拿到带外层引号和转义符的 JSON 字符串表示。例如 Web 逻辑值是:

# 标题

正文 "引用"

原生返回文本可能表达为:

"# 标题\n\n正文 \"引用\""

如果直接保存,标签切换回来后编辑器会看到外层引号,换行变成两个可见字符 \n,引号前多出反斜杠。简单单行英文测试没有覆盖这些字符,缺陷直到多标签与导出联调才出现。

这类问题的本质是跨运行时序列化。ArkWeb 需要把 JavaScript 值转换为原生接口可以携带的字符串,原生层不能假设结果等同于 String(value),必须根据接口实际表示恢复逻辑值。

解码函数只处理字符串结果

OhMarkdown 增加一个小型边界函数:

private decodeJavaScriptString(result: string): string {
  try {
    return JSON.parse(result) as string;
  } catch (_) {
    return result;
  }
}

正常 JSON 字符串通过 JSON.parse 去掉外层引号并还原转义。解析失败时返回原值,兼容平台直接返回裸字符串的情况,也避免一次格式差异让内容变为空。

为什么不手工删除首尾引号并替换 \n?因为 JSON 字符串不只有换行转义,还包含反斜杠、制表符、回车、Unicode 转义和嵌套引号。手工顺序稍有错误就会把用户真实输入的 \\n 误还原成换行。标准解析器已经定义完整语义,应直接使用结构化 API。

这个函数名明确带 String,不能用于所有返回值。若 JavaScript 返回数字 2JSON.parse('2') 得到 number,但函数类型声明为 string,ArkTS 的强制断言不会在运行时转换,后续字符串操作可能出错。布尔值同理。调用方必须先决定期待的逻辑类型。

更严格实现可以检查解析结果:

private decodeJavaScriptString(result: string): string {
  try {
    const decoded: unknown = JSON.parse(result);
    return typeof decoded === 'string' ? decoded : result;
  } catch (_) {
    return result;
  }
}

当前代码使用类型断言满足既定 Web API 契约,后续为了防御 Web 版本不一致,可以加入运行时类型检查。跨运行时接口即使两端都由同一项目维护,也应该把返回类型视为不可信边界。

捕获活动文档的完整回退路径

修复后的会话捕获如下:

private async captureActiveDocumentSession(): Promise<void> {
  let content = this.documentContent;
  if (this.editorReady) {
    try {
      const result = await this.editorController.runJavaScript(
        'window.OhMarkdownEditor?.getDocument() ?? ""'
      );
      content = this.decodeJavaScriptString(result);
    } catch (_) {
    }
  }
  this.documentContent = content;
  this.syncActiveDocumentSession(content);
}

函数先使用原生侧最近正文作为回退。只有编辑器 ready 并且脚本成功,才用解码结果覆盖。ArkWeb 重载、页面销毁或脚本异常时,不会把会话写成空字符串。

catch 目前不显示错误,因为会话切换仍可使用原生快照继续,属于可降级路径。生产观测可以记录非敏感错误码,但不能把正文写入日志。编辑器内容可能包含凭据、日记或公司文档,调试跨运行时序列化时尤其要避免打印完整返回值。

捕获完成后再同步活动会话,并且调用顺序位于切换目标 sessionId 之前。若先切换身份再解码,即使字符串内容正确,也会写进错误会话。序列化正确性与状态机顺序必须同时成立。

HTML 导出复用了同一修复

HTML 导出由 Web 侧生成完整字符串,因为渲染、净化和导出 CSS 都位于 Web 内核:

const exportedResult = await this.editorController.runJavaScript(
  `window.OhMarkdownEditor?.exportHtml(` +
  `${JSON.stringify(this.documentName)}) ?? ''`
);
const exportedHtml = this.decodeJavaScriptString(exportedResult);
if (exportedHtml.length === 0) {
  throw new Error('The editor did not produce HTML output.');
}
await writeUtf8Document(saveUri, exportedHtml);

完整 HTML 包含大量引号和换行,是最容易暴露 JSON 编码差异的返回值。如果不解码,导出文件的第一个字符可能是引号,内部 <!doctype html> 前后带转义,浏览器打开只显示一段 JSON 文本而不是网页。

复用同一个解码函数避免会话捕获和导出各写一套字符串处理。边界函数虽小,却属于跨运行时基础设施;凡是 runJavaScript 期待字符串的调用,都应经过它。

导出前还检查空结果。解码成功不等于内容有效,Web API 不存在、脚本返回 undefined 或页面版本不匹配时,空值不能被写成“成功导出”的零字节文件。

数字返回值不要走字符串解码

搜索 API 返回匹配数量:

const result = await this.editorController.runJavaScript(
  `(window.OhMarkdownEditor?.find(` +
  `${JSON.stringify(this.searchQuery)}, ` +
  `${JSON.stringify(this.searchCaseSensitive)}, ` +
  `${JSON.stringify(this.searchWholeWord)}, ` +
  `${JSON.stringify(this.searchRegularExpression)}, ` +
  `${JSON.stringify(backwards)}) ?? 0)`
);
this.searchMatchCount = Number.parseInt(result);

JavaScript 返回 number,原生接口提供其文本表示,例如 2,直接 Number.parseInt。如果误用 decodeJavaScriptString,类型断言可能掩盖 number,后续 .length 等调用会出错。

数字解析还应检查 Number.isNaN。当前 Web API与原生代码同版本,返回契约稳定;若未来 Web 资源可能独立更新,应对非法结果回退为零并标记协议错误。parseInt('2.5') 会得到 2,也会宽松接受尾部字符;匹配数契约更适合严格数字转换后检查整数。

正则、整词和方向开关作为参数传入 Web,返回值仍只有匹配数。不要为了“统一接口”把所有结果包成字符串,因为丢失类型会把错误推迟到业务层。

布尔返回值使用明确比较

打印前,Web API 返回预览是否准备完成:

const printReady = await this.editorController.runJavaScript(
  'window.OhMarkdownEditor?.preparePrint() === true'
);
if (printReady !== 'true') {
  throw new Error(
    'The editor preview is not ready for printing.'
  );
}

脚本表达式本身强制得到 boolean,原生侧比较文本 true。没有写 if (printReady),因为任何非空字符串在许多语言中都可能被视为真,字符串 'false' 也不是 false。

当预览不可用,例如大文档模式,API 返回 false,原生层不会创建打印适配器。布尔协议保持最小,不需要 JSON 字符串解码。更统一的 Bridge 层可以提供 decodeBooleandecodeNumber,但只有调用点足够多、确实减少重复时才值得增加抽象。

参数方向同样需要 JSON 编码

返回值要解码,传入 JavaScript 的字符串也要编码。OhMarkdown 不使用:

// 错误示例
`find('${this.searchQuery}')`

查询包含单引号时脚本会语法错误,包含反斜杠和换行时语义改变,恶意文本甚至可能闭合字符串并执行额外表达式。正确方式是:

`window.OhMarkdownEditor?.find(` +
`${JSON.stringify(this.searchQuery)}, ` +
`${JSON.stringify(this.searchCaseSensitive)})`

文档正文、sessionId、文件名、主题名和替换式都遵循同样规则。JSON.stringify 生成合法 JavaScript 字面量,避免手工转义遗漏。

结构化编码并不意味着可以调用任意脚本。原生层只调用 window.OhMarkdownEditor 的白名单方法,Web 页只向原生代理暴露 onReadyonStateonChangeonSnapshotonCommand。窄接口与正确编码共同降低边界风险。

双向 Bridge 的职责不同

OhMarkdown 有两条通信方向。

Web 到原生使用 javaScriptProxy

.javaScriptProxy({
  object: this.editorBridge,
  name: 'ohMarkdownBridge',
  methodList: [
    'onReady',
    'onState',
    'onChange',
    'onSnapshot',
    'onCommand'
  ],
  controller: this.editorController
})

这条方向适合事件通知和命令快照,参数有明确类型。原生到 Web 使用 runJavaScript 调用受限 API,适合设置文档、切换会话、查找、跳转和导出。

如果所有通信都靠拼接脚本,用户输入每次按键都跨边界,性能和转义风险都会增大。如果所有通信都靠代理回调,原生层又难以主动请求当前值。两种机制各自服务不同数据流,关键是协议集中且类型清楚。

Web TypeScript 声明列出完整接口:

OhMarkdownEditor?: {
  setSessionDocument(
    sessionId: string,
    content: string,
    recovered?: boolean
  ): void;
  activateSession(
    sessionId: string,
    content: string,
    recovered?: boolean
  ): void;
  getDocument(): string;
  find(
    query: string,
    matchCase?: boolean,
    wholeWord?: boolean,
    regexp?: boolean,
    backwards?: boolean
  ): number;
  exportHtml(title: string): string;
}

声明不能保证 ArkTS 运行时自动验证,却能约束 Web 实现和 Playwright 测试。原生侧仍需为序列化结果做运行时处理。

鸿蒙 PC 模拟器中的真实影响

下图来自 MateBook Pro 2in1 模拟器。两个标签切换后分别保留正文,说明活动文档从 ArkWeb 返回、解码并写入正确 session,再恢复目标 EditorState 的链路已经工作。

在这里插入图片描述

单凭截图不能验证引号和换行转义,测试文档还需要包含:多行中文、单双引号、反斜杠、制表符、CRLF、emoji、Markdown 代码围栏中的 JSON、末尾换行以及空字符串。切换标签后逐字节或逐字符串比较,才能锁住解码行为。

HTML 导出测试应断言文件以 <!doctype html> 开始,而不是只检查文件存在。包含标题闭合片段、脚本标签和危险链接的文档可以同时覆盖字符串解码与安全净化。

不要用 as string 代替验证

ArkTS/TypeScript 的 as string 只影响编译器,不改变运行时值。跨边界结果若实际是 number、null 或对象,断言不会自动转换。边界代码应尽可能使用 unknown 思维:先解析,再检查类型,再进入业务层。

当前 decodeJavaScriptString 的回退兼容实际平台行为,但还可以改进:解析得到非字符串时抛出协议错误;为正文设置最大长度;导出 HTML检查 doctype;记录协议版本;在 Web ready 时交换能力列表。随着接口增长,能力协商比依赖可选链返回空值更容易诊断版本不一致。

另一个风险是大字符串复制。正文和完整 HTML 通过 runJavaScript 返回时会经历序列化与解码,几兆文本可能产生多份内存副本。当前大文档模式禁用 HTML 导出和周期全文恢复,降低压力;多标签捕获仍需要关注大文档切换性能。未来可考虑增量 Bridge、共享文件写入或由 Web 只返回 revision,再从原生已同步缓冲区取内容。

测试应围绕协议而不是页面

Web 单元路径可以验证 getDocument() 返回逻辑字符串,却无法完全模拟 ArkWeb 原生接口如何包装结果。最终必须在鸿蒙模拟器调用真实 runJavaScript,记录经过类型检查后的结果特征,同时避免打印用户正文。

建议建立一个协议测试页面,分别返回:空字符串、普通字符串、多行字符串、引号、反斜杠、中文、emoji、数字零、负数、布尔值、null 和对象。ArkTS 侧对每项使用对应解码器断言。这样平台 SDK升级后,可以快速发现返回格式变化,而不必等到多标签内容损坏。

协议测试还应覆盖 Web API 不存在、页面未 ready、调用中重载和超长返回。可恢复路径必须保留原生旧值,不能用空结果覆盖。编辑器最重要的不是每次调用都成功,而是通信失败时不丢缓冲区。

结语

ArkWeb runJavaScript 的字符串返回值不是可以忽略的实现细节,而是跨运行时协议的一部分。OhMarkdown 的修复使用标准 JSON 解析恢复正文和 HTML,数字结果用数值解析,布尔结果做明确文本比较;反向参数统一用 JSON.stringify,所有调用限制在窄 API 中。

这类缺陷通常躲过简单演示,因为 hello 没有转义字符。只有把多行 Markdown、引号、反斜杠和导出 HTML 当成真实数据,才能看到边界。鸿蒙 PC 编辑器要保证多标签和文件内容可靠,必须把每一次跨 ArkUI 与 ArkWeb 的值传递当作正式协议,而不是一次方便的脚本调用。

Logo

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

更多推荐