vue内置组件keep-alive多级路由缓存最佳实践

1,465 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第7天,点击查看活动详情

在我们的业务中,我们常常会有列表页跳转详情页,详情页可能还会继续跳转下一级页面,当我们返回上一级页面时,我想保持前一次的所有查询条件以及页面的当前状态。一想到页面缓存,在vue中我们就想到keep-alive这个vue的内置组件,在keep-alive这个内置组件提供了一个include的接口,只要路由name匹配上就会缓存当前组件。你或多或少看到不少很多处理这种业务代码,本文是一篇笔者关于缓存多页面的解决实践方案,希望看完在业务中有所思考和帮助。

正文开始...

业务目标

首先我们需要确定需求,假设A是列表页,A-1是详情页,A-1-1,A-1-2是详情页的子级页面,B是其他路由页面

我们用一个图来梳理一下需求

大概就是这样的,一图胜千言

然后我们开始,主页面大概就是下面这样

pages/list/index.vue我们暂且把这个当成A页面模块吧

<template>
  <div class="list-app">
    <div><a href="javascript:void(0)" @click="handleToHello">to hello</a></div>
    <el-form ref="form" :model="condition" label-width="80px" inline>
      <el-form-item label="姓名">
        <el-input
          v-model="condition.name"
          clearable
          placeholder="请输入搜索姓名"
        ></el-input>
      </el-form-item>
      <el-form-item label="地址">
        <el-select v-model="condition.address" placeholder="请选择地址">
          <el-option
            v-for="item in tableData"
            :key="item.name"
            :label="item.address"
            :value="item.address"
          >
          </el-option>
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button @click="featchList">刷新</el-button>
      </el-form-item>
    </el-form>
    <el-table
      :data="tableData"
      style="width: 100%"
      row-key="id"
      border
      lazy
      :load="load"
      :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
    >
      <el-table-column prop="date" label="日期"> </el-table-column>
      <el-table-column prop="name" label="姓名"> </el-table-column>
      <el-table-column prop="address" label="地址"> </el-table-column>
      <el-table-column prop="options" label="操作">
        <template slot-scope="scope">
          <a href="javascript:void(0);" @click="handleView">查看详情</a>
          <a href="javascript:void(0);" @click="handleEdit(scope.row)">编辑</a>
        </template>
      </el-table-column>
    </el-table>
    <!--分页-->
    <el-pagination
      @current-change="handleChangePage"
      background
      layout="prev, pager, next"
      :total="100"
    >
    </el-pagination>
    <!--弹框-->
    <list-modal
      title="编辑"
      width="50%"
      v-model="formParams"
      :visible.sync="dialogVisible"
      @refresh="featchList"
    ></list-modal>
  </div>
</template>

我们再看下对应页面的业务js

<!--pages/list/index.vue-->
<script>
import { sourceDataMock } from '@/mock';
import ListModal from './ListModal';

export default {
  name: 'list',
  components: {
    ListModal,
  },
  data() {
    return {
      tableData: [],
      cacheData: [], // 缓存数据
      condition: {
        name: '',
        address: '',
        page: 1,
      },
      dialogVisible: false,
      formParams: {
        date: '',
        name: '',
        address: '',
      },
    };
  },
  watch: {
    // eslint-disable-next-line func-names
    'condition.name': function (val) {
      if (val === '') {
        this.tableData = this.cacheData;
      } else {
        this.tableData = this.cacheData.filter(v => v.name.indexOf(val) > -1);
      }
    },
  },
  created() {
    this.featchList();
  },
  methods: {
    handleToHello() {
      this.$router.push('/hello-world');
    },
    handleChangePage(val) {
      this.condition.page = val;
      this.featchList();
    },
    handleSure() {
      this.dialogVisible = false;
    },
    load(tree, treeNode, resolve) {
      setTimeout(() => {
        resolve(sourceDataMock().list);
      }, 1000);
    },
    handleView() {
      this.$router.push('/detail');
    },
    handleEdit(row) {
      this.formParams = { ...row };
      this.dialogVisible = true;
      console.log(row);
    },
    featchList() {
      console.log('----start load data----', this.condition);
      const list = sourceDataMock().list;
      // 深拷贝一份数据
      this.cacheData = JSON.parse(JSON.stringify(list));
      this.tableData = list;
    },
  },
};
</script>

以上业务代码主要做了以下几件事情

1、用mockjs模拟了一份列表数据

2、根据条件筛选对应的数据,分页操作

3、从当前页面跳转子页面,或者跳转其他页面,还有打开编辑弹框

首先我们要确认几个问题,当前页面的几个特殊条件:

1、当前页面的条件变化,页面要更新

2、分页器切换,页面就需要更新

3、点击编辑弹框修改数据也是要更新

当我从列表去详情页,我从详情页返回时,此时要缓存当前页的所有数据以及页面状态,那要该怎么做呢?

我们先看下主页面

大概需求已经明白,其实就是需要缓存条件以及分页状态,还有我展开子树也需要缓存

我的大概思路就是,首先在路由文件的里放入一个标识cache,这个cache装载的就是当前的路由name

