小程序(六)内嵌H5的终极解决方案

这是我参与更文挑战的第3天,活动详情查看: 更文挑战

一、小程序和H5的区别

1.1 运行环境方面

从运行环境方面开看,H5 的宿主环境是浏览器,只要有浏览器,就可以使用,包括APP中的 web-view 组件,以及小程序提供的 web-view 组件小程序就不一样了,它运行于特定的移动软件平台 (Wechat / 支付宝 / 字节跳动 / 百度 / QQ 等)。

拿微信小程序来说,它是基于浏览器内核重构的内置解析器,它并不是一个完整的浏览器,官方文档中重点强调了脚本内无法使用浏览器中常用的 window 对象和 document 对象,就是没有 DOM 和 BOM 的相关的 API,这一条就干掉了 JQ 和一些依赖于 BOM 和 DOM 的NPM包。

1.2 运行机制方面

H5 的运行就是一个网页的运行,这里不过多叙述,小程序还是以微信小程序举例。

1.2.1 启动

如果用户已经打开过某小程序,在一定时间内再次打开该小程序,此时无需重新启动,只需将后台态的小程序切换到前台,整个过程就是所谓的 热启动。如果用户首次打开或小程序被微信主动销毁后再次打开的情况,此时小程序需要重新加载启动,就是 冷启动

1.2.2 销毁

当小程序进入后台一定时间,或系统资源占用过高,或者是你手动销毁,才算真正的销毁

1.3 系统权限方面

H5最被诟病的地方在哪?系统权限不够,比如网络通信状态、数据缓存能力、通讯录、或调用硬件的,如蓝牙功能等等一些APP有的功能,H5就没有这些系统权限,因为它重度依赖浏览器能力,依旧是微信小程序举例,微信客户端的这些系统级权限都可以和微信小程序无缝衔接,官方宣称拥有 Native App 的流畅性能。

1.4 开发编码层面

H5 开发大家都知道,标准的 HTML、CSS、JavaScript ,万变不离其三剑客小程序不同, (Wechat / 支付宝 / 字节跳动 / 百度 / QQ 等)不同的小程序都有自己定义独特的语言最常用的微信小程序,自定义的 WXML、WXSS,WXML 中全部是微信自定义的标签,WXSS、JSON 和 JS 文件中的写法都稍有限制,官方文档中都有明确的使用介绍,虽容易上手,但还是有区别的。

1.5 更新机制方面

H5 的话想怎么更新就怎么更新,更新后抛开CDN/浏览器缓存啥的,基本上更新结束刷新就可以看到效果
小程序不同,还是微信举例,嘿嘿,微信小程序更新啥的是需要通过审核的。而且开发者在发布新版本之后,无法立刻影响到所有现网用户,要在发布之后 24 小时之内才下发新版本信息到用户
小程序每次 冷启动 时,都会检查有无更新版本,如果发现有新版本,会异步下载新版本代码包,并同时用客户端本地包进行启动,所以新版本的小程序需要等下一次 冷启动 才会应用上,当然微信也有 wx.getUpdateManager 可以做检查更新

1.6 渲染机制方面

H5就是 web 渲染,浏览器渲染。微信小程序的宿主环境是微信,宿主环境为了执行小程序的各种文件:wxml文件、wxss文件、js文件,提供了双线模型。

二、小程序环境分析

小程序的渲染层和逻辑层分别由两个线程管理:

image.png

  • 渲染层的界面使用 WebView 进行渲染,一个小程序存在多个界面,所以渲染层存在多个 WebView。
  • 逻辑层采用 JSCore 线程运行 JavaScript 脚本。

这两个线程间的通信经由小程序 Native 侧中转,逻辑层发送网络请求也经由 Native 侧转发。如此设计的初衷是为了管控和安全,微信小程序阻止开发者使用一些浏览器提供的,诸如跳转页面、操作 DOM、动态执行脚本的开放性接口。将逻辑层与视图层进行分离,视图层和逻辑层之间只有数据的通信,可以防止开发者随意操作界面,更好的保证了用户数据安全。

三端的脚本执行环境以及用于渲染非原生组件的环境是各不相同的:

