html5 history微信浏览器返回不刷新

History API与浏览器历史堆栈管理:

移动端开发在某些场景中有着特殊需求,如为了提高用户体验和加快响应速度,常常在部分工程采用SPA架构。传统的单页应用基于url的hash值进行路由,这种实现不存在兼容性问题,但是缺点也有–针对不支持onhashchange属性的IE6-7需要设置定时器不断检查hash值改变,性能上并不是很友好。

而如今,在移动端开发中HTML5规范给我们提供了一个History接口,使用该接口可以自由操纵历史记录。本文并不详细介绍History接口,而是探究History接口如何影响浏览器历史堆栈,并且利用这个规律应用到具体的实际业务中,提出两种历史记录保存策略,使路由逻辑更清晰,让SPA更容易。

History API回顾

HTML5 History API包括2个方法:history.pushState()history.replaceState(),和1个事件:window.onpopstate

pushState

history.pushState(stateObject, title, url),包括三个参数。

第一个参数用于存储该url对应的状态对象,该对象可在onpopstate事件中获取,也可在history对象中获取。

第二个参数是标题,目前浏览器并未实现。

第三个参数则是设定的url。一般设置为相对路径,如果设置为绝对路径时需要保证同源。

pushState函数向浏览器的历史堆栈压入一个url为设定值的记录,并改变历史堆栈的当前指针至栈顶。

在这里笔者使用历史堆栈和当前指针,用以说明浏览器对历史记录的管理策略。文档中并没有使用这样的词汇,笔者为了更形象的介绍接口对浏览器历史记录的影响,使用这样的描述,如有不当之处请及时指出(不过目前以这套模型为基础的逻辑实现中并未出现悖论)。

replaceState

该接口与pushState参数相同,含义也相同。唯一的区别在于replaceState是替换浏览器历史堆栈的当前历史记录为设定的url。需要注意的是,replaceState不会改动浏览器历史堆栈的当前指针。

onpopstate

该事件是window的属性。该事件会在调用浏览器的前进、后退以及执行history.forward、history.back、和history.go触发,因为这些操作有一个共性,即修改了历史堆栈的当前指针。在不改变document的前提下,一旦当前指针改变则会触发onpopstate事件。

History API与业务实践

最常见的单页应用场景:列表页、商品详情页以及其内部的其他链接入口如图片页、评论页及其推荐其他商品详情页。以上提到的已经涉及到了4个单独业务逻辑页面(推荐的商品可复用商品详情页逻辑),分别是:列表、详情、图片详情和评论。将这4个页面合并到一个页面中,这就是最简单的SPA。为了用户的良好体验,必须设计合理的交互逻辑,最直观的就是浏览器(或手机app、微信公众号)的后退和前进必须合乎业务逻辑特点。因此,这就涉及到了History API的使用,也牵扯到浏览器的历史记录管理。

这里写图片描述

上图为具体的逻辑示意图。在列表页,点击其中一个商品,这里是商品1,进入详情页。详情页包括了该商品的轮播图、商品的图片详情入口、评论入口和推荐的其他商品入口。接下来进行如下操作:进入图片详情页,后退至详情页再进入评论页;后退至商品1详情页再由推荐商品入口进入商品9详情页,同样在商品9详情页进入图片详情页和评论页,再后退至商品9详情页;由推荐商品入口进入商品34详情页,再进行类似操作。最后保证在商品34图片详情页或评论页可以顺利后退至最初的商品列表页。

上文中加粗的“后退”,意味着使用浏览器后退按钮,或者使用手机自带的返回,再或者使用页面上提供的后退按钮。

这样一个很细小的需求,但是一旦真正放手去做却不是那么容易。仅仅根据History API的2个函数和1个事件去盲目的尝试实现,这属于盲人摸象,鲁棒性不高。不清楚浏览器的历史记录管理策略,不了解当前页面的历史记录数量,此种情况若要实现上述场景就有些麻烦。所以在具体动手写业务代码之前,需要搞懂History的pushState和replaceState具体如何影响历史记录栈。

探究浏览器历史记录策略与History API的关系

由于浏览器并未针对每个页面的历史记录提供具体访问的接口,因此所有的测试都是黑盒。但是在移动端的中,大都是webkit内核,其webcore的具体实现也都相近,因此该节得出的结论完全可以在移动端使用。