import Vue from 'vue';
import Router from 'vue-router';
import HelloWorld from '@/components/HelloWorld';
import List from '@/pages/list';
import Detail from '@/pages/detail';
Vue.use(Router);

export default new Router({
  routes: [
    {
      path: '/hello-world',
      name: 'HelloWorld',
      component: HelloWorld,
    },
    {
      path: '/',
      name: 'list',
      component: List,
      meta: {
        cache: ['list'],
      },
    },
    {
      path: '/detail',
      name: 'detail',
      component: Detail,
      meta: {
        cache: [],
      },
    },
  ],
});

然后我们在App.vue中的router-view中加入keep-alive,并且include指定对应路由页面

<template>
  <div id="app">
    cache Page:{{ cachePage }}
    <keep-alive :include="cachePage">
      <router-view />
    </keep-alive>
  </div>
</template>

我们看下cachePage是从哪里来的,我们通常把这种公用的变量放在全局store中管理

import store from '@/store';
export default {
  name: 'App',
  computed: {
    cachePage() {
      return store.state.global.cachePage;
    },
  },
};

当我们进入这个页面时就要根据路由上设置的meta去确认当前页面是否有缓存的name,所以本质上也就成了,我如何设置keep-alive中的include

import store from '@/store';
export default {
  ...
  methods: {
    cacheCurrentRouter() {
      const { meta } = this.$route;
      if (meta) {
        if (meta.cache) {
          store.commit('global/setGlobalState', {
            cachePage: [
              ...new Set(store.state.global.cachePage.concat(meta.cache)),
            ],
          });
        } else {
          store.commit('global/setGlobalState', {
            cachePage: [],
          });
        }
      }
    },
  },
  created() {
    this.cacheCurrentRouter();
    this.$watch('$route', () => {
      this.cacheCurrentRouter();
    });
  },
};

我们注意到,我们是根据$routemeta.cache然后去修改store中的cachePage

然后我们去store/index.js看下

// store/index.js
import Vue from 'vue';
import Vuex from 'vuex';
import { gloablMoudle } from './modules';
Vue.use(Vuex);
const initState = {};
const store = new Vuex.Store({
  state: initState,
  modules: {
    global: gloablMoudle,
  },
});
export default store;

我们继续找到最终设置cachePagemodules/global/index.js

// modules/global/index.js
export const gloablMoudle = {
  namespaced: true,
  state: {
    cachePage: [],
  },
  mutations: {
    setGlobalState(state, payload) {
      Object.keys(payload).forEach((key) => {
        if (Reflect.has(state, key)) {
          state[key] = payload[key];
        }
      });
    },
  },
};

所以我们可以看到mutations有这样的一段设置state的操作setGlobalState

这块代码可以给大家分享下,为什么我要循环payload获取对应的key,然后再从state中判断是否有key,最后再赋值?

在业务中我们看到不少这样的代码

export const gloablMoudle = {
  namespaced: true,
  state: {
    a: [],
    b: []
  },
  mutations: {
    seta(state, payload) {
      state.a = payload
    },
    setb(state, payload) {
      state.b = payload
    },
    ...
  },
  actions: {
    actA({commit, state}, payload) {
        commit('seta', payload)
    },
    actB({commit, state}, payload) {
        commit('setb', payload)
    }
    ...
  }
  ...
};

在具体业务中大概就下面这样

store.dispatch('actA', {})
store.dispatch('actB', {})

所以你会看到如此重复的代码,写多了,貌似会越来越多,有没有可以一劳永逸呢?

因此上面一块代码,你可以优化成下面这样

export const gloablMoudle = {
  namespaced: true,
  state: {
    a: [],
    b: []
  },
  mutations: {
    setState(state, payload) {
       Object.keys(payload).forEach(key => {
           if (Reflect.has(state, key)) {
               state[key] = payload[key]
            }
       })
    },
  },
  actions: {
    setActionState({commit, state}, payload) {
      commit('setState', payload)  
    }
  }
};

在业务代码里你就这样做

store.dispatch('setActionState', {a: [1,2,3]})
store.dispatch('setActionState', {b: [1,2,3]})

或者是下面这样

store.commit('setState', {a: [1,2,3]})
store.commit('setState', {b: [1,2,3]})

所以你会看到我这个文件会非常的小,同样达到目的,而且维护成本会降低很多,达到了我们代码设计的高内聚,低耦合,一劳永逸的抽象思想。

回到正题,我们已经设置的全局storecachePage

我们注意到在created里面我们除了有去更新cachePage,还有去监听路由的变化,当我们切换路由去详情页面,我们是要根据路由标识更新cachePage的。

import store from '@/store';
export default {
   ...
  methods: {
    cacheCurrentRouter() {
      const { meta } = this.$route;
      if (meta) {
        if (meta.cache) {
          store.commit('global/setGlobalState', {
            cachePage: [
              ...new Set(store.state.global.cachePage.concat(meta.cache)),
            ],
          });
        } else {
          store.commit('global/setGlobalState', {
            cachePage: [],
          });
        }
      }
    },
  },
  created() {
    this.cacheCurrentRouter();
    // 监听路由,根据路由判断当前是否应该要缓存
    this.$watch('$route', () => {
      this.cacheCurrentRouter();
    });
  },
};