运行环境 逻辑层 渲染层
Android V8 Chromium 定制内核
IOS JavaScriptCore WKWebView
小程序开发者工具 NWJS Chrome WebView

运行环境逻辑层渲染层 AndroidV8Chromium 定制内核IOS JavaScriptCoreWKWebView 小程序开发者工具NWJSChrome WebView小程序的视图是在WebView里渲染的,那搭建视图的方式自然就需要用到HTML语言。但是HTML语言标签众多,增加了理解成本,而且直接使用HTML语言,开发者可以利用<a>标签实现跳转到其他在线网页,也可以动画执行 JAVAScript,前面所提到的为解决管控与安全而建立的双线程模型就成摆设了。

因此,小程序设计一套组件框架—— Exparser 。基于这个框架,内置了一套组件,以涵盖小程序的基础功能,便于开发者快速搭建出任何界面。同时也提供了自定义组件的能力,开发者可以自行扩展更多的组件,以实现代码复用。值得一提的是,内置组件有一部分较复杂组件是用客户端原生渲染的,以提供更好的性能。

三、H5浏览器环境分析

大家都知道,浏览器缓存是个非常有用的特性,它能够提升性能、减少延迟,还可以减少带宽、降低网络负荷。关于浏览器的缓存机制可以总结成下面 2 句话:

  • 浏览器每次发起请求,都会先在浏览器缓存中查找该请求的结果以及缓存标识
  • 浏览器每次拿到返回的请求结果都会将该结果和缓存标识存入浏览器缓存中

更进一步,我们可以粗略了解一下强制缓存和协商缓存的运行机理。若强制缓存(Expires 和 Cache-Control)生效则直接使用缓存,若不生效则进行协商缓存(Last-Modified/If-Modified-Since 和 Etag/If-None-Match),协商缓存由服务器决定是否使用缓存,若协商缓存失效,那么代表该请求的缓存失效,返回 200,重新返回资源和缓存标识,再存入浏览器缓存中;生效则返回 304,继续使用缓存。这段文字是想让读者拓展一下知识面,如果想要更输入了解,可以通过上面的一些关键字(强缓存、协商缓存、Expire、Cache-Control 等)去查找更详细的资料。
微信的 web-view 组件就是一个嵌在小程序里的浏览器,它在缓存上并没有完全遵照上述的规则,也即它的缓存并不能及时得到清理。想必下面的操作大家都有尝试过:

  • 手动退出小程序,再次进入;
  • 将微信从后台退出再打开并重新进入小程序;
  • 修改 Nginx 关于 Cache-Control 的配置;
  • 用 debugx5.qq.com 手动清除安卓微信浏览器缓存;
  • iOS 利用微信自带清楚缓存功能。

无法及时刷新缓存会导致发布了最新的页面,而小程序里仍然是以前的页面,从而会带来许多问题,如前后端的数据不一致,新的特性无法及时起作用,修改的问题没有得到解决等等。这里需要说明一下:web-view 在过一段时间(时间不定,一天或者几小时,无明显规律)是可以进行缓存刷新的,而本 Chat 要解决的是及时刷新的问题。

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部分,带有__isonshowpro=1再进行通过hash方式传参。改造原有逻辑,让__isonshowpro=1时,hash处理逻辑优先级最高参数定义,在前面加入了两个下划线,目的是为了分区url中正常的参数。我们来看看h5端的sdk是怎么实现的

import util from './util';

