评论

小程序富文本能力的深入研究与应用

富文本插件的开发历程介绍

前言

在开发小程序的过程中,很多时候会需要使用富文本内容,然而现有的方案都有着或多或少的缺陷,如何更好的显示富文本将是一个值得继续探索的问题。

现有方案

WxParse

WxParse 作为一个应用最为应用最广泛的富文本插件,在很多时候是大家的首选,但其也明显的存在许多问题。

  • 格式不正确时标签会被原样显示
    很多人可能都见到过这种情况,当标签里的内容出现格式上的错误(如冒号不匹配等),在WxParse中都会被认为是文本内容而原样输出,例如:
    <span style="font-family:"宋体"">Hello World!</span>
    
    这是由于WxParse的解析脚本中,是通过正则匹配的方式进行解析,一旦格式不正确,就将导致无法匹配而被直接认为是文本
    //WxParse的匹配模式
    var startTag = /^<([-A-Za-z0-9_]+)((?:\s+[a-zA-Z_:][-a-zA-Z0-9_:.]*(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)>/,
      endTag = /^<\/([-A-Za-z0-9_]+)[^>]*>/,
      attr = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:(?:"((?:\\.|[^"])*)")|(?:'((?:\\.|[^'])*)')|([^>\s]+)))?/g;
    
    然而,html 对于格式的要求并不严格,一些诸如冒号不匹配之类的问题是可以被浏览器接受的,因此需要在解析脚本的层面上提高容错性。
  • 超过限定层数时无法显示
    这也是一个让许多人十分苦恼的问题,WxParse 通过 template 迭代的方式进行显示,当节点的层数大于设定的 template 数时就会无法显示,自行增加过多的层数又会大大增加空间大小,因此对于 wxml 的渲染方式也需要改进。
  • 对于表格、列表等复杂内容支持性差
    WxParse 对于 tableolul 等支持性较差,类似于表格单元格合并,有序列表,多层列表等都无法渲染

rich-text

rich-text 组件作为官方的富文本组件,也是很多人选择的方案,但也存在着一些不足之处

  • 一些常用标签不支持
    rich-text 支持的标签较少,一些常用的标签(比如 section)等都不支持,导致其很难直接用于显示富文本内容
    ps:最新的 2.7.1 基础库已经增加支持了许多语义化标签,但还是要考虑低版本兼容问题
  • 不能实现图片和链接的点击
    rich-text 组件中会屏蔽所有结点事件,这导致无法实现图片点击预览,链接点击效果等操作,较影响体验
  • 不支持音视频
    音频和视频作为富文本的重要内容,在 rich-text 中却不被支持,这也严重影响了使用体验

共同问题

  • 不支持解析 style 标签
    现有的方案中都不支持对 style 标签中的内容进行解析和匹配,这将导致一些标签样式的不正确

方案构建

因此要解决上述问题,就得构建一个新的方案来实现

渲染方式

对于该节点下没有图片、视频、链接等的,直接使用 rich-text 显示(可以减少标签数,提高渲染效果),否则则继续进行深入迭代,例如:

对于迭代的方式,有以下两种方案:

  • 方案一
    WxParse 那样通过 template 进行迭代,对于小于 20 层的内容,通过 template 迭代的方式进行显示,超过 20 层时,用 rich-text 组件兜底,避免无法显示,这也是一开始采用的方案
    <!--超过20层直接使用rich-text-->
    <template name='rich-text-floor20'>
    <block wx:for='{{nodes}}' wx:key>
      <rich-text nodes="{{item}}" />
    </block>
    </template>
    
  • 方案二
    添加一个辅助组件 trees,通过组件递归的方式显示,该方式实现了没有层数的限制,且避免了多个重复性的 template 占用空间,也是最终采取的方案
    <!--继续递归-->
    <trees wx:else id="node" class="{{item.name}}" style="{{item.attrs.style}}" nodes="{{item.children}}" controls="{{controls}}" />
    

