小程序富文本能力的深入研究与应用
发布于 4 年前 作者 laixia 2630 次浏览 来自 分享

前言

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

现有方案

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}}" />
    
  • 视频自动暂停的问题
    当播放下一个视频时,希望能够自动暂停其他视频的播放
    解决方案

    • 方案一:
      通过wx.createSelectorQuery().selectAll('.contain>>>.v').context()来获取所有视频的context对象,当一个videobindplay事件触发时,通过context暂停其他视频
      缺陷:NodesRef.context2.4.2开始支持,存在低版本兼容性问题
    • 方案二:
      通过selectAllComponents选择所有子组件,遍历各个子组件,若该子组件下有video标签,则通过wx.createVideoContext(id,node)来获取context,通过这样的方式解决了兼容性的问题
      let nodes = [Component.selectComponent('#contain')];
      nodes = nodes.concat(Component.selectAllComponents('#contain>>>#node'));
      for (let node of nodes) {
        for (let item of node.data.nodes) {
          if (item.name == 'video')
            Component.videoContext.push({
              id: item.attrs.id,
              context: wx.createVideoContext(item.attrs.id, node)
            });
        }
      }
      
  • 文本复制的问题
    小程序中只有text组件可以通过设置selectable属性来实现长按复制,在富文本组件中实现这一功能就存在困难
    解决方案
    在顶层标签上加上user-select:text;-webkit-user-select

实现更加丰富的功能

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

  • 自动设置页面标题
    在浏览器中,会将title标签中的内容设置到页面标题上,在小程序中也同样可以实现这样的功能
    if (res.title) {
      wx.setNavigationBarTitle({
        title: res.title
      })
    }
    
  • 设置动画渐显效果
    小程序中提供了wx.createAnimation的方式创建动画,可通过这种方式对于整个内容设置渐显的动画效果
    if (this.data.showWithAnimation) {
      hideAnimation = wx.createAnimation({
        duration: this.data.animationDuration,
        timingFunction: "ease"
      }).opacity(0).step().export();
      showAnimation = wx.createAnimation({
        duration: this.data.animationDuration,
        timingFunction: "ease"
      }).opacity(1).step().export();
    }
    
  • 多资源加载
    由于平台差异,一些媒体文件在不同平台可能兼容性有差异,在浏览器中,可以通过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的内容渐隐,将富文本内容渐显,提高用户体验,避免在加载过程中一片空白。
  • 后端加强包
    前端受限于时间和空间的限制,一些功能较为精简,在后端,通过node.js可以实现更加丰富的功能,如代码高亮,website模式(输入网址即可),markdown模式等等

最终效果

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

前端组件包:GitHub地址
后端加强包:npm地址

总结

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

10 回复

大佬,我使用了这个插件,overflow:hidden失效了

目前不支持iframe是吗?现在需要插入腾讯视频。。。

最近测试发现一个问题,经常的点开内容是空白的,要在空白处随便点那么几下比如点到一个图片了,图片预览出现了,然后再返回内容就显示了,这是咋回事

请教一个问题,如何把word式排版“段首空2个字符”统一改成新媒体式排版“段首顶格,段与段之前空一行”呢?

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

楼主,问个问题

<section style=“margin: 10px 0%; position: static;”>

<section style=“display: inline-block; width: 100%; border-left: 3px solid #c88b3f; border-top-style: solid; border-right-style: solid; border-bottom-style: solid; border-right-color: #c88b3f; padding-right: 5px; padding-left: 5px; box-shadow: #000000 0px 0px 0px;”>

<section style=“margin-right: 0%; margin-bottom: 8px; margin-left: 0%; transform: translate3d(-5px, 0px, 0px); text-align: left; position: static;”>

<section style=“display: inline-block; height: 2em; padding: 0.3em 0.5em; vertical-align: top; background-color: #c88b3f; color: #ffffff;”>

<p style=“margin-top: 0px; margin-bottom: 0px;”><strong>对话摄影师</strong></p>

</section>

<section style=“width: 0.5em; display: inline-block; height: 2em; vertical-align: top; border-bottom: 1em solid #c88b3f; border-top: 1em solid #c88b3f; border-right: 1em solid transparent !important;”></section>

</section>

<section style=“font-size: 18px; color: #c88b3f;”>

<p style=“margin-top: 0px; margin-bottom: 0px;”><strong>易享+ <span style=“color: #f96e57;”><span style=“color: #3e3e3e;”>×</span></span>&nbsp;金晓伟</strong></p>

</section>

</section>

</section>

这段代码在富文本里的效果是这样的,

在小程序中是这样的:

这种支持多张图片在一行吗?还是说传小一点的图片就可以了。还有就是有的我在富文本里预览都是显示一行的,但是在小程序就不行了。

我这边需要做个限制,只显示一屏的内容

render() {
  let { current, tabList, live_brief, hqHeight, num } =this.state
  return (
    <View>
      <AtTabs swipeable sroll height='100%' className='navlist' current={current} tabList={tabList} onClick={this.handleClick.bind(this)}>
        <AtTabsPane className='items' current={current} index={0}>
          <Chatmod />
        </AtTabsPane>
        <AtTabsPane className='items' current={current} index={1}>
          <ScrollView
            scrollY
            scrollWithAnimation
            style={{ height: hqHeight +'rpx' }}
          >
            <ParserRichText html={live_brief}></ParserRichText>
          </ScrollView>
        </AtTabsPane>
        {tabList.map((item, index) => {
          let jsonData = JSON.stringify(tabList);// 转成JSON格式
          let result = JSON.parse(jsonData);// 转成JSON对象
  

          return (
            <AtTabsPane className='items' key={item} current={current} index={index + 2}>
              <ScrollView
                scrollY
                scrollWithAnimation
                style={{ height: hqHeight +'rpx' }}
              >
                <View className='txt'>C{result[index + 2].data}</View>
                {/* <RichText nodes='{{tabList[index+2].data}}' bindtap='tap'></RichText> */}
                <ParserRichText html={result[2].data} id={result[index + 2].title}></ParserRichText>
              </ScrollView>
            </AtTabsPane>
          )
        })
        }
 
 
 
ParserRichText 的html={result[2].data}  如果改成 {result[index+2].data} 就失效,只要加index 就报错,但是 它里面的id 可以正常打印各个数据,小程序源生的RichText 可以正常遍历出数据,就是 ParserRichText  不可以,写个2是因为,它只能固定,不能动态传值进去否则报错。我就想像源生小程序那样写获取数据,就是报错。

那么问题来了,怎么设置图片的边距呢

回到顶部