深入探讨和实现原生App前进刷新后退缓存-vue.js实现

1,965 阅读5分钟

前言

大家使用原生app时会发现原生app返回原来页面,原来页面的滚动位置和数据会保持和原来一模一样,这是非常好的的设计,比如列表页跳转详情页,回跳时还可以定位到原来的索引,并且回跳的页面不会重新请求向服务器请求数据从而减少服务器的压力,这是非常有意义的。

vue.js中自带的keep-alive组件实现了这个特性,但是也存在很多痛点。 比如,keep-alive组件去管理缓存的组件时是根据组件的名字来作为key。 keep-alvie方案比较好的思路是用vuex去管理keepAliveList,当前进时向keepAliveList加入当前页面组件的name,返回时从keepAliveList中剔除页面的name 代码长这样:

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

痛点之一是手动删除一个页面的缓存时,我还找到那个路由对应的组件的名字,这样做的话每一个路由组件都得有自己特定的名字,然后可读性非常低 痛点之二是虽然是可以实现原生App前进刷新后退缓存,但是在App.vue里面写浏览器方案判断前进还是后退的代码,迁移项目时还得拷贝一遍里面的代码,简单来说就是代码没有抽离出这一块独立的逻辑

所以我就想,能不能抽离这些东西出来,优雅的实现一套方案呢。这篇文章将从原理到API的设计实现一个这样插件。

vue.js keep-alive源码分析


render () {
    const slot = this.$slots.default
    const vnode: VNode = getFirstComponentChild(slot)
    const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
    if (componentOptions) {
      // check pattern
      const name: ?string = getComponentName(componentOptions)
      const { include, exclude } = this
      if (
        // not included
        (include && (!name || !matches(include, name))) ||
        // excluded
        (exclude && name && matches(exclude, name))
      ) {
        return vnode
      }

      const { cache, keys } = this
      const key: ?string = vnode.key == null
        // same constructor may get registered as different local components
        // so cid alone is not enough (#3269)
        ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
        : vnode.key
      if (cache[key]) {
        // 重点
        vnode.componentInstance = cache[key].componentInstance
        // make current key freshest
        remove(keys, key)
        keys.push(key)
      } else {
        cache[key] = vnode
        keys.push(key)
        // prune oldest entry
        if (this.max && keys.length > parseInt(this.max)) {
          pruneCacheEntry(cache, keys[0], keys, this._vnode)
        }
      }

      vnode.data.keepAlive = true
    }
    return vnode || (slot && slot[0])
}

可以看到keep-alive的slot(也就是router-view)被渲染时,就会把componentInstance存起来放在this.cache对象,key是这个组件特有的标识,key存放在this.keys数组里,类似栈堆地址这种读取数据的方法。然后如果props.include没有这个组件的name时,直接不走缓存的逻辑。 原理我们都明白了,我们只要像keep-alive去管理一个存放keys的数组,然后通过keys的数组去间接管理componentInstance的堆仓库cache对象就可以做到缓存的增删改查了,剩下的问题是浏览器前进时去存key,后退时去删除后退页面的key

怎么判断浏览器是前进还是后退

首先我想到的是监听popstate,hashchange之类的api,在vue-router里面点击浏览器自带的前进后退都会触发这两个事件 当一个页面第一次打开时,浏览器是没有前进按钮的,这意味着第一次触发popstate,hashchange事件的必然是点击后退按钮触发的! 然后从一个数组把浏览器的经过的历史管理起来,a页面进入时,historyStack存a页面的地址,前进到b页面,historyStack存b页面的地址,这时候b页面是不能点击浏览器的前进按钮的,当b页面点击了后退,那么如果historyStack第2个元素是a页面,这就说明是浏览器点击了后退,然后historyStack把最后一个元素删了(也就是b页面的地址)。这相当于b页面从来没进过历史记录,唯一不同的是a页面中浏览器的前进按钮可以点击了。 so~ 我们已经可以知道浏览器是前进还是后退的了,这时候就写一个on,emit的事件系统,在前进时把前进的信号派发出去,后退的把后退的信号派发出去,提供给keys增删页面缓存