我们看下最终的效果

8d75047a-4de3-4967-bedb-4fae9fb1694e (1).gif

当我们从当前页面切换到tohello页面时,再回来,当前页面就会重新被激活,然后重新再次缓存

如果我需要detial/index.vue也需要缓存,那么我只需要在路由文件新增当前路由名称即可

export default new Router({
  routes: [
    {
      path: '/hello-world',
      name: 'HelloWorld',
      component: HelloWorld,
    },
    {
      path: '/',
      name: 'list',
      component: List,
      meta: {
        cache: ['list'],
      },
    },
    {
      path: '/detail',
      name: 'detail',
      component: Detail,
      meta: {
        cache: ['detail'], // 这里的名称就是当前路由的名称
      },
    },
  ],
});

所以无论多少级页面,跳转哪些页面,都可以轻松做到缓存,而且核心代码非常简单

keep-alive揭秘

最后我们看下vue中这个内置组件keep-alive有什么特征,以及他是如何实现缓存路由组件的

从官方文档知道,当一个组件被keep-alive缓存时

1、该组件不会重新渲染

2、不会触发created,mounted钩子函数

3、提供了一个可触发的钩子函数activated函数【当前组件缓存时会激活该钩子】

4、deactivated离开当前缓存组件时触发

我们注意到keep-alive提供了3个接口props

  • include,被匹配到的路由组件名(注意必须时组件的name
  • exclude,排序不需要缓存的组件
  • max 提供最大缓存组件实例,设置这个可以限制缓存组件实例

不过我们注意,keep-alive并不能缓在函数式组件里使用,也就是是申明的纯函数组件不会有作用

我们看下keep-alive这个内置组件是怎么缓存组件的

vue2.0源码目录里看到/core/components/keep-alive.js

首先我们看到,在created钩子里绑定了两个变量cache,keys

  created () {
    this.cache = Object.create(null)
    this.keys = []
  },

然后我们会看到有在mountedupdated里面有去调用cacheVNode

...
mounted () {
    this.cacheVNode()
    this.$watch('include', val => {
      pruneCache(this, name => matches(val, name))
    })
    this.$watch('exclude', val => {
      pruneCache(this, name => !matches(val, name))
    })
},

我们可以看到首先在mounted里就是cacheVNode(),然后就是监听props的变化

 methods: {
    cacheVNode() {
      const { cache, keys, vnodeToCache, keyToCache } = this
      if (vnodeToCache) {
        const { tag, componentInstance, componentOptions } = vnodeToCache
        cache[keyToCache] = {
          name: getComponentName(componentOptions),
          tag,
          componentInstance,
        }
        keys.push(keyToCache)
        // prune oldest entry
        if (this.max && keys.length > parseInt(this.max)) {
          pruneCacheEntry(cache, keys[0], keys, this._vnode)
        }
        this.vnodeToCache = null
      }
    }
  },

上面一段代码大的大意就是,如果有vnodeToCache存在,那么就会将组件添加到cache对象中,并且如果有max,则会对多余的组件进行销毁

render里,我们看到会获取默认的slot,然后会根据slot获取根组件

首先会判断路由根组件上的是否有name,没有就不缓存,直接返回vnode

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
      }
    ...
  }

当再次访问时,就会从当前缓存对象里去找,直接执行

vnode.componentInstance = cache[key].componentInstance,组件实例会从cache对象中寻找

 render () {
    const slot = this.$slots.default
    const vnode: VNode = getFirstComponentChild(slot)
    const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
      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对象中寻找
        vnode.componentInstance = cache[key].componentInstance
        // make current key freshest
        remove(keys, key)
        // 在删除的时候会有用到keys
        keys.push(key)
      } else {
        // delay setting the cache until update
        this.vnodeToCache = vnode
        this.keyToCache = key
      }
      vnode.data.keepAlive = true
    }
    return vnode || (slot && slot[0])
  }

总结

  • keep-alive缓存多级路由,主要思路根据路由的meta标识,然后在App.vue组件中keep-alive包裹router-view路由标签,我们通过全局store变量去控制includes判断当前路由是否该被缓存,同时需要监听路由判断是否有需要缓存,通过设置全局cachePage去控制路由的缓存

  • 优化store数据流代码,可以减少代码,提高的代码模块的复用度

  • 当一个组件被缓存时,加载该缓存组件时是会触发activated钩子,当从一个缓存组件离开时,会触发deactivated,在特殊场景可以在这两个钩子函数上做些事情

  • 简略剖析keep-alive实现原理,从默认插槽中获取组件实例,然后会根据是否有nameinclude以及exclude,判断是否每次返回vnode,如果include有需要缓存的组件,则会从cache对象中获取实例对vnode.componentInstance进行重新赋值优先从缓存对象中获取

  • 本文示例 code example