class WASDK {
  /**
   * Create a instance.
   * @ignore
   */
  constructor(){
    // hashchang事件处理
    if('onhashchange' in window && window.addEventListener && !WASDK.hashInfo.isInit){
      // 更新标志位
      WASDK.hashInfo.isInit = true;
      // 绑定hashchange
      window.addEventListener('hashchange', ()=>{
        // 如果小程序webview修改的hash,才进行处理
        if (util.getHash(window.location.href, '__wachangehash') === '1') {
          // 这块有个坑:
          // ios小程序webview在修改完url的hash之后,页面hashchange和更新都可以正常触发
          // 但是:h5调用部分小程序能力会失败(如:ios在设置完hash后,调用wx.uploadImg会失败,需要重新设置wx.config)
          // 因为ios小程序的逻辑是,url只要发生变化,wx.config中的appId就找不到了
          // 所以需要重新进行wx.config配置
          // 这一步是获取之前设置wx.config的参数(需要从服务端拿,因为之前已经获取过了,这里从缓存直接取)
          const jsticket = window.native && window.native.adapter && window.native.adapter.jsticket || null;
          const ua = navigator.userAgent;
          // 非安卓系统要重新设置wx.config
          if (jsticket && !(ua.indexOf('Android') > -1 || ua.indexOf('Adr') > -1)) {
            window.wx.config({
              debug: false,
              appId: jsticket.appId,
              timestamp: jsticket.timestamp,
              nonceStr: jsticket.noncestr,
              signature: jsticket.signature,
              jsApiList: ['onMenuShareTimeline', 'onMenuShareAppMessage', 'onMenuShareQQ',
                'onMenuShareQZone', 'onMenuShareWeibo', 'scanQRCode', 'chooseImage', 'uploadImage', 'previewImage', 'getLocation', 'openLocation']
            })
          }
          // 触发缓存数组的回调
          WASDK.hashInfo.callbackArr.forEach(callback=>{
            callback();
          })
          // 执行返回操作(这一步是重点!!)
          // 因为webview设置完hash参数后,会使webview历史栈+1
          // 而实际并不需要这次多余的历史记录,所以需要执行返回操作把它去掉
          // 即便是返回操作,也仅仅是hash层面的变更,所以不会触发页面刷新
          // 用setTimeout表示在下一次事件循环进行返回操作。如果后面有对dom操作可以在当前次事件循环完成
          setTimeout(()=>{
            window.history.go(-1);
          }, 0);
        }
      }, false)
    }
  }

  /**
   * hash相关信息
   */
  static hashInfo = {
    // 是否已经初始化
    isInit: false,
    // hash回调数组
    callbackArr: []
  }

  /**
   * 页面再次展示时钩子方法
   * @param {Function} callback - 必填, callback回调方法, 回传参数为hash部分问号后面的参数解析对象
   */
  @execLog
  onShow(callback){
    if (typeof callback === 'function') {
      // 对回调方法进行onshow逻辑包装,并推入缓存数组
      WASDK.hashInfo.callbackArr.push(function(){
        // 检查是否是指定参数发生变化
        if(util.getHash(window.location.href, '__isonshow') === '1'){
          // 触发onShow回调
          callback();
        }
      })
    } else {
      util.console.error(`参数错误,调用onShow请传入正确callback回调`);
    }
  }

  /**
   * 业务处理完成并发送消息
   * @param {Object}           obj - 必填项,消息对象
   * @param {String}           obj.key - 必填项,消息名称
   * @param {String}           obj.content - 可选项,消息内容,默认空串,如果是内容对象,请转换成字符串
   * @param {String|Number}    condition - 可选项,默认仅进行postMessage
   *                           String - 可以传指定url的路径,当小程序webview打开指定的url或者onshow时,会触发该消息
   *                           也可传小程序path,这个为以后预留
   *                           Number - 返回到指定的测试,类似history.go(-1),如: -1,-2
   */
  @execLog
  serviceDone(obj, condition){
    if(obj && obj.key){
      // 消息体
      const message = {
        // 消息名称
        key: obj.key,
        // 消息体
        content: obj.content || '',
        // 触发条件
        trigger: {
          // 类型 'immediately'在下一次onshow中立刻触发, 'url',在找到指定h5链接时触发,'path'在打开指定小程序路径时触发
          type: 'immediately',
          // 条件内容,immediately是为空,url是为h5链接地址,path是为小程序路径
          content: ''
        }
      };
      // 解析触发条件
      condition = condition || 0;
      // 如果是路径
      if(typeof condition === 'string' && (condition.indexOf('http') > -1 || condition.indexOf('pages/') > -1)){
        // 设置消息触发条件
        message.trigger = {
          type: condition.indexOf('http') > -1 ? 'url' : 'path',
          content: condition
        }
      }
      // 发送消息
      wx.miniProgram.postMessage({
        data: {
          messageData: message
        }
      });
      // 如果不是url或者path触发,则对conditon是否需要返回进行判断
      if(message.trigger.type === 'immediately'){
        // 查看是否需要返回指定的层级,兼容传入'-1'字符串这种类型的场景
        try{
          condition = parseInt(condition, 10);
        }catch(e){}
        // 保证返回级数的正确性
        if(condition && typeof condition === 'number' && !isNaN(condition)){
          this.handler.navigateBack({delta: Math.abs(condition)});
        }
      }
    }else{
      util.console.error(`参数错误,调用serviceDone方法,传入的对象中不包含key值`);
    }
  }

