作者简介
思语,携程高级前端开发工程师,关注互动营销领域;
Olivio,携程高级前端开发工程师,关注React Node 组件化;
Stone,携程高级研发经理,关注跨端解决方案,云原生落地等领域。
一、背景
这篇文章将向大家分享团队在小程序 webview 方面的开发心得,以微信小程序为主要环境,介绍在业务开发中处理小程序webview内嵌H5所遇到的问题及解决方案。具体将从小程序平台与H5差异、小程序内嵌webview通信、小程序webview常见问题展开叙述。
二、平台差异
下面将浅析并回顾一下小程序和H5在渲染方面的几点差异。
2.1 小程序方面
以微信小程序为例,相信今天大部分的读者对微信小程序的系统架构都比较熟悉了,总体来讲分为两部分:
- iew 视图端通过小程序的框架,将用户采用 WXML 和 WXSS 描述的UI信息处理成 H5 元素,最终交给 WebView 去渲染;
- 逻辑层运行JS逻辑,并且可以调用具有微信开放能力的 JSAPI。逻辑和视图分离,通过事件和数据彼此之间建立联系。
微信小程序使用 WebView 渲染,与原生客户端的是两套不同的视图渲染体系。一个小程序存在多个界面,所以渲染层存在多个 WebView。逻辑层采用 JSCore 线程运行 JavaScript 脚本。这两个线程间的通信经由小程序 Native 侧中转,逻辑层发送网络请求也经由 Native 侧转发。
如此设计的初衷是为了管控和安全,微信小程序阻止开发者使用一些浏览器提供的,诸如跳转页面、操作 DOM、动态执行脚本的开放性接口。将逻辑层与视图层进行分离,视图层和逻辑层之间只有数据的通信,可以防止开发者随意操作界面,更好地保证了用户数据安全。同时小程序设计一套组件框架—— Exparser ,基于这个框架内置了一套组件,以涵盖小程序的基础功能,便于开发者快速搭建出任何界面,同时也提供了自定义组件的能力,开发者可以自行扩展更多的组件,以实现代码复用。
值得一提的是,内置组件有一部分较复杂组件是用客户端原生渲染的,同时微信团队又通过结合 Flutter 和 LV-CPP,把实现代码收敛在 C++ 和 Dart 上,进一步简化了基于小程序技术栈实现跨平台业务开发的框架维护成本,以提供更好的性能。
2.2 小程序WebView内嵌H5
H5页面投放在小程序WebView,在配置完合法域名后,即可在小程序应用中展示。那么,针对不同厂商小程序,可能法务、厂商合规有所差异,需要H5判断所在的环境,去调用不同 api 方法,展示不同的业务页面。
在携程内部封装了小程序CWX的SDK,小程序端主要采用原生+Taro框架,H5这块主要是NFES(React)和Vue,无论是哪一段端都通过一个CWX来连接,内部封装了各端通用的功能比如登录、发布、支付、个人中心等功能,这些功能都可以直接通过CWX这个中间件进行调用。
并且,H5在检测到当前处于小程序webview环境下时,会根据环境异步加载SDK文件、及其厂商的JS-SDK,初始化小程序版本wx.config。这里的关键点是我们要做个api调用的队列,因为sdk加载异步的过程,如果期间页面内发生了api调用,那肯定得不到正确的响应。因此要做个调用队列,当sdk初始化完毕之后再处理这些调用。其实CWX原理很纯粹,如果你想实现多端适配,那么只需要根据所在的环境去加载不同的sdk就可以了。
> 下面简要列举一下工作中常用的几个小程序环境判断:
使用时的注意事项:
使用前,最好查阅相应小程序的文档,因为各个小程序对API的支持程度不同。引用bridge.js的方式视情况而定,因为 bridge.js 引入JSSDK的方式是 为 head标签添加 script标签,若在 head标签中引入bridge.js,就会报错若打开h5,显示“页面访问受限”之类的提示信息,可尝试下方的操作:(这种情况,一般是打开测试环境的h5 url 时出现)勾选IDE中的“忽略webview域名合法性检查” 和 “忽略request域名合法性检查”。
【快应用相关】
目前Vivo、Oppo、华为三家厂商已支持新版快应用,Vivo、OPPO已上线,华为正在测试中,小米不支持。对于新版快应用,若H5页面需要调用新版快应用JS-SDK中提供的API,需要提前将该H5链接的域名配置到可信任的网址里(应写成正则表达式的形式进行配置)。
【头条相关】
头条小程序的redirectTo、navigateTo 等页面跳转的 api 只支持 url 为 / 开始的绝对路径。
【支付宝相关】
目前的1.0.73版 bridge.js 判断是否处于支付宝小程序的方法,会将h5处于支付宝小程序、h5处于支付宝内置浏览器都判断为处于支付宝小程序内。因此,在调my.XXXX之前,需要先调环境工具函数判断一下,确保确实是处于支付宝小程序内,而非支付宝内置浏览器内。
三、小程序内嵌WebView通信
3.1 小程序中h5页面onShow和跨页面通信的实现
首先想到的是onShow方法的实现,之前有人提议用visibilitychange来实现onShow方法,但调研过后,发现这种方式在ios中表现符合预期,但是在安卓手机里,是不能按预期触发的。
于是就有了下面的方案,这个方案需要h5和小程序的webview都做处理。核心思想:利用webview的hash特性。
- 小程序通过hash传参,页面不会更新(这个和浏览器一样)
- h5可以通过hashchange捕获最新参数,进行自定义逻辑处理
- 最后执行window.history.go(-1)
为什么要执行window.history.go(-1) ? 因为hash变更会导致webview历史栈长度+1,用户需要多一次返回操作。但这一步明显是多余的。同时window.history.go(-1)后,会把webview在hash中添加的参数去掉,还能保证和之前的url一致。
3.2 注意点
出于平滑接入的考虑,不能上来搞一刀切,要保证现有页面不再做任何修改的情况下继续访问。新能力要通过额外参数区分,如:检测url中的query部分,带有 __isnotallow=1 再进行通过hash方式传参。改造原有逻辑,让__isnotallow=1时,hash处理逻辑优先级最高参数定义,在前面加入了两个下划线,目的是为了区分url中正常的参数。我们来看看h5端的sdk是怎么实现的。
总结下来是两点:
- onShow方法的实现
绑定一个hashchange事件(这里做了防止重复绑定事件的处理),将传入的onShow自定义事件缓存在一个数组中,hashchange触发时,根据特有的标志位__isonshow和__wachangehash确定是否触发。
- serviceDone方法的实现
触发条件:immediately表示最近的一次onShow触发,或者自己指定url通过wx.miniProgram.postMessage发送数据。
浏览器访问资源是通过 URL 地址,如果内嵌 H5 的地址不发生变化,那么 web-view 访问资源会从缓存里取,而缓存里并没有最新的数据,这就导致了服务端的最新资源根本无法到达浏览器,这也解释了为什么修改 Nginx 的 Cache-Control 配置也无法生效的原因。
所以,要想彻底解决及时刷新,必须让 web-view 去访问新的地址。我们假定小程序访问的 URL 地址为:https://www.yourdomain.com/101/#/index ,其中 101 就是构建的一个版本号,每次递增,保证次次不同即可。
四、WebView常见难题与解决方案
小程序和h5 之间的通信基本上常用两种方式,一个是postMessage,这个方法大家都知道,只有在三种情况才可以触发,后退、销毁和分享。但也有个问题,就是需要注意这个方法是基础库1.7.1才开始支持的,1.7.1以下就只能通过第二种方法来传递数据,也就是设置和检测webview组件的url变化,类似pc时代的iframe的通信方式。
sdk这块怎么做呢,定义一个share方法,首先需要检测下基础库版本,看是否支持postMessage,如果支持直接调用,如果不支持,把分享参数拼接到url当中,然后进行一次重载。也就是说,通过url传递数据有个缺点,就是页面可能需要刷新一次才能设置成功。
目前在webview环境下支持支持的几种通用业务:
4.1 左上角返回
在访问小程序webview页面时,首先进入的是一个空白的中转页,然后进入h5页面,这样左上角就会出现返回按钮了,当用户按左上角的返回按钮时候,页面会被重载到小程序首页去,这个看似简单又微小的动作,对业务其实有很大的影响。
经过我们的数据统计发现,左上角返回按钮点击率高达70%以上,因为这种落地页一般是被用户分享出来的,以前纯h5的时候只能通过左上角返回,所以在小程序里用户也习惯如此;第二个数字,重载到首页以后,后续页面访问率有10%以上,这两个数字对业务提升其实蛮大的。其实现原理很简单,都是通过第二次触发onShow时进行处理。
4.2 H5和小程序登录态同步问题
分两种情况,接入的H5可能一开始就需要登录,也可能开始不需要登录态中途需要登录,这两种情况我们约定了h5通过自己的url上一个参数进行控制。
一开始就需要登录态的情况,具体来讲就是在加载webview之前,首先进行授权登录,然后把登录信息拼接到url里面,再去来加载webview,在h5里面通过adapter来把登录信息提取出来并且存到cookie里,这样h5一进来就是有登录态的。
一开始不需要登录态的情况,一进入小程序就直接通过webview加载h5,h5调用login方法的时候,把needLogin这个参数拼接到url中,然后利用api进行重载,就走了第一种情况进行授权登录了。
Q:可能出现的登录同步问题
A: 跳到个人页登录完成,此时是新开的webview同步两端登录态,点返回,到上一个webview,此时这个webview嵌套的首页,没有触发react-imvc onshow事件。这个页面是老的,退出登录也是一样,所以在首页会去跳h5的登录而不是小程序登录,导致登录不同步。
解决思路:需要返回首页刷一下h5页面。
误区:直接在个人登录之后,relaunch到首页,会导致没有直接调用注销webview把token置换,无法退出。
解决方案:判断从个人页返回的时候,设置webview的url加个参数,重新刷一下。
4.3 WebView分享
在没接入websocket之前,小程序主要通过bind。首先通过bindmessage事件接收h5传回来的数据,然后在用户分享的时候onShareAppMessage判断有没有回传的数据,如果没有就到webviewurl当中取,否则就是用默认分享数据。
4.4 支付
1)WebView页面刷新问题
因为小程序webview里面不支持直接调起微信支付,所以基本上需要支付的时候,都需要来到小程序里面,支付完再回去。上面做好了以后,在h5这块调用一句话就可以了。
针对产品有大量内嵌H5页面的情况下,最好根据业务分两种支付页面,一是有的业务h5有自己完善的交易体系,下单动作在h5里面就可以完成,他们只需要小程序付款,因此我们有一个精简的支付页,进来直接就拉起微信支付。
还有一种情况是业务需要小程序提供完整的下单支付流程,通过直接进入小程序的收银台来,图上是sdk里面的基本逻辑,通过payOnly这个参数来决定进到哪个页面。再看下小程序里面精简支付怎么实现的,onload之后直接调用api拉起微信支付,支付成功以后根据h5传回来的参数,如果是个小程序页面,直接跳转过去,否则就刷新上一个webview页面,然后返回回去。
新的问题与挑战:webview返回上一页数据刷新问题
有客户反馈在A页面点击任务后跳转到B页面,待任务完成后,手机手势左滑返回或点击默认导航栏的左上角返回,上一个页面不会触发任务的更新。原因是上一个页面已经初始化并没有执行重渲染,在APP环境下JSBridge 没有提供侦听手势左滑返回、左上角物理返回的回调事件,且在小程序webview页面也会遇到上述同样的情况。
由于微信并没有提供侦听手势左滑返回、左上角物理返回的,且webview页面也不支持自定义导航栏,这导致下一个页面触发的新事件,在返回上个页面时 无法做到针对性的更新。前期可以简单粗暴地通过约定参数 doRefreshWhileBack=true 作为options,来通过webview页面每次onShow刷新页面,但是刷新整个页面的成本太大,且用户体验不好。
2)引入WebSocket
带着这些疑问,我们进行一系列的尝试与试验,最终采用了 websocket 的方式,解决并封装出我们市场业务的轻量的websocket服务,主要用于解决webview跨页面通信和游戏方面的业务。
在这个过程中,我们总结出了一些经验,希望能给从事相关研究的同学带来一些帮助。上述做法是针对不同的应用环境,分别使用或约定不同的api派发给各自的事件系统,从而解决页面物理回退时页面不主动刷新的方案。
简要介绍一下websocket,websocket标准诞生于2011年,RFC 文档编号是 6455。TML 5 规范定义了 WebSocket 协议,它可以通过 HTTP 的端口(或者 HTTPS 的端口)来完成,从而最大程度上对 HTTP 协议通透的防火墙保持友好。但是,它是真正的双向、全双工协议,也就是说,客户端和服务端都可以主动发起请求,回复响应,而且两边的传输都互相独立。和上文的 Comet 不同,WebSocket 的服务端推送是完全可以由服务端独立、主动发起的,因此它是服务端的"真 Push"。
WebSocket 是一个可谓"科班出身"的二进制协议,也没有那么大的头部开销,这样就解决了接线员要反复解析HTTP协议,还要查看identity info的信息,因此它的传输效率更高。同时,和 HTTP 不一样的是,它是一个带有状态的协议,双方可以约定好一些状态,而不用在传输的过程中带来带去。而且,WebSocket 相比于 HTTP,它没有同源的限制,服务端的地址可以完全和源页面地址无关,即不会出现的浏览器"跨域问题"。
优势:
- 消息实时:真正的双向、全双工协议,完全的服务端推送保证了数据的时效性。
- 通信高效:可以由客户端和服务端主动发送请求,不会像轮询那样产生大量无效传输报文。
- 协议支持:标准诞生较早,浏览器支持度高,且没有同源策略的限制。
劣势:
- 开发与维护成本:服务器长期维护长连接需要一定的成本,且受网络限制比较大,需要处理好重连。
借助websocket的辅助,在小程序webview内嵌H5的业务场景中,可做的事情就更多了。在市场的webview容器加载流程中。
3)WebSocket背景下的WebView通信实践
小程序webview初始化并在onLoad阶段通过 options.useMktsocket 判断是否需要加载 socket,同时判断应用环境通过 wx.connectSocket api 连接不同的 socket 服务;
初始化webview socket服务,接受服务器消息-对服务器消息进行甄别,如果H5页面通过socket传递给webview容器的数据data格式符合预期,且H5环境下登录态中的openId与小程序环境一致,则认为此次通信合法;
webview容器中绑定了 小程序分享miniShare 、小程序订阅openScribe、 健康检查health等常用业务API,用于处理广告、订阅、任务更新等业务实时回调;H5业务可通过此接口设置触发小程序原生页面的一些原生功能,为上层业务提供服务。
H5页面就可以通过 socket 通信更改并调用小程序的胶囊栏分享、通知webview容器页面调用小程序广告、也可以调用唤起小程序页面中的分享组件面板、触发左上角物理返回时及时通知H5页面触发回调等诸多业务;同时小程序容器页面原生事件完成后(比如广告、分享)再次通过socket返回给H5页面的回调,实现小程序webview跨页面的实时通信。
在websocket加持下,此时的小程序webview赋予了更多和H5通信的功能。
4.5 自定义分享面板
H5页面可以通过 websocket 通信更改并调用小程序的分享参数,不再依赖于页面options参数,可以调用在webview页面封装的分享面板,提供更加灵活的分享方式。
4.6 H5调用小程序原生的激励广告
H5页面可以通过 WebSocket 通信调用小程序原生的激励广告。
4.7 任务体系中用户任务组件状态的更新
用户在访问加载了webview-h5的页面会与websocket的server A服务器连接、小程序原生页面与server B连接时,这两个页面因为在不同的容器下,所以无法通信和告知;但是只要这两个页面加载的是同一个市场的websocket服务,服务端可以设置共享一个redis,通过redis的发布订阅功能,连通集群内部各个机器,那么在页面前进、回退时都可以绑定对应的回调事件,实现任务组件的灵活更新,给用户展示最新的任务状态。
五、总结
在处理小程序webview的业务方面,可以通过封装一个包含各端环境的SDK,在H5初始化时加载,打通H5和小程序webview之间的通道,实现H5控制分享、登录态同步、支付信息同步等功能。
在遇到跨页面数据刷新问题时,借助了websocket这把利器,通过redix的发布订阅通知链接了websocket服务器的页面,实现小程序webview物理返回上一页而数据不刷新的问题,同时websocket使得H5与webview的通信更加便捷灵活,拓展了H5调用小程序原生激励广告、封装并调用小程序原生的分享面板等功能。
【参考文献】
- 《WebSockets 教程》,链接:https://www.tutorialspoint.com/websockets/