为什么Web App的返回逻辑如此复杂

目录
  1. 导读
  2. 可供尝试的api
  3. 重定向过后怎么返回
  4. H5是否可以打开新的网页窗口
  5. native提供jsBridge
  6. native维护H5的历史记录
  7. 如何在webview中判断页面是否加载完成
  8. 基于hash跳转产生的历史项如何记录
  9. 小结

导读

最近我在梳理公司web app新产品线的返回逻辑, 推演了N种方案, 竟然没有一种完全通用的. 这让我迷惑不已. 仔细体验了公司的各种H5页面, 发现返回逻辑还真是五花八门. 那么, 问题来了, 为什么Web App的返回逻辑如此难以设计?

可供尝试的api

相对于较复杂的返回场景, 可用的api少得可怜. webview的history对象中, 仅 back 和 go 方法可用. 它们遵循如下规律:

  • 调用history.back 如果存在历史的话, 只能回到前一个页面, 否则将静默失败.
  • 调用history.go(-n) 如果存在历史的话, 可以回到前n个页面, 否则将静默失败.

从这里看起来, 似乎想回到哪就回到哪. 自然不是, 不然就不会有这篇文章了.

不得不说的是, H5中有个堪称坑爹的设定, 就是history.length属性, 该属性无论何时, 都是当前webview历史栈的总长度. 也就是说, 无论你的网页是第一个打开的, 还是中间打开的, 还是最后一个打开的, 只要返回到你的网页, 你获取到的history.length都是相同的值. 无论如何你都不能直接拿到你的网页在webview历史栈中的位置(或者序号), 这将导致你不知道要往前跳几步(假设要跳过若干个历史), 因此你不能随心所欲的调用history.go方法.

通常情况下,若web app自带返回按钮, 如果其中一个网页A是通过重定向模拟返回到它之前的某个网页B, 用户在新的网页B点击返回按钮, 将返回到网页A, 此时再点击网页A的返回按钮, 那么又将进入到新的网页B2中. 如下:

①—>A —②重定向—> B —③返回—> A —④重定向—> B2 —⑤返回—> A

我们来看看webview历史栈发生了什么, 假设历史栈已经存储了n项历史:

  • ①栈顶压入A页面, 此时当前页面指针后移1位, 指向A页面, 且A页面位于栈顶, 此时历史栈长度为n+1;
  • ②栈顶压入B页面, 此时当前页面指针后移1位, 指向B页面, 且B页面位于栈顶, 此时历史栈长度为n+2;
  • ③当前页面指针前移1位, 指向A页面, 此时B页面位于栈顶, 历史栈长度依然为n+2;
  • ④当前页面指针后的栈被清空, 历史栈长度为n+1, 栈顶压入B2页面, 当前页面指针后移1位, 指向B2页面, 且B2页面位于栈顶, 此时历史栈长度为n+2;
  • ⑤当前页面指针前移1位, 指向A页面, 此时B2页面位于栈顶, 历史栈长度依然为n+2;

B和B2其实是同一个网页, 除了历史栈中的位置不同, 他们没有任何不同. 如此逻辑将使得我们将陷入返回的死循环中. 为避免这种体验上的缺陷, 请尽量不要在返回逻辑中重定向到某个之前的页面.

webview中通过location.href方式跳转链接, 可起到清理浏览历史项的作用, 如此时webview中共存在100个历史项, 我们一路返回至第90个页面(该页面的history.length依然是100), 然后在该页面通过location.href跳转至另一个页面, 那么新的页面将处于历史项的第91项, 原来的第91~100项历史将被清空. 于是新的页面中获取的history.length将准确地标示了该页面处于历史项的第几项.

重定向过后怎么返回

愿望是美好的, 现实是残酷的. 纯H5下想要在返回逻辑中不重定向, 先砍了这些需求再说:

  • 从网页A跳走, 中间经过n个其他域名的网页, 最终不希望用户按步返回, 希望能够直接返回网页A的场景.
  • 从详情页B跳走, 中间需要经过各种支付中间页, 然后进入web版收银台, 弹出支付宝或者微信支付弹框, 支付成功后进入到成功页, 从成功页返回时希望直接回到详情页B的场景.

从产品上看, 这些需求都是合理的. 那么如何从最后一个页面, 成功地返回到初始的A或B页面, 这里我想到了一个解决方案. 思路如下:

history.go方法是可用的. 只要get到了网页处于历史栈的位置, 就可以正常的返回n步. 虽然通常情况下从初始页面A跳出时, history.length并不可靠, 但是从A页面跳到(通过location.href跳转)的第一个页面X中, history.length却是可靠的, 此时该值准确地记录了页面X在历史栈中的位置.(不懂的可以去看上述第④步解析) 只要在页面X中执行如下语句, 便可记录页面A的位置.