  ...
}

window.native = new Native();
export default native;
复制代码

这个看着也挺多,总结下来是两点:

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 就是构建的一个版本号,每次递增,保证次次不同即可。

3.4 如何判断小程序当前页面所处的环境

这部分需要在H5页面种下一个sdk,比如名字就叫bridge.js,下面是我做了几年小程序总结出来的常用方法:

// bridge.js
let ua = window.navigator.userAgent.toLowerCase();
const globalObj = {
    testDataArr: [],
    doJSReadyFuncExecuted: false,
    errorInfo: '',
    miniappSDK: null,
    miniappType: '',
    actionQueue: [],
    MINIAPP_TYPE: {
        WECHATMINIAPP:  'WECHATMINIAPP',// miniprogram
        WECHATAPP:      'WECHATAPP',    // miniprogram + offiaccount
        OLDQUICKAPP:    'OLDQUICKAPP',  // old
        NEWQUICKAPP:    'NEWQUICKAPP',  // new
        ALIPAYAPP:      'ALIPAYAPP',
        BAIDUAPP:       'BAIDUAPP',
        TOUTIAOAPP:     'TOUTIAOAPP',
        QQAPP:          'QQAPP'         // No longer maintained
    },
    JSSDK_URL_OBJ: {
        WECHATMINIAPP: 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js',
        WECHATAPP: 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js',
        OLDQUICKAPP: 'https://xxxxxxxx/amsweb/quickApp/mixBridge.js',
        NEWQUICKAPP: 'https://quickapp/jssdk.webview.min.js',
        ALIPAYAPP: 'https://appx/web-view.min.js',
        BAIDUAPP: 'https://b.bdstatic.com/searchbox/icms/searchbox/js/swan-2.0.21.js',
        TOUTIAOAPP: 'https://s3.pstatp.com/toutiao/tmajssdk/jssdk-1.0.1.js',
        QQAPP: 'https://qqq.gtimg.cn/miniprogram/webview_jssdk/qqjssdk-1.0.0.js'
    },
    bversion: '1.0.0'
}

if(typeof window['__bfi'] == 'undefined') {
    window['__bfi'] = [];
};

window['__bfi'].push([
    '_tracklog', 
    '174537', 
    `ua=${ua}&pageId=\${page_id}`
]);

function isAndroid () {
    return ua.includes('android');
}

function isWechatMiniapp () {
    // @source https://developers.weixin.qq.com/community/develop/doc/00022e37c78b802f186750b5751000
    // in wechat && (in android || in ios)
    return isWechat() && (ua.includes('miniprogram') || window.__wxjs_environment === 'miniprogram');
}

function isWechat () {
    // in wechat-web-browser
    // https://blog.csdn.net/daipianpian/article/details/86543080
    // @source blog ( https://www.jianshu.com/p/6a10f833b099 )
    return /micromessenger/i.test(ua) || /windows phone/i.test(ua);
}

function isOldQuickapp () {
    return (/(hap|OPPO\/Hybrid)\/\d/i.test(ua)) && !isNewQuickapp();
}

function isNewQuickapp () {
    // @source 2020.04.10, Vivo( Li Chunjiao ) has confirmed that this method is feasible
    return ua.includes('mode-quickapp');
}

function isAlipay () {
    // @source 2020.06.15, Alipay has confirmed that this method is feasible
    let isAli = (/APXWebView/i.test(ua)) || (/MiniProgram/i.test(ua) && !ua.includes('micromessenger'));
    // @source 2020.11.17, https://www.yuque.com/books/share/6d822c34-9121-47d8-a805-4c57b0b2d2f0/hiv1tn
    let isUCKuake = ua.includes('aliapp') && (ua.includes('uc') || ua.includes('quark'));
    // @source 2021.03.26
    let isGaode = ua.includes('aliapp') && ua.includes('amapclient');
    return isAli || isUCKuake || isGaode;
}

