5.History对象的原理和实现—路由设计

History对象的原理和实现—路由设计

History处理是单页面一大难点,而且容易被开发者忽略,如果不看重这一块,做出来的项目很容易让用户觉得混乱,本人经常看到有些比较优秀的作品,在这个方面做的很一般。例如有以下细节处理:

用户进入首页,点击某个操作详情页面,在详情页面中点击进入更深的操作页面,做完后返回到首页。 这时候点击手机的后退键,又回到了上个操作界面, 原因如下:

  1. 按照正常逻辑,从首页直接到操作页中间直接跳过详情页,对于操作逻辑是有问题的;
  2. 如果操作页面有些数据是从详情页带过来的,直接从首页返回到操作页面会带来隐患的bug;
  3. 如果返回到操作页面数据都没有错,这个系统将会存留着每个页面的数据信息,当页面越多,内存消耗越大,那就不适合做大型的项目了。

正确的流程逻辑是从操作页面返回到首页,将会从history栈中释放详情页和操作页的内存,如果再点击后退,将会退出App。

history管理看似与渲染无关可以忽略,其实暗藏着很多的玄机,如果使用不当,做出的webapp体验还不如以前的多页面。因为多页面每切换一个页面,浏览器会帮忙处理之前页面的内存,单页面则完全需要开发者来解除引用关系来间接销毁内存。内存消耗过多会大量的消耗手机电量,或者造成浏览器卡死甚至闪退。

在浏览器的api中,history对应的路由控制只有几个非常简单的api,这里做一个简要的介绍。尽量使用原生的api是很重要的,虽然js上可以通过模拟解决很多问题(主要说的是切换页面效果),然而对于底层支持,每个浏览器支持是具备一致性的,这主要反馈在硬件的交互上。比如我们可以点击web上的按钮退到上一页(模拟的切换页面)和点击手机的后退物理键退到上一页的意义是不一样的,我们要做到一致,不然开发者对页面的切换操控力会变得紊乱。

因此保持页面的和历史状态页面一致是非常重要的,下面是原生的history api简要介绍

  • 属性

    1. scrollRestoration: 允许Web应用程序在历史导航上显式地设置默认滚动恢复行为;
    2. state: 返回一个表示历史堆栈顶部的状态的值,用于获取当前页面数据。
  • 方法

    1. back(): 前往上一页, 用户可点击浏览器左上角的返回按钮模拟此方法, 手机上物理键点后退;

    2. forward(): 前往下一页,用户可点击浏览器左上角的前进按钮模拟此方法, 手机物理键不支持;

    3. go(): 通过当前页面的相对位置从浏览器历史记录( 会话记录 )加载页面;

    4. pushState(): 按指定的名称和URL(如果提供该参数)将数据push进会话历史栈;

    5. replaceState(): 按指定的数据,名称和URL(如果提供该参数),更新历史栈上最新的入口。

      用图描述pushState和replaceState的细节
      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sFkB4sdr-1602654292699)(./history.png)]

  • 事件:popstate

刚开始进入页面,用户未做任何操作,js进行的历史状态更改都是不会生效的,当操作后,之前的更改才会对应到历史状态中

需求

把相关的路由操作放在App对象上,它具有监听历史状态和更改的功能,即使应对常见非js调用的情况,比如back()和forward()的外部操作也能做出正确页面管理,对于go()一般都是用js调用,因此可以略过。还需要实现一些常见的场景需求,虽然无法通过api简单的实现,总结如下:

  1. 保证显示页面和历史状态中始终保持一致,前进渲染后一个页面,后退显示前一个页面;
  2. 能很好的模拟原生api,并且保证页面和历史状态页面保持一致;
  3. 可以锁定页面,让用户无法切换页面(常见于页面锁定,需要密码);
  4. 可以切换到任何一个页面,且让它无法进行前进操作(历史状态的最后一页),而且前面的历史记录,每个记录的状态都可以自定义;
  5. 可以切换到任何一个页面,它的前进和后退的历史记录,记录状态都可以自定义。

实现思路

