把富文本从 WebView 里拽出来:HarmonyOS 原生富文本组件踩坑记

这个项目起因很朴素:资讯详情页的正文,官方给的两条路都不太合脚。 Web 组件渲染没问题,但滚动位置、图片手势、字体跟原生一套体系隔着一条缝; RichText 组件又只认自己那套结构化标签,拿到后台编辑器吐出来的 HTML 只能干瞪眼。

这个项目起因很朴素:资讯详情页的正文,官方给的两条路都不太合脚。 Web 组件渲染没问题,但滚动位置、图片手势、字体跟原生一套体系隔着一条缝; RichText 组件又只认自己那套结构化标签,拿到后台编辑器吐出来的 HTML 只能干瞪眼。

于是我用十七天重写了一个:手写递归下降解析器 + ArkUI 原生渲染,中间产物是一棵渲染树, 最后落成一组扁平的 RenderBlock。现在它支持 h1~h6下划线删除线高亮、行内 code、上标 x2、 下标 H2O、链接、图片、嵌套列表、引用块、代码块和表格。

这一段刻意写成后台编辑器的典型产出:margin:0px;padding:0px;text-indent:2em。 浏览器会给首行缩进两个字,组件不认 text-indent,会顶格显示——这是本次重点记录的一处差异。 另外注意 margin 简写也不被解析(只认 margin-top / margin-bottom),所以段落间距仍按组件的 paragraphSpacing 走。

这篇是我自己整理的验收清单,写完直接扔进数据库当测试数据用。 每一节都对应一组真实踩过的坑,文末有完整用例表。

一、标题层级:先把 h1~h6 全部摆出来

组件默认给六级标题配了递减的字号(24/21/19/17/16/15)、字重(700/700/700/700/600/600) 和独立的颜色。下面这一段是全量对照,肉眼扫一遍就能看出有没有塌陷、有没有和正文混在一起:

三级标题:H3 · 19vp / 700

四级标题:H4 · 17vp / 700

五级标题:H5 · 16vp / 600
六级标题:H6 · 15vp / 600

顺带测三种"标题里的花样":

行内代码加粗的标题

被内联 style 改色的标题(应覆盖默认色)

写了 text-align:center 的标题(内联样式应覆盖标题默认样式,居中生效)
写了 align 属性的标题(组件不读 align,应保持左对齐)

二、行内标签:一次全量核对

正文里最容易被吞掉的就是这些"看起来一样"的标签。下面按类别排开, 每一组后面括号里是期望表现。

strong 加粗b 加粗em 斜体i 斜体cite 斜体引用var 变量斜体u 下划线ins 下划线s 删除线del 删除线strike 删除线

mark 默认黄底行内 codeCtrl + Ssamp 等宽tt 等宽small 小两号big 大两号、x2 + y1ABBR 缩写q 短引用裸 spandata 标签bdibdo

嵌套叠加:加粗里套斜体再套下划线最后套代码, 以及 加粗 + 删除线斜体 + 高亮。 期望:多属性并行生效、互不覆盖。

font 标签—— 编辑器从老 CMS 里带出来的老写法,组件不读 font 的属性, 期望样式不变(继承正文),文字正常显示不丢失。

三、内联 style:能继承什么,不能继承什么

这一整段是内联 style:橙色、18vp、加粗。

换行测试:局部 span 覆盖蓝色, 然后回到段落自己的绿色,并带浅黄底纹。背景色应随文字行而不是整段铺满。

字间距 2px 的段落,中文间距拉开后仍应正常换行不断行。

居中文本

右对齐文本

两端对齐文本(ArkUI 无 Justify 枚举,组件会退化成左对齐,这是已知取舍)。

行高 2.2 倍的段落,用于确认行高确实按倍数乘字号换算成 vp,而不是被忽略。

同时给了外边距、左内边距与圆角。 期望:上下间距明显变大、整段左缩进 20vp;圆角对纯文本块无视觉影响(不报错即可)。

四种颜色写法各来一处: 三位简写 / rgb() / rgba() 半透明 / 英文色名;字体族也试一个: Consolas 等宽字体

这条写了五个组件不支持或不完整支持的属性 (display / box-shadow / float / border 简写 / text-shadow)。期望:安静地忽略,不抛异常、不丢文字

这条只用了"简写以外的变体": padding-top / padding-bottom / margin-left / margin-right。 组件只认 padding-left / padding-right / margin-top / margin-bottom, 所以这四个属性全部无效——期望视觉上没有任何变化,这是已知边界。

四、链接:一个 href 的十种写法

先是最常规的四种,全部点一遍: 官方站点(绝对 https)组件官网(http)带路径的文档链接另一个域名

