在微信小程序中渲染HTML内容3种解决方案及分析与问题解决


Posted in Javascript onJanuary 12, 2020

大部分Web应用的富文本内容都是以HTML字符串的形式存储的,通过HTML文档去展示HTML内容自然没有问题。但是,在微信小程序(下文简称为「小程序」)中,应当如何渲染这部分内容呢?

在微信小程序中渲染HTML内容的3种解决方案

wxParse

小程序刚上线那会儿,是无法直接渲染HTML内容的,于是就诞生了一个叫做「wxParse」的库。它的原理就是把HTML代码解析成树结构的数据,再通过小程序的模板把该数据渲染出来。

rich-text

后来,小程序增加了「rich-text」组件用于展示富文本内容。然而,这个组件存在一个极大的限制:组件内屏蔽了所有节点的事件。也就是说,在该组件内,连「预览图片」这样一个简单的功能都无法实现。

web-view

再后来,小程序允许通过「web-view」组件嵌套网页,通过网页展示HTML内容是兼容性最好的解决方案了。然而,因为要多加载一个页面,性能是较差的。

当「WePY」遇上「wxParse」

基于用户体验和功能交互上的考虑,我们抛弃了「rich-text」和「web-view」这两个原生组件,选择了「wxParse」。然而,用着用着却发现,「wxParse」也不能很好地满足需要:

我们的小程序是基于「WePY」框架开发的,而「wxParse」是基于原生的小程序编写的。要想让两者兼容,必须修改「wxParse」的源代码。

「wxParse」只是简单地通过image组件对原img元素的图片进行显示和预览。而在实际使用中,可能会用到云存储的接口对图片进行缩小,达到「用小图显示,用原图预览」的目的。

「wxParse」直接使用小程序的video组件展示视频,但是video组件的层级问题经常导致UI异常(例如把某个固定定位的元素给挡了)。

此外,围观一下「wxParse」的代码仓库可以发现,它已经两年没有迭代了。所以就萌生了基于「WePY」的组件模式重新写一个富文本组件的想法,其成果就是「WePY HTML」项目。

实现过程

解析HTML

首先仍然是要把HTML字符串解析为树结构的数据,我采用的是「特殊字符分隔法」。HTML中的特殊字符是「<」和「>」,前者为开始符,后者为结束符。

如果待解析内容以开始符开头,则截取开始符到结束符之间的内容作为节点进行解析。

如果待解析内容不以开始符开头,则截取开头到开始符之前(如果开始符不存在,则为末尾)的内容作为纯文本解析。

剩余内容进入下一轮解析,直到无剩余内容为止。

为了形成树结构,解析过程中要维护一个上下文节点(默认为根节点):

如果截取出来的内容是开始标签,则根据匹配出的标签名和属性,在当前上下文节点下创建一个子节点。如果该标签不是自结束标签(br、img等),就把上下文节点设为新节点。

如果截取出来的内容是结束标签,则根据标签名关闭当前上下文节点(把上下文节点设为其父节点)。

如果是纯文本,则在当前上下文节点下创建一个文本节点,上下文节点不变。

上下文(解析前) 解析内容 上下文(解析后)
根节点 div
div p
p Hello world p
p div
div 根节点

经过上述流程,HTML字符串就被解析为节点树了。

对比

  本组件算法 wxParse parse5
性能 3~6ms 20ms左右 20ms左右
容错性 一般
文件大小(未压缩) 6kb 22kb 接近400kb

可见,在不考虑容错性(产生错误的结果,而非抛出异常)的情况下,本组件的算法与其余两者相比有压倒性的优势,符合小程序「小而快」的需要。而一般情况下,富文本编辑器所生成的代码也不会出现语法错误。因此,即使容错性较差,问题也不大(但这是需要改进的)。

模板渲染

树结构的渲染,必然会涉及到子节点的递归处理。然而,小程序的模板并不支持递归,这下仿佛掉入了一个大坑。

看了一下「wxParse」模板的实现,它采用简单粗暴的方式解决这个问题:通过13个长得几乎一模一样的模板进行嵌套调用(1调用2,2调用3,……,12调用13),也就是说最多可以支持12次嵌套。一般来说,这个深度也足够了。

由于「WePY」框架本身是有构建机制的,所以不必手写十来个几乎一模一样的模板,通过一个构建的插件去生成即可。

<!-- wepyhtml-repeat start -->
<template name="wepyhtml-0">
  <block wx:if="{{ content }}" wx:for="{{ content }}">
    <block wx:if="{{ item.type === 'node' }}">
      <view class="wepyhtml-tag-{{ item.name }}">
        <!-- next template -->
      </view>
    </block>
    <block wx:else>{{ item.text }}</block>
  </block>
</template>
<!-- wepyhtml-repeat end -->

以下是对应的构建代码(需要安装「wepy-plugin-replace」):

