乐趣区

关于vue.js:vuerouter记录路由历史完美解决回退缓存

单页面框架一个常见的问题就是地址回退的页面缓存,即从某个页面回退到上个页面不必从新加载,并且保留上次来到时的状态。

<keep-alive> 简介

<keep-alive>vue 的内置组件,并且是一个形象组件,它承受 3 个属性值:

  • include – 字符串或正则表达式。只有名称匹配的组件会被缓存。
  • exclude – 字符串或正则表达式。任何名称匹配的组件都不会被缓存。
  • max – 数字。最多能够缓存多少组件实例,超过此下限时,缓存组中最久没被拜访的组件会销毁。

<keep-alive> 包裹组件时,会缓存不流动的组件实例,而不是销毁它们,这不包含函数式组件,因为它们没有实例。
vue-router中配合 <keep-alive> 缓存页面的个别写法如下

<keep-alive :include="['routeA','routeB', ...]" :max="{{10}}">
  <router-view></router-view>
</keep-alive>

缓存的组件在激活、失活时会触发 activateddeactivated钩子。

history 与 location 接口

前端更新浏览器地址次要有 locationhistory 2 个接口,除去会从新加载页面的 api:

  • location.hash=[string] – 不会从新加载页面,触发 hashchange 事件
  • history.pushState(…)、history.replaceState(…) – 页面更新与替换,不触发任何事件
  • history.back()、history.forward() – 后退后退,触发 hashchange/popstate 事件,浏览器自身的按钮性能与这相似
  • history.go([number]) – 当参数是 0 相当于 reload,从新加载页面;不为 0 时与下面的backforward 类似

另一边 vue-router 提供了 hashstate 2 种模式, 默认应用state, 在不反对 html5 的环境会降级成hash。他们与 api 对应的关系以及会触发的事件查看下表

api 或者操作 vue-router 模式 触发的事件
location.hash = … hash* hashchange
history.pushState(…), history.replaceState(…) state none
history.back(), history.forward(), history.go(…) all hashchange/popstate
点击浏览器后退 / 后退 all hashchange/popstate

* 阐明:vue-router若设置为 hash 模式,也并不一定调用 location.hash 办法, 查源码可知底层仍旧是优先调用 pushState 办法, 不反对的环境才会降级成location.hash

//vue-router 源码
function pushHash (path) {if (supportsPushState) {pushState(getUrl(path));
  } else {window.location.hash = path;}
}

function replaceHash (path) {if (supportsPushState) {replaceState(getUrl(path));
  } else {window.location.replace(getUrl(path));
  }
}

vue-route 缓存历史的难点

直观的看,<keep-alive>只须要页面组件名字,实现不会很难。实际上,include的值在路由后退后退时必须是变动的,否则会产生很多凌乱。
思考这种状况:routeArouteB 都须要缓存,从 routeA 进入到 routeB 再回退到 routeA 后,此时 routeB 是缓存未激活状态,如果此时再进入 routeB 看到的就是缓存的页面,而不会刷新,这显然会呈现 bug。正确的做法是从 routeB 回退后,include就须要去掉 routeB 的了。
所以随着路由后退后退批改 include,保障只有history 里的路由被缓存就十分必要。
个别的做法是利用全局钩子,然而钩子不能判断是后退还是后退,这里论述下我的办法。

vuex 存储路由数据

先不思考如何怎么实现代码,首先设计数据结构贮存历史路由数据。显然数组最直观,路由变动操作对应于增删数组末位项。
此时数据结构如下图:

然而有一种状况比拟非凡,浏览器后退再后退时,此时只触发了 2 次 pop,而 pop 事件不带 url 地址,无奈取得必要信息。看上去浏览器的路由历史齐全没有变动,然而数组最初一项却是空了。
所以后退的时候删除数组末位项行不通,一个方法是把路由都保留下来,而后用索引标识以后路由的地位。同时设置一个参数 direction 标识路由是后退还是后退。
更改模型后的数据结构如下图:

数据用 vuex 保留。

//store 数据结构
state = {records: [], // 历史路由数组
  index: 0, // 以后路由索引
  direction: '', //history 变动方向, forward/backward
}

路由变动时对应的数据变动

  • push 新路由, 数组增加新数据, direction是 forward
  • replace 路由, 数组末位项替换数据, direction是 forward
  • pop 后退 / 后退, 更改索引 index, direction 须要判断

路由记录独自写成一个module:

//history.js
// 假设 route 的 meta 里蕴含 keepAlive 和 componentName 属性
const formRecord = (vueRoute) => {
  return {
    name: vueRoute.name,
    path: vueRoute.fullPath,
    keepAlive: vueRoute.meta && vueRoute.meta.keepAlive,
    componentName: r.meta && r.meta.componentName ? r.meta.componentName : ''
  }
}