样式变体:链接内联改色改字重链接里套加粗链接里套代码。 期望:链接色(或内联色)+ 下划线仍在,点击仍可跳转。

相对路径与协议相对:相对路径链接(需配 baseUrl 才可点开)协议相对链接。 期望:不崩溃、可渲染;无 baseUrl 时相对路径拼不出完整地址,点击后跳转失败属预期。

换行链接(v1.0.2 修过的点,两行都要能点): 第一行写着超长链接文字
第二行继续写着超长链接文字

链接包图片:被链接包裹的图片  后面跟一段普通文字,确认图片不会被挤到下一行。无 href 的链接:这行没有 href, 空 href 的链接:这行 href 是空的。 编辑器常带的属性:带 target 与 rel 的链接 (这两个属性组件不读,期望照常渲染并可点)。

五、图片:大图自适应、小图不放大、坏图有占位

正文大图,尺寸写在 style 里 (编辑器就是这么干的,不写 width 属性)。声明 800×450,超过屏宽 60%,应铺满宽度并按比例缩放,即忽略后台设的固定宽高:

正文大图 800x450

只有宽度、没有高度(style 写法):只有宽度, 期望保持比例不拉伸、不塌成一条线。

写成百分比宽度的(编辑器拖拽缩放图片时常产出百分比):百分比宽度parseLen% 返回 undefined, 等于没给尺寸,期望按原尺寸或自适应,至少不能 0 高。

没有写任何尺寸:无尺寸声明, 期望按原尺寸或自适应渲染,至少不能为 0 高。

小图 / 图标 / 表情(48×48,低于屏宽 60%,应保持原名尺寸不放大): 小图标 48x48 更小的图标 图标与文字在同段内混排,期望基线大致对齐、文字正常换行。

属性与 style 冲突的图:属性与样式冲突。 属性写的是 400×200、style 写的是 800×400。浏览器里 CSS 优先,会按 800×400 渲染; 组件读的是 attrs.get('width') ?? style.width属性优先,会拿到 400×200。这是一处需要记录的差异(400 低于屏宽 60%,按小图保持原尺寸)。

坏图(域名不可解析):这张图预期加载失败, 期望显示占位底色、不闪退,并触发 onImageLoadError 回调。

内嵌图一(base64 PNG,80×40): base64 PNG

内嵌图二(base64 SVG,120×60): base64 SVG

懒加载图(src 是真的,另有 data-original / data-src / data-w 等编辑器加的属性, 组件不读 data-*,期望不影响渲染):懒加载图

图 1:带图注的大图
图 1:figure + figcaption 图注(居中灰色小字);点图应能全屏预览,且从第 1 张开始。
图 2:第二张大图
图 2:连续多图,点这张应直接从第 2 张开始预览,可左右滑动。

被拦截的图片协议:伪协议应被丢弃 期望:整张图不渲染, 不出现空白占位框,也不报错。(这一行里除了本句文字,不应再有别的内容。)

六、列表:嵌套三层之后最容易出错

无序列表(三级嵌套 + 多种 marker)

  • 一级:纯文本项
  • 一级:含加粗行内代码链接的项
    • 二级 marker 期望为空心圆 ○
    • 二级:含较长文本,用于确认换行后悬挂缩进是否与 marker 对齐,不会让第二行跑到圆点下面
      • 三级 marker 期望为实心方块 ▪
      • 三级:再嵌套一层就到极限了
  • 一级:最后一项

有序列表(含长项与行内标签)

  1. 第一步:把 HTML 字符串塞给组件(@Prop html,变化自动重解析)
  2. 第二步:按需注入 RichTextConfig,不传就用默认样式
  3. 第三步:验证斜体下划线在列表项里不被吞掉
  4. 第四步:确认序号是 1. 2. 3. 4. 连续递增,不跳号
  5. 第五步:写了 start="9" 的有序列表另起一段测试(见下)

下面是 <ol start="9">,组件不读 start 属性: 期望从 1 开始编号,这是已知限制,不算 bug。

  1. 期望显示为 1. 而不是 9.
  2. 第二项

<ul type="square"> 同理,type 属性被忽略,marker 仍按层级走。

  • 期望 marker 仍是 •

列表项的边界写法

  • 第一行内容
    第二行用 br 换行,期望两行同属一个列表项,marker 只在第一行
  • 项里有图片:列表内小图 期望不撑破列表宽度
  • 项上写了内联 style 改色改字号:期望不生效——li 自身的样式不参与计算 (walkListItem 只摊平 li 的 children),所以这一项应保持正文颜色与字号, 但 li 内部的 span 是有效的:这个 span 的绿色加粗应当生效。
  • 空项上面那个应该没有内容,期望不渲染空行或只留极小高度
  • 项里嵌代码块(<pre> 在 li 内,组件只按行内文本处理,属已知降级)
    const a = 1;
    console.log(a);