由于暂时没有任何原生api告诉开发者,用户切换的页面和之前那个页面有什么关系。因此我们手动创建一个本地模拟history对象,它拥有一个数组,对应着原生的历史状态,这样子我们就可以通过订阅popstate事件,来确定用户到底是点了前进还是后退。对于go的监听,牵涉到页面较多且不确定性,所幸的是支持go操作的浏览器按键几乎没有,用js的时候是可以确定的知道到底要跳哪个页面。这里假设我们已经实现了对于的History对象globalHistory。我们的popstate事件应该是这么写的(注:这里只有实现思路,内部细节实现还要考虑很多,比这个情景要更复杂)

window.addEventListener("popstate", function (ev) {
   // 获取当前页面的前后页面的url,如果是前面页面匹配,渲染前面,否则是后面
   // 前提在于避免一个页面的前后页面都是同一个页面
   var urlObj = globalHistory._getSurroundUrl();
   // 从当前浏览器上获取的url,主要是location.path
   var currentUrl = globalHistory._getCurrentHistoryName();
   if (urlObj.prev === currentUrl) {
       globalHistory.previous();
   }
   else {
       globalHistory.next();
   }
   // 根据当前的页面信息渲染页面
   domChangeMethod(globalHistory, history.state);
}, false);

接下来实现History,主要是思路,内部代码实现需要考虑更多东西,所以略有不同

function History() {
    this.history = []; // 对应浏览器的历史状态
    this.index = null; // 代表当前的位置
    // 还可以有其他内容,这里与主题无关,暂略
}
History.prototype = {
    constructor: History,
    // 初始化
    initialize: function (url) {
        this.history = [url];
        this.index = 0;
    },
    // 对应history.pushState方法,细节实现图的原理
    pushState: function (page, data) {
        // 先移除后面的历史状态,然后添加到最后一项
        for (var i = this.history.length - 1; i >= this.index + 1; i++) {
            this.history.splice(i, 1); //移除
        }
        this.history.push(page.url) // 添加
        this.index++;
        history.pushState(data, page.title, page.url); // 对应起来
    },
    // 对应history.replaceState方法,细节实现图原理
    replaceState: function (page, data) {
        this.history.splice(this.index, 1, page.url);
        history.replaceState(data, page.title, page.url);
    },
    _getSurroundUrl: function () {
         return {
             next: this.history[this.index + 1],
             prev: this.history[this.index - 1]
         }
    },
    _getCurrentHistoryName: function () {
        return location.pathname;
    },
    previous: function () { this.index-- },
    next: function () { this.index++; },
};

锁定页面的思路很简单,就是监听popstate事件,如果是锁定状态,然后判断是否前进或者后退。然后执行反向操作,因此需要有个状态值,要用来截断后续页面更新操作,代码如下

在History.prototype加入该方法

setLocked: function (isLocked) { this.locked = isLocked; };

在popstate事件中修改

window.addEventListener("popstate", function (ev) {
   // 获取当前页面的前后页面的url,如果是前面页面匹配,渲染前面,否则是后面
   // 前提在于避免一个页面的前后页面都是同一个页面
   var urlObj = globalHistory._getSurroundUrl();
   if (globalHistory.locked) {
       if (urlObj.prev === currentUrl) {
           globalHistory.next(); // 保持一致
           return history.forward();
       }
       else (urlObj.next === currentUrl) {
           globalHistory.previous(); // 保持一致
           return history.back();
       }
       return true; // js执行forward()和back()导致popstate事件
   }
   // 其它代码
}, false);

对于4,5两点,归根于同一个问题就是可以随意的配置浏览器中的历史页面列表,可以通过pushstate添加历史记录,并移除后面的历史记录,并把索引指向最后一个页面。也可以使用replaceState更换当前的状态。因此第一步我们先使用pushState保证页面的数量,并且保证当前的页面索引指向最后一个页面。然后通过切换索引,使用replaceState更改所有页面的url和配置项。虽然理论上可行,但是还存在着一个特殊情况:从拥有多个页面的历史列表转换为只有一个页面的历史列表。