function isBaidu () {
    // @source 2020.11.05, baidu's doc ( https://smartprogram.baidu.com/docs/develop/component/open_web-view/ )
    return /swan\//.test(ua) || /^webswan-/.test(window.name);
}

function isToutiao () {
    // @source 2020.11.05, toutiao's doc ( https://microapp.bytedance.com/docs/zh-CN/mini-app/develop/component/open-capacity/web-view/ )
    return ua.includes("toutiaomicroapp");
}

function isQQ () {
    // @source 2021.04.21, add ua.includes('miniprogram'), qq's doc ( https://q.qq.com/wiki/develop/miniprogram/component/open-ability/web-view.html )
    return ua.includes('qq') && ua.includes('miniprogram');
}

// return miniapp type of the environment
function isMiniProgram () {
    let appType = false;
    let typeNameObj = globalObj.MINIAPP_TYPE

    try {
        if (isWechatMiniapp()) {
            appType = typeNameObj.WECHATMINIAPP;
        } else if (isOldQuickapp()) {
            appType = typeNameObj.OLDQUICKAPP;
        } else if (isNewQuickapp()) {
            appType = typeNameObj.NEWQUICKAPP;
        } else if (isAlipay()) {
            appType = typeNameObj.ALIPAYAPP;
        } else if (isBaidu()) {
            appType = typeNameObj.BAIDUAPP;
        } else if (isToutiao()) {
            appType = typeNameObj.TOUTIAOAPP;
        } else if (isQQ()) {
            appType = typeNameObj.QQAPP;
        }

        console.log('判断所处环境,isMiniProgram 返回值: ', appType);
        window['__bfi'].push([
            '_tracklog', 
            '174537', 
            `api_name=isMiniProgram&miniappType=${appType}&pageId=\${page_id}`
        ]);

        return appType;
    } catch (e) {
        window['__bfi'].push([
            '_tracklog', 
            '174537', 
            `api_name=isMiniProgram&err_msg=${e.message}&err_stack=${e.stack}`
        ]);
        return false; // 'catch error'
    }
}

export {
    isAndroid,       // 判断H5页面是否处于安卓系统
    isWechatMiniapp, // 判断H5页面是否处于微信小程序环境
    isWechat,        // 判断H5页面是否处于微信环境
    isOldQuickapp,   // 判断H5页面是否处于【老版快应用】小程序环境
    isNewQuickapp,   // 判断H5页面是否处于【新版快应用】小程序环境
    isAlipay,        // 判断H5页面是否处于支付宝小程序环境
    isBaidu,         // 判断H5页面是否处于百度小程序环境
    isToutiao,       // 判断H5页面是否处于头条小程序环境
    isQQ,            // 判断H5页面是否处于QQ小程序环境
    isMiniProgram    // 返回H5页面所处环境的应用名
}
复制代码

使用时的注意事项

使用前,最好查阅相应小程序的文档,因为各个小程序对API的支持程度不同。js文件的引用不能放在里,bridge.js 里面对当前页面的head进行操作了。因为 bridge.js 引入JSSDK的方式是 为 head标签添加 script标签,若在 head标签中引入bridge.js,就会报错。

若打开h5,显示“页面访问受限”之类的提示信息,可尝试下方的操作:(这种情况,一般是打开测试环境的h5 url 时出现)勾选IDE中的“忽略webview域名合法性检查” 和 “忽略request域名合法性检查”。

【快应用相关】
目前Vivo,Oppo,华为三家厂商已支持新版快应用,VivoOPPO已上线,小米不支持。对于新版快应用,若H5页面需要调用新版快应用JS-SDK中提供的API,需要提前将该H5链接的域名配置到可信任的网址里(应写成正则表达式的形式进行配置)。

【头条相关】
头条小程序的redirectTo、navigateTo 等页面跳转的 api 只支持 url 为 / 开始的绝对路径

