小程序富文本渲染那些事
1. 背景
在小程序业务开发中,常常会遇到各种各样的“配置化”需求,如商品详情展示、会员权益说明、推广落地文章等等,这些需求都没有固定的模板,需要运营人员在 B 端使用富文本编辑器自定义配置内容,然后由小程序端进行展示。
这就要求小程序提供足够的字符串解析能力,小程序方也提供了rich-text组件来满足富文本渲染的需求,但其存在不支持部分语义化标签、不支持交互、不支持事件点击、不支持音视频标签等等不足,导致使用范围受限。
github 也开源了多个小程序富文本组件,其中mp-html因功能强大,使用最为广泛。
但在不同的业务开发中,研发人员在 B 端所使用的富文本编辑器不尽相同,富文本标签使用的侧重点也略有不同,且涉及到小程序性能方方面面的优化。因此mp-html组件并不是拿来即用,需要根据具体的业务需求进行 定制化的“改造”。那么,了解富文本组件的渲染原理,掌握各种富文本标签的优化方法,就尤为重要,这也是本文的重点阐述内容。
2. 官方rich-text组件
根据小程序开发者文档,rich-text组件支持传入html string和nodes 数组两种格式。如下图所示,要使用rich-text组件渲染富文本,可以传入左边的html string或者右边的nodes 节点。

可世界上哪有“我全都要”的福分,必定是有人在背后默默负重前行。根据官方Tips说明,nodes 不推荐使用 String 类型,性能会有所下降。 可以推测,rich-text组件底层必定封装了一个 HTML 字符串解析器,将html string解析成易于处理的数据结构,即nodes 节点。
事实的确如此,根据文章《LLParser: Web 环境下高性能 Parser 生成器及对 asm.js 的应用》,我们了解到,小程序组件系统内核
exparser中封装了一个LLParser生成器,可以根据语法定义生成各种各样的字符串解析器。LLParser所生成的最重要的一个解析器是 HTML 解析器,用于小程序的rich-text组件和一些预编译期的分析。这个 HTML 解析器可以将html string解析成易于处理的数据结构,以便rich-text组件进行安全过滤。
nodes 节点的数据结构如下所示,
| 属性 | 说明 | 类型 | 必填 | 备注 |
|---|---|---|---|---|
name | 标签名 | string | 是 | 支持部分受信任的 HTML 节点 |
attrs | 属性 | object | 否 | 支持部分受信任的属性,遵循 Pascal 命名法,如style、class |
children | 子节点列表 | array | 否 | 结构和 nodes 一致 |
出于安全性考虑,HTML 解析器仅解析部分受信任的节点,如果传入的html string字符串中有不受信任的 HTML 节点,该节点及其所有子节点将会被移除。这会导致 rich-text组件不支持部分语义化标签,造成部分富文本内容丢失的事故(缺陷 1)。
HTML 解析器在解析过程中,仅会为对应的html标签添加部分受信任的属性,如style、class等,其它属性都会被过滤掉,且不支持id。这就导致 rich-text组件屏蔽了交互,如无法支持图片预览、链接跳转、锚点、事件点击等等(缺陷 2)。
由于nodes 节点的数据结构局限性问题,rich-text组件内的img标签仅支持网络图片,不支持 base64,不支持 svg。对于table标签,仅支持width属性,对于嵌套复杂标签的表格、单元格合并表格则表现得束手无策。出于安全性问题,rich-text组件过滤掉所有的媒体标签,如video、audio等音视频标签,导致富文本功能大大受限(缺陷 3、4、5)。
3. wxParse 组件
前面提到过,小程序组件系统内核exparser中封装了一个LLParser生成器(c+javascript),由此衍生的HTML 解析器可以将 html 字符串解析成代码容易理解的数据结构(node tree)。这有点像浏览器解析 html 文档的过程,将 html 文档解析成DOM tree。《How Browsers Work: Behind the scenes of modern web browsers》这篇神作详细阐述了HTML Parser的过程,感兴趣可以详细了解。
这给我们启发,能否借助开源社区成熟的HTML Parser解析器,将html string解析成我们预期的数据结构即node tree,然后通过迭代渲染的方式,将node tree渲染成小程序支持的各种标签。