解析脚本

htmlparser2 包进行改写,其通过状态机的方式取代了正则匹配,有效的解决了容错性问题,且大大提升了解析效率

//不同状态各通过一个函数进行判断和状态跳转
for (; this._index < this._buffer.length; this._index++)
    this[this._state](this._buffer[this._index]);
  • 兼容 rich-text
    为了解析结果能同时在 rich-text 组件上显示,需要对一些 rich-text不支持的组件进行转换
    //以u标签为例
    case 'u':
        name = 'span';
        attrs.style = 'text-decoration:underline;' + attrs.style;
        break;
    
  • 适配渲染需要
    在渲染过程中,需要对节点下含有图片、视频、链接等不能由 rich-text直接显示的节点继续迭代,否则直接使用 rich-text 组件显示;因此需要在解析过程中进行标记,遇到 imgvideoa 等标签时,对其所有上级节点设置一个 continue 属性用于区分
    case 'a':
       attrs.style = 'color:#366092;display:inline;word-break:break-all;overflow:auto;' + attrs.style;
       element.continue = true;
       //冒泡:对上级节点设置continue属性
       this._bubbling();
       break;
    

处理style标签

解析方式

  • 方案一
    正则匹配
    var classes = style.match(/[^\{\}]+?\{([^\{\}]*?({[\s\S]*?})*)*?\}/g);
    
    缺陷:
    1. style 字符串较长时,可能出现栈溢出的问题
    2. 对于一些复杂的情况,可能出现匹配失败的问题
  • 方案二
    状态机的方式,类似于 html 字符串的处理方式,对于 css 的规则进行了调整和适配,也是目前采取的方案

匹配方式

  • 方案一
    style 标签解析为一个形如 {key:content} 的结构体,对于每个标签,通过访问结构体的相应属性即可知晓是否匹配成功
    if (this._style[name]) attrs.style += (';' + this._style[name]);
    if (this._style['.' + attrs.class]) attrs.style += (';' + this._style['.' + attrs.class]);
    if (this._style['#' + attrs.id]) attrs.style += (';' + this._style['#' + attrs.id]);
    
    优点:匹配效率高,适合前端对于时间和空间的要求
    缺点:对于多层选择器等复杂情况无法处理
    因此在前端组件包中采取的是这种方式进行匹配
  • 方案二
    style 标签解析为一个数组,每个元素是形如 {key,list,content,index} 的结构体,主要用于多层选择器的匹配,内置了一个数组 list 存储各个层级的选择器,index 用于记录当前的层数,匹配成功时,index++,匹配成功的标签出栈时,index--;通过这样的方式可以匹配多层选择器等更加复杂的情况,但匹配过程比起方案一要复杂的多。