【支付宝相关】
目前的1.0.73版 bridge.js 判断是否处于支付宝小程序的方法,会将h5处于支付宝小程序、h5处于支付宝内置浏览器都判断为处于支付宝小程序内。因此,在调my.XXXX之前,需要先调判断环境工具函数 判断一下,确保确实是处于支付宝小程序内,而非支付宝内置浏览器内。

3.5 小程序获取最新版本号

在小程序中,我们利用 app 的 onShow 钩子函数来完成最新的 URL 获取,同时还要保证只有获取了版本号之后才能加载其他的页面,因此这里要用到同步接口调用。请参考下面代码:

//这里加入同步请求到服务器获取最新路径
onShow: function (options) {
    this.getFEVersion()
},
getFEVersion: function () {
    //下面是利用Promise进行同步调用的写法
    return new Promise(function (resolve, reject) {
      wx.request({
        //下面是本机调试的一个地址,上线时请改成自己服务端的地址
        url: 'http://192.168.0.168:8090/getFEVersion',
        data: {},
        method: 'POST',
        header: {
          'content-type': 'application/json',
        },
        success: function (res) {
          if (res.data.success) {
            const app = getApp();
            //res.data.version 是从服务端返回的最新fe的版本号,即上面的数字101
            app.globalData.feUrl = 'https://www.yourdomain.com/' + res.data.version + '/#/index'
          }
          resolve();
        },
        fail: function (error) {
          console.log(error);
          reject();
        }

      })
    });
  },
webview动态处理
/**
 * @file 根据入参的小程序类型,动态加载相应的 JavaScript文件
 * 指定<script>元素的src属性,指定事件处理程序(onload事件 onerror事件)
 */

const globalObj = {
    testDataArr: [],
    doJSReadyFuncExecuted: false,
    errorInfo: '',
    miniappSDK: null,
    miniappType: '',
    actionQueue: [],
    MINIAPP_TYPE: {
        WECHATMINIAPP:  'WECHATMINIAPP',// miniprogram
        WECHATAPP:      'WECHATAPP',    // miniprogram + offiaccount
        OLDQUICKAPP:    'OLDQUICKAPP',  // old
        NEWQUICKAPP:    'NEWQUICKAPP',  // new
        ALIPAYAPP:      'ALIPAYAPP',
        BAIDUAPP:       'BAIDUAPP',
        TOUTIAOAPP:     'TOUTIAOAPP',
        QQAPP:          'QQAPP'         // No longer maintained
    },
    JSSDK_URL_OBJ: {
        WECHATMINIAPP: 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js',
        WECHATAPP: 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js',
        OLDQUICKAPP:'https://xxxxxxxxx/amsweb/quickApp/mixBridge.js',
        NEWQUICKAPP: 'https://quickapp/jssdk.webview.min.js',
        ALIPAYAPP: 'https://appx/web-view.min.js',
        BAIDUAPP: 'https://b.bdstatic.com/searchbox/icms/searchbox/js/swan-2.0.21.js',
        TOUTIAOAPP: 'https://s3.pstatp.com/toutiao/tmajssdk/jssdk-1.0.1.js',
        QQAPP: 'https://qqq.gtimg.cn/miniprogram/webview_jssdk/qqjssdk-1.0.0.js'
    },
    bversion: '1.0.0'
}

let n = 0;
function loadListener (type) {
    // 先执行一次,再进入setTimeout
    // 多加几个埋点,记录不同类型的信息
    console.log(`====== 重试次数:${n} ======`);
    if(n === 0) {
        processAddRes(type);
    } else {
        setTimeout(function () {
            processAddRes(type);
        }, 200)
    }
}

function processAddRes(type) {
    let curMiniappType = globalObj.miniappType;
    let curLoadJsUrl = globalObj.JSSDK_URL_OBJ[curMiniappType];

    if(!addJSSDKToGlobalObj()){
        n++;

        loadListener();

        if(n % 10 === 0) {
            const msg = `重试达到【${n}】次`
            console.log(msg);
            console.log(globalObj.errorInfo || '======');
        }
        return;
    }

    let actionQueue = globalObj.actionQueue;
    if (actionQueue && actionQueue.length) {
        let aItem = null;
        while (aItem = actionQueue.shift()) {
            try {
                globalObj.miniappSDK[aItem.apiName].apply(globalObj.miniappSDK, aItem.args)
            } catch (e) {
                //
            }
        }
    }
}