早期流行的wxParse 组件便是这么玩的,wxParse组件通过正则匹配的方式解析html string为node tree(节点数组),然后迭代遍历数组,将每个 html 标签渲染成对应的小程序标签。
但这个方案也存在以下三个致命的缺陷:
一是容错性低,wxParse组件的解析脚本,是通过 正则匹配 的方式解析,一旦出现错误的字符串不满足匹配规则,就会解析为文本,导致显示错误。
二是层级过深无法显示,为了达到性能和准确性的最佳平衡,wxParse组件设置了最大层数,当要渲染的node tree的层级超过设置的最大层数时,这些该层级下所有子节点标签就会被抛弃而无法显示,导致显示内容遗失。
三是功能限制,WxParse 对于 table、ol、ul 等支持性较差,类似于表格单元格合并、有序列表、多层列表等都无法渲染。对于音视频渲染也表现得束手无策。
4. mp-html 组件
4.1 实现方案
根据上述经验,借助HTML Parser解析器可将html string转换成我们想要的数据结构node tree,然后通过迭代遍历的方式渲染每个节点对应的标签。但是当node tree的层级过深时,对小程序性能的挑战是十分严峻的。
鉴于此,mp-html组件的作者,提出了一个两全的方案:迭代遍历node tree,每遍历到一个节点,就查询该节点的所有子节点是否包含图片、音视频、链接、表格、列表等需要“定制”的标签。如果不包含,则直接用rich-text组件渲染该节点及其所有子节点。如果包含,则使用view渲染该标签,并继续遍历其子节点。
如下图所示,遍历到第一层的节点div,由于该节点的子孙节点包含img、a这两个需要“定制”的标签,则将div标签渲染成view标签,然后继续往下遍历其子节点。遍历到第二层的第一个节点h1,由于该节点的子孙节点不含需要“定制”的标签,则直接用rich-text组件渲染。第二个节点p的所有子孙节点也不包含需要“定制”的标签,也用rich-text组件渲染。遍历到第三个节点p,该节点的子节点包含img、a这两个需要“定制”的标签,则将p标签渲染成view标签,然后遍历其子节点,将img标签渲染成小程序支持的image标签,将a标签用小程序支持的方法实现。

通过这种方案,可以直观的发现,迭代的次数减少了,渲染的标签数变少了,可以显著提高渲染效率。
4.2 htmlparse2 原理
mp-html组件底层采用目前市面上性能最佳的 JS 语言编写的 html 解析器htmlparser2来解析富文本字符串。
据了解,小程序组件系统内核
exparser封装的LLParser生成器,虽然在性能对比上相较于htmlparser2略胜一筹,但是LLParser生成器的核心算法使用 C 语言实现的,并不适用,且它并未对html解析进行针对性的优化。而htmlparser2是业内顶流的html解析器,历经多次迭代,且使用Javascript编写,完全符合开发小程序富文本渲染组件的业务场景。
htmlparser2工具包底层封装了lexer和parse两个类,lexer 的作用就是将输入的内容转换成合法的 tokens,比如开始标签、结束标签、属性名、属性值等等。parser 的作用就是根据语法规则分析字符串的结构进而构造 node tree。
lexer采用状态机的方式解析字符串。每个状态都会识别一个或多个字符,然后根据字符的结果来更新下一个状态,每一步都会受到当前识别字符的状态和Tree Construction的影响。
解析的过程是不断重复的,通常 parser 会向 lexer 请求 token,并且尝试将 token 与某一条语法规则匹配。如果匹配的话,与 token 对应的节点将会被添加到 node tree,parser 继续向 lexer 请求 token。
当然,如果 token 暂时没有匹配到规则的话,parser 会将 token 先保存在栈中,继续请求其他的 token 直到有规则可以匹配到存储在内部的 tokens。通俗来讲,就是通过栈来存储未闭合的标签,标签闭合就出栈。如果最后确实还是没有匹配到规则的话,则 parser 将抛出异常,这意味着文档中存在着语法错误,可在对应的钩子函数进行错误处理。
可知,parser类主要负责node tree的构建,那么在node tree的构建过程中,parser暴露了许多不同状态的钩子函数,mp-html组件底层正是对htmlparser2包进行二次改造,在不同的钩子函数中针对不同的标签定制化逻辑处理,最终得到自己想要的数据结构。
想要进一步了解htmlparse2的解析原理,可以查看html-parser。
4.3 mp-html 组件精妙之处
4.3.1 减少渲染节点数
比如,如何判断某个节点下的子节点是否包含图片、音视频、链接、表格、列表等需要“定制”的标签 ?这里运用到的原理是parser 解析过程中会将未闭合的标签节点先保存在栈中,也就是解析到某个需要“定制”的标签时,此刻栈中存储的节点都是该标签的父节点以及祖先节点,将栈中所有的标签都打上一个标记(比如continue属性),就表示这些节点的子孙节点或者其本身包含需要“定制”的标签。