尽管无法访问当前页的历史记录栈,但是浏览器却提供了history.length属性,它标明了当前历史记录栈的个数。该值会帮助我们更好地分析History API对历史记录栈的影响。 
这里写图片描述

上图为测试实例。其中白色箭头意味着点击该链接并执行pushState操作(即操作1),黑色箭头则执行浏览器后退,红色的圆点为历史记录栈中的当前指针,而每个项则为历史记录栈,历史记录的个数则为其子项的数量。

初始在第一个搜索列表页,执行操作1后历史堆栈数量增加,当前指针上移一位至26788.html; 
同理在执行3次操作1,历史堆栈递增3个,当前指针仍在栈顶,即78099.html; 
此后进行浏览器后退,历史堆栈数量不变,当前指针下移一位至8819.html; 
在此处再执行操作1,栈顶元素改变,当前指针移至栈顶,历史堆栈数量不变; 
继续执行操作1,栈顶元素改变,指针移至栈顶,历史堆栈数量加一; 
执行浏览器后退,栈顶元素不变,指针下移一位至8128.html,历史堆栈数量不变; 
执行浏览器后退,栈顶元素不变,指针下移一位至8819.html,历史堆栈数量不变; 
执行浏览器后退,栈顶元素不变,指针下移一位至8128.html,历史堆栈数量不变; 
执行浏览器后退,栈顶元素不变,指针下移一位至26788.html,历史堆栈数量不变; 
执行操作1,栈顶元素变为9721.html,指针上移至栈顶,历史堆栈数量变为3; 
执行操作1,栈顶元素变为8387.html,指针上移至栈顶,历史堆栈数量变为4; 
执行浏览器后退,栈顶元素不变,指针下移一位至9721.html,历史堆栈数量不变; 
执行浏览器后退,栈顶元素不变,指针下移一位至26788.html,历史堆栈数量不变; 
执行浏览器后退,栈顶元素不变,指针下移一位至search.html,历史堆栈数量不变; 
执行操作1,栈顶元素变为xxx.html,指针上移至栈顶,历史堆栈数量变为2; 

至此,实验结束。虽然这里仅仅列出了这一个测试用例,但是其实笔者做了更多更复杂的测试,并且平台涉及了pc和移动端的浏览器、微信和原生webview,结果都一样。这一系列测试说明了很多问题,总结之一句话则是:

浏览器针对每个页面维护一个History栈。执行pushState函数可压入设定的url至栈顶,同时修改当前指针;

当执行back操作时,history栈大小并不会改变(history.length不变),仅仅移动当前指针的位置;

若当前指针在history栈的中间位置(非栈顶),此时执行pushState会改变history栈的大小。

总结pushState的规律,可发现当前指针在history栈顶部时执行pushState,会增加history栈大小;

若current指针不在栈顶则会在当前指针所在位置添加项。执行back操作并不修改history栈大小,因此可以通过back和forward在当前大小的history栈中自由移动。

掌握这个规律,就知道如何维护历史记录,就知道在什么状态下需要pushState。回到最初的需求,产品经理规定从商品34的评论页,按后退按钮可以到达最初的列表页,但是他并没有详细规定如何后退。在这里就会有2中实现方式:

每一次后退,会回到上次的访问地方。如,在商品34的评论页,会后退至商品34的详情页,再后退则会回到商品9的详情页,直至回到列表页。

总共维护三层历史记录,第一层(栈底)为列表页,第二层为详情页,第三层(栈顶)为评论页或图片详情页。在该种实现下,由商品34的评论页第一次后退至商品34的详情页,第二次后退至列表页。

针对第一种,其实实现最为简单,因为这完全是由浏览器默认控制历史记录堆栈,而我们只需在合适的时机调用pushState将url插入到堆栈,然后在onpopstate处理函数中监听对应的时间即可:

window.addEventListener('popstate', function (e) {

    console.log('popstate')
    // 后退(前进)至商品详情页,异步加载数据并渲染
    if(e.state && e.state.indexOf('/shop/sku/') !== -1){
      ajaxDetail(e.state,true);   
    }else
    // 后退(前进)至评论页,异步加载数据渲染
    if(e.state && e.state.indexOf('/shop/comment/commentList.html') !== -1){
      ajaxComment(e.state,true);
    }else 
    // 后退(前进)至图片详情页,异步加载数据渲染
    if(e.state && e.state.indexOf('/shop/item/pictext/') !== -1){
      ajaxPic(e.state,true);
    }else
    // 后退(前进)至列表页,隐藏浮层
    if(e.state && e.state.indexOf('/search/') !== -1){
      // 隐藏spa的浮层
      $('.spa-container').css('zIndex','-1');
    }

  });
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

针对第二种实现,则是本文的重点。毕竟,由浏览器默认维护的历史堆栈在某些业务场景中并不匹配,因此需要开发者自己维护一个历史记录栈。在本次实现中,由于总共涉及4张页面的显示,因此我们设定了3层历史堆栈,这很好理解。

为了构建这样的历史记录栈,在主页面(即列表页)中需要额外添加两条历史记录。这是由于默认打开列表页时,当前页面的url已加入历史记录栈中,

function push(state){
    history.pushState(state, null, location.pathname + location.search);
  }
  // 'abc'用于标示初始列表页
  history.replaceState('abc',null,location.pathname + location.search)

  // 压入两条历史记录
  push();
  push();
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

这样,打开列表页后就会创建3个历史记录,并且这3个历史记录的url都为列表页的url,这与后面的操作并无影响。

在列表页中打开详情页,需要做额外的处理。由于按照我们设计的历史记录栈,第二层应该为详情页,而此时在初始化后,历史记录栈的当前指针已指向栈顶元素,因此需要将当前指针下移一位。这里就需要history.back来完成。

$('.item-list').on('click','a',handler);

// 异步加载详情数据
var handler = function(e,isScrollXClick){
    var a = this;
    ajaxDetail($(a).attr('href'),isScrollXClick);
    return false;
};

var isScrollXClick;
  /**
   * @params: url 请求路径 isScrollXClick: 是否点击推荐商品
   *
   */ 
  var ajaxDetail = function(url,isScrollXClick){

     $.ajax({
      url: '/api' + url,
      success: function(data){
        ...
        ...
        if(!isScrollXClick){
          console.log('I am back!')

          // 在代码中进行back or forward并不会立即出发popstate事件,以v8引擎为例,在执行back之后
          // 的大概18us之后会触发事件,而此时如果立即通过replaceState修改url则会造成失败,修改的是
          // history stack栈顶的url.

          // 这里通过异步执行replaceState兼容
          history.back();       

        }

        // 异步触发
        setTimeout(function(){
          history.replaceState(url, null, url);
        })

        // 针对推荐栏的商品,循环绑定事件,此处用事件代理优化
        $('#J_PDSlider').on('click','a',function(e){
          isScrollXClick = 1;
          handler.call(this,e,isScrollXClick);
          return false;
        });
      },
      error: function(xhr, type){
        alert('Ajax error!')
      }
     })
  };
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50

在此处实现,通过isScrollXClick变量判断是否点击的是推荐商品,如果不是则需要执行back操作,下移指针。此时指针是指在第二层,但是浏览器和第二层历史记录的url仍为初始化设定的url,因此需要修改,在这里异步修改当前url。

之所以异步执行replaceState,是由于webkit触发popState事件决定的。在代码中执行history.back 或者history.forward,并不会立即返回,也不会立即触发popState事件。由于没有阅读webkit的源码,因此无从推测执行back或者forward后具体需要额外做什么操作,它们之间有着10us级别的间隔,因此此处必须使用setTimeout实现异步改变url。

在具体开发过程中,这个问题困扰着笔者好几天,终于在一次调试过程中发现浏览器url的变动,才联想到可能是由事件触发的时间差导致。

对于图片详情和评论的逻辑处理,则和上文类似,无需多言。

最后一次后退需要回到列表页,而在初始化阶段我们给列表页设置了state为“abc”,特殊的标示该路由,因此在popState事件处理中,我们就可以根据该项回到初始页:

window.addEventListener('popstate', function (e) {

    if(e.state && e.state.indexOf('/shop/sku/') !== -1){
      ajaxDetail(e.state,true);   
    }else if(e.state && e.state.indexOf('abc') !== -1){
      // 隐藏spa的浮层
      $('.spa-container').css('zIndex','-1');


      push();
      push();
    }


  });
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