// 将JSSDK提供的方法保存到global
function addJSSDKToGlobalObj () {
    let curMiniappType = globalObj.miniappType;

    try{
        let _miniappSDK = null;
        switch(curMiniappType) {
            case 'WECHATMINIAPP':
            case 'WECHATAPP':
            case 'OLDQUICKAPP':
                _miniappSDK = typeof wx !== 'undefined' && wx.miniProgram;
                break;
            case 'NEWQUICKAPP':
                _miniappSDK = qa;
                break;
            case 'ALIPAYAPP':
                _miniappSDK = my;
                break;
            case 'BAIDUAPP':
                _miniappSDK = typeof swan !== 'undefined' && swan.webView;
                break;
            case 'TOUTIAOAPP':
                _miniappSDK = typeof tt !== 'undefined' && tt.miniProgram;
                break;
            case 'QQAPP':
                _miniappSDK = typeof qq !== 'undefined' && qq.miniProgram;
                break;
        }

        if(_miniappSDK) {
            globalObj.miniappSDK = _miniappSDK
        }

        if (!globalObj.miniappSDK || !globalObj.miniappSDK.navigateTo) {
            console.log(globalObj)
            let g_errmsg = (!globalObj.miniappSDK ? 'miniappSDK_is_undefined' : 'API_is_undefined');
            let g_errstack = 'none'
            globalObj.errorInfo = 'g_errmsg=' + g_errmsg + '&g_errstack=' + g_errstack;

            return false;
        }
    } catch (e) {
        // 记录下是什么原因return的false: 在return false 的地方,将原因挂到全局变量上,loadListener触发埋点时,记录下来
        globalObj.errorInfo = 'g_errmsg=' + e.message + '_have_catch_error' + '&g_errstack=' + e.stack;

        return false;
    }
    globalObj.errorInfo = 'g_errmsg=outof_try-catch_return_true';

    return true;
}

function parseQuery(url) {
    let query = {};
    let idx = url.indexOf("?");
    let str = url.substr(idx + 1);
    if (str == "" || idx == -1) {
        return {};
    }
    let pairs = str.split('&');
    for (let i = 0; i < pairs.length; i++) {
        let pair = pairs[i].split('=');
        // 当根据 = 号分割后有多条数据时,从数组第1位起之后的要全部保留。
        // 比如 src=/issue/create?type=1752,要处理成为:src: '/issue/create?type=1752',而不是 src: '/issue/create?type'
        if (pair.length > 2) {
            pair[1] = pair.slice(1).join('=');
        }
        query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || '');
    }
    return query;
};

function loadScript() {
    // 埋点信息,增加加载的jssdk-url,后续可能可以从url中获取到微信的版本号
    // 设备品牌、设备型号、微信版本号、操作系统及版本、客户端平台、客户端基础库版本 Object wx.getSystemInfoSync()
    let curMiniappType = globalObj.miniappType;
    let curLoadJsUrl = globalObj.JSSDK_URL_OBJ[curMiniappType];

    let jSBridgeReady = function(type) {
        console.log('jSBridgeReady, event type: ', type);

        // 保证后续逻辑只会执行一次
        if (globalObj.doJSReadyFuncExecuted) {
            return;
        }
        globalObj.doJSReadyFuncExecuted = true;
        console.log('script is onload, doJSReadyFuncExecuted')

        loadListener(type);
    }


    if (curMiniappType === "WECHATMINIAPP" || curMiniappType === "WECHATAPP" || curMiniappType === "OLDQUICKAPP") {
        // 监听WeixinJSBridgeReady 和 onload 前,发个埋点,看下当前是否已经有wx 和 wx.miniProgram(因为目前nfes 只引入了微信jssdk)
        document.addEventListener('WeixinJSBridgeReady', function() {
            console.log('WeixinJSBridgeReady ======');
            jSBridgeReady('WeixinJSBridgeReady')
        }, false)
    }

    if (curMiniappType === "NEWQUICKAPP") {
        document.addEventListener('QaJSBridgeReady', function() {
            console.log('QaJSBridgeReady ======');
            jSBridgeReady('QaJSBridgeReady')
        }, false)
    }

    let script = document.createElement("script");
    script.src = curLoadJsUrl;
    script.async = false; // 注释掉,因为添加async的话,执行顺序无法保证

    let scriptArr = document.getElementsByTagName('script');
    console.log(scriptArr);

    for(let i = 0; i < scriptArr.length; i++) {
        let item = scriptArr[i];
        if(item.src.includes('/ares2/market/mixappBridge/') && item.src.includes('/default/bridge')) {
            // 取参数,动态设置async
            let queryObj = parseQuery(item.src); // 兜底的值为 {}
            console.log('queryObj: ', queryObj);
            if(typeof queryObj.bridgeAsync !== 'undefined') {
                script.async = queryObj.bridgeAsync === '1' ? true : false;
            }
        }
    }
    console.log('最终,script.async: ', script.async);

    script.onload = function(e) {
        console.log('script is onload ======')
        jSBridgeReady('onload')
    }

    script.onerror = function(e) {
        console.log('script is onerror')
    }

    window.onerror = function(message, source, lineNo, columnNo, error) {
        // to do track
    }
    document.getElementsByTagName('head')[0].appendChild(script)
}

