keep-alive 多級路由緩存最佳實踐

在我們的業務中,我們常常會有列表頁跳轉詳情頁,詳情頁可能還會繼續跳轉下一級頁面,下一級頁面還會跳轉下一級頁面,當我們返回上一級頁面時,我想保持前一次的所有查詢條件以及頁面的當前狀態。一想到頁面緩存,在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>
    <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"
     
      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>
    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();
    });
  },
};

當我們從當前頁面切換到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有什麼特徵,以及他是如何實現緩存路由組件的

從官方文檔知道 [1],當一個組件被keep-alive緩存時

1、該組件不會重新渲染

2、不會觸發created,mounted鉤子函數

3、提供了一個可觸發的鉤子函數activated函數【當前組件緩存時會激活該鉤子】

4、deactivated離開當前緩存組件時觸發

我們注意到keep-alive提供了 3 個接口props

不過我們注意,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])
  }

總結

參考資料

[1] 從官方文檔知道: https://v2.cn.vuejs.org/v2/api/#keep-alive

[2]code example: https://github.com/maicFir/lessonNote/tree/master/vue/05-keep-alive

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/bQTk2v_yqqjlxABBAonrrw