如上图所示,遍历到img标签时,该标签是需要“定制”的,则将其父/祖先节点p->div都打上标记continue属性为true。当然a标签也是同理。
通过这种方式,迭代遍历node tree时,如果该节点的continue属性为false,则表示该节点的子孙节点不包含需要“定制”的标签,那么直接用template渲染。相反,则用view标签渲染,并继续迭代遍历。
4.3.2 优化遍历层级
之前提到过,通过continue属性来判断是否用view组件渲染该节点并继续迭代。对于不继续迭代的节点,要么渲染成“定制”的标签,要么渲染成rich-text组件,我们可以将其抽象成一个template。
封装一个use工具函数,判断该标签是使用view标签渲染并继续迭代,还是使用template渲染。判断逻辑如下:
- 对于子节点拥有需要“定制”的标签,该节点渲染为
view标签,继续迭代。 - 对于不存在子节点或者当前标签为
a标签,用template渲染成“定制”的标签。 - 对于存在子节点,如果当前节点是内联标签或者存在内联样式,无法直接用
rich-text组件渲染(rich-text组件不支持内联样式导致),则使用view标签渲染,并继续迭代。相反,则用template渲染成rich-text组件。

template的实现逻辑如下图所示,对img、br、a、video、audio等标签以及文本进行定制渲染,其它则使用rich-text组件渲染。

按照之前层级遍历的逻辑,我们可以实现代码如下,使用wx:for层级遍历节点,通过use函数判断该节点是否使用template渲染,如若不用,则使用view标签渲染,并迭代遍历下一层级。这里需要注意的是,每个template都传入了i属性,可以通过data-i绑定到对应的标签上,如第一层就是i1,第二层就是i1_i2,以此类推,通过这个属性,可以在逻辑层事件处理函数中,通过e.currentTarget.dataset.i获取到当前事件触发节点的索引,遍历node tree获取到对应的节点,从而减少渲染层和逻辑层的数据传递消耗。

然而,解析的node tree的深度是不可控的,按照层级遍历的写法,哪怕写了 20 层,也有可能会“抛弃”很多节点。为了突破层级限制,这里借鉴函数递归的思路,将该富文本节点渲染组件名命名为node组件,当遍历到第 5 层时,如果仍然无法使用template渲染,那么引用自身组件node渲染。
4.3.3 不信任标签渲染
另外,之前提到过,rich-text组件解析到不信任的节点,该节点及其所有子节点将会被移除,导致渲染的富文本内容缺失。
那么如何解决这个问题呢?
可以在parser过程中 onCloseTag钩子函数(解析到标签结束) 中对不信任的标签进行转换,力求尽可能的显示文本内容。比如,对于不信任的块级标签,如address、article、aside、body、caption、center、cite、footer、header、html、nav、pre、section等,转换为div标签。而对于其它不信任的标签,转换成span内联标签,力求尽可能完整的显示文本。
除此之外,mp-html在parser类的原型上定义了多个钩子函数,如下图所示,其中,onOpenTag、onCloseTag函数,针对需要“定制”的标签,做了很多精妙的逻辑处理,在后续每个“定制”标签优化原理的介绍中作者会具体阐述。
| 函数名 | 说明 | 主要处理逻辑 |
|---|---|---|
onTagName | 解析标签名 | 转换成小写,解决大小写不敏感的问题 |
onAttrName | 解析属性名 | 处理data-等属性,转换成对应的属性名 |
onAttrVal | 解析属性值 | 部分属性实体解码、拼接域名 |
onText | 解析文本 | 合并空白符、实体解码 |
parseStyle | 解析样式表 | 转换 rpx 单位,转换 width、height 等属性 |
onOpenTag | 解析到标签开始 | 不同标签定制逻辑处理,添加需要的属性,入栈 |
onCloseTag | 解析到标签结束 | 不同标签定制逻辑处理,转换属性,出栈 |
4.3.4 样式设置
前两小节提到过,rich-text组件不支持内联样式,比如如下写法:

在这种情况下,虽然对 rich-text 中的顶层 div 设置了 display:inline-block,但没有对 rich-text 本身进行设置的情况下,无法实现行内元素的效果,类似的还有 float、width(设置为百分比时)等情况。
解决方案,就是在Parser过程中将顶层标签的 display、float、width 等样式提取出来放在 rich-text 组件的 style 中。
那么问题来了,我们解析的style属性是字符串格式如style='display:inline-block;padding:10px;color:#ff00ff;',如何解决样式属性名冲突的问题?
这时候,{key: value}的数据结构就派上用场了。在parseStyle钩子函数中,使用类似状态机的方式匹配style属性的样式,将其转换为{key: value}的数据结构,对重复的样式名进行覆盖,同时转换rich-text组件无法识别的rpx单位。
那么如何实现子节点的样式提升呢?
在onCloseTag解析到标签闭合的钩子函数中,对特定的节点进行操作,此时栈中保存该节点的父/祖先节点,当该节点遇到特定样式时,对栈中元素的style属性进行对应的操作,由于是{key: value}的数据结构,所以能有效解决样式覆盖的问题。在节点出栈前,再将{key: value}的数据结构转换成字符串,赋值给节点的attrs.style属性。
如此处理,不仅仅解决样式提升问题,还能及时修正父节点的不合理样式。在onOpenTag解析到标签开始的钩子函数中,比如,对于img标签,如果标签样式存在flex:1且不设置宽度,那么该样式的宽度就应该设置为100% !important,同时其父/祖先节点不应该包含内联样式。
5. img 标签富文本渲染
5.1 存在问题
根据小程序开发者文档,rich-text组件传入的img标签仅支持class、style、alt、src、width、height六个属性。
用rich-text组件渲染img标签存在以下几个问题:
- 不支持交互,包括图片预览,图片长按保存。
- 不支持图片缩放、图片压缩。下载原图导致图片加载慢,占用带宽资源。
- 不支持懒加载。图片较多时会影响性能。
- 仅支持网络图片,不支持 svg,不支持 base64。
- 用户体验太差。图片加载过程中未显示标签,图片加载失败显示图裂了。
- 移动端适配差。图片宽度大于屏幕逻辑像素,会溢出屏幕。如设置宽度自适应,容易导致图片变形。
5.2 优化方案
实现原理是通过htmlparse2解析成nodes 节点数据结构,然后迭代遍历渲染各个节点标签,遇到标签名为img的标签,渲染自定义的template。
在parser过程的onOpenTag解析到标签开始的函数中,如果解析到img标签,则执行以下几步操作:
- 当前栈中是该
img标签的父/祖先节点,由于img标签是需要自定义渲染的标签,遍历栈中的所有父/祖先节点,标记continue属性为true。并遍历他们的样式,如遇到flex、inline-block样式则需修改本img标签的本身的样式,以及父/祖先节点对应的样式修复。 - 维护一个
imgList数组,用于图片点击预览,每当遍历到一个img标签,将该标签的src属性push进imgList数组。由于预览调用的wx.previewImage传入的current属性表示当前图片的链接,那么富文本如果存在多张相同链接的图片,就可能导致图片错位。解决方法就是在图片push进imgList数组之前,判断是否重复,如若重复,则对图片域名进行随机大小写。 - 判断
img标签是否传入width、height属性。当两者都存在合法的值时,mode使用scaleToFill(缩放模式,不保持纵横比缩放图片,使图片的宽高完全拉伸至填满 image 元素),其它情况则这默认为widthFix(缩放模式,宽度不变,高度自动变化,保持原图宽高比不变)。同时,由于组件设置了max-width兜底,当图片设置的宽度超出屏幕,为强制改成max-width,那么按照scaleToFill规则缩放会导致图片变形。所以当图片宽度超出屏幕时,则去掉高度,让其按widthFix规则缩放。
在parser过程的onCloseTag函数中,对svg标签的内容进行转换,添加 mime 头部,变成 Webview 可以识别的 Data URI,便可使用image组件渲染svg标签,具体可了解《小程序里显示 svg 的方法》。
由于小程序image组件不支持width、height属性传入,所以在parser过程的parseStyle函数中将这两个属性的值拼接到style样式中。最终,nodes 节点数据结构如下表格所示。
| 属性名 | 二级属性名 | 说明 | 值类型 | 默认值 |
|---|---|---|---|---|
name | - | 标签名 | string | img |
attr | id | 跟rich-text不同的是可以解析到标签的id 属性 | string | - |
attr | style | 样式,width、height整合到style里面 | string | - |
attr | class | 类 | string | - |
attr | src | 图片链接 | string | - |
mode | - | 图片裁剪、缩放的模式 | string | scaleToFill/widthFix |
index | - | 图片索引,用于预览点击 | number | - |
拿到想要的数据结构后,便可以自定义img标签渲染的template。
5.2.1 图片处理
通常情况下,B 端富本文编辑器引入的素材图片,在上传素材库时,都是未进行图片处理的。因此小程序端渲染富文本时,img标签引用的图片链接下载的都是高质量的原图,当下载图片数量过多时,就会造成带宽浪费,影响性能。
在实际的富文本展示中,也不需要展示过于“高清”的图片。针对这个问题,腾讯云对象存储通过数据万象 imageMogr2 接口提供图片处理功能。
可以在wxs中封装一个图像处理函数imageMogr2(src,options),image标签src属性的值进行转换。