export {
    loadScript
}
复制代码

工作中小程序webview业务细节总结

5.1 区分环境

微信提供了一个环境变量,加载h5以后第一个页面可以及时拿到,但后续的页面都需要在微信的sdk加载完成以后才能拿到,因此建议大家在wx.ready或者是weixinjsbridgeready事件里面去判断,区别就在于前者需要加载jweixin.js才有,但这里有坑,坑在于h5的开发者可能并不知道你这个检测过程需要时间,是一个异步的过程,他们可能页面一加载就需要调用一些api,这时候就可能会出错,因此你一定要提供一个api调用的队列和等待机制。具体做法见上面代码。

5.2 支付

第二个常见问题是支付,因为小程序webview里面不支持直接调起微信支付,所以基本上需要支付的时候,都需要来到小程序里面,支付完再回去。上面做好了以后,在h5这块调用就一句话就可以了。针对产品有大量内嵌H5页面的情况下,最好根据业务分两种支付页面,一是有的业务h5有自己完善的交易体系,下单动作在h5里面就可以完成,他们只需要小程序付款,因此我们有一个精简的支付页,进来直接就拉起微信支付,还有一种情况是业务需要小程序提供完整的下单支付流程,那么久可以直接进入我们小程序的收银台来,图上就是sdk里面的基本逻辑,我们通过payOnly这个参数来决定进到哪个页面。

我们再看下小程序里面精简支付怎么实现的,就是onload之后直接调用api拉起微信支付,支付成功以后根据h5传回来的参数,如果是个小程序页面,那直接跳转过去,否则就刷新上一个webview页面,然后返回回去。

5.3 左上角返回

那怎么解决这种流失呢,我们加了一个左上角返回的功能。首先进入的是一个空白的中转页,然后进入h5页面,这样左上角就会出现返回按钮了,当用户按左上角的返回按钮时候,页面会被重载到小程序首页去,这个看似简单又微小的动作,对业务其实有很大的影响,我们看两个数字,经过我们的数据统计发现,左上角返回按钮点击率高达70%以上,因为这种落地页一般是被用户分享出来的,以前纯h5的时候只能通过左上角返回,所以在小程序里用户也习惯如此,第二个数字,重载到首页以后,后续页面访问率有10%以上,这两个数字对业务提升其实蛮大的。其实现原理很简单,都是通过第二次触发onShow时进行处理。

Q: 可能出现的登录登出同步问题

A: 跳到个人页登录完成,此时是新开的webview同步两端登录态,点返回,到上一个webview,此时这个webview嵌套的首页,没有触发react-imvc onshow事件。这个页面是老的,退出登录也是一样,所以在首页会去跳h5的登录而不是小程序登录,导致登录态不同步。
解决思路:需要返回首页刷一下h5页面。

误区:直接在个人登录之后,relaunch到首页,会导致没有直接调用注销webview把token置换,无法退出
解决方案:判段从个人页返回的时候,设置webview的url加个参数,重新刷一下。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享