例子: 用户进入首页,然后点击详情,接着点击详情跳到下一个页面。 这时候历史列表中拥有3个页面分别是
首页 -> 详情 -> 下一页面
要想把它变成仅有一个 首页 的历史列表。使用当前的api是做不到的。
然而可以把它变成 首页 -> 详情
过程如下:后退两步, pushState详情页面。渲染详情页面

使用在历史列表中导航同样会造成popstate事件,因此还需要有一个状态来控制popstate,避免他切换页面。在History原型对象中添加一个方法

setSkip: function (isSkip) { this.isSkip = isSkip; };

在popstate事件中加入一个一段,判断是否略过,然后return掉,代码如下

 window.addEventListener("popstate", function (ev) {
   // 获取当前页面的前后页面的url,如果是前面页面匹配,渲染前面,否则是后面
   // 前提在于避免一个页面的前后页面都是同一个页面
   var urlObj = globalHistory._getSurroundUrl();
   // 是否locked代码
   if (globalHistory.isSkip) {
       return;
   }
   // 其它代码
}, false);

通过上面的思路,可以实现包含两个元素以上的任意历史列表。如何实现仅有一个 元素 的历史列表。我们把思路切换一下。加入一个新的历史列表规则,任何时期的历史状态都是大于等于2个,如下例子
刚进入: 历史状态为[’/str’, ‘/home’],
进入详情:历史状态为[’/str’, ‘/home’, ‘/detail’]
再进入一个页面: 历史状态为[’/str’, ‘/home’, ‘/detail’, ‘next’]
如果要回到首页没有前进状态,就可以使用上面的节奏
回退三步,再pushState首页,渲染首页。

接着我们怎么处理第一个名为/str的页面呢?我们可以假设它是一个空白页。当切换到这个页面的时候,提示用户再点击一次后退退出App。这样的操作行为也是符合用户的操作习惯。因此可以更改History初始化方法

initialize: function (url) { 
    this.history = ["/str", url];
    this.index = 1;
}

更改popstate事件,将它改为

window.addEventListener("popstate", function (ev) {
   // 获取当前页面的前后页面的url,如果是前面页面匹配,渲染前面,否则是后面
   // 前提在于避免一个页面的前后页面都是同一个页面
   var urlObj = globalHistory._getSurroundUrl();
   // 是否locked代码
   // 是否isSkip代码
   if (globalHistory._getCurrentHistoryName() == "/str") {
       popUp.show("2秒再次点击后退,退出App");
       setTimeout(function () {
          // 如果用户没有点击后退,返回到之前的历史状态
          history.pushState(globalState[1], "", option);
       }, 2000);
   }
   // 其它代码
}, false);

上诉的历史状态操作,仅仅在思路上的实现了历史列表以及页面的切换、缓存的处理。这是一个单页面的核心,也是单页面中最复杂的部分。我们在App原型对象中添加了有用的关于历史状态相关处理的api,简要如下:

  • setSkipHistoryPop(isSkip): 阻止popstate事件中切换页面,但是原生历史状态指向还是会更改的;
  • setSkipPopBack(popBack): 阻止popstate事件中切换页面时触发的方法,这里可以跟随原生历史状态指向做一些其它操作;
  • renderBack(option): 后退一步且为最后一个历史状态页面(无法前进);
  • renderTo(num, option): 后退num步,且为最后一个历史状态页面(无法前进);
  • renderPageAfterNum(num, pagename, option): 后退num步后,然后渲染某个页面,该页面为最后一个历史状态页面;
  • render(pagename, isReplace, data): 渲染某个页面,第三个参数代表为pushState还是用replaceState进入历史列表中;
  • lock(): 锁定App让无法用前进后退改变页面;
  • unlock(): 解锁。

结语

单页面与多页面最大的区别在于history api的运用了,也是一大难点。这篇主要讲述了在单页面使用history的思路,并给出简单的代码案例,在框架源代码中,还有更复杂的封装,因为页面切换、内存使用、弹窗与历史记录的关系等都和history使用有不可分割的关系,这些在后续篇章进行详解。

推广

底层框架开源地址:https://gitee.com/string-for-100w/string
演示网站: https://www.renxuan.tech/

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值