如上代码所示,如果img标签传入width和height属性,为了防止图片变形,需要对图片进行裁剪。当图片实际像素大于传入的width和height属性,需要对图片进行缩放,前提是image组件的webp属性为true。开启webp 压缩。当然也可以添加其它处理,比如去除元信息、对.jpg文件进行渐进式加载等等。
5.2.2 体验优化
parser解析时,已经将所有图片的链接放入imgList数组进行维护,给image组件绑定data-index属性,当点击图片触发catchtap事件时,可根据index索引找到该图片的链接,调用wx.previewImage函数进行图片预览。
当image组件的lazy-load属性设置为true,可开启图片懒加载,在即将进入一定范围(上下三屏)时才开始加载。
当image组件的show-menu-by-longpress属性设置为true,支持长按图片显示发送给朋友、收藏、保存图片、搜一搜、打开名片/前往群聊/打开小程序(若图片中包含对应二维码或小程序码)的菜单。
如果富文本内容全部(或大部分)是图片,由于其图片未加载时大小为零,即使数量很多也会全部进入视图范围,导致懒加载失效。所幸,image标签也支持binderror、bindload事件绑定,可在其中添加逻辑,当图片未加载完成时,显示占位图,当图片加载失败时,显示加载出错的占位图。
6. a 标签富文本渲染
6.1 存在问题
前面提到过,rich-text渲染a标签,会过滤掉href属性,仅支持class和style属性,也就是说,会将a标签渲染成普通文本,从而失去跳转功能。
6.2 优化方案
parser过程解析的a标签的数据结构如下所示:
| 属性名 | 二级属性名 | 说明 | 值类型 | 默认值 |
|---|---|---|---|---|
name | - | 标签名 | string | a |
attr | id | 跟rich-text不同的是可以解析到标签的id 属性 | string | - |
attr | style | 样式 | string | - |
attr | class | 类 | string | - |
attr | href | 跳转链接 | string | - |
children | - | node 节点数组 | array | - |
在template模板中,如果该节点是a标签,则使用如下渲染:

使用view标签来模拟a标签的效果,定义_a和_hover两个默认类来模拟a标签的原始样式和hover效果。绑定catchtap事件实现链接点击效果,这里通过data-i属性传入当前节点索引,可在逻辑层遍历直接获取该节点。由于a标签嵌套的子节点的内容不可预知,所以使用node组件渲染子节点。
在linkTap函数中,进行逻辑处理。支持以下几种跳转逻辑:
-
锚点跳转。支持跳转内部锚点,给
a标签的href属性设置为#id,点击时即可跳转到对应id的位置(设置为#则跳转到开头)。 -
跳转内部路径。如果需要点击
a标签跳转到小程序内的一个页面,直接将其href属性设置为页面路径即可通过wx.navigateTo或wx.switchTab跳转到对应页面。 -
复制外部链接。对于外部链接,由于小程序无法直接打开,使用
wx.setClipboardData将自动复制到剪贴板。
在实际应用中,锚点跳转包括页面锚点跳转和容器锚点跳转两种情况,对于容器锚点跳转,需要将锚点的跳转范围限定在容器内,常见容器为scroll-view,那么就需要向mp-html富文本渲染组件传入以下三个参数。
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| page | object | 是 | - | scroll-view 标签所在页面实例 |
| selector | string | 是 | - | scroll-view 标签 的选择器 |
| scrollTop | string | 是 | - | scroll-view 标签 scrollTop 属性绑定的变量名 |
比如在scroll-view中显示富文本,写法如下:

那么可以在当前页面,根据 id=“article”获取mp-html富文本渲染组件实例,调用in方法传入三个参数,如下所示:
// 三个参数分别表示: page、selector、scrollTop
ctx.in(this, '#scroll', 'top');
那么锚点跳转实现逻辑如下图所示,this._in存在值表示是在容器中进行锚点跳转,相反则是在页面。两者的实现逻辑略有不懂,原理都是获取要跳转的元素相对于页面/容器的scrollTop值。对于容器锚点跳转则改变scroll-view的scroll-top属性进行滚动定位,对于页面锚点跳转则通过wx.pageScrollTo方法进行滚动定位。

7. 其它定制标签优化
7.1 表格
当表格宽度过大时,超出正常手机屏幕的宽度,就会撑开容器的宽度,导致整个富文本内容横向滚动,从而影响用户体验。这种情况下,可以在onCloseTag解析到标签闭合的钩子函数中,给表格节点包裹一个可以横向滚动的容器,比如view标签,设置其样式为overflow-x:auto;padding:1px。
由于小程序不支持table标签,对于不同的table,可以采用不同的渲染方案,如下所示:
| 显示方式 | 适用情况 | 说明 |
|---|---|---|
rich-text 标签 | 表格内部没有链接、图片等特殊标签 | 效果最佳,几乎不需要进行转换 |
table 布局 | 表格内有特殊标签但没有使用合并单元格 | 需要进行一定转换,将 table, tr, td 等标签转为对应的布局 |
grid 布局 | 表格内有特殊标签且使用了合并单元格 | 需要进行复杂的转换将合并单元格用 grid 布局表现出来 |
7.2 列表
rich-text组件无法支持列表多层嵌套,可以通过预定义样式来支持嵌套多层列表,对于无序列表,不同的层级会显示不同的样式,通过list-style-type实现。

同时可以通过对外暴露type属性,来支持用户选择显示数字、字母、罗马数字等多种形式的标号。也可以通过设置设置 list-style:none 的方式不显示 li 标签开头的标号。
7.3 音视频
通过htmlparse2可以将embed标签转换成对应的音视频标签,同时给音视频标签设置id,用于获取上下文。用数组_source存储所有可用的source,用数组_videos存储所有音视频实例。
在存在多个视频的情况下,同时播放可能会影响体验,可以在播放视频的回调中,通过获取当前事件的id,通过遍历_videos数组播放当前视频,同时暂停其它视频。
不同平台支持播放的格式不同,只设置一个 src 可能会出现兼容性问题导致无法播放,因此本组件支持像 html 中一样给 video 和 audio 设置多个 source,将其遍历存储到_source数组中,将按照顺序进行加载,直到可以播放,最大程度上避免无法播放。
8. 最后
本文基于业务需求对mp-html组件进行改造,阐述了该组件的设计思路以及部分标签渲染的优化点。组件原作者也分享过文章《小程序富文本能力的深入研究与应用》,同时代码也在 github 平台上开源。想了解关于表格、列表、音频、视频等标签的更多优化思路,可以自行查阅源码。
创作不易,点个赞再走吧 ೭(˵ᴛ ʏ ᴛ˵)౨
转载自:https://juejin.cn/post/7077747390664900621