在微信小程序中渲染HTML内容的方法示例


Posted in Javascript onSeptember 28, 2018

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

解决方案

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中的特殊字符是「<」和「>」,前者为开始符,后者为结束符。

  • 如果待解析内容以开始符开头,则截取 开始符到结束符之间 的内容作为节点进行解析。
  • 如果待解析内容不以开始符开头,则截取 开头到开始符之前 (如果开始符不存在,则为末尾)的内容作为纯文本解析。
  • 剩余内容进入下一轮解析,直到无剩余内容为止。

正如下图所示:

在微信小程序中渲染HTML内容的方法示例

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

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

过程正如下面的表格所示:

在微信小程序中渲染HTML内容的方法示例

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

对比

把上述算法与其他类似的解析算法进行对比(性能以「解析10000长度的HTML代码」进行测定):

在微信小程序中渲染HTML内容的方法示例

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

模板渲染

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

看了一下「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」在生成组件代码时,为了避免组件数据与页面数据的变量名冲突,会 根据一定的规则给组件的变量名增加前缀 (如上面代码中的「

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;
}

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

图片

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

  • 把图片原路径(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
 });
}

视频

在小程序中,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></pre>

<pre class="prettyprint hljs clojure" style="word-wrap: break-word; margin: 0px 0px 1.5em; padding: 0.5em; text-decoration: none; font-style: normal; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; line-height: 1.5em; word-break: break-all; background-color: rgb(246, 246, 246); border: none; overflow-x: auto;">{
 // 点击封面图,播放视频
 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 相关文章推荐
用户注册常用javascript代码
Aug 29 Javascript
JavaScript初学者需要了解10个小技巧
Aug 25 Javascript
jquery和雅虎的yql服务实现天气预报服务示例
Feb 08 Javascript
JavaScript charCodeAt方法入门实例(用于取得指定位置字符的Unicode编码)
Oct 17 Javascript
jQuery实现可编辑的表格实例讲解(2)
Sep 17 Javascript
微信小程序Server端环境配置详解(SSL, Nginx HTTPS,TLS 1.2 升级)
Jan 12 Javascript
JavaScript字符集编码与解码详谈
Feb 02 Javascript
有关JS中的0,null,undefined,[],{},'''''''',false之间的关系
Feb 14 Javascript
layui的table单击行勾选checkbox功能方法
Aug 14 Javascript
vue.js input框之间赋值方法
Aug 24 Javascript
Vue.js项目实战之多语种网站的功能实现(租车)
Aug 07 Javascript
layer.confirm()右边按钮实现href的例子
Sep 27 Javascript
Vue父子组件之间的通信实例详解
Sep 28 #Javascript
vue引入axios同源跨域问题
Sep 27 #Javascript
解决vue-cli脚手架打包后vendor文件过大的问题
Sep 27 #Javascript
Vue CLI 3搭建vue+vuex最全分析(推荐)
Sep 27 #Javascript
让webpack+vue-cil项目不再自动打开浏览器的方法
Sep 27 #Javascript
基于vue-cli npm run build之后vendor.js文件过大的解决方法
Sep 27 #Javascript
vue-cli的工程模板与构建工具详解
Sep 27 #Javascript
You might like
windows服务器中检测PHP SSL是否开启以及开启SSL的方法
2014/04/25 PHP
php获取访问者浏览页面的浏览器类型
2017/01/23 PHP
php转换上传word文件为PDF的方法【基于COM组件】
2019/06/10 PHP
基于jquery的使ListNav兼容中文首字拼音排序的实现代码
2011/07/10 Javascript
滚动图片效果 jquery实现回旋滚动效果
2013/01/08 Javascript
Ajax异步提交表单数据的说明及方法实例
2013/06/22 Javascript
Javascript改变CSS样式(局部和全局)
2013/12/18 Javascript
JS脚本defer的作用示例介绍
2014/01/02 Javascript
ajaxFileUpload.js插件支持多文件上传的方法
2014/09/02 Javascript
jQuery中slideUp()方法用法分析
2014/12/24 Javascript
JQuery select(下拉框)操作方法汇总
2015/04/15 Javascript
jquery实现在网页指定区域显示自定义右键菜单效果
2015/08/25 Javascript
JavaScript中的 attribute 和 jQuery中的 attr 方法浅析
2017/01/04 Javascript
ES6新数据结构Set与WeakSet用法分析
2017/03/31 Javascript
JavaScript中this的用法及this在不同应用场景的作用解析
2017/04/13 Javascript
mui上拉加载功能实例详解
2017/04/13 Javascript
实例详解带参数的 npm script
2019/05/28 Javascript
js实现一个简易计算器
2020/03/30 Javascript
详解elementUI中input框无法输入的问题
2020/04/27 Javascript
[01:11]辉夜杯战队访谈宣传片—CDEC.Y
2015/12/26 DOTA
[02:58]魔廷新尊——痛苦女王至宝语音台词节选
2020/06/14 DOTA
python使用正则搜索字符串或文件中的浮点数代码实例
2014/07/11 Python
Python爬虫中urllib库的进阶学习
2018/01/05 Python
python实现用户答题功能
2018/01/17 Python
Python从list类型、range()序列简单认识类(class)【可迭代】
2019/05/31 Python
K最近邻算法(KNN)---sklearn+python实现方式
2020/02/24 Python
如何用python免费看美剧
2020/08/11 Python
学点简单的Django之第一个Django程序的实现
2021/02/24 Python
numpy实现RNN原理实现
2021/03/02 Python
全球在线商店:BerryLook
2019/04/14 全球购物
董事长助理工作职责范本
2014/07/01 职场文书
2014年中秋寄语
2014/08/11 职场文书
爱护公共设施倡议书
2014/08/29 职场文书
民事诉讼代理委托书
2014/10/08 职场文书
2016年度先进班组事迹材料
2016/03/01 职场文书
详解Nginx 工作原理
2021/03/31 Servers