小程序开发中的一些经验总结

叁叁肆2018-11-30 09:56

此文已由作者刘诗川授权网易云社区发布。

欢迎访问网易云社区,了解更多网易技术产品运营经验。


前段时间做了一个小程序的项目,出于一些原因,除了小程序的相关开发外,还使用到了小程序提供的webview组件,这里对其中小程序开发中遇到的一些问题和解决办法进行一些总结。

我的这部分小程序工作中经常要用到小程序的<web-view>组件,具体原因后面会做介绍。

小程序webview的特点

  1. 自动铺满整个页面
  2. 业务域名受到限制
  3. 与小程序通信受限

这些都是微信强制规定的,目的应该就是降低开发者使用webview的频率和场景,限制开发者在小程序框架内开发,一方面确实能提高小程序的体验,一方面也是为其他竞争平台制造技术壁垒。但是现在已经有很多技术方案(tarouni-appMegalo等等)通过内部转换,基本实现了多平台上一致的开发体验,所以用什么技术已经不是主要问题了。这里也不详细讨论,本文主要介绍不得不在微信小程序上开发时的一些经验总结。

前两个特点受微信小程序的机制所限,基本是没有办法绕开的,只能按照微信的要求来,第三点,还是能通过一些办法来实现的。

Web与小程序的通信

Web向小程序发送消息

微信小程序是提供了webview内网页向小程序发送消息的api的,但是这个能力受到了很大的限制,但是经过我的测试,确实也没有其他不借助第三方的方法直接在web里向小程序发送消息:

<!-- html -->
<script type="text/javascript" src="https://res.wx.qq.com/open/js/jweixin-1.3.2.js"></script>
<script>
  wx.miniProgram.postMessage({ data: 'foo' })
  wx.miniProgram.postMessage({ data: {foo: 'bar'} }) //建议采用这种格式,社区中有反馈上一种数据格式在有的安卓机上无法收到
</script>

还有一点需要注意的是:

网页向小程序 postMessage 时,会在特定时机(小程序后退、组件销毁、分享)触发并收到消息。e.detail = { data }

所以这个消息并不是实时的,小程序内部会把消息都压入消息栈,在触发时一起收到。

经过我的实践,如果需要保存一些重要数据,依赖postMessage机制让小程序来兜底无法完全的到保证,测试阶段就发现有的安卓机即使按要求发送了数据依然在页面后退或者销毁时无法接收到,但是触发分享的时候基本还是能保证收到消息的。所以除了传递一些分享场景所需要的数据,其他重要数据还是建议直接通过Web发送到服务端来保存


小程序向Web发送消息

这里主要介绍一下如何通过小程序向Web发送消息,其原理利用就是<web-view>组件的src属性,动态改变页面的hash,它不会导致页面刷新,还可以做到实时的消息传递。 比如我们产品中有一个场景,用户的小程序切换到后台或者用户的微信切换到后台时,停止消耗用户的阅读时长,就需要利用小程序的前后台的onShowonHide事件,而Web这边的visibilitychange事件的兼容性还没有那么乐观。

<web-view src="https://du.163.com#isShow={{isShow}}"></web-view>

Web这边要做的就是监听hashchange事件再做处理就可以了

window.addEventListener("hashchange", () => {
  const hashData = parseUrl(location.hash.slice(1));
  if(hashData.isShow){
    const isShow = hashData.isShow === 'true';
    if(isShow){
      //切到前台
      this.sendReadLog(true); //上报阅读记录,并且开启定时上报
      this.createWs(); //重新打开websocket,接收消息通知
    }else{
      //切到后台
      this.sendReadProgress(true); //通过web上报一次阅读进度,防止阅读进度丢失
      this.sendReadLog(false); //上报阅读记录,并且关闭定时上报
      this.disconnectWs(); //关闭websocket,防止消息通知在后台展示
    }
  }
}, false);


小程序向Web发送消息造成的问题

小程序的web-view组件其实与我们常见的客户端内的webview容器没有本质的区别,上面这种消息传递机制导致了一个问题:

webview容器就相当于一个浏览器,浏览器对于每个打开的标签页都会维护一个历史栈,小程序每次更改web-viewsrc属性向Web传递消息,都会增加这个历史栈的长度,而小程序的顶部导航栏是没有按钮可以直接关闭web-view的,这就意味着传递了多少次消息,退出这个小程序页面的时候就需要多点击多少次返回按钮,这是相当影响用户体验的。

修改hash造成的多次返回问题:

想要解决这个问题,就需要借助onpopstate事件,因为不管是修改web-viewsrc发送消息,还是点击返回按钮(包括右滑手势返回),都会触发浏览器历史栈的改变,不同的是:

  • 前者会增加历史栈的长度,因为这个操作就相当于用户在浏览器里直接修改了页面链接,浏览器会将这个新的链接压入历史栈
  • 后者并不会修改历史栈的长度,只会将当前浏览器指向历史栈中的前一个历史记录,相当于用户在浏览器中点击了返回按钮,用户还是可以再点击前进按钮回到之前最新的那个历史

所以,我们可以根据这个不同点来进行区分。一旦onpopstate事件触发,但是历史栈长度又没有发生变化,此时可以判断用户是需要退出本页,直接调用小程序的api来直接退出整个页面,最终效果还是很理想的。

let oldHistoryLength = history.length;
window.onpopstate = () => {
  //每次小程序切换前后台,都会修改history栈,触发popstate事件,并且增加history栈的长度,
  //而点击返回按钮虽然会触发popstate事件,但是并不会修改堆栈长度,通过这个区别知道用户是想点击返回退出正文,调用小程序的返回接口
  if(history.length > oldHistoryLength){
    oldHistoryLength = history.length;
  }else{
    window.wx.miniProgram.navigateBack();
  }
}


利用web-view关联小程序与URS账号体系

通常情况下,产品的小程序都只使用微信的账号体系就可以满足需求了,这样的话接入和使用也比较简单,但是我们所做的这个小程序需要使用原有账号体系下的一些数据和内容,所以不得不同时支持微信小程序账号体系与产品现有账号体系,并且将他们关联起来。 URS是公司的帐号体系负责部门,主要对外提供有关邮箱帐号、手机帐号、三方帐号的注册登录及其他相关密保服务。蜗牛读书这边使用的网易的URS账号体系,在客户端与Web端都是使用URS提供的登录SDK来实现账号的注册和登录功能。 由于URS这边并没有直接提供登录验证的接口,而是提供的一个Javascript SDK(其原理就是动态加载一个iframe对输入进行验证后再写入cookie),所以如果要在微信账号体系之外使用其他登陆方式(邮箱、手机号等),就知道从web-view上想办法了,以下是整个登录流程的示意图: 


  • 如果选择的是微信登录,就需要按照小程序的登陆流程进行,其原理就是小程序页面通过api获取的对应于用户的code,再把这个code发送到产品服务端,产品服务端再去向小程序服务器验证并获取用户的一些数据
  • 只要蜗牛之前的产品使用微信登陆所关联的服务号和小程序所归属的公众号从属于同一个开发者账号,我们就能获取到一个一致的UnionID,从而避免使用同一个微信产生2个不同的用户的情况
  • 如果是选择了其他方式登录,就需要借助一个Web中转页和一个小程序中转页
  • 首先需要将URS的域名dl.reg.163.comdl2.reg.163.com加入小程序的业务域名,这里需要配合做一个验证,还好URS这边还是比较愿意配合的
  • Web中转页的作用就是在登陆成功后(URS cookie写入成功),在此页面从服务端获取到用户的authToken,再通过小程序的JS SDK回传给小程序中转页
  • 小程序中转页接收到authToken后,将它储存下来,并跳转回之前的小程序页面
  • 这两个中转页并不是必要的,Web中转页的的作用是将小程序登录相关的部分与以前的H5登录页面解耦,因为它需要引入小程序的JS SDK小程序中转页的作用是避免在每个需要登录的页面都得引入登录相关的逻辑代码,把这块的功能抽离出来,其他页面如果需要登录的话只需要跳转到小程序登陆页即可

这个流程的核心就在于使用authToken来作为小程序内部的登陆凭据,一旦在小程序内存储authToken,之后所有的请求header中都会附带这个信息,而这个authToken一旦过期,后端的响应就会返回http status 401的错误,遇到这个错误的时候就统一再跳转到登陆页要求用户重新登陆。


webview中选中文字的实现

需求介绍

由于业务需要,我们的小程序需要实现类似webview原生的长按选中文字然后出现操作菜单的功能,类似下图: 


这个就是我们很常见的,webview或者手机端浏览器的长按选中文字以及菜单的效果。 而我们业务需要的下面这个效果: 


这个效果之前是在客户端内webview上实现了的,但那是因为客户端对自己使用的webview有足够的控制权,但是在小程序的webview里如果使用原生的选中机制就无法隐藏原生的选中菜单(出现2个菜单肯定是不好看的),所以这里的选中机制得自行实现了。