七、引用块:那个 669vp 的幽灵

v1.0.2 之前,引用块的高度会莫名其妙变成 669vp,跟内容多少无关。 根因是左侧竖线用了一个 height('100%') 的子组件, 在外层 Scroll 的无界高度约束下被解析成"最大可用高度"。现在竖线改用 border.left 实现,高度完全由内容驱动。

单段引用:这一块的背景高度应当紧紧贴着文字,上下各留一个 padding,不能拉长。 注意这里 blockquote 自带背景/边框/内边距的内联样式,组件不读这些属性, 背景色、左侧竖线、内边距全由 RichTextConfig 的 quoteBackground / quoteBorderColor / quotePadding 决定; 但 color 是会被继承的, 所以引用块里的文字色用的是上面那个 #57606A。

多段引用第一段:用来确认段间距不会跑到背景外面形成一条空白带。

多段引用第二段:中间段落之间应该保留正常段间距,作为视觉分隔。

多段引用第三段:最后一段的下边距应被清零,背景底部不留多余空白。

引用块里放列表:

  • 列表项一
  • 列表项二

引用块里放代码块(这个 pre 刻意写了深色背景与亮色文字,模拟后台代码高亮插件):

git commit -m "fix: quote height" && git tag v1.0.2

组件的代码块样式不读内联 style, 会回落到 config 的 codeBlockBackground / codeBlockTextColor,所以这里应当是浅底深字,而不是黑底白字。

引用块里放标题

标题应继承引用块的文字颜色,而不是用标题自己的深色,视觉上要"陷进去"。

引用里再嵌套一层引用,确认不会崩、层级缩进合理。

八、代码块:要横向滚动,不要断行毁缩进

代码块会把空格替换成不换行空格,保证每行不自动折行,超宽靠横向滚动条解决。 下面这段每行都带缩进,重点看四层缩进是否对齐、能否左右滑动。 语言识别用的是 data-language 属性 (后台若用 class="language-xxx",组件也认):

// 轻量语法高亮:注释、关键字、字符串、数字、函数名
import { RichTextConfig } from './core/RichTextConfig';

export class RichTextBuilder {
  private readonly maxCacheSize: number = 32;      // 常量
  private cache: Map<string, RichNode> = new Map(); // 泛型尖括号在 HTML 里必须转义

  build(html: string): RenderBlock[] {
    const root: RichNode = HtmlParser.parse(html);
    const blocks = new Array<RenderBlock>();
    if (html.length > 0 && this.cache.has(html)) {
      console.log("cache hit: " + html.length + " chars, ratio = " + 0.8732);
      return this.cache.get(html)!;
    }
    return blocks;
  }
}

没有任何语言标记的代码块(高亮应关闭/走纯文本路径,颜色回退到代码块默认色):

这是一段没有 data-language 的 pre 内容
  第二行缩进两个空格
    第三行缩进四个空格
	第四行是 tab 缩进(应展开为四个空格宽)
末尾行

代码块里的空行(应该保留行高,而不是被吃掉):

{
  "name": "htmlrichtext",

  "version": "1.0.2",

  "tags": ["harmonyos", "arkui", "rich-text"]
}

超长单行(必须横向滚动,绝不能折行把变量拆成两半):

const url = "https://cdn.example.com/a/really/really/really/long/path/segment/segment/segment/segment/segment/asset.png?v=20260911&format=webp&width=1080&quality=85";

代码块与内联 HtmlParser.parse(html) 混在同一段, 然后是普通文字。期望行内代码有底色和粉色文字,代码块有灰底和圆角。

九、表格:最难啃的一块骨头

表格单独拆了一个 TableBlockView: 用 onAreaChange + 双 minHeight 测量行高, 斑马纹、表头样式、底部边框都在它内部管。下面按"从简到繁"排开, 所有表格都写成编辑器的产出形态——border/cellpadding/cellspacing 属性和 border-collapsewidth:100% 内联样式都会带上, 这些组件全都不读,边框与内边距一律由 config 的 tableBorderColor / tableCellPadding 决定。

9.1 最简表格(无表头):

单元格 A1单元格 B1
单元格 A2单元格 B2

9.2 标准表格(thead + tbody + th,被测最多的一种):

能力项 实现方式 状态
行内样式Text + Span已完成
图片独立 Image + Flex wrap已完成
表格Grid + 列模板已完成

