评论

腾讯课堂小程序详情页开发总结

原腾讯课堂小程序采用webview嵌入删减版的H5页面,在功能性上落后H5且无法使用微信特有的功能;在性能和体验上明显不如原生小程序,同时兼容性不好;采用原生开发后续可以低成本复用到手Q轻应用。

状态管理

一开始为了借鉴和复用课堂H5详情页的状态管理,引入 redux ,但由于 reducer 总是返回一个新的更新后的对象,这意味着每次 setData 时会传递全量的数据,而在小程序双线程界面渲染数据通信模型下,传输数据量与性能正相关,因此对于数据量比较大的详情页来说,每次 action 操作都比较耗性能,体验不好。

于是改用腾讯开源的小程序状态管理方案 westore, 它利用小程序 setData 函数支持以数据路径的形式传递数据的特点,通过 update 函数先进行 diff 得到最小更新的数据路径集合,然后再调用 setData 函数传递变化的数据以达到更优的性能。

可是 westore 是基于页面路径来同步数据的,如果同时存在两个相同路径的页面,则只有最新的页面会更新;例如当前页面 A (pages/course?cid=A)打开相同路径的页面 B (pages/course?cid=B)时,由于 store 数据是共享的,这时页面 B 持有页面 A 的数据,同时页面 A、B 路径(pages/course)相同,此时 westore 已经丢掉页面 A 的引用,当 westore 更新数据时只会影响到页面 B ,页面 B 返回页面 A 后,已经无法再更新页面 A 了。

对于这个问题,只要增加一个栈来记录页面路径实例,新开页面时,重置 westore 数据,页面返回时,将旧页面实例的数据同步到 westore 即可。

除此之外,H5详情页中很多复合的状态逻辑都放在嵌套较深的自定义组件中,可在小程序环境下就有点力不从心了,所以必须要将这部分常变状态和遍历逻辑提前计算,以便 westore diff 局部更新。

富文本


课堂详情页中需要展示由富文本编辑器 CKEditor 生成的课程详情,里面可能包含视频,但小程序提供的 rich-text 组件无法支持 video 标签,因此用到 wxParse 来将 HTML 文本解析成 JSON 树,然后通过 view + css 来模拟 HTML 元素进行渲染。

可是 wxParse 已经很久没有更新了,在使用过程中发现它有很多问题和局限性,以下是踩坑改造优化经验:

  • 缺少解码、解析和渲染完成等钩子:由于后台 CGI 返回的 HTML 文本存在二次编码的情况,只经过 wxParse 的一次解码后仍有部分字串没有被正确解析,同时针对某些解析后的 HTML 标签需要扩展其属性等等。
    因此只能修改源码增加 beforeDiscode、afterDiscode、parsedStartTag、parsedEndTag、parsed 和 complete 等钩子来提高其灵活性。

  • 含有较多复杂属性的 HTML 标签无法解析出来:主要是 wxParse 中 startTag 的正则表达式不够全面导致的。

    上图无法解析出第一个 p 标签。
    修改一下 startTag 的正则表达式即可。

  • 12个相同 template:wxParse 定义了 wxParse0 到 wxParse11 共 12 个 template,这 12 个 template 除了子结构调用不同的 wxParseXX template 之外其余代码都是一样,究其原因是因为小程序 template 不能递归引用,当然这种变通的处理方式有个局限性,就是它处理不了超过 12 层的结构,超过以后就解析不了,再加上小程序的机制,这样是不会报错的,导致查 bug 很困难。要解决这个问题,除了官方支持 template 递归,可以将 wxParse 改为自定义组件(暂未尝试),或者尽可能的合并 HTML 结构。例如

    wxParse 解析渲染后的结果

    这里可以发现每个 wxParse-inline 元素的样式完全可以合并,同时形如 wxParse-s 等元素是通过 css 来模拟 HTML 元素的,因此对于这样嵌套的行内元素,可以进行合并

  • a 标签作为块元素:由于 a 标签允许包裹其中没有交互内容的块元素,wxParse 把 a 标签视为块级元素,导致解析 a 时将其前一个行内元素提前闭合了,造成显示错误。解决办法是将 a 标签从块级元素中剔除。

  • 不支持腾讯视频 vid:小程序 video 仅支持视频地址和云文件 ID,但课程详情会包含腾讯视频,而腾讯视频播放路径需要通过腾讯视频 SDK 将视频 vid 转换出来,由于已经引入腾讯视频组件,VID 转换这一步可以省略交给腾讯视频组件,只需要将 wxParse template 中 video 标签改为 txv-video,同时在 wxParse 解析出 video 数据时计算出 authExt ,连同 vid 等必要字段一并提供给 txv-video 即可播放视频。考虑到课程详情中的视频播放频次不高,没必要详情展示时就生成腾讯视频组件,因此使用封面 + 播放按钮来替代,等待用户点击封面时才生成。

  • wxParse 样式污染全局:定义了 view 样式,但没有限定在 .wxParse 作用域下生效,导致影响了页面全局。

  • 标签内文本含有 < 则解析结束标签有误

  • setData含有较多与界面渲染无关的数据