//假定原页面A中跳转时执行如下语句
let linkA = "http://www.a.com/a?params=abc";
linkA = window.encodeURIComponent(linkA);
const targetLink = `http://www.x.com/x?from=${linkA}#test`;
location.href = targetLink;

//在页面X执行如下语句
const cursor = history.length - 1;
let params = location.search.match(/from=([^&]*)/),
    from;
if (params instanceof Array && params.length === 2) {
  // 获取原页面A的链接
  from = window.decodeURIComponent(params[1]);
  const a = document.createElement('a');
  a.href = from;
  const paramStr = `historyCursor=${history.length - 1}`;
  // 追加historyCursor=history.length参数
  const searchStr = a.search ? `${a.search}&${paramStr}` : `?${paramStr}`;
  from = a.origin + a.pathname + searchStr + a.hash;
}

页面X经过n次跳转, 其中可能经过了各种支付中间页, 最终又重定向回到页面X’(与页面x链接相同, 但是新的页面), 在页面X’上点击返回按钮时, 此时可以直接重定向回到原页面A’(与页面A链接相似, 仅仅多了参数historyCursor, 是新的页面).

待用户回到了页面A’后, 此时点击返回按钮时, 走的并不是通常的history.back , 而应该是回到页面A的前一个页面, 换句话说 , 此时用户将往回跳n个页面. 这里的主要判断逻辑如下:

function goBack() {
  let start, current, step;
  const params = location.search.match(/historyCursor=(\d+)/);
  if(params instanceof Array && params.length === 2) {
    // 如参数中带有historyCursor, 返回时将回跳n步
    start = +params[1];
    current = history.length;
    step = current - start + 1;
    location.go(-step);
  } else {
    // 默认将返回上一个页面
    history.back();
  }
}

以上, 由多个页面传递historyCursor的值, 基本将需求中的返回逻辑落地了. 美中不足的是, 页面A的返回逻辑依赖了页面X的代码(页面X中需要设置对的historyCursor值), 存在耦合. 这样开发页面A的同学将通知开发页面X的同学, 返回时你要给我加一个historyCursor的参数, 巴拉巴拉. 开发页面X的同学也很纠结, 因为他始终要确认是不是从页面A跳过来的, 如果是, 那他就要加一个historyCursor参数, 并且重定向到页面A. 同时Android系统自带的返回键也会让这套方案更加雪上加霜.

有鉴于此, 以上纯H5的解决方案便不太完美.

H5是否可以打开新的网页窗口

对于非嵌入app的H5应用, 那么使用场景就是各家的浏览器, 应用中对于有可能打乱历史记录的网页, 直接新开窗口就行.

对于嵌入app内的H5应用, 通常来说, H5本身不具备新开webview的能力. 这里需要native辅助. 接下来我们将主要关注嵌入app内的H5的应用.

native提供jsBridge

app内嵌的H5应用, 可借助native的jsBridge新开webview, 从而避免历史记录混乱. 为此, native客户端(包括Android和IOS以及其他)将提供接口以便js打开或关闭webview. 值得考虑的是, 这里面可能带来一个负面影响, js有可能多次申请新开webview, 从而大量消耗内存和电量. 因此, native有必要对webview的个数予以限制.

native维护H5的历史记录

既然开多个webview开销会增大, 基于此, 我突发奇想, 有没有可能由native客户端来维护单个webview的历史记录, 从而所有的页面跳转将由native接管?

我认为这是有可能的. 首先native可以保留每次加载的页面链接, 同时, 页面跳转时可提前设置下一个页面的返回逻辑. 既然历史记录和返回逻辑都在native中注册, 剩下的问题就是, js怎么通知native返回了? 这个也很简单, native不止可以loadUrl, 还可以load页面上的方法. 又页面上用于返回的两个js方法: history.gohistory.back 都是可以重写的. 因此, native可在页面DOMContentLoaded事件回调中重写go和back方法, 改为调用jsBridge接口(此前, 为了解决第三方OAuth2.0登录后返回到空白页的问题, 我写了部分native逻辑, 用于重写js原生go和back方法已在生产环境下使用).

