单页面框架一个常见的问题就是地址回退的页面缓存,即从某个页面回退到上个页面不用重新加载,并且保留上次离开时的状态。
<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>
缓存的组件在激活、失活时会触发activated
和deactivated
钩子。
history与location接口
前端更新浏览器地址主要有location
和history
2个接口,除去会重新加载页面的api:
- location.hash=[string] - 不会重新加载页面,触发
hashchange
事件 - history.pushState(...)、history.replaceState(...) - 页面更新与替换,不触发任何事件
- history.back()、history.forward() - 前进后退,触发
hashchange/popstate
事件,浏览器本身的按钮功能与这类似 - history.go([number]) - 当参数是0相当于
reload
,重新加载页面;不为0时与上面的back
、forward
相似
另一边vue-router
提供了hash
和state
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
的值在路由前进后退时必须是变化的,否则会产生很多混乱。
考虑这种情况:routeA
和routeB
都需要缓存,从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 && records[index - 1].path === path) {
// 后退
direction = 'backward'
index -= 1
} else if (index < records.length - 1 && records[index + 1].path === 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
不管什么模式底层优先调用pushState
、replaceState
,这2个方法不触发任何事件,事件监听的想法显然走不通。
另一方面,vue-router
提供了导航的全局钩子,这好像替代了事件监听
router.beforeEach((to, from, next) => { /* ... */ })
然而尝试过之后发现beforeEach
只给了路由信息,没有给出引起路由变化的方法,到底是replace
还是push
,不知道方法路由数组就不准确。全局钩子的想法也只能放弃。
从以上两点来看路由的push
和replace
是无法准确监听的,这也要求我们换个思路,不是去监听路由变化,而是想办法发掘引起变化的方法。
可以看到vue-router
输出的是对象,对象中包含了push
和replace
方法,我们可以继承对象重写方法,在调用的时候就记录下路由(当然也可以自定事件)。
继承Vue Router对象
class myRouter extends VueRouter {
push() {
...
super.push()
}
replace() {
...
super.replace()
}
}
这样push
和replace
记录好了,还有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
}
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。