WXML

WXML(WeiXin Markup Language)是小程序视图层的一套标签语言,它与 Vue 的模板语法很相似,但在实际开发过程中经常会遇到一些问题与限制。

数据绑定中的数据处理

在 WXML 中,数据绑定只支持简单的 js 表达式,不能调用方法。例如保留数据的小数点后两位

<view>{{num.toFixed(2)}}</view>

这种写法是不会生效的,为了弥补 WXML 中数据处理的短板,小程序提供了 WXS(WeiXin Script)脚本,可以这么做

<view>{{tools2.toFixed(num, 2)}}</view>
<wxs module="tools2">
  function toFixed(num, len) { return num.toFixed(len); }
  module.exports = { toFixed: toFixed }
</wxs>

但要注意的是

  • wxs 与 javascript 是不同的语言,有自己的语法,并不和 javascript 一致,更不能使用 es6 语法。
  • wxs 的运行环境和其他 javascript 代码是隔离的,wxs 中不能调用其他 javascript 文件中定义的函数,也不能调用小程序提供的API。
  • wxs 函数不能作为组件的事件回调。
  • wxs 目前共有以下几种数据类型:number,string,boolean,object,function,array,date,regexp

template 的 data 传参

如果只看 官方文档 template 说明,你可能不知道 template 的 data 传参有三种方式

  • 格式一:data="{{ …value1,…value2,… }}",value 前面的 ... 是扩展运算符。
  • 格式二:data="{{ value1,value2,… }}"。
  • 格式三:data="{{ key1: value1,key2: value2,… }}"。

value 可以是 boolean、number、string、null、object、array。

例如 value = { a: 1, b: 2, c: 3},那么在 template 中的使用如下:

<!-- 格式一 -->
<template name="example1">
  <view>{{a}}: {{b}}: {{c}}</view>
</template>
<template is="example1" data="{{...value}}" />

<!-- 格式二 -->
<template name="example2">
  <view>{{value.a}}: {{value.b}}: {{value.c}}</view>
</template>
<template is="example2" data="{{value}}" />

<!-- 格式三 -->
<template name="example3">
  <view>{{k.a}}: {{k.b}}: {{k.c}}</view>
</template>
<template is="example3" data="{{k:value}}" />

如果在列表渲染时,想要将列表的索引 index 在 template 中使用,可以这样做

<template name="example4">
  <view>{{index}}: {{msg}}: {{time}}</view>
</template>
<template is="example4" wx:for="{{items}}" data="{{index, ...item}}" />

除此之外还可以结合 wxs

<template name="example5">
  <view>{{msg}}: {{time}}</view>
</template>
<template is="example5" data="{{...tools.getLast(items)}}" />
<wxs module="tools">
  function getLast(items) { return items[items.length - 1]; }
  module.exports = { getLast: getLast };
</wxs>

数据缓存与自定义组件和 wx:if

在做页面数据缓存时,由于页面数据字段比较多且嵌套深,有时图方便,我们会省略嵌套深的字段定义同时将缓存赋给 data,然后直接在 wxml 中使用

如果 wxml 中刚好使用了 wx:if 和自定义组件,那么在小程序基础库 2.4.0 及以下,从第二次进入该页面时就会报错 Expect FLOW_CREATE_NODE but get another,对于这个问题,有几种解决办法:

  • _list 列出所有字段定义。
  • data 中 list 不直接赋值 _list,改在 onLoad 时通过 setData 传递。
  • wxml 中 wx:if 改为 hidden 处理,或者不适用自定义组件。

