浅谈高大上的微信小程序中渲染html内容—技术分享


Posted in Javascript onOctober 25, 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」框架本身是有构建机制的,所以不必手写十来个几乎一模一样的模板,通过一个构建的插件去生成即可。

以下为需要重复嵌套的模板(精简过),在其代码的开始前和结束后分别插入特殊注释进行标识,并在需要嵌入下一层模板的地方以另一段特殊注释(「<!-- next template -->」)标识:

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

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

图片

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

  • 把图片原路径(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>
{
  // 点击封面图,播放视频
  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();
    }
  }
}

本文分享就到这里了。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
AngularJS单选框及多选框实现双向动态绑定
Jan 13 Javascript
jquery操作select元素和option的实例代码
Feb 03 Javascript
js和C# 时间日期格式转换的简单实例
May 28 Javascript
vue.js事件处理器是什么
Mar 20 Javascript
AngularJS自定义指令详解(有分页插件代码)
Jun 12 Javascript
JS设计模式之数据访问对象模式的实例讲解
Sep 30 Javascript
详解angular部署到iis出现404解决方案
Aug 14 Javascript
详解多页应用 Webpack4 配置优化与踩坑记录
Oct 16 Javascript
js array数组对象操作方法汇总
Mar 18 Javascript
JS计算斐波拉切代码实例
Sep 12 Javascript
JavaScript中while循环的基础使用教程
Aug 11 Javascript
Element-ui树形控件el-tree自定义增删改和局部刷新及懒加载操作
Aug 31 Javascript
使用ECharts实现状态区间图
Oct 25 #Javascript
jquery使用FormData实现异步上传文件
Oct 25 #jQuery
详解js访问对象的属性和方法
Oct 25 #Javascript
深入浅析js原型链和vue构造函数
Oct 25 #Javascript
AngularJS 多指令Scope问题的解决
Oct 25 #Javascript
jQuery+Datatables实现表格批量删除功能【推荐】
Oct 24 #jQuery
webpack打包非模块化js的方法
Oct 24 #Javascript
You might like
php中异常处理方法小结
2015/01/09 PHP
JS下高效拼装字符串的几种方法比较与测试代码
2010/04/15 Javascript
javascript中创建对象的三种常用方法
2010/12/30 Javascript
JavaScript词法作用域与调用对象深入理解
2012/11/29 Javascript
开发 Internet Explorer 右键功能表(ContextMenu)
2013/07/03 Javascript
js实现日历可获得指定日期周数及星期几示例分享(js获取星期几)
2014/03/14 Javascript
用Jquery.load载入页面后样式没了页面混乱的解决方法
2014/10/20 Javascript
JS获取复选框的值,并传递到后台的实现方法
2016/05/30 Javascript
Javascript中常见的逻辑题和解决方法
2016/09/17 Javascript
微信小程序-小说阅读小程序实例(demo)
2017/01/12 Javascript
js 性能优化之快速响应的用户界面
2017/02/15 Javascript
Vue表单验证插件Vue Validator使用方法详解
2017/04/07 Javascript
Vue项目组件化工程开发实践方案
2018/01/09 Javascript
从零开始在NPM上发布一个Vue组件的方法步骤
2018/12/20 Javascript
微信小程序中data-key属性之数据传输(经验总结)
2020/08/22 Javascript
python实现斐波那契数列的方法示例
2017/01/12 Python
Python中str is not callable问题详解及解决办法
2017/02/10 Python
使用Python实现在Windows下安装Django
2018/10/17 Python
基于python历史天气采集的分析
2019/02/14 Python
numpy.linspace函数具体使用详解
2019/05/27 Python
PyCharm License Activation激活码失效问题的解决方法(图文详解)
2020/03/12 Python
基于SQLAlchemy实现操作MySQL并执行原生sql语句
2020/06/10 Python
Python 如何测试文件是否存在
2020/07/31 Python
Python程序慢的重要原因
2020/09/04 Python
python 将Excel转Word的示例
2021/03/02 Python
男女时尚与复古风格在线购物:RoseGal(全球免费送货)
2017/07/19 全球购物
环法自行车赛官方商店:Le Tour de France
2017/08/27 全球购物
super关键字的用法
2012/04/10 面试题
就业自我评价
2014/02/04 职场文书
运动会稿件50字
2014/02/17 职场文书
八项规定对照检查材料
2014/08/31 职场文书
党员三严三实心得体会
2014/10/13 职场文书
群众路线四风对照检查材料
2014/11/04 职场文书
申报优秀教师材料
2014/12/16 职场文书
2015年乡镇工会工作总结
2015/05/19 职场文书
铁人纪念馆观后感
2015/06/16 职场文书