遇到的问题

  • rich-text 组件整体的显示问题
    在显示过程中,需要把 rich-text 作为整体的一部分,在一些情况下会出现问题,例如:

    Hello <rich-text nodes="<div style='display:inline-block'>World!</div>"/>
    

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

    • 方案一
      用一个 view 包裹在 rich-text 外面,替代最外层的标签
      <view style="{{item.attrs.style}}">
        <rich-text nodes="{{item.children}}" />
      </view>
      
      缺陷:当该标签为 tableol 等功能性标签时,会导致错误
    • 方案二
      rich-text 组件使用最外层标签的样式
      <rich-text nodes="{{item}}" style="{{item.attrs.style}}" />
      
      缺陷:当该标签的 style 中含有 marginpadding 等内容时会被缩进两次
    • 方案三
      通过 wxs 脚本将顶层标签的 displayfloatwidth 等样式提取出来放在 rich-text 组件的 style 中,最终解决了这个问题
      var res = "";
      var reg = getRegExp("float\s*:\s*[^;]*", "i");
      if (reg.test(style)) res += reg.exec(style)[0];
      reg = getRegExp("display\s*:\s*([^;]*)", "i");
      if (reg.test(style)) {
        var info = reg.exec(style);
        res += (';' + info[0]);
        display = info[1];
      } else res += (';display:' + display);
      reg = getRegExp("[^;\s]*width\s*:\s*[^;]*", "ig");
      var width = reg.exec(style);
      while (width) {
        res += (';' + width[0]);
        width = reg.exec(style);
      }
      return res;
      
  • 图片显示的问题
    html 中,若 img 标签没有设置宽高,则会按照原大小显示;设置了宽或高,则按比例进行缩放;同时设置了宽高,则按设置的宽高进行显示;在小程序中,若通过 image 组件模拟,需要通过 bindload 来获取图片宽高,再进行 setData,当图片数量较大时,会大大降低性能;另外,许多图片的宽度会超出屏幕宽度,需要加以限制
    解决方案
    rich-text 中的 img 替代 image 组件,实现更加贴近 html 的方式 ;对 img 组件设置默认的效果 max-width:100%;

  • 视频显示的问题
    当一个页面出现过多的视频时,同时进行加载可能导致页面卡死
    解决方案
    在解析过程中进行计数,若视频数量超过3个,则用一个 wxss 绘制的图片替代 video 组件,当受到点击时,再切换到 video 组件并设置 autoplay 以模拟正常效果,实现了一个类似懒加载的功能

    <!--视频-->
    <view wx:if="{{item.attrs.id>'media3'&&!controls[item.attrs.id].play}}" class="video" data-id="{{item.attrs.id}}" bindtap="_loadVideo">
      <view class="triangle_border_right"></view>
    </view>
    <video wx:else src='{{controls[item.attrs.id]?item.attrs.source[controls[item.attrs.id].index]:item.attrs.src}}' id="{{item.attrs.id}}" autoplay="{{item.attrs.autoplay||controls[item.attrs.id].play}}" />
    
  • 文本复制的问题
    小程序中只有 text 组件可以通过设置 selectable 属性来实现长按复制,在富文本组件中实现这一功能就存在困难
    解决方案
    在顶层标签上加上 user-select:text;-webkit-user-select

实现更加丰富的功能

在此基础上,还可以实现更多有用的功能

  • 自动设置页面标题
    在浏览器中,会将 title 标签中的内容设置到页面标题上,在小程序中也同样可以实现这样的功能
    if (res.title) {
      wx.setNavigationBarTitle({
        title: res.title
      })
    }
    
  • 多资源加载
    由于平台差异,一些媒体文件在不同平台可能兼容性有差异,在浏览器中,可以通过 source 标签设置多个源,当一个源加载失败时,用下一个源进行加载和播放,在本插件中同样可以实现这样的功能
    errorEvent(e) {
        //尝试加载其他源
        if (!this.data.controls[e.currentTarget.dataset.id] && e.currentTarget.dataset.source.length > 1) {
          this.data.controls[e.currentTarget.dataset.id] = {
            play: false,
            index: 1
          }
        } else if (this.data.controls[e.currentTarget.dataset.id] && e.currentTarget.dataset.source.length > (this.data.controls[e.currentTarget.dataset.id].index + 1)) {
          this.data.controls[e.currentTarget.dataset.id].index++;
        }
        this.setData({
          controls: this.data.controls
        })
        this.triggerEvent('error', {
          target: e.currentTarget,
          message: e.detail.errMsg
        }, {
          bubbles: true,
          composed: true
        });
    },
    
  • 添加加载提示
    可以在组件的插槽中放入加载提示信息或动画,在加载完成后会将 slot 的内容渐隐,将富文本内容渐显,提高用户体验,避免在加载过程中一片空白。

最终效果

经过一个多月的改进,目前实现了这个功能丰富,无层数限制,渲染效果好,轻量化(30.0KB),效率高,前后端通用的富文本插件,体验小程序的用户数已经突破1k啦,欢迎使用和体验

github 地址
npm 地址

总结