如果回到初始页,隐藏浮层,同时在执行2次push操作。根据上节发现的规律,在初始页执行2次push操作,会在当前指针位置重新添加2个历史记录,当前指针指向栈顶元素,历史记录栈的数量不变,仍为3。这样就完成了简单的由开发者自定义维护历史堆栈的spa系统。


history对象

概述

浏览器窗口有一个history对象,用来保存浏览历史。

如果当前窗口先后访问了三个网址,那么history对象就包括三项,history.length属性等于3。

history.length // 3
  • 1

history对象提供了一系列方法,允许在浏览历史之间移动。

back():移动到上一个访问页面,等同于浏览器的后退键。
forward():移动到下一个访问页面,等同于浏览器的前进键。
go():接受一个整数作为参数,移动到该整数指定的页面,比如go(1)相当于forward(),go(-1)相当于back()。
history.back();
history.forward();
history.go(-2);
  • 1
  • 2
  • 3

如果移动的位置超出了访问历史的边界,以上三个方法并不报错,而是默默的失败。

history.go(0)相当于刷新当前页面。

history.go(0);
  • 1

常见的“返回上一页”链接,代码如下

document.getElementById('backLink').onclick = function () {
  window.history.back();
}
  • 1
  • 2
  • 3

注意,返回上一页时,页面通常是从浏览器缓存之中加载,而不是重新要求服务器发送新的网页。

history.pushState()

HTML5为history对象添加了两个新方法,history.pushState()history.replaceState(),用来在浏览历史中添加和修改记录。

if (!!(window.history && history.pushState)){
  // 支持History API
} else {
  // 不支持
}
  • 1
  • 2
  • 3
  • 4
  • 5

上面代码可以用来检查,当前浏览器是否支持History API。如果不支持的话,可以考虑使用Polyfill库History.js。 
history.pushState方法接受三个参数,依次为:

state:一个与指定网址相关的状态对象,popstate事件触发时,该对象会传入回调函数。如果不需要这个对象,此处可以填null。
title:新页面的标题,但是所有浏览器目前都忽略这个值,因此这里可以填null。
url:新的网址,必须与当前页面处在同一个域。浏览器的地址栏将显示这个网址。

假定当前网址是example.com/1.html,我们使用pushState方法在浏览记录(history对象)中添加一个新记录。

var stateObj = { foo: 'bar' };
history.pushState(stateObj, 'page 2', '2.html');
  • 1
  • 2

添加上面这个新记录后,浏览器地址栏立刻显示example.com/2.html,但并不会跳转到2.html,甚至也不会检查2.html是否存在,它只是成为浏览历史中的最新记录。假定这时你访问了google.com,然后点击了倒退按钮,页面的url将显示2.html,但是内容还是原来的1.html。你再点击一次倒退按钮,url将显示1.html,内容不变。

总之,pushState方法不会触发页面刷新,只是导致history对象发生变化,地址栏会有反应。

如果pushState的url参数,设置了一个新的锚点值(即hash),并不会触发hashchange事件。如果设置了一个跨域网址,则会报错。

// 报错
history.pushState(null, null, 'https://twitter.com/hello');
  • 1
  • 2

上面代码中,pushState想要插入一个跨域的网址,导致报错。这样设计的目的是,防止恶意代码让用户以为他们是在另一个网站上。

history.replaceState()

history.replaceState方法的参数与pushState方法一模一样,区别是它修改浏览历史中当前纪录。

假定当前网页是example.com/example.html。

history.pushState({page: 1}, 'title 1', '?page=1');
history.pushState({page: 2}, 'title 2', '?page=2');
history.replaceState({page: 3}, 'title 3', '?page=3');

history.back()
// url显示为http://example.com/example.html?page=1

history.back()
// url显示为http://example.com/example.html

history.go(2)
// url显示为http://example.com/example.html?page=3
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

history.state属性

history.state属性返回当前页面的state对象。

history.pushState({page: 1}, 'title 1', '?page=1');

history.state
// { page: 1 }

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

popstate事件

每当同一个文档的浏览历史(即history对象)出现变化时,就会触发popstate事件。

需要注意的是,仅仅调用pushState方法或replaceState方法 ,并不会触发该事件,只有用户点击浏览器倒退按钮和前进按钮,或者使用JavaScript调用back、forward、go方法时才会触发。另外,该事件只针对同一个文档,如果浏览历史的切换,导致加载不同的文档,该事件也不会触发。