// wepy.config.js
{
  plugins: {
    replace: {
      filter: /\.wxml$/,
      config: {
        find: /<\!-- wepyhtml-repeat start -->([\W\w]+?)<\!-- wepyhtml-repeat end -->/,
        replace(match, tpl) {
          let result = '';
          // 反正不要钱,直接写个20层嵌套
          for (let i = 0; i <= 20; i++) {
            result += '\n' + tpl
              .replace('wepyhtml-0', 'wepyhtml-' + i)
              .replace(/<\!-- next template -->/g, () => {
                return i === 20 ?
                  '' :
                  `<template is="wepyhtml-${ i + 1 }" wx:if="{{ item.children }}" data="{{ content: item.children"></template>`;
              });
          }
          return result;
        }
      }
    }
  }
}

然而,运行起来后发现,第二层及更深层级的节点都没有渲染出来,说明嵌套失败了。再看一下dist目录下生成的wxml文件可以发现,变量名与组件源代码的并不相同:

<block wx:if="{{ $htmlContent$wepyHtml$content }}" wx:for="{{ $htmlContent$wepyHtml$content }}">

「WePY」在生成组件代码时,为了避免组件数据与页面数据的变量名冲突,会根据一定的规则给组件的变量名增加前缀(如上面代码中的「$htmlContent$wepyHtml$」)。

所以在生成嵌套模板时,也必须使用带前缀的变量名。 先在组件代码中增加一个变量「thisIsMe」用于识别前缀:

<!-- wepyhtml-repeat start -->
<template name="wepyhtml-0">
  {{ thisIsMe }}
  <block wx:if="{{ content }}" wx:for="{{ content }}">
    <block wx:if="{{ item.type === 'node' }}">
      <view class="wepyhtml-tag-{{ item.name }}">
        <!-- next template -->
      </view>
    </block>
    <block wx:else>{{ item.text }}</block>
  </block>
</template>
<!-- wepyhtml-repeat end -->

然后修改构建代码:

replace(match, tpl) {
  let result = '';
  let prefix = '';

  // 匹配 thisIsMe 的前缀
  tpl = tpl.replace(/\{\{\s*(\$.*?\$)thisIsMe\s*\}\}/, (match, p) => {
    prefix = p;
    return '';
  });

  for (let i = 0; i <= 20; i++) {
    result += '\n' + tpl
      .replace('wepyhtml-0', 'wepyhtml-' + i)
      .replace(/<\!-- next template -->/g, () => {
        return i === 20 ?
          '' :
          `<template is="wepyhtml-${ i + 1 }" wx:if="{{ item.children }}" data="{{ ${ prefix }content: item.children }}"></template>`;
      });
  }

  return result;
}

至此,渲染问题就解决了。

微信小程序中HTML包含图片

为了节省流量和提高加载速度,展示富文本内容时,一般都会按照所需尺寸对里面的图片进行缩小,点击小图进行预览时才展示原图。

这主要涉及节点属性的修改: 把图片原路径(src属性值)存到自定义属性(例如「data-src」)中,并将其添加到预览图数组。

把图片的src属性值修改为缩小后的图片URL(一般云服务商都有提供此类URL规则)。

点击图片时,使用自定义属性的值进行预览。 为了实现这个需求,本组件在解析节点时提供了一个钩子(onNodeCreate):

onNodeCreate(name, attrs) {
  if (name === 'img') {
    attrs['data-src'] = attrs.src;
    // 预览图数组
    this.previewImgs.push(attrs.src);
    // 缩图
    attrs.src = resizeImg(attrs.src, 640);
  }
}

对应的模板和事件处理逻辑如下:

<template name="wepyhtml-img">
  <image class="wepyhtml-tag-img" mode="widthFix" src="{{ elem.attrs.src }}" data-src="{{ elem.attrs['data-src'] || elem.attrs.src }}" @tap="imgTap"></image>
</template>
// 点击小图看大图
imgTap(e) {
  wepy.previewImage({
    current: e.currentTarget.dataset.src,
    urls: this.previewImgs
  });
}

微信小程序中HTML包含视频

在小程序中,video组件的层级是较高的(且无法降低)。

如果页面设计上存在着可能挡住视频的元素,处理起来就需要一些技巧了: 隐藏video组件,用image组件(视频封面)占位; 点击图片时,让视频全屏播放; 如果退出了全屏,则暂停播放。

相关代码如下:

<template name="wepyhtml-video">
  <view class="wepyhtml-tag-video" @tap="videoTap" data-nodeid="{{ elem.nodeId }}">
    <!-- 视频封面 -->
    <image class="wepyhtml-tag-img wepyhtml-tag-video__poster" mode="widthFix" src="{{ elem.attrs.poster }}"></image>
    <!-- 播放图标 -->
    <image class="wepyhtml-tag-img wepyhtml-tag-video__play" src="./imgs/icon-play.png"></image>
    <!-- 视频组件 -->
    <video style="display: none;" src="{{ elem.attrs.src }}" id="wepyhtml-video-{{ elem.nodeId }}" @fullscreenchange="videoFullscreenChange" @play="videoPlay"></video>
  </view>