import Events from '../util/events'
import historyStack from './history-stack'
import { BACK, FORWARD } from './history-direction-name'
window.addEventListener('hashchange', () => {
 if (historyStack.getByIndex(1) === window.location.href) {
   historyStateEvent.emit(BACK)
 } else {
   historyStateEvent.emit(FORWARD)
 }
})

API设计

实现了判断前进还是后退后,到底用什么做页面的标识key的痛点还没解决。 然后我想了一个方案,就是这个key应该和路由的申明结合起来,每个路由不是没有唯一的path和name吗,这些唯一的东西正好适合做key so~ 想一想vue-router怎么知道push方法的参数会定位到哪个路由和组件的,这个翻一下vue-router源码就可以解决啦~

resolveKeyFromRoute(route) {
 return route.name ? route.name : route.path
},
resolveKeyFromLocation(location) {
 const router = config.router
 const route = router.resolve(location).route
 return this.resolveKeyFromRoute(route)
},

然后我想删除某个页面缓存时,API大概是这样

  this.$routerCache.remove({name: 'mainNumberList'})
  // or
  this.$routerCache.remove('/main/number-list')
  // or
  this.$routerCache.remove({
    path: '/main/number-list'
  })

内部resolveKeyFromRoute会帮我们去解析remove中的和push方法一样的参数成这个路由的名字或者路径,做到开发的时候用熟悉的api去用这个插件的api,无缝对接~

管理keys和historyStack这两个数组时,我想做一些改良,简单来说传统的数组push新元素时,新元素总是放在末尾,但是我关心是正是这些新元素,我想把新元素放在第一位,这样我能很快从调试中看到 这种新的数据结构有这些功能

  • 1.添加新元素是放在前面
  • 2.有最大存放量,当超过这个存放量时,自动把最旧的(末尾的)元素删了,然后存新元素
  • 3.提供一些方法可以删除里面元素
  • 4.元素不可重复,如果添加新元素时发现已经存在,删除存在的再添加

第2点是因为页面缓存数据太多时,有造成内存泄漏,通过第2点的机制可以避免这个问题

返回时保持原来的滚动位置

实现这个效果,一开始我看到vu-router文档有scrollBehavior这个api,但是实践了发现这个api切换时会有浏览器滚动闪屏的问题 参考了下天猫京东app,猜测是用了刚好一个屏幕的页面,在dom里面滚动而不是body下做滚动 这个可以结合一些scroll库,比如better-scroll或者实现一个简单的页面保持返回位置的滚动组件 在即将跳转时存下滚动位置,激活时滚动储存的滚动位置

<template>
  <div ref="page" class="vi-page">
    <slot></slot>
  </div>
</template>
export default {
  activated() {
    this.scrollTo(0, this.scrollTop)
  },
  deactivated() {
    this.scrollTop = this.$refs.page.scrollTop
  },
  methods: {
    scrollTo() {
      this.$refs.page.scrollTo(...arguments)
    },
  }   
}

一些使用上的改良,框架之外灵活的扩展

实现了基本的前进刷新,后退缓存这一套框架里的东西后,考虑到会有一些特殊的需求 比如A => B => C, 在C中什么都没修改就返回到B,要求B页面刷新页面, 在C中修改数据然后这个数据会影响到B页面的数据,需要B页面刷新而不是缓存

this.$routerCache.remove({name: 'B路由的名字'})
// or
// this.$routerCache.remove('B路由的路径')
// or
// this.$routerCache.remove({path: 'B路由的路径'})
this.$router.back()

然后还可以直接push也可以让B重新刷新 分析一下 A => B => C => D 如果D页面push到A页面,那么A, B, C页面都应该是被删除缓存的,也就是说从A到D之间经过的页面都要被刷新 为了满足这样的需求,可以重写下vue-router的push方法,在pushf方法执行之前删除缓存

const originPush = router.push.bind(router)

router.push = (location, onComplete, onAbort) => {
if (config.isSingleMode && routerCache.has(location)) {
  routerCache.removeBackInclue(location)
}
originPush(location, onComplete, onAbort)
}

然后剩下的都是些边缘性的问题~

最后

放上实现这个插件的地址vue-router-cache

原生app效果的页面deom案例