以上就是我在开发这样一个富文本插件的过程大致介绍,希望对大家有所帮助;本人在校学生,水平所限,不足之处欢迎提意见啦!

最后一次编辑于  2020-12-27  
点赞 59
收藏
评论

34 个评论

  • 学习使我快乐
    学习使我快乐
    2019-07-08

    现在的在校生都那么强了吗。。。我这个loser感觉快被淘汰了,可怕

    2019-07-08
    赞同 9
    回复
  • 没有喵捉的老鼠🐭
    没有喵捉的老鼠🐭
    2021-05-31

    大佬,佩服了

    2021-05-31
    赞同 2
    回复
  • 万cheng
    万cheng
    2021-02-19

    又是技术又幽默,给同志点赞、!

    2021-02-19
    赞同 1
    回复
  • ZLY
    ZLY
    2022-11-15

    点赞👍

    2022-11-15
    赞同
    回复
  • 刑天
    刑天
    发表于移动端
    2022-05-13
    t 9
    2022-05-13
    赞同
    回复
  • 满目山河
    满目山河
    2022-03-28

    你好 有解析后的回调吗 想复制富文本中的文字

    2022-03-28
    赞同
    回复 5
  • 是胡不是霍
    是胡不是霍
    2022-03-14

    你好支持对class样式的解析吗,如下:

    <!DOCTYPE html>
    <html lang="en">
    
    
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
    </head>
    <style>
        .box {
            color: red;
            padding: 20px;
            border: 1px solid #ddd;
        }
    </style>
    
    
    <body>
        <div>
            <div>
                <div class="box"> 你看到就回复开始</div>
            </div>
        </div>
        <div>
            <div>
                <div style="padding: 20px; color: blueviolet; border: 1px solid #ddd;"> 你看到就回复开始</div>
            </div>
        </div>
    </body>
    
    
    </html>
    
    2022-03-14
    赞同
    回复 1
  • 姚可鑫
    姚可鑫
    2022-02-21

    组件改名字了?

    2022-02-21
    赞同
    回复 1
    • 金煜峰
      金煜峰
      2022-02-21
      是的
      2022-02-21
      回复
  • 阿东
    阿东
    2021-09-28

    同学你好!富文本内容在IPhone XS MAX中无法长按复制,或者说长按没反应,怎么破?

    2021-09-28
    赞同
    回复 5
    • 阿东
      阿东
      2021-09-29
      安卓平台正常
      2021-09-29
      回复
    • 金煜峰
      金煜峰
      2021-10-02
      ios现在css里的user-select失效了,只能通过text组件的user-select实现长按复制,且会变成inline-block,可能影响布局。必要的话可以把selectable属性设置成force,具体参考 https://jin-yufeng.gitee.io/mp-html/#/basic/prop?id=selectable
      2021-10-02
      回复
    • 阿东
      阿东
      2021-10-13回复金煜峰
      我也查到了你这段内容,谢谢啦
      2021-10-13
      回复
    • 阿东
      阿东
      2021-10-14回复金煜峰
      <ol> <li>排版中,序号会排在该段文本的最后一行而不是第一行
      2021-10-14
      回复
    • 阿东
      阿东
      2021-10-15回复金煜峰
      还发现,如果字体改变,例如<strong></strong>就会强制换行。这条和上面那条都是如果selectable="force"后引起的
      2021-10-15
      回复
  • 翁榕生
    翁榕生
    发表于小程序端
    2021-08-18

    请教个问题,清除富文本内容,为啥还会一个‹p›‹/p›标签存在

    2021-08-18
    赞同
    回复 2
    • 金煜峰
      金煜峰
      2021-08-19
      留一个空标签用于输入新的内容
      2021-08-19
      回复
    • 翁榕生
      翁榕生
      2021-08-19回复金煜峰
      这个空标签能去除吗? 因为不去除,前端会读取空内容
      2021-08-19
      回复

正在加载...

登录 后发表内容