87133050 2018-12-02
对于web开发和移动端开发,两者在路由上的处理是不同的。对于移动端来说,页面的路由是相当于栈的结构的。vue-router与keep-alive提供的路由体验与移动端是有一定差别的,因此常常开发微信公众号的我想通过一些尝试来将两者的体验拉近一些。
目标
问题
首先一个问题是keep-alive的行为。我们可以通过keep-alive来保存页面状态,但这样的行为对于类似于APP的体验是有些奇怪的。例如我们的应用有首页、列表页、详情页3个页面,当我们从列表页进入详情页再返回,此时列表页应当是keep-alive的。而当我们从列表页返回首页,再次进入列表页,此时的列表页应当在退出时销毁,并在重新进入时再生成才比较符合习惯。
第二个问题是滚动位置。vue-router提供了 scrollBehavior 来帮助维护滚动位置,但这一工具只能将页面作为滚动载体来处理。但我在实际开发中,喜欢使用flex来布局页面,滚动列表的载体常常是某个元素而非页面本身。
使用环境
对于代码能正确运行的环境,这里严格假定为微信(或是APP中内嵌的web页面),而非通过普通浏览器访问,即:用户无法通过直接输入url来跳转路由。在这样的前提下,路由的跳转是代码可控的,即对应于vue-router的push、replace等方法,而唯一无法干预的是浏览器的回退行为。在这样的前提下,我们可以假定,任何没有通过vue-router触发的路由跳转,是 回退1个记录 的回退行为。
改造前
这里我列出改造前的代码,是一个非常简单的demo,就不详细说了(这里列表页有两个列表,是为了展示改造后的滚动位置维护):
// css * { margin: 0; padding: 0; box-sizing: border-box; } html, body { height: 100%; } #app { height: 100%; }
// html <div id="app"> <keep-alive> <router-view></router-view> </keep-alive> </div>
// js const Index = { name: 'Index', template: `<div> 首页 <div> <router-link :to="{ name: 'List' }">Go to List</router-link> </div> </div>`, mounted() { console.warn('Main', 'mounted'); }, }; const List = { name: 'List', template: `<div style="display: flex;flex-direction: column;height: 100%;"> <div>列表页</div> <div style="flex: 1;overflow: scroll;"> <div v-for="item in list" :key="item.id"> <router-link style="line-height: 100px;display:block;" :to="{ name: 'Detail', params: { id: item.id } }"> {{item.name}} </router-link> </div> </div> <div style="flex: 1;overflow: scroll;"> <div v-for="item in list" :key="item.id"> <router-link style="line-height: 100px;display:block;" :to="{ name: 'Detail', params: { id: item.id } }"> {{item.name}} </router-link> </div> </div> </div>`, data() { return { list: new Array(10).fill(1).map((_,index) => { return {id: index + 1, name: `item${index + 1}`}; }), }; }, mounted() { console.warn('List', 'mounted'); }, activated() { console.warn('List', 'activated'); }, deactivated() { console.warn('List', 'deactivated'); }, }; const Detail = { name: 'Detail', template: `<div> 详情页 <div> {{$route.params.id}} </div> </div>`, mounted() { console.warn('Detail', 'mounted'); }, }; const routes = [ { path: '', name: 'Main', component: Index }, { path: '/list', name: 'List', component: List }, { path: '/detail/:id', name: 'Detail', component: Detail }, ]; const router = new VueRouter({ routes, }); const app = new Vue({ router, }).$mount('#app');
当我们第一次从首页进入列表页时, mounted 和 activated 将被先后触发,而在此后无论是进入详情页再回退,或是回退到首页再进入列表页,都只会触发 deactivated 生命周期。
keep-alive
includes
keep-alive有一个 includes 选项,这个选项可以接受一个数组,并通过这个数组来决定组件的保活状态:
// keep-alive render () { const slot = this.$slots.default const vnode: VNode = getFirstComponentChild(slot) const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions if (componentOptions) { const name: ?string = getComponentName(componentOptions) const { include, exclude } = this if ( (include && (!name || !matches(include, name))) || (exclude && name && matches(exclude, name)) ) { return vnode } const { cache, keys } = this const key: ?string = vnode.key == null ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '') : vnode.key if (cache[key]) { vnode.componentInstance = cache[key].componentInstance remove(keys, key) keys.push(key) } else { cache[key] = vnode keys.push(key) if (this.max && keys.length > parseInt(this.max)) { pruneCacheEntry(cache, keys[0], keys, this._vnode) } } vnode.data.keepAlive = true } return vnode || (slot && slot[0]) }
这里我注意到,可以动态的修改这个数组,来使得本来处于保活状态的组件/页面失活。
afterEach
那我们可以在什么时候去维护/修改includes数组呢?vue-router提供了 afterEach 方法来添加路由改变后的回调:
updateRoute (route: Route) { const prev = this.current this.current = route this.cb && this.cb(route) this.router.afterHooks.forEach(hook => { hook && hook(route, prev) }) }
在这里虽然 afterHooks 的执行是晚于路由的设置的,但组件的 render 是在 nextTick 中执行的,也就是说,在keep-alive的render方法判断是否应当从缓存中获取组件时,组件的保活状态已经被我们修改了。
劫持router.push
这里我们将劫持router的push方法:
let dir = 1; const includes = []; const routerPush = router.push; router.push = function push(...args) { dir = 1; routerPush.apply(router, args); }; router.afterEach((to, from) => { if (dir === 1) { includes.push(to.name); } else if (dir === -1) { includes.pop(); } dir = -1; });
我们将router.push(当然这里需要劫持的方法不止是push,在此仅用push作为示例)和浏览器的回退行为用不同的 dir 标记,并根据这个值来维护includes数组。
然后,将includes传递给keep-alive组件:
// html <div id="app"> <keep-alive :include="includes"> <router-view></router-view> </keep-alive> </div> // js const app = new Vue({ router, data() { return { includes, }; }, }).$mount('#app');
维护滚动
接下来,我们将编写一个 keep-position 指令(directive):
Vue.directive('keep-position', { bind(el, { value }) { const parent = positions[positions.length - 1]; const obj = { x: 0, y: 0, }; const key = value; parent[key] = obj; obj.el = el; obj.handler = function ({ currentTarget }) { obj.x = currentTarget.scrollLeft; obj.y = currentTarget.scrollTop; }; el.addEventListener('scroll', obj.handler); }, });
并对router进行修改,来维护position数组:
const positions = []; router.afterEach((to, from) => { if (dir === 1) { includes.push(to.name); positions.push({}); } ... });
起初我想通过指令来移除事件侦听(unbind)以及恢复滚动位置,但发现使用unbind并不方便,更重要的是指令的几个生命周期在路由跳转到保活的页面时都不会触发。
因此这里我还是使用 afterEach 来处理路由维护,这样在支持回退多步的时候也比较容易去扩展:
router.afterEach((to, from) => { if (dir === 1) { includes.push(to.name); positions.push({}); } else if (dir === -1) { includes.pop(); unkeepPosition(positions.pop({})); restorePosition(); } dir = -1; }); const restorePosition = function () { Vue.nextTick(() => { const parent = positions[positions.length - 1]; for (let key in parent) { const { el, x, y } = parent[key]; el.scrollLeft = x; el.scrollTop = y; } }); }; const unkeepPosition = function (parent) { for (let key in parent) { const obj = parent[key]; obj.el.removeEventListener('scroll', obj.handler); } };
最后,我们分别给我们的列表加上我们的指令就可以了:
<div style="flex: 1;overflow: scroll;" v-keep-position="'list1'"> <!-- --> </div> <div style="flex: 1;overflow: scroll;" v-keep-position="'list2'"> <!-- --> </div>