9.3 colspan / rowspan:这是最容易出问题的一组, 已知限制是"跨列单元格位于行中间时,起始列按数组索引定位,可能与相邻行重叠(colspan=1 不受影响)"。 请重点看下面这张表的第 2 行是不是错位:

表头跨三列(colspan=3)
普通单元格 跨两列,且位于行中间(高风险用例)
行首跨两列 行尾单列
纵向跨两行(rowspan=2) 右侧第一行 右侧第一行
右侧第二行 右侧第二行

9.4 单元格样式与对齐:组件只读 align 属性。 单元格上的内联 style 完全不生效(连 color 都不行,因为 td 自身不参与样式计算), 只有单元格内部的 span 样式有效。三行都看一眼:

align=left align=center align=right
style 居中 + style 改红(预期两者都无效) style 右对齐(预期无效) 单元格内 span 改色(预期有效)

9.5 单元格里的富内容(加粗/代码/链接/多行/空单元格/长文本混在一起):

类型 内容
行内标签 加粗 + 斜体 + code + 链接
强制换行 第一行
第二行
第三行
第四行
长文本自动换行 这一格塞了很长的一段中文,用来验证单元格内部的自动换行以及同一行其余单元格的高度是否被同步撑高,避免出现行高错乱或内容被裁切的情况。
超长无空格串 https://cdn.example.com/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png
空单元格
仅一个短字 x

9.6 宽表(6 列,窄屏下考验列宽分配与是否溢出):

版本类型核心改动影响面风险状态
1.0.0首个稳定版手写解析器 + 原生渲染全量已发布
1.0.1修复大图自适应、预览手势、协议白名单图片已发布
1.0.2修复表格重构、引用块高度、混排换行表格/引用已发布
1.1.0新增解析错误回调 + AST 导出解析层规划中

9.7 被 div 包裹的表格(v1.0.2 修过的点,之前会整表丢失或退化成文本。 编辑器在表格外面套一层可拖拽容器是常态):

场景修复前修复后
被 div 包裹整表丢失正常渲染
被 tbody 包裹退化为文本正常渲染

9.8 引用块里的表格(引用块内部会再走一遍块级遍历,表格应正常存活):

参数默认值
tableMinRowHeight0
tableHeaderTextWeight600

9.9 单元格里嵌表格(已知降级):外层收集行时会在 td 处停止下钻, 内层表格不会被外层吞掉;内层表格本身按行内文本降级处理。期望:不崩、外层行列数不错乱。

外层表头 含内嵌表格的单元格
普通格
内层 A内层 B
内层表格后面的文字