使用的时候,可以为popstate事件指定回调函数。这个回调函数的参数是一个event事件对象,它的state属性指向pushState和replaceState方法为当前URL所提供的状态对象(即这两个方法的第一个参数)。

window.onpopstate = function (event) {
  console.log('location: ' + document.location);
  console.log('state: ' + JSON.stringify(event.state));
};

// 或者

window.addEventListener('popstate', function(event) {
  console.log('location: ' + document.location);
  console.log('state: ' + JSON.stringify(event.state));
});
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

上面代码中的event.state,就是通过pushState和replaceState方法,为当前URL绑定的state对象。

这个state对象也可以直接通过history对象读取。

var currentState = history.state;
  • 1

注意,页面第一次加载的时候,在load事件发生后,Chrome和Safari浏览器(Webkit核心)会触发popstate事件,而Firefox和IE浏览器不会。

URLSearchParams API

URLSearchParams API用于处理URL之中的查询字符串,即问号之后的部分。没有部署这个API的浏览器,可以用url-search-params这个垫片库。

var paramsString = 'q=URLUtils.searchParams&topic=api';
var searchParams = new URLSearchParams(paramsString);
  • 1
  • 2

URLSearchParams有以下方法,用来操作某个参数。


    has():返回一个布尔值,表示是否具有某个参数
    get():返回指定参数的第一个值
    getAll():返回一个数组,成员是指定参数的所有值
    set():设置指定参数
    delete():删除指定参数
    append():在查询字符串之中,追加一个键值对
    toString():返回整个查询字符串
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
var paramsString = 'q=URLUtils.searchParams&topic=api';
var searchParams = new URLSearchParams(paramsString);

searchParams.has('topic') // true
searchParams.get('topic') // "api"
searchParams.getAll('topic') // ["api"]

searchParams.get('foo') // null,注意Firefox返回空字符串
searchParams.set('foo', 2);
searchParams.get('foo') // 2

searchParams.append('topic', 'webdev');
searchParams.toString() // "q=URLUtils.searchParams&topic=api&foo=2&topic=webdev"

searchParams.append('foo', 3);
searchParams.getAll('foo') // [2, 3]

searchParams.delete('topic');
searchParams.toString() // "q=URLUtils.searchParams&foo=2&foo=3"
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

URLSearchParams还有三个方法,用来遍历所有参数。

keys():遍历所有参数名
values():遍历所有参数值
entries():遍历所有参数的键值对

上面三个方法返回的都是Iterator对象。

var searchParams = new URLSearchParams('key1=value1&key2=value2');

for (var key of searchParams.keys()) {
  console.log(key);
}
// key1
// key2

for (var value of searchParams.values()) {
  console.log(value);
}
// value1
// value2

for (var pair of searchParams.entries()) {
  console.log(pair[0]+ ', '+ pair[1]);
}
// key1, value1
// key2, value2

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

在Chrome浏览器之中,URLSearchParams实例本身就是Iterator对象,与entries方法返回值相同。所以,可以写成下面的样子。

for (var p of searchParams) {
  console.log(p);
}
  • 1
  • 2
  • 3

下面是一个替换当前URL的例子。

// URL: https://example.com?version=1.0
var params = new URLSearchParams(location.search.slice(1));
params.set('version', 2.0);

window.history.replaceState({}, '', `${location.pathname}?${params}`);
// URL: https://example.com?version=2.0
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

URLSearchParams实例可以当作POST数据发送,所有数据都会URL编码。

let params = new URLSearchParams();
params.append('api_key', '1234567890');

fetch('https://example.com/api', {
  method: 'POST',
  body: params
}).then(...)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

DOM的a元素节点的searchParams属性,就是一个URLSearchParams实例。

var a = document.createElement('a');
a.href = 'https://example.com?filter=api';
a.searchParams.get('filter') // "api"
  • 1
  • 2
  • 3

URLSearchParams还可以与URL接口结合使用。

var url = new URL(location);
var foo = url.searchParams.get('foo') || 'somedefault';
  • 1
  • 2


猜你喜欢

转载自blog.csdn.net/mazegong/article/details/79992797
今日推荐