</template>
{
  // 点击封面图,播放视频
  videoTap(e) {
    const nodeId = e.currentTarget.dataset.nodeid;
    const context = wepy.createVideoContext('wepyhtml-video-' + nodeId);
    context.play();
    // 在安卓微信下,如果视频不可见,则调用play()也无法播放
    // 需要再调用全屏方法
    if (wepy.getSystemInfoSync().platform === 'android') {
      context.requestFullScreen();
    }
  },
  // 视频层级较高,为防止遮挡其他特殊定位元素,造成界面异常,
  // 强制全屏播放
  videoPlay(e) {
    wepy.createVideoContext(e.currentTarget.id).requestFullScreen();
  },
  // 退出全屏则暂停
  videoFullscreenChange(e) {
    if (!e.detail.fullScreen) {
      wepy.createVideoContext(e.currentTarget.id).pause();
    }
  }
}

开源

最后贴一下「WePY HTML」的项目仓库: https://github.com/beiliao-web-frontend/wepy-html ,具体使用方法见项目内的 README 。

如果你在使用过程中遇到了问题,或者是有好的建议和意见,都可以在 Issues 中提出。

随着微信小程序的不断完善相信用不了多长时间就会有一种更加完美的解决方案,那时我们就不会再改来改去了。更多关于微信小程序开发的文章请点击下面的相关文章

Javascript 相关文章推荐
jquery parent和parents的区别分析
Oct 02 Javascript
判断文件是否正在被使用的JS代码
Dec 21 Javascript
jQuery使用元素属性attr赋值详解
Feb 27 Javascript
jQuery中Find选择器用法示例
Sep 21 Javascript
JS基于递归实现倒计时效果的方法
Nov 26 Javascript
js编写三级联动简单案例
Dec 21 Javascript
超全面的javascript中变量命名规则
Feb 09 Javascript
JS 设置Cookie 有效期 检测cookie
Jun 15 Javascript
关于js对textarea换行符的处理方法浅析
Aug 03 Javascript
vue插槽slot的理解和使用方法
Apr 03 Javascript
微信小程序实现点击空白隐藏的方法示例
Aug 13 Javascript
JS实现随机点名器
Apr 12 Javascript
es6 for循环中let和var区别详解
Jan 12 #Javascript
js 计数排序的实现示例(升级版)
Jan 12 #Javascript
JS实现动态无缝轮播
Jan 11 #Javascript
原生js实现无缝轮播图
Jan 11 #Javascript
JS实现轮播图效果
Jan 11 #Javascript
js实现带搜索功能的下拉框
Jan 11 #Javascript
js实现select下拉框选择
Jan 11 #Javascript
You might like
我的论坛源代码(七)
2006/10/09 PHP
深入PHP curl参数的详解
2013/06/17 PHP
php CI框架插入一条或多条sql记录示例
2014/07/29 PHP
PHP多进程编程之僵尸进程问题的理解
2017/10/15 PHP
js同时按下两个方向键
2007/12/01 Javascript
IE和firefox浏览器的event事件兼容性汇总
2009/12/06 Javascript
JS获得QQ号码的昵称,头像,生日的简单实例
2013/12/04 Javascript
判断一个对象是否为jquery对象的方法
2014/03/12 Javascript
js如何改变文章的字体大小
2016/01/08 Javascript
javascript中this指向详解
2016/04/23 Javascript
jQuery获取attr()与prop()属性值的方法及区别介绍
2016/07/06 Javascript
解析JavaScript模仿块级作用域
2016/12/29 Javascript
jQuery实现联动下拉列表查询框
2017/01/04 Javascript
JavaScript实现二分查找实例代码
2017/02/22 Javascript
JS库之Highlight.js的用法详解
2017/09/13 Javascript
vue2 全局变量的设置方法
2018/03/09 Javascript
vue + any-touch实现一个iscroll 实现拖拽和滑动动画效果
2019/04/08 Javascript
微信小程序实现卡片层叠滑动效果
2019/06/21 Javascript
使用zrender.js绘制体温单效果
2019/10/31 Javascript
JS监听组合按键思路及实现过程
2020/04/17 Javascript
[44:40]2018DOTA2亚洲邀请赛3月30日 小组赛A组Liquid VS OG
2018/03/31 DOTA
python脚本实现统计日志文件中的ip访问次数代码分享
2014/08/06 Python
Python书单 不将就
2017/07/11 Python
Python 中Django安装和使用教程详解
2019/07/03 Python
python3利用Axes3D库画3D模型图
2020/03/25 Python
加拿大百叶窗和窗帘定制网站:Blinds
2017/01/30 全球购物
受欢迎的大学生自我评价
2013/12/05 职场文书
宝宝周岁宴答谢词
2014/01/26 职场文书
2014年机关植树节活动方案
2014/02/27 职场文书
五年级学生评语
2014/04/22 职场文书
领导干部廉政自律承诺书
2014/05/26 职场文书
创先争优公开承诺书
2014/08/30 职场文书
关于Vue Router的10条高级技巧总结
2021/05/06 Vue.js
Mysql基础知识点汇总
2021/05/26 MySQL
Mysql数据库值的添加、修改、删除及清空操作实例
2021/06/20 MySQL
vue3中provide && inject的使用
2021/07/01 Vue.js