思路如下:

  1. 记录历史栈: native存储webview中加载的每一个页面, 形成一个历史记录栈. 并且标记当前页面处于该历史记录栈的位置.

  2. 重写返回方法: webview中每个页面加载完成后, 重写history.gohistory.back方法, 改为调用jsBridge接口, 方便native感知网页的后退. (下面将详细说明重写的时机)

  3. 设置下一个页面的返回逻辑: 页面跳转之前, 可调用jsBridge强制设置下一个页面的返回url(如从a跳转至b页面, 设置后, 无论b处于历史记录栈的哪一项, 从b返回都将回到a页面)

  4. 回退时检查当前页面的返回逻辑: 一旦H5中调用history.gohistory.back方法返回之前的页面, native自动检查该页面之前是否设置过返回url, 如有则从历史记录栈中捞出该url的位置, 继续调用js原生的history.go方法进行跳转, 同时忽略本次历史; 如无则直接通过原方法跳转页面, 同时忽略本次历史.

  5. 重写Android自带的物理返回键.

    //改写物理返回键的逻辑
    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
      if(keyCode==KeyEvent.KEYCODE_BACK){
        //参照第4步策略实现
      }
    }
    
  6. 激进策略—拦截页面主动发起的重定向(选用): 每次加载url前, 都将检查该url是否在当前页面之前的历史记录栈中出现过, 如有则直接调用js原生的history.go方法, 回退到该url原来的页面.

注: 虽然返回时shouldOverrideUrlLoading事件不会触发, 但onPageStarted和onPageFinished会依次触发一次. 因此上述第4步返回时需要忽略本次历史.

那么如何记录webview历史栈, 并且重写js方法呢?

嫌我啰嗦, 你可能会说 “Talk is cheap, show me the code.” 那么, 请看如下Android代码:

public class History {
  public url;
  public backItem;
  public History(String url){
    this.url = url;
  }
  public History(String url, History backItem){
    this.url = url;
    this.backItem = backItem;
  }
}
/*-----------华丽丽的class分界线------------*/

ArrayList<History> historyList = new ArrayList<History>;

/*-----------华丽丽的class分界线------------*/
public class WebViewManager {
  //此处略去webView元素的获取
  webView.setWebViewClient(new WebViewClient() {
    @Override
    public void onPageStarted(WebView view, String url, Bitmap favicon) {
      // 存储当前页面URL
      History history = new History(url);
      historyList.append(history);
      // 重写js返回方法
      String fnString = "(function(){/*在这里重写history.go和history.back方法*/})()";
      webView.loadUrl("javascript:" + fnString);
    }
  });
}

上面只是Simple的体验, 实际请求中, 一定会有url重定向场景. 接下来我们将着重讨论这种场景.

如何在webview中判断页面是否加载完成

首先, 加载页面有两种方式:

  1. webview主动loadUrl.
  2. 页面上触发的url加载行为.

我们先来模拟一个两次重定向的场景, 通常情况下, 直接访问 http://www.baidu.com 将发生一次重定向. 在此之前用一个短链接重定向到 http://www.baidu.com 这样便多了一次重定向. 下面将基于这个场景进行两次测试.

那么第一种方式, 将依次触发webview的以下事件回调:

webview主动loadUrl

整理如下:

  1. onPageStarted
  2. onPageStarted
  3. shouldOverrideUrlLoading
  4. onPageFinished
  5. onPageStarted
  6. onPageStarted
  7. shouldOverrideUrlLoading
  8. onPageFinished
  9. onPageStarted
  10. onPageFinished

第二种方式, 将依次触发webview的以下事件回调:

页面上触发的url加载行为

整理如下:

  1. shouldOverrideUrlLoading
  2. onPageStarted
  3. onPageStarted
  4. shouldOverrideUrlLoading
  5. onPageFinished
  6. onPageStarted
  7. onPageStarted
  8. shouldOverrideUrlLoading
  9. onPageFinished
  10. onPageStarted
  11. onPageFinished

可见, 除了最后一次onPageFinished事件, 其他的onPageFinished事件都紧跟shouldOverrideUrlLoading事件之后触发.

基于上述现象, 可以设置全局状态位(flag), onPageStarted触发时设置为true, shouldOverrideUrlLoading触发时设置为false, onPageFinished触发时, 判断flag是否为true, 如果为true则意味着页面加载完成, 此时便可放心的记录页面url以及重写js原生返回方法.

基于hash跳转产生的历史项如何记录

上述方法真的可以记录webview所有的历史项吗?

其实还不能. 实际上, webview的网页上进行hash跳转时, onPageStarted 和 shouldOverrideUrlLoading 都不会触发. 所幸的是 onPageFinished 能够感知到hash值的变化. 我们可以在该方法内继续维护历史记录栈.

小结

至此, 我想, 基于native的这套返回方案应该是可行. 但有native的同学告知: 有些页面native无法记录页面url? 这是为什么呢? 至少到目前为止, 我还没有发现这样的场景. 欢迎阅读本文的你留下个脚印, 一起讨论和完善web app返回方案.


本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者: louis

本文链接: http://louiszhai.github.io/2017/02/20/back/

参考文章

Fork me on GitHub