实现原理

  1. 为了实现选中文字的选区跟随手指而变化,那么就不能在手指与屏幕交互的过程中修改的dom的结构,而只能通过修改元素的背景色来实现选区的变化效果;
  2. 所以我将正文段落的每个字都渲染成一个独立的<span>,然后再通过监听页面的手势事件touchstarttouchmovetouchend来识别长按的事件的发生以及详细的信息;
  3. 监听手势的过程中用到了一个关键的api,就是document.elementFromPoint(x, y),这个api的功能就是根据给定的相对于视口的坐标获取该位置的节点元素,就是因为这个api没能在小程序的体系中找到合适替代者,所以才选择了在小程序的web-view组件中进行开发和实现;
  4. 最终的实现就是通过它来获取到手指移动过程中经过的文字节点,从而设置长按开始的文字到手指停留的所有文字的背景色,并在手指松开时操作菜单,从而实现选中文字的模拟效果。


性能调优

基本的实现方式就是上面所述,但是还需要解决几个性能问题:

  • touchmove的事件在手指触摸屏幕开始移动后会一直触发直到手指松开,这里的监听函数如果每次都执行,对于一些性能较差的设备而言是一个不小负担,所以这里需要使用throttle方法来进行限流,设置其一定时间间隔内最多运行一次
  • 有的文学名著,一个章节将会有5,6万甚至更多的字数,如果将这样的章节也按照每个段落每个字都渲染成独立的节点的话,页面的dom元素数量就会有几万个。这对于移动设备上的浏览器来说,每次页面滚动所需要计算和渲染的开销就太大了,这将导致页面滚动中出现白屏、卡顿等问题,触摸事件的计算性能也受到影响,所以最后采用的方案还是利用document.elementFromPoint(x, y),在每次滚动结束获取视口内的可见段落,只对这部分段落逐字渲染,不可见的段落还是整段渲染,这样就大大减少了dom元素的数量,但是又较好的保证的长按选中文字的体验
  • 上面一点中提到的滚动结束后获取视口内可见段落,也不能每次滚动结束就立刻进行获取,这里需要使用dobounce方法,限制滚动结束后一定时间内没有再次发生滚动时,才进行获取,防止用户的快速的多次滚动引起不必要的计算。


在小程序中使用基于STOMP协议的WebSocket

我们在小程序中使用了WebSocket来做一些实时消息的推送和展示,STOMP的具体内容本文不做详细展开,简单来说它就是一个WebSocket通信中比较常用的消息协议,有了它我们可能更方便更专注的利用WebSocket完成我们的一些业务需求。

STOMP

STOMP is a simple text-orientated messaging protocol. It defines an interoperable wire format so that any of the available STOMP clients can communicate with any STOMP message broker to provide easy and widespread messaging interoperability among languages and platforms.

STOMP即Simple (or Streaming) Text Orientated Messaging Protocol,简单(流)文本定向消息协议,它提供了一个可互操作的连接格式,允许STOMP客户端与任意STOMP消息代理(Broker)进行交互。STOMP协议由于设计简单,易于开发客户端,因此在多种语言和多种平台上得到广泛地应用。


在小程序的WebSocket中使用STOMP

直接上代码

const Stomp = require('../../utils/stomp.js').Stomp;

createSocket: function () {
  //新建一个简单的websocket对象
  const ws = {
    send: msg => {
      wx.sendSocketMessage({
        data: msg
      })
    },
    close: () => {
      console.log('stomp is close')
    }
  }
  // stomp.setInterval是用来发心跳包的,而小程序没有window对象
  Stomp.setInterval = function (interval, f) {
    return setInterval(f, interval);
  }
  Stomp.clearInterval = function (id) {
    return clearInterval(id);
  }
  //新建stomp客户端
  const client = Stomp.over(ws);
  client.debugger = console.log
  //一些事件的处理
  wx.onSocketError(function (err) {
    console.log('socket err:', err)
  })
  wx.onSocketClose(() => {
    client.disconnect()
  })
  //连接websocket
  wx.connectSocket({
    url: `wss://${config.host}/ws`,
    header: {
      'X-Auth-Token': wx.getStorageSync('authToken')
    },
    success: () => {
      //连接stomp,connect方法有2个参数时第一个代表header
      client.connect({}, () => {
        console.log('stomp connect')
        //订阅消息
        client.subscribe(`/topic/shareRead.${this.shareReadId}`, this.handleMessage)
        client.subscribe(`/user/topic/shareRead.${this.shareReadId}`, this.handleMessage)
      }, (err) => {
        console.log('err', err)
      })
    }
  })
},
_handleMessage: function (message) {
  console.log('websokcet get message !', message.headers.destination);
  //发送消息回执
  message.ack({
    destination: message.headers.destination,
    'message-id': message.headers['message-id']
  })
},

以上就是我在开发小程序过程中的一些经验,有不当之处欢迎讨论指出,谢谢。


免费领取验证码、内容安全、短信发送、直播点播体验包及云服务器等套餐

更多网易技术、产品、运营经验分享请点击