小程序富文本渲染那些事

1. 背景

在小程序业务开发中,常常会遇到各种各样的“配置化”需求,如商品详情展示、会员权益说明、推广落地文章等等,这些需求都没有固定的模板,需要运营人员在 B 端使用富文本编辑器自定义配置内容,然后由小程序端进行展示。

这就要求小程序提供足够的字符串解析能力,小程序方也提供了rich-text组件来满足富文本渲染的需求,但其存在不支持部分语义化标签、不支持交互、不支持事件点击、不支持音视频标签等等不足,导致使用范围受限。

github 也开源了多个小程序富文本组件,其中mp-html因功能强大,使用最为广泛。

但在不同的业务开发中,研发人员在 B 端所使用的富文本编辑器不尽相同,富文本标签使用的侧重点也略有不同,且涉及到小程序性能方方面面的优化。因此mp-html组件并不是拿来即用,需要根据具体的业务需求进行 定制化的“改造”。那么,了解富文本组件的渲染原理,掌握各种富文本标签的优化方法,就尤为重要,这也是本文的重点阐述内容。

2. 官方rich-text组件

根据小程序开发者文档rich-text组件支持传入html stringnodes 数组两种格式。如下图所示,要使用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 命名法,如styleclass
children 子节点列表 array 结构和 nodes 一致

出于安全性考虑,HTML 解析器仅解析部分受信任的节点,如果传入的html string字符串中有不受信任的 HTML 节点,该节点及其所有子节点将会被移除。这会导致 rich-text组件不支持部分语义化标签,造成部分富文本内容丢失的事故缺陷 1)。

HTML 解析器在解析过程中,仅会为对应的html标签添加部分受信任的属性,如styleclass等,其它属性都会被过滤掉,且不支持id。这就导致 rich-text组件屏蔽了交互,如无法支持图片预览、链接跳转、锚点、事件点击等等缺陷 2)。