上述问题出现的条件比较特殊,很大部分是编码问题,但从小程序基础库 2.4.1 开始就不会出现。对比了不同版本基础库在 onLoad 阶段输出的 data 信息,发现 2.4.1 及以上 data 的初始值不再等于当前缓存的 _list 值。

2.4.0 及以下第一次和第二次进入该页面时的 data 值,第二次进入已有缓存

2.4.1 及以上第一次和第二次进入该页面时的 data 值相同

其他

template 模板与 component 组件

template 模块与 component 组件是小程序中组件化的方式。二者的区别:

  • template 模块主要是展示,交互需要在使用 template 的页面中定义。
  • component 组件拥有自己的数据处理与交互逻辑,类似一个 page 页面。

在需要频繁更新的场景下或者在列表中涉及到列表子项独立的操作时,使用自定义组件可以只在组件内部进行更新,即实现页面局部更新,而不受页面其他部分内容的影响。

onPageScroll 与 IntersectionObserver

在做图片懒加载、元素曝光上报和元素吸顶展示时,离不开元素位置与页面滚动位置的判断,与之相关的事件或API有:

  • onPageScroll:page 中监听用户滑动页面的事件。自定义组件无法使用,只能通过传参或事件总线来获取变化状态。
  • IntersectionObserver:监听某些节点与参照物边界相交状态的对象。参照物可以是指定一个节点或者页面显示区域。

从触发回调频次来看,onPageScroll 远远高于 IntersectionObserver,而且每一次事件回调都是一次视图到逻辑的通信过程。因此应该只在必要的时候才使用 onPageScroll,其他情况使用 IntersectionObserver 替代较好。

最后一次编辑于  2019-05-07  
点赞 16
收藏
评论

3 个评论

  • 2019-07-08

    txv-video能在rich-text里面播放吗?还是把视频从富文本里单拿出来,然后拼起来展示的呢?

    2019-07-08
    赞同
    回复 4
    • mh
      mh
      2019-07-08

      rich-text不支持video,只能采用解析rich-text,使用原生组件加样式模拟rich-text显示。

      2019-07-08
      回复
    • 2019-07-08回复mh

      诶,我现在项目的需求和你们的一模一样,好想用你们的魔改wxParse啊😂

      2019-07-08
      回复
    • mh
      mh
      2019-07-09回复

      主要是增加了多个钩子,方便业务处理

      function html2json(html, bindName, { debug = false, hooks = {} }) {
        var _html;
        if (typeof hooks.beforeDiscode === 'function') {
          _html = hooks.beforeDiscode(html);
          html = _html || html;
        }
        html = wxDiscode.strDiscode(html);
        if (typeof hooks.afterDiscode === 'function') {
          _html = hooks.afterDiscode(html);
          html = _html || html;
        }
        HTMLParser(html, {
          start: function (tag, attrs, unary) {
            ...
            if (typeof hooks.parsedStartTag === 'function') {
              hooks.parsedStartTag(node);
            }
          },
          end: function (tag) {
            ...   
            if (typeof hooks.parsedEndTag === 'function') {
              hooks.parsedEndTag(node);
            }
          },
        }
        if (typeof hooks.parsed === 'function') {
          hooks.parsed(results);
        }
      }


      function wxParse(bindName = 'wxParseData', type = 'html', data = '<div class="color:red;">数据不能为空</div>', target, opts = {}) {
        ...
        if (type == 'html') {
          transData = HtmlToJson.html2json(data, bindName, opts);
        }
      }


      WxParse.wxParse('summary', 'html', details, this, {
        imagePadding: 24,
        hooks: {
          beforeDiscode: this.beforeDiscode,
          afterDiscode: this.afterDiscode,
          parsedStartTag: this.parsedStartTag,
          parsedEndTag: this.parsedEndTag,
          parsed: this.parsed,
        },
        complete: this.complete,
        // debug: true,
      });


      2019-07-09
      2
      回复
    • 2019-07-16回复mh
      非常感谢!
      2019-07-16
      回复
  • Terrance
    Terrance
    2019-05-05

    666

    2019-05-05
    赞同
    回复
  • 陈超
    陈超
    2019-05-02

    666

    2019-05-02
    赞同
    回复
登录 后发表内容