export default {
  namespaced: true,
  state: {records: [], // 历史路由数组
    index: 0, // 以后路由索引
    direction: '', //history 变动方向, forward/backward
  },
  getters: {
    routes: state => {const { records, index} = state
      if(records.length > 0 && index < records.length) {return records.slice(0, index + 1)
      }
      return []}
  },
  mutations: {
    // 记录 router.push
    PUSH_ROUTE(state, vueRoute) {const record = formRecord(vueRoute)
      const {records, index} = state
      if (index + 1 < records.length) {records = records.slice(0, index + 1)
      }
      records.push(record)
      state.records = records
      state.index = records.length - 1
      state.direction = 'forward'
    },
    // 记录 router.replace
    REPLACE_ROUTE(state, vueRoute) {const record = formRecord(vueRoute)
      const {records, index} = state
      if (index + 1 < records.length) {records = records.slice(0, index + 1)
      }
      records.pop()
      records.push(record)
      state.records = records
      state.index = index
      state.direction = 'forward'
    },
    // 记录 router.pop 后退 / 后退
    //count 是跳跃的历史记录数, >0 是后退, <0 是回退,path 是以后的 location.href
    POP_ROUTE(state, { count, path}) {let { records, index, direction} = state

      if (count) {
        direction = count > 0 ? 'forward' : 'backward'
        index += count
        index = Math.min(records.length, index)
        index = Math.max(0, index)
      } else {
        // 后退
        if (index > 0) {let prevPath = records[index - 1].path
          if (prevPath === path) {
            direction = 'backward'
            index -= 1
          }
        }
        // 后退
        if (index < records.length - 1) {let nextPath = records[index + 1].path
          if (nextPath === path) {
            direction = 'forward'
            index += 1
          }
        }
      }

      state.records = records
      state.index = index
      state.direction = direction
    }
  }
}

vux 中应用

//store.js
import history from './history'
Vue.use(Vuex)
export default new Vuex.Store({
  modules: {history}
})

记录路由

数据结构设计好了,该把数据放进去了,然而路由什么时候变动,怎么获取路由信息是个难点。
大体的思路分 2 种:1. 事件监听;2. 全局钩子
从源代码上看 vue-router 不论什么模式底层优先调用 pushStatereplaceState,这 2 个办法不触发任何事件,事件监听的想法显然走不通。
另一方面,vue-router提供了导航的全局钩子,这如同代替了事件监听

router.beforeEach((to, from, next) => {/* ... */})

然而尝试过之后发现 beforeEach 只给了路由信息,没有给出引起路由变动的办法,到底是 replace 还是push,不晓得办法路由数组就不精确。全局钩子的想法也只能放弃。

从以上两点来看路由的 pushreplace是无奈精确监听的,这也要求咱们换个思路,不是去监听路由变动,而是想方法挖掘引起变动的办法。
能够看到 vue-router 输入的是对象,对象中蕴含了 pushreplace办法,咱们能够继承对象重写办法,在调用的时候就记录下路由(当然也能够自定事件)。

继承 Vue Router 对象

class myRouter extends VueRouter {push() {
    ...
    super.push()}
  replace() {
    ...
    super.replace()}
}

这样 pushreplace记录好了,还有 pop 事件怎么解决。pop其实分 2 种状况,一种是 router.go(),另一种是用户操作浏览器后退 / 后退。对于前一种能够重写 router,后一种须要用钩子事件,并且判断不是router 办法导致的。
残缺的代码如下

let routerTrigger = false
class myRouter extends VueRouter {push(location, onComplete, onAbort) {
    routerTrigger = true
    store.commit('history/PUSH_ROUTE', super.resolve(location).resolved)
    super.push(location, onComplete, onAbort)
  }
  replace(location, onComplete, onAbort) {
    routerTrigger = true
    store.commit('history/REPLACE_ROUTE', super.resolve(location).resolved)
    super.replace(location, onComplete, onAbort)
  }
  go(n) {if (n !== 0) {
      routerTrigger = true
      store.commit('history/POP_ROUTE', { count: n})
      super.go(n)
    } else {window.location.reload()
    }
  }
}

const router = new MyRouter(...)
router.afterEach((to, from) => {if (to.matched.length > 0 && store.state.history.records.length === 0) {store.commit('history/PUSH_ROUTE', to)
  } else if (!routerTrigger && to.fullPath) {
    store.commit('history/POP_ROUTE', {path: to.fullPath})
  }
  routerTrigger = false
})

app.vue里能够计算出须要缓存的组件数组。

  <keep-alive :include="keepAliveComponents">
    <router-view></router-view>
  </keep-alive>

  computed: {...mapGetters('history', ['routes']),
    keepAliveComponents() {let array = []
      if (this.routes) {array = this.routes.filter(r => !!r.keepAlive).map(h => h.componentName)
      }
      return array
    }
  }

因为历史路由全副被记录在 vuex 里,所以是能够更加细粒度的管制缓存数组的。比方在 store 减少一个人为的数组,每次获取历史数组时调整路由的 keep-alive

  //store
  ...
  state: {manualRecords: [],
  },
  getters: {
    routes: state => {const routes = []
      const {records, index} = state
      if (records.length > 0 && index < records.length) {routes = records.slice(0, index + 1)
      }
      routes.map((item) => {const m = this.manualRecords.find((i) => i.name === item.name)
        if (m) item.keepAlive = m.keepAlive
        return item
      })
      return routes
    }
  },
  mutations: {EDIT_KEEPALIVE(state, routeName, keepAlive) {let { manualRecords} = state
      manualRecords = manualRecords.filter((item) => item.name !== routeName)
      manualRecords.push({name: routeName, keepAlive})
      state.manualRecords = manualRecords
    }
  }
退出移动版