由于nodes 节点的数据结构局限性问题,rich-text组件内的img标签仅支持网络图片,不支持 base64,不支持 svg。对于table标签,仅支持width属性,对于嵌套复杂标签的表格、单元格合并表格则表现得束手无策。出于安全性问题,rich-text组件过滤掉所有的媒体标签,如videoaudio等音视频标签,导致富文本功能大大受限(缺陷 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 stringnode tree(节点数组),然后迭代遍历数组,将每个 html 标签渲染成对应的小程序标签。

但这个方案也存在以下三个致命的缺陷:

一是容错性低wxParse组件的解析脚本,是通过 正则匹配 的方式解析,一旦出现错误的字符串不满足匹配规则,就会解析为文本,导致显示错误。

二是层级过深无法显示,为了达到性能和准确性的最佳平衡,wxParse组件设置了最大层数,当要渲染的node tree的层级超过设置的最大层数时,这些该层级下所有子节点标签就会被抛弃而无法显示,导致显示内容遗失。

三是功能限制WxParse  对于  tableolul  等支持性较差,类似于表格单元格合并、有序列表、多层列表等都无法渲染。对于音视频渲染也表现得束手无策。

4. mp-html 组件

4.1 实现方案

根据上述经验,借助HTML Parser解析器可将html string转换成我们想要的数据结构node tree,然后通过迭代遍历的方式渲染每个节点对应的标签。但是当node tree的层级过深时,对小程序性能的挑战是十分严峻的

鉴于此,mp-html组件的作者,提出了一个两全的方案:迭代遍历node tree,每遍历到一个节点,就查询该节点的所有子节点是否包含图片、音视频、链接、表格、列表等需要“定制”的标签。如果不包含,则直接用rich-text组件渲染该节点及其所有子节点。如果包含,则使用view渲染该标签,并继续遍历其子节点。

如下图所示,遍历到第一层的节点div,由于该节点的子孙节点包含imga这两个需要“定制”的标签,则将div标签渲染成view标签,然后继续往下遍历其子节点。遍历到第二层的第一个节点h1,由于该节点的子孙节点不含需要“定制”的标签,则直接用rich-text组件渲染。第二个节点p的所有子孙节点也不包含需要“定制”的标签,也用rich-text组件渲染。遍历到第三个节点p,该节点的子节点包含imga这两个需要“定制”的标签,则将p标签渲染成view标签,然后遍历其子节点,将img标签渲染成小程序支持的image标签,将a标签用小程序支持的方法实现。

通过这种方案,可以直观的发现,迭代的次数减少了,渲染的标签数变少了,可以显著提高渲染效率

4.2 htmlparse2 原理

mp-html组件底层采用目前市面上性能最佳的 JS 语言编写的 html 解析器htmlparser2来解析富文本字符串。

据了解,小程序组件系统内核exparser封装的LLParser生成器,虽然在性能对比上相较于htmlparser2略胜一筹,但是LLParser生成器的核心算法使用 C 语言实现的,并不适用,且它并未对html解析进行针对性的优化。而htmlparser2是业内顶流的html解析器,历经多次迭代,且使用Javascript编写,完全符合开发小程序富文本渲染组件的业务场景。

htmlparser2工具包底层封装了lexerparse两个类,lexer 的作用就是将输入的内容转换成合法的 tokens,比如开始标签、结束标签、属性名、属性值等等。parser 的作用就是根据语法规则分析字符串的结构进而构造 node tree

lexer采用状态机的方式解析字符串。每个状态都会识别一个或多个字符,然后根据字符的结果来更新下一个状态,每一步都会受到当前识别字符的状态和Tree Construction的影响。

解析的过程是不断重复的,通常 parser 会向 lexer 请求 token,并且尝试将 token 与某一条语法规则匹配。如果匹配的话,与 token 对应的节点将会被添加到 node treeparser 继续向 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渲染。判断逻辑如下:

  1. 对于子节点拥有需要“定制”的标签,该节点渲染为view标签,继续迭代。
  2. 对于不存在子节点或者当前标签为a标签,用template渲染成“定制”的标签。
  3. 对于存在子节点,如果当前节点是内联标签或者存在内联样式,无法直接用rich-text组件渲染(rich-text组件不支持内联样式导致),则使用view标签渲染,并继续迭代。相反,则用template渲染成rich-text组件。

template的实现逻辑如下图所示,对imgbravideoaudio等标签以及文本进行定制渲染,其它则使用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钩子函数(解析到标签结束) 中对不信任的标签进行转换,力求尽可能的显示文本内容。比如,对于不信任的块级标签,如addressarticleasidebodycaptioncentercitefooterheaderhtmlnavpresection等,转换为div标签。而对于其它不信任的标签,转换成span内联标签,力求尽可能完整的显示文本。

除此之外,mp-htmlparser类的原型上定义了多个钩子函数,如下图所示,其中,onOpenTagonCloseTag函数,针对需要“定制”的标签,做了很多精妙的逻辑处理,在后续每个“定制”标签优化原理的介绍中作者会具体阐述。

函数名 说明 主要处理逻辑
onTagName 解析标签名 转换成小写,解决大小写不敏感的问题
onAttrName 解析属性名 处理data-等属性,转换成对应的属性名
onAttrVal 解析属性值 部分属性实体解码、拼接域名
onText 解析文本 合并空白符、实体解码
parseStyle 解析样式表 转换 rpx 单位,转换 width、height 等属性
onOpenTag 解析到标签开始 不同标签定制逻辑处理,添加需要的属性,入栈
onCloseTag 解析到标签结束 不同标签定制逻辑处理,转换属性,出栈

4.3.4 样式设置

前两小节提到过,rich-text组件不支持内联样式,比如如下写法:

在这种情况下,虽然对  rich-text  中的顶层  div  设置了  display:inline-block,但没有对  rich-text  本身进行设置的情况下,无法实现行内元素的效果,类似的还有  floatwidth(设置为百分比时)等情况。

解决方案,就是在Parser过程中将顶层标签的  displayfloatwidth  等样式提取出来放在  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标签仅支持classstylealtsrcwidthheight六个属性。

rich-text组件渲染img标签存在以下几个问题:

  1. 不支持交互,包括图片预览,图片长按保存。
  2. 不支持图片缩放、图片压缩。下载原图导致图片加载慢,占用带宽资源。
  3. 不支持懒加载。图片较多时会影响性能。
  4. 仅支持网络图片,不支持 svg,不支持 base64。
  5. 用户体验太差。图片加载过程中未显示标签,图片加载失败显示图裂了。
  6. 移动端适配差。图片宽度大于屏幕逻辑像素,会溢出屏幕。如设置宽度自适应,容易导致图片变形。

5.2 优化方案

实现原理是通过htmlparse2解析成nodes 节点数据结构,然后迭代遍历渲染各个节点标签,遇到标签名为img的标签,渲染自定义的template

parser过程的onOpenTag解析到标签开始的函数中,如果解析到img标签,则执行以下几步操作:

  1. 当前栈中是该img标签的父/祖先节点,由于img标签是需要自定义渲染的标签,遍历栈中的所有父/祖先节点,标记continue属性为true。并遍历他们的样式,如遇到flexinline-block样式则需修改本img标签的本身的样式,以及父/祖先节点对应的样式修复。
  2. 维护一个imgList数组,用于图片点击预览,每当遍历到一个img标签,将该标签的src属性pushimgList数组。由于预览调用的wx.previewImage传入的current属性表示当前图片的链接,那么富文本如果存在多张相同链接的图片,就可能导致图片错位。解决方法就是在图片pushimgList数组之前,判断是否重复,如若重复,则对图片域名进行随机大小写
  3. 判断img标签是否传入widthheight属性。当两者都存在合法的值时,mode使用scaleToFill(缩放模式,不保持纵横比缩放图片,使图片的宽高完全拉伸至填满 image 元素),其它情况则这默认为widthFix(缩放模式,宽度不变,高度自动变化,保持原图宽高比不变)。同时,由于组件设置了max-width兜底,当图片设置的宽度超出屏幕,为强制改成max-width,那么按照scaleToFill规则缩放会导致图片变形。所以当图片宽度超出屏幕时,则去掉高度,让其按widthFix规则缩放。

parser过程的onCloseTag函数中,对svg标签的内容进行转换,添加 mime 头部,变成 Webview 可以识别的 Data URI,便可使用image组件渲染svg标签,具体可了解《小程序里显示 svg 的方法》

由于小程序image组件不支持widthheight属性传入,所以在parser过程的parseStyle函数中将这两个属性的值拼接到style样式中。最终,nodes 节点数据结构如下表格所示。

属性名 二级属性名 说明 值类型 默认值
name - 标签名 string img
attr id rich-text不同的是可以解析到标签的id 属性 string -
attr style 样式,widthheight整合到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标签传入widthheight属性,为了防止图片变形,需要对图片进行裁剪。当图片实际像素大于传入的widthheight属性,需要对图片进行缩放,前提是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标签也支持binderrorbindload事件绑定,可在其中添加逻辑,当图片未加载完成时,显示占位图,当图片加载失败时,显示加载出错的占位图。

6. a 标签富文本渲染

6.1 存在问题

前面提到过,rich-text渲染a标签,会过滤掉href属性,仅支持classstyle属性,也就是说,会将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函数中,进行逻辑处理。支持以下几种跳转逻辑:

  1. 锚点跳转。支持跳转内部锚点,给  a  标签的  href  属性设置为   #id,点击时即可跳转到对应  id  的位置(设置为   #   则跳转到开头)。

  2. 跳转内部路径。如果需要点击  a  标签跳转到小程序内的一个页面,直接将其  href  属性设置为页面路径即可通过wx.navigateTowx.switchTab跳转到对应页面。

  3. 复制外部链接。对于外部链接,由于小程序无法直接打开,使用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-viewscroll-top属性进行滚动定位,对于页面锚点跳转则通过wx.pageScrollTo方法进行滚动定位。

7. 其它定制标签优化

7.1 表格

当表格宽度过大时,超出正常手机屏幕的宽度,就会撑开容器的宽度,导致整个富文本内容横向滚动,从而影响用户体验。这种情况下,可以在onCloseTag解析到标签闭合的钩子函数中,给表格节点包裹一个可以横向滚动的容器,比如view标签,设置其样式为overflow-x:auto;padding:1px

由于小程序不支持table标签,对于不同的table,可以采用不同的渲染方案,如下所示:

显示方式 适用情况 说明
rich-text  标签 表格内部没有链接、图片等特殊标签 效果最佳,几乎不需要进行转换
table  布局 表格内有特殊标签但没有使用合并单元格 需要进行一定转换,将  tabletrtd  等标签转为对应的布局
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 平台上开源。想了解关于表格、列表、音频、视频等标签的更多优化思路,可以自行查阅源码。

创作不易,点个赞再走吧 ೭(˵ᴛ ʏ ᴛ˵)౨

猜你喜欢

转载自juejin.im/post/7077747390664900621