文章目录
- 前言
- 一、RichText 做了什么:HTML 字符串 → 内部组件树
- 1.1 基本用法
- 1.2 与「手写 ArkUI」的本质区别
- 1.3 Demo 中的示例 HTML
- 二、坑一:图片大小失控
- 2.1 现象
- 2.2 根因:RichText 尊重 HTML 中的尺寸信息
- 2.3 常见误区
- 2.4 Demo 错误场景
- 三、解法一:正则预处理 `<img>` 标签(渲染前改 HTML)
- 3.1 思路
- 3.2 Demo 核心实现
- 3.3 关键细节
- 3.4 使用方式
- 3.5 正则方案的边界
- 四、解法二:HTML 解析为 `Text + Span + ImageSpan`
- 4.1 思路
- 4.2 段落类型定义
- 4.3 解析器:顺序扫描 + 正则分词
- 4.4 渲染:ImageSpan 控制尺寸 + onClick
- 4.5 为什么 ImageSpan 能绑点击?
- 4.6 链接点击与路由分发
- 五、坑二:点击事件绑不上
- 5.1 RichText 的事件能力
- 5.2 典型产品需求 vs 可行性
- 5.3 常见 workaround 及其问题
- 六、三种方案对照总表
- 七、HTML → ArkUI 解析流程(完整链路)
- 7.1 推荐流水线(生产级)
- 7.2 正则 vs AST
- 八、ImageSpan 尺寸与排版细节
- 8.1 必须显式设 width / height
- 8.2 行内 vs 块级图片
- 8.3 verticalAlign
- 九、选型决策树
- 十、系统性排查流程
- 十一、Demo 体验指南
- 十二、生产环境 Checklist
- 十三、总结
- 附录:快速参考卡
前言
商品详情、资讯正文、活动规则——后台 CMS 吐出的 HTML 富文本,是移动端最常见的「拿来即用」内容形态。HarmonyOS 提供了RichText(htmlString),一行代码就能把 HTML 渲染成 ArkUI 组件树,开发体验很顺滑。
但上线后,两类问题几乎必踩:
图片撑破布局:后台
<img>没有width/height,原图 800×600 直接渲染,卡片宽度被撑开,列表横向溢出。
点击绑不上:产品要求「点图片预览大图」「点链接跳转详情」,却发现RichText没有onClick给单张图片用。
不少开发者第一反应是「是不是 RichText 有 bug?」——其实根因在RichText 的能力边界:它吃的是HTML 字符串,不是可编程的 ArkUI 子组件。图片尺寸由 HTML 属性决定;交互事件只有整段加载的onStart/onComplete,无法对单个<img>或<a>绑回调。
本文结合项目 Demo,从 RichText 渲染链路、图片失控根因、正则预处理、HTML → Span 解析、点击事件绑定到选型决策,做一次完整拆解。读完后,你应该能回答:
- 为什么原图会撑破布局?
- 正则改
<img>能解决哪些问题、解决不了哪些? - 什么时候必须放弃 RichText,改用
Text + Span + ImageSpan?
一、RichText 做了什么:HTML 字符串 → 内部组件树
1.1 基本用法
RichText('<p>Hello <b>World</b></p><img src="https://example.com/a.jpg" />').width('100%').onStart(()=>{/* 开始解析 */}).onComplete(()=>{/* 解析完成 */})| 能力 | 支持 | 说明 |
|---|---|---|
| 传入 HTML 字符串 | ✅ | string或Resource |
| 粗体/斜体/段落等标签 | ✅ | 常见 HTML 标签 |
网络图片<img src> | ✅ | 按 HTML 属性渲染 |
| 整段加载回调 | ✅ | onStart/onComplete |
单张图片onClick | ❌ | 无 per-element 事件 API |
单条链接onClick | ❌ | <a href>行为由系统处理,难自定义 |
| 运行时改某张图尺寸 | ❌ | 只能改 HTML 字符串后重渲染 |
1.2 与「手写 ArkUI」的本质区别
后台 HTML 字符串 ↓ RichText 内部解析器(黑盒) ↓ 内部 Image / Text 等组件(开发者不可直接访问) ↓ 屏幕显示对比手写:
解析后的 HtmlSegment[] ↓ Text() { Span() · ImageSpan() · Span().onClick() } ↓ 开发者完全掌控尺寸、样式、事件RichText 适合「只展示、弱交互」;强交互必须走到第二层——把 HTML 解析成 ArkUI 原生组合。
1.3 Demo 中的示例 HTML
exportconstDEMO_RAW_HTML:string='<p>HarmonyOS 富文本演示:下图来自后台 HTML,原图 800×600,无 width 约束会撑破布局。</p>'+'<img src="https://picsum.photos/seed/rich_demo/800/600" />'+'<p>文末附 <a href="action://open_detail">购买详情</a>,RichText 难以单独绑定图片点击。</p>'这段 HTML 非常典型:段落 + 无尺寸图片 + 自定义 scheme 链接——正是线上 CMS 的常见输出。
二、坑一:图片大小失控
2.1 现象
- 富文本区域横向溢出,左右滑动才能看全;
- 图片把
Column/List条目撑到极高,列表跳动; - 外层
Scroll高度固定 220vp,内部图片仍按原图比例展示,视觉「顶破」容器。
2.2 根因:RichText 尊重 HTML 中的尺寸信息
HarmonyOSRichText渲染<img>时,会读取 HTML 里的:
| HTML 属性 | 作用 |
|---|---|
width/height | 显式像素尺寸 |
style="width:...;height:..." | 内联样式 |
| 无以上属性 | 按图片原始分辨率或固有尺寸布局 |
后台编辑器导出的 HTML 往往只有src,没有width/height——移动端拿到 800×600、1200×900 的原图 URL,RichText 就按大图排版。
2.3 常见误区
| 误区 | 为什么无效 |
|---|---|
给RichText设.width('100%') | 只约束容器,不约束内部图片 |
给外层Column设clip(true) | 只是裁剪,图片仍占布局空间,可能留白或滚动异常 |
用Image组件包一层 | RichText 内部图片不是你能直接操作的Image |
CSSmax-width:100%写在业务样式里 | 必须写进HTML 字符串才生效 |
2.4 Demo 错误场景
RichTextCase.WRONG_RAW_HTML:
RichText(DEMO_RAW_HTML).width('100%')——未对<img>做任何预处理,800×600 图片在窄屏卡片中失控。
三、解法一:正则预处理<img>标签(渲染前改 HTML)
3.1 思路
在交给RichText之前,用正则扫描所有<img>,注入统一的width、height、style:
原始 HTML → normalizeImageTags() → 规范化 HTML → RichText()这是成本最低的修复:不改渲染方式,只改输入字符串。
3.2 Demo 核心实现
constIMG_TAG_PATTERN:RegExp=/<img\s+([^>]*?)>/giexportfunctionnormalizeImageTags(html:string,width:number,height:number):string{returnhtml.replace(IMG_TAG_PATTERN,(match:string,attrs:string):string=>{// 已有 width 的尊重后台配置,避免重复注入if(/width\s*=/i.test(attrs)){return`<img${attrs}>`}conststyle='object-fit:cover;border-radius:8px;max-width:100%;'return`<img${attrs}width="${width}" height="${height}" style="${style}" />`})}处理前后对比:
<!-- 处理前 --><imgsrc="https://picsum.photos/seed/rich_demo/800/600"/><!-- 处理后 --><imgsrc="https://picsum.photos/seed/rich_demo/800/600"width="280"height="158"style="object-fit:cover;border-radius:8px;max-width:100%;"/>3.3 关键细节
| 要点 | 说明 |
|---|---|
尊重已有width | 后台已配尺寸时不覆盖,避免破坏运营配置 |
object-fit: cover | 等比裁剪填满框,避免拉伸变形 |
max-width: 100% | 双重保险,防止略大于容器 |
| 全局默认尺寸 | Demo 用280 × 158(约 16:9),可按设计稿统一 |
| 在服务端做 | 生产环境更推荐CMS 输出时或接口层统一清洗,客户端兜底 |
3.4 使用方式
constsafeHtml=normalizeImageTags(rawHtml,280,158)RichText(safeHtml).width('100%')Demo 中RichTextCase.CORRECT_REGEX_HTML即此方案;面板底部会预览注入后的 HTML 片段,方便对照。
3.5 正则方案的边界
✅能解决:图片撑破布局、统一视觉尺寸、批量兜底历史脏数据。
❌不能解决:单张图片点击、链接自定义路由、复杂 DOM 结构、动态替换某张图。
正则适合「展示型富文本」;一旦产品要「可点图片 / 可点链接 / 埋点统计」,必须进入解法二。
四、解法二:HTML 解析为Text + Span + ImageSpan
4.1 思路
把 HTML 拆成结构化段落数组HtmlSegment[],再映射到 ArkUI 原生组件:
HTML 字符串 ↓ parseHtmlSegments() HtmlSegment[] (TEXT | IMAGE | LINK) ↓ Text() { Span('段落文字') ImageSpan(url).width().height().onClick() Span('链接文字').onClick() }4.2 段落类型定义
exportenumHtmlSegmentType{TEXT=0,IMAGE=1,LINK=2}exportinterfaceHtmlSegment{type:HtmlSegmentType text:stringimageUrl:stringimageWidth:numberimageHeight:numberlinkAction:string}4.3 解析器:顺序扫描 + 正则分词
constTOKEN_PATTERN:RegExp=/<img\s+[^>]*src=["']([^"']+)["'][^>]*>|<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/giexportfunctionparseHtmlSegments(html:string,imgWidth:number,imgHeight:number):HtmlSegment[]{constsegments:HtmlSegment[]=[]letlastIndex=0letmatch:RegExpExecArray|null=TOKEN_PATTERN.exec(html)while(match!==null){// 1. 匹配点之前的文本 → TEXT 段pushTextSegment(segments,html.substring(lastIndex,match.index))// 2. 图片if(match[1]?.length>0){segments.push({type:IMAGE,imageUrl:match[1],imageWidth:imgWidth,imageHeight:imgHeight,...})}// 3. 链接elseif(match[2]!==undefined){segments.push({type:LINK,linkAction:match[2],text:stripHtmlTags(match[3]),...})}lastIndex=TOKEN_PATTERN.lastIndex match=TOKEN_PATTERN.exec(html)}pushTextSegment(segments,html.substring(lastIndex))returnsegments}stripHtmlTags()负责去掉<p>、<br>等包裹标签,输出纯文本。
4.4 渲染:ImageSpan 控制尺寸 + onClick
Text(){ForEach(this.segments(),(segment:HtmlSegment,index:number)=>{if(segment.type===HtmlSegmentType.TEXT){Span(segment.text+'\n').fontSize(14)}elseif(segment.type===HtmlSegmentType.IMAGE){Span('\n')ImageSpan(segment.imageUrl).width(segment.imageWidth)// 280.height(segment.imageHeight)// 158.objectFit(ImageFit.Cover).onClick(()=>{this.clickMessage=`图片点击 ·${segment.imageUrl}`})Span('\n')}else{Span('链接:')Span(segment.text).fontColor('#4F46E5').decoration({type:TextDecorationType.Underline}).onClick(()=>{this.clickMessage=`链接点击 ·${segment.linkAction}`})}})}4.5 为什么 ImageSpan 能绑点击?
ImageSpanAttribute继承BaseSpan,而BaseSpan继承CommonMethod——因此拥有.onClick(),与Span一致。这是原生组合方案相比 RichText 的核心优势。
4.6 链接点击与路由分发
Demo 中链接href="action://open_detail"是自定义 scheme。在Span.onClick里可统一分发:
.onClick(()=>{if(segment.linkAction.startsWith('action://')){constaction=segment.linkAction.replace('action://','')if(action==='open_detail'){router.pushUrl({url:'pages/Detail'})}}elseif(segment.linkAction.startsWith('http')){// 外链 WebView}})RichText 里的<a href>行为由内部 Web 引擎处理,难以插入业务路由逻辑;Span 方案则可完全自定义。
五、坑二:点击事件绑不上
5.1 RichText 的事件能力
查阅 SDKrich_text.d.ts,RichTextAttribute仅提供:
| 回调 | 触发时机 |
|---|---|
onStart | 开始加载/解析 HTML |
onComplete | 加载/解析完成 |
没有onImageClick、onLinkClick或类似 API。
5.2 典型产品需求 vs 可行性
| 需求 | RichText | Span 方案 |
|---|---|---|
| 点击图片预览大图 | ❌ | ✅ImageSpan.onClick |
| 点击链接跳原生页 | ❌ 难自定义 | ✅Span.onClick+ router |
| 图片点击埋点 | ❌ | ✅ 回调里上报 |
| 长按图片保存 | ❌ | ✅ 可扩展gesture |
| 仅展示无交互 | ✅ 最简单 | 过度工程 |
5.3 常见 workaround 及其问题
| 做法 | 问题 |
|---|---|
在 RichText 上方叠透明Button | 无法对准每张图,多图失效 |
整段 RichText 绑onClick | 无法区分图片/链接/空白 |
放弃富文本,纯Image+Text拼 | 可行但失去 HTML 一体排版 |
使用Web组件渲染 HTML | 重量大、与 ArkUI 样式割裂 |
正解:按交互复杂度分级——弱交互用 RichText + 正则;强交互用解析 + Span。
六、三种方案对照总表
| 策略 | 代码入口 | 图片尺寸 | 图片点击 | 链接自定义 | 复杂度 |
|---|---|---|---|---|---|
| ❌ 原始 HTML | RichText(raw) | 失控 | ❌ | ❌ | 最低 |
| ✅ 正则改 img | RichText(normalize(...)) | 受控 | ❌ | ❌ | 低 |
| ✅ 解析为 Span | Text{ Span/ImageSpan } | 受控 | ✅ | ✅ | 中 |
Demo 三种 case 一一对应RichTextCase枚举,并支持「并排对比」开关,同时看 ❌ 原始 vs ✅ 正则的差异。
七、HTML → ArkUI 解析流程(完整链路)
┌─────────────────────────────────────────────────────────┐ │ 1. 接口返回 rawHtml │ └────────────────────────┬────────────────────────────────┘ ↓ ┌──────────────────────┐ │ 需要点击交互? │ └──────────┬───────────┘ 否 │ 是 ↓ │ ↓ normalizeImageTags parseHtmlSegments ↓ │ ↓ RichText(html) Text + Span + ImageSpan ↓ │ ↓ 展示完成 展示 + 事件分发 + 路由7.1 推荐流水线(生产级)
- 服务端清洗:剥离 script、统一 img 尺寸、HTTPS 化 URL;
- 客户端兜底:
normalizeImageTags再扫一遍; - 交互分流:含「可点图片/自定义链接」的页面走 Span 解析;
- 缓存解析结果:
HtmlSegment[]可缓存,避免每次build()重解析; - 复杂 HTML:逐步引入完整 AST(如 h1/h2/列表/表格),或评估
StyledString。
7.2 正则 vs AST
| 维度 | 正则(Demo) | AST 解析器 |
|---|---|---|
| 实现成本 | 低 | 高 |
| 嵌套标签 | 易出错 | 稳健 |
| 表格/列表 | 难支持 | 可扩展 |
| 适用场景 | 资讯正文、简单 CMS | 复杂编辑器输出 |
Demo 用正则是为突出核心机制;上线后 HTML 变复杂,建议换 AST 或服务端转 Markdown/JSON。
八、ImageSpan 尺寸与排版细节
8.1 必须显式设 width / height
与独立Image组件一样,ImageSpan需要明确尺寸,否则仍可能按原图固有尺寸参与排版:
ImageSpan(url).width(280).height(158).objectFit(ImageFit.Cover)8.2 行内 vs 块级图片
长文插图通常是块级(独占一行)。Demo 在图片前后插入Span('\n')换行,避免与文字挤在同一行。
8.3 verticalAlign
.verticalAlign(ImageSpanAlignment.CENTER)多行文字与行内小图标混排时,用对齐方式微调基线;块级大图一般靠换行分隔即可。
九、选型决策树
富文本里有图片? │ ├─ 只展示、无点击? │ ├─ 后台 HTML 已带 width → RichText 直接用 │ └─ 无 width → normalizeImageTags 后 RichText │ ├─ 需要点击图片? │ └─ parseHtmlSegments → ImageSpan.onClick │ ├─ 需要自定义链接跳转? │ └─ <a> 解析为 Span.onClick,自行 router │ ├─ HTML 极复杂(表格/嵌套列表)? │ └─ AST / Web / RichEditor / 服务端转 JSON │ └─ 需编辑而非展示? └─ RichEditor 组件,而非 RichText十、系统性排查流程
- 确认 HTML 源码:抓包看
<img>是否带width/height/style; - 看容器约束:
RichText.width('100%')不等于图片宽度受限; - 试正则清洗:
normalizeImageTags后是否恢复正常; - 确认交互需求:是否要单图点击——若要,RichText 无解,切 Span;
- 检查 ImageSpan 尺寸:是否写了
.width().height(); - 检查 onClick 绑定在谁上:必须绑在
ImageSpan/Span,不是外层Text; - @Link 传事件:子组件用
@Link clickMessage回传父级,避免@Prop传函数类型(ArkTS 限制); - 网络图加载:大图仍耗流量,可让 CDN 带缩放参数(如
/w/280)。
十一、Demo 体验指南
路径:entry/src/main/ets/richtext/
| 文件 | 职责 |
|---|---|
RichTextTypes.ets | 三种 case、示例 HTML、段落类型 |
RichHtmlProcessor.ets | normalizeImageTags、parseHtmlSegments |
RichTextViews.ets | RichText / NativeSpan 预览面板 |
RichTextPitfallDemo.ets | 主页面、并排对比、事件日志 |
建议操作:
- 打开❌ 原始 HTML,观察图片撑破预览区;
- 打开并排对比,左侧原始 vs 右侧正则,直观感受尺寸差异;
- 切换✅ 解析为 Span,点击图片 → 底部出现
图片点击 · https://...; - 点击购买详情链接 → 出现
链接点击 · action://open_detail; - 阅读「要点速记」巩固选型原则。
十二、生产环境 Checklist
- 接口层或 CMS 输出时统一为
<img>补充width/height - 客户端
normalizeImageTags作兜底,尊重已有 width - 展示型页面用
RichText;交互型页面用 Span 解析 ImageSpan必须显式.width().height().objectFit()- 链接
href统一协议(https/ 自定义action://),在onClick分发 - 解析结果可缓存,避免重复正则扫描
- 复杂 HTML 评估 AST,避免纯正则维护地狱
- 大图使用 CDN 缩放参数,不仅靠前端裁剪
- 埋点写在
onClick回调,而非 RichTextonComplete - 子组件事件回传用
@Link,勿@Prop传函数
十三、总结
RichText 图片失控与点击绑不上,不是用法错误,而是组件能力边界的必然结果。
| 核心结论 | 说明 |
|---|---|
| 尺寸失控 | 后台<img>无 width/height,RichText 按原图排版 |
| 快速修复 | 渲染前normalizeImageTags正则注入尺寸 |
| 点击绑定 | RichText 无 per-element 事件,需ImageSpan.onClick |
| 链接跳转 | <a>解析为Span.onClick,自行路由 |
| 解析链路 | HTML 字符串 →HtmlSegment[]→ ArkUI 组件 |
| 选型原则 | 弱交互 RichText + 正则;强交互 Span 原生组合 |
记住一句话:
RichText 负责「把 HTML 画出来」;尺寸在 HTML 里改,交互在 ArkUI 里接。
附录:快速参考卡
// ❌ 原始:图片可能撑破布局,无法绑点击RichText(rawHtml).width('100%')// ✅ 正则:尺寸受控,仍无法绑点击constsafe=normalizeImageTags(rawHtml,280,158)RichText(safe).width('100%')// ✅ 原生:尺寸 + 点击constsegments=parseHtmlSegments(safe,280,158)Text(){ForEach(segments,(seg)=>{if(seg.type===IMAGE){ImageSpan(seg.imageUrl).width(seg.imageWidth).height(seg.imageHeight).objectFit(ImageFit.Cover).onClick(()=>{/* 预览 / 埋点 */})}})}// 正则核心html.replace(/<img\s+([^>]*?)>/gi,(_,attrs)=>{if(/width\s*=/i.test(attrs))return`<img${attrs}>`return`<img${attrs}width="280" height="158" style="object-fit:cover;max-width:100%;" />`})