9.10 表头样式与斑马纹配置验证(默认配置下奇偶行同色,即看不出斑马纹; 把 tableOddRowBackground 改成 #FAFAFA 后, 下面这张表的第 2、4 行应出现浅灰底。注意单元格里写的 background-color 内联样式是不生效的,别被它误导):

序号行类型期望背景
1表头#F5F6F7
2偶数数据行tableEvenRowBackground
3奇数数据行tableOddRowBackground
4偶数数据行tableEvenRowBackground

9.11 只有表头、没有数据行的表格(预期:既然存在 tr 就会成表, 应正常渲染出这一行表头,不崩、不变形):

只有表头

9.12 完全没有行的表格(预期:整块不渲染,不留空白——判据是与上下两段之间没有多余空隙。 编辑器删空表格内容后常常残留这种空壳):

十、HTML 实体与编码:别让 emoji 变成方块

这一节全部是编码问题,配合 utf8mb4 一起看。编辑器把 & 转义成 &amp; 是常规操作, 所以实体解码这条链路一定要通。

命名实体:& < > ' "   © ® ™ … — – « » · • × ÷ ¥ € ° ± µ ² ³ ½ ¼ ‘ ’ “ ” ′ § ¶ ¢ £ ∑ √ ∞ ≤ ≥ ≠ ≈ π(末尾五项依次是 ≤ ≥ ≠ ≈ π, 若未替换会在这里看到 &le; 这样的原始写法)

数字实体(十进制):' " © 中 文 好 😀 这一行依次是单引号、双引号、©、中、文、好、😀——少一个都会在这里露出破绽。

数字实体(十六进制):' 中 文 𠮷(生僻字:𠮷,4 字节 UTF-8)😀(😀)🚀(🚀)

未收录实体原样保留:α β γ ω &unknown;(这四个组件未收录,期望原样输出,不变成空)。 无分号写法:  ©(缺少分号,正则不匹配,期望原样输出)。

换行实体与制表实体(预期存在差异,不算 bug):换行实体 → ,制表实体 → 。 若组件把它们解码成真实的换行与缩进、而浏览器把它们折叠成一个空格,这属于两边排版模型的固有差异,记录下来即可。

直接写入的字符:emoji 😀🚀🎉👍、全角标点:,。;:?!""''()【】《》、破折号——省略号…… 数学符号:±×÷≈≠≤≥∑∏√∞、箭头 → ← ↑ ↓ ⇒、货币 ¥ $ € £ ₩、形近字符 O0oO·Il1|、易混淆空格: 普通空格| |全角空格( )|(第二种是 &nbsp; 不换行空格、第三种是 U+3000 全角空格, 三种宽度应当肉眼可分)。

中文编码边界:起始字符(U+4E00)一、末尾字符(U+9FFF)鿿、扩展 A 区(U+3400)㐀、 代理对边界(U+FFFD)�、零宽连接符(U+200D)与变体选择符(U+FE0F)组合:👨‍👩‍👧‍👦(一个家庭 emoji, 由多个码点加 ZWJ 组成)——这一项最容易在入库阶段被截断。

十一、容错:喂给它一堆脏 HTML

后台编辑器、手工粘贴、爬虫抓取,什么脏 HTML 都会遇到。下面这些写法都不规范, 期望结果只有一条:不抛异常、不闪退、能渲染多少渲染多少

大写标签强转小写大写 EM大写 U大写 A 带大写属性

大写 P 段落,内联样式也应照常解析

属性三种引号写法:双引号单引号无引号裸属性无引号alt

自闭合与 void 标签:
换行自闭合、
换行不闭合、



、 图片自闭合 自闭合图

文本里出现尖括号:3 < 5 且 7 > 2,以及没转义的 3 < 5 —— 第二个"小于号"后面跟的是空格与数字,不是合法标签名,组件应按普通字符输出,不能把它后面的内容当标签吞掉。

下面这一行是五个"凭空多出来的闭合标签",它们对应的开始标签根本不存在:

上一行那五个孤立闭合标签应当被全部忽略:本段文字必须完整显示, 并且不能被并进上一段——如果本段消失或与上一段合并成一行,说明闭合标签的容错逻辑有问题。

Word 粘贴的痕迹(后台最真实的脏 HTML 来源): 这一段在 <o:p></o:p> 之后。 注意上面那个 </o> 是复位标签—— <o:p> 被标签名正则截断成了 o,而 </o:p> 切出来是 o:p,对不上,所以必须用 </o> 才能闭合(详见案例 E7)。 本段文字必须完整显示。

再补一条 Word 风格的样式: mso-bidi-font-weight:normal;layout-grid-mode:char;text-justify:inter-ideograph, 以及大写属性名 MARGIN:0cm 0cm 0pt;COLOR:#333333。 期望:属性名统一转小写,COLOR 生效(所以上一段文字应是这个 #333333, 和别处相比略有差异但不明显),而 MARGIN/text-justify 这类不支持的属性被安静忽略。

下面这段里 中间是脚本, 中间是样式, 以及 , 以上内容全部不可见,本句前后的文字要完整。

空标签与仅空白的标签(编辑器产生的空段落就是这种):



上面连续几个空标签不应产生可见的空行堆叠,也不应报错。 其中两个是编辑器最爱的 <p><br/></p> 空行写法。

还有一种常见写法是"隐式闭合段落":<p>A<p>B, 浏览器认为第二个 <p> 会自动关掉第一个。本组件的解析器没有实现这条隐式闭合规则, 会把第二个 p 嵌进第一个里面,并一直向后吞并直到遇到够数量的 </p>。 因为这种写法会改变后续内容的归属、干扰本文其余章节的判读,所以挪到了文末《附:极端容错样本》里单独测试。

十二、混排与换行:text + br + a + img 的顶对齐

v1.0.2 修过一个很隐蔽的错位:同一段里"多行文本"与"单行链接"放在同一个 Flex 里时, 链接会被顶到上一行。修复方式是按视觉行拆开、逐行独立布局。下面集中复现这几种组合。

第一行是普通文本,
第二行是跟上来的文本,第三行是链接

文本第一行
文本第二行混排图这段是链接
文本第三行

第一行

第三行(中间应有一个空行的高度,不能塌陷)

第一行


第四行(中间应有两个空行的高度)


本段以一个 br 开头,期望顶部不要多出一整行空白(或与浏览器表现一致即可)。

本段以一个 br 结尾。


上一个段落只有一个 br,期望高度很小。

超长英文单词:pneumonoultramicroscopicsilicovolcanoconiosis; 更极端的一串:abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz; 超长 URL:https://www.example.com/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z?a=1&b=2; 超长数字串:12345678901234567890123456789012345678901234567890。 期望:不横向溢出屏幕、不闪退;是否折行、在哪里折行,记录下来与浏览器对比即可。

中英文混排与标点:这是 Chinese 与 English 混排 mixed inline text(含括号)、 「引号」与"双引号"、数字 123 与 456.78、以及 % 和 ‰ 和 °C, 期望换行时标点不跑到行首(允许做不到,记录即可)。

    用四个 &nbsp; 做段首缩进,期望看到缩进(nbsp 是不换行空格,不会被合并成普通空格)。 这也是编辑器代替 text-indent 的常见做法,值得和前面那条 text-indent 对照看。

这一段里面塞了一个块级标签:

我是嵌在 p 里的 div,期望被拆成独立的一段,而不是和上一句挤在同一行。
这一句在 div 之后,期望另起一段。

十三、容器标签与未知标签的降级

13.1 section / header / footer / nav / main / aside / address

header 里的段落

section 里的普通段落

footer 里的段落

address 里的联系信息:Room 101, Building B(内联 font-style:normal 应把 address 默认的斜体压回正常)

center 标签内的段落(组件把 align 忽略,期望保持左对齐,不报错)

details + summary(组件不实现折叠交互,期望内容直接展开显示)

这里是被折叠的内容,期望它直接可见。

form 内的文字(组件不提交表单,只渲染文字)

自定义标签:标签名不认识时,期望里面的文字仍然显示(svg 无文字,期望不渲染或渲染为空,不留大块空白)

注意:上面用的是不含连字符、不含冒号的自定义标签。 含连字符(<my-widget>)或含冒号 (<o:p>)的写法 会命中一个解析器缺陷,后果比较严重,所以单独放到文末《附:极端容错样本》的案例 E5、E7 里测试—— 这两个是本次核对用逻辑层跑出来、需要修的点。

十四、长文压力测试

这一节的目的是把整篇的节点数量拉上去,看解析耗时、滚动流畅度、长列表内存占用。 下面这几段是正常长度的资讯段落:

解析器的核心是一个逐字符推进的游标。它按 < 切分文本与标签:文本段做实体解码后合并进相邻文本节点,注释、DOCTYPE、处理指令整段跳过, 闭合标签与当前递归层的目标标签匹配就返回、否则忽略,开始标签解析完属性后决定是入栈递归还是直接作为 void 元素挂上父节点。 整个过程不依赖正则回溯,也不需要构建完整的 DOM 树,因此在移动端能保持很低的开销。

渲染层刻意避免了"一个块一个组件"的递归结构。所有内容先被摊平成扁平的 RenderBlock[], 连续的行内内容合并成一个 Text (内部多个 Span), 这样做的代价是样式表达力受限——Span 放不下图片、也没有点击事件—— 但换来的是整段文字的自然换行:英文单词不会被拦腰截断,中文也不会莫名其妙多出一个字的空间。

图片、链接这两个"必须能交互"的元素被单独抽成 InlineGroup, 在组件层与文本用 Flex + FlexWrap.Wrap 混排。 这里踩过的坑是"视觉行"的对齐:多行文本与单行链接同处一个 Flex 时,行高最大的那一项会把同排的所有项按基线拉齐, 于是单行链接被顶到了上一行的位置。最终的解法是在构建阶段就按 \n 把行内组切成视觉行,每一行单独一个 Flex。

表格是最后攻克的。ArkUI 的 Grid 在只给 columnsTemplate 的情况下表现正常, 但一旦同时给 rowsTemplate 指定固定行列模式,实测只能渲染出第一行;另一方面单元格内容长度不一致时,行高测量会失真。 最后的方案是 GridItem 内层再包一个 Column,用 onAreaChange 与双重 minHeight 把同一行的所有单元格拉到等高。

性能层面的取舍:解析与样式级联都在纯逻辑层完成,不碰 UI 线程的重活; 代码高亮在构建阶段一次性算成 token,渲染时零计算;图片点击的全屏预览用独立的 Swiper 弹层, 避免在长文里挂载大图节点导致的滚动卡顿。这些设计在千级节点的文章里表现稳定, 但真正的考验还是长表格与大量图片的混合场景——也就是你现在手里这篇文档。

结束语:兼容性测试最怕的不是崩溃,而是"看起来正常但某一处悄悄变形"。 所以这篇文章的每一节都尽量配一个能一眼看出问题的形式——全量对照、边界值、脏数据、极端长度。 如果哪一节在设备上的渲染和浏览器不一致,那一节就值得单独开一个 issue。


附录:用例清单

下表本身就是最后一个测试对象(七十多行的宽表,专治行高测量)。 同时它也充当验收清单:在设备上逐条核对"期望表现",不相符的记到"实测"列里。

编号 覆盖点 期望表现 实测
01h1~h6 六级标题字号/字重递减,不与正文混色
02标题内行内标签加粗/代码在标题里仍生效
03标题的内联 style 覆盖改色生效、text-align 居中生效
04标题的 align 属性不生效,保持左对齐
0520+ 行内标签加粗/斜体/下划线/删除线/上下标/等宽各就各位
06font 标签属性忽略、文字保留
07内联 style 基础属性色/字号/字重/行高/字距/对齐生效
08编辑器高频属性(重点)text-indent / margin 简写 / padding 简写 均无效
09受支持的长写属性margin-top/bottom、padding-left/right 生效
10四类颜色写法简写、rgb、rgba、英文色名均正确
11不支持的 CSS 属性静默忽略,不丢文字
12链接四类地址http/https/带路径/相对均不崩
13链接内嵌 br两段各自成行且都可点击
14链接包图 / 空 href / 无 href / target+rel可渲染,无跳转也不闪退
15大图自适应(尺寸写在 style 里)≥60% 屏宽按比例铺满
16只有宽度 / 百分比宽度 / 无尺寸不拉伸、不塌陷、不崩
17小图不放大48/32 图标保持原尺寸
18宽高属性与 style 冲突组件属性优先(与浏览器相反)
19坏图占位色 + onImageLoadError
20data: 内嵌图PNG/SVG 两种编码都能显示
21懒加载图 data-* 属性不影响渲染
22多图预览序号点第 2 张从第 2 张打开、可滑动
23列表三级嵌套marker 依次 • ○ ▪,缩进递增
24ol 编号连续性 / start 属性1. 2. 3. 不跳号;start 被忽略
25ul type / 列表内联样式均无效,marker 与样式不变
26li 自身 style 与内部 spanli 样式无效、内部 span 生效
27li 内 br / 图片 / 空项同项内换行,图片不撑破,空项不堆叠
28blockquote 单段/多段高度紧贴内容,无 669vp 异常拉伸
29blockquote 自带样式被覆盖背景/边框/内边距走 config,color 可继承
30blockquote 内嵌套列表/代码/标题/嵌套引用均存活
31代码块缩进与横向滚动四层缩进对齐,超宽行横向滚动
32data-language 语言识别高亮生效;无标记则纯文本渲染
33pre 自带深色主题被覆盖代码块样式走 config,不读内联 style
34代码块空行行高保留,不被吃掉
35最简表格(无表头)正常成表,行等高
36thead/tbody + th 表头表头底色与字重生效
37colspan 位于行中间已知风险点:是否与相邻行重叠
38colspan 行首 / rowspan 跨行跨列与纵向合并正确,右侧不错位
39td 的 align 属性左/中/右对齐生效
40td 上的内联 style完全无效(含 color 与 text-align)
41单元格内部的 span 样式有效(改色可见)
42table border/cellpadding/cellspacing被忽略,样式走 config
43单元格富内容行内标签/br/长文本/空单元格均正常
44单元格内超长无空格串不撑破列宽
456 列宽表窄屏不横向溢出,文字换行
46div 包裹的表格整表渲染不丢失、不退化
47引用块内表格表格在引用块内正常存活
48单元格内嵌表格已知降级:不崩、外层列数不错乱
49斑马纹配置改配置后奇偶行背景区分
50只有表头 / 完全没有行前者成表、后者整块不渲染
51本清单表本身(76 行)长表行高稳定、滚动流畅
52命名实体全量全部正确替换,无残留 &xxx;
53数字/十六进制实体含 emoji 与 4 字节汉字均正确
54未收录实体与缺分号原样输出,不丢字
55换行/制表实体与浏览器存在预期差异,不算 bug
56emoji / 全角 / 数学符号utf8mb4 全程无损
57大写标签与属性统一转小写,style 照常解析
58三种引号与裸属性双引号/单引号/无引号均能解析
59自闭合与 void 标签br/hr/img 各种写法均正常
60文本里的裸尖括号按普通字符输出,不吞后续内容
61多余闭合标签全部忽略,前后文字完整
62Word 粘贴痕迹(o:p / MSO 样式)不崩不吞文;o:p 的吞并见案例 E6
63script/style/注释/模板不可见内容完全不可见
64空标签与空白段落不产生空行堆叠(含 <p><br/></p>)
65文本+br+链接混排链接留在本行,不上浮错位
66连续 br 空行空行高度保留不塌陷
67段首/段尾 br与浏览器一致,或差异可接受
68超长无空格串折行不横向溢出、不闪退
69中英混排与标点记录折行位置差异
70nbsp 缩进不换行空格不被合并
71p 内嵌块级标签拆成独立段落,不挤在一行
72容器标签(section/header/footer/address)内容正常渲染,address 的斜体可被压回
73center / details / summary / form只渲染文字,不崩
74未知标签(无连字符/冒号)兜底渲染文字,不留大块空白
75长文压力解析耗时与滚动流畅度可接受
76极端容错样本 E1~E7不崩不闪退;各案例说明的行为一致

附:极端容错样本(建议单独入库测试)

下面七组样本都会触发"未闭合标签向后吞并"的容错语义:一个没有闭合的开始标签, 会把后面的兄弟节点一直收进自己的子树,直到遇到"够数量"的同名闭合标签(或直到字符串结束)。 这跟浏览器基于 DTD 的隐式闭合规则不一样,属于设计取舍——渲染顺序和文字内容都不丢,但结构归属会偏移。 因为这种偏移会影响后续内容的判读,所以把它们统一挪到全文最后,前面每一组都用复位标签兜住。

案例 E1:这一段的加粗标签没有闭合。从这里开始进入加粗区, 它会一直向后吞并兄弟节点,直到遇到够数量的 </b>——也就是下面那个复位标签。

案例 E1 收尾:上面那个复位标签同时也是一个"多余闭合标签",应被忽略。 本段文字不应带加粗、不应消失,也不能与上一段合并。

案例 E2:下面这个 div 没有闭合,它会把后面直到复位标签之间的兄弟节点都收进自己的子树。 期望:渲染顺序不变、文字不丢,只是结构归属变了。

未闭合 div 里的第一段

这一段在 div 之后、但按容错语义会被收进 div,期望仍然渲染出来。

案例 E2 收尾:上面那个 </div> 是复位标签,本段应在 div 外面正常渲染。

案例 E3:下面这一行全是孤立的闭合标签,对应的开始标签压根没有:

案例 E3 收尾:上一行的六个孤立闭合标签应被安静忽略,本段文字必须完整显示。 如果本段消失,说明闭合标签的容错逻辑有问题。

案例 E4:下面这一行里有两个并列的 p 开始标签,按浏览器规则第二个会自动闭合第一个, 按本组件的语义则是嵌套。两个未闭合的 p,需要两个 </p> 才配平:

第一个 p(未闭合)

第二个 p(未闭合,嵌在第一个里面)

案例 E4 收尾:上面两行 </p> 是复位标签。本段应正常渲染在它们之后, 且不带任何多余的段落嵌套。

案例 E5:下面这个自定义标签名里带连字符。解析器的标签名正则只接受 [a-zA-Z][a-zA-Z0-9]*, 遇到连字符就截断,于是 <my-widget> 被当成 my 标签打开; 而闭合标签按空格切分后是 my-widget, 跟 my 对不上, 所以它等不到闭合标签,会一路吞并后续内容。下面用不带连字符的复位标签把它兜住:

被当成 my 标签打开,从这句开始的内容都归它所有,一直到下面的复位标签为止。

案例 E5 收尾:上面那个 </my> 是复位标签——只有写成不带连字符的 </my> 才能匹配上。本段必须完整显示; 如果本段连同它后面的所有内容一起消失了,就说明这个缺陷仍在 (在文档中段遇到这种标签时,会把后面整篇内容吞掉,而且连颜色都会被那个内联 style 污染)。

案例 E6:Word 粘贴最常见的产物 <o:p> 会命中同一类缺陷—— 标签名正则在冒号处截断,于是它被当成 o 标签打开: 这一段在复位标签 </o> 之后, 必须完整显示。注意真实场景里不会有这个复位标签——后台直接粘 Word 内容时, 第一个 <o:p> 就会把后面的正文全部吞掉(案例 E5 是同一个根因,一起修)。

案例 E7:这是最后一组,也是最极端的一组。下面有一个未闭合的链接和一个未闭合的 span, 它们后面不再有任何复位标签,所以会把后面的全部文字都吞进去。

链接开始:从这句话开始一直到本文结束,都会是链接的一部分, 接着是未闭合的 span:从这里到文末都会是粉色文字。 所以下面这段看起来也会是粉色带下划线的链接:

这段文字预期会被前面的未闭合标签吞并,因此显示为粉色 + 下划线 + 可点击。 这正是容错语义的必然结果,不是崩溃也不是样式错误——只要文字没丢、没闪退,就算通过。

原创文章,作者:ECHO陈文,如若转载,请注明出处:https://www.luweipai.cn/notes/1789536043/