Vue 2.x手摸手实现页面缓存

2,341 阅读8分钟

前言

学如逆水行舟,不进则退。

日常鼓励式开头,今天又是元气满满的一天哦,哈哈哈~

话不多说,进入正题。

Vue也有一段时间了,加上最近做的项目里面涉及了很多组件,所幸多花点时间整理整理,方便回顾和下次使用。本文实用的是Vue 2.x版本,后面时机成熟再更新Vue 3版本的封装。

千里之行始于足下,加油打工人~~

组件地址:github

组件功能

具体功能如下:

  • 打开新页面,缓存目标页面;
  • 关闭已打开的页面,清空相应的缓存;
  • 菜单右键实现:重置当前、关闭当前、关闭其他、关闭左侧、关闭右侧、全部关闭;
  • 页面切换动画;

准备

初始化项目:

// @vue/cli 4.5.6
vue create vue-comp

安装必要的依赖:

// element-ui@2.14.1;sass-loader@10.1.0;node-sass@5.0.0;
npm install element-ui node-sass sass-loader --save-dev

// vuex@3.6.0;vue-router@3.4.9;
npm install vuex vue-router --save

简单布局:

准备几个页面并配置路由,大众化的中后台布局,这块就不贴代码了,有需要可参考github上的样例代码(本文样式文件几乎都不会贴出来哈,重要的是思路和逻辑嘛~)。

思路

使用vuexkeep-alive实现中后台项目页面缓存(移动端类似)。

通过keep-aliveinclude属性控制哪些页面需要被缓存;

vuex中创建变量两个变量:

  • cachedViews:用于存储需要缓存的页面的集合(需要特别注意的是,存到数组中的必须是组件的name属性对应的值,缓存功能才能生效)。

    数据格式:['Comp1', 'Comp2', ...]

  • visitedViews:当前访问的所有页面的集合(存入的值为当前路由的信息,即:vm.$route)。

    数据格式:[{ path: '/comp1', name: 'Comp1', ... }, { path: '/comp2', name: 'Comp2', ... }, ...]

实现思路:

  • 当访问页面时,将当前路由信息存入visitedViews,并判断该页面是否需要被缓存(在设置路由时,添加meta对象,设置title,有则表示需要缓存,否则反之。具体的判断条件可根据业务场景制定),如需要,则将当前路由的name存入cachedViews
  • 清空页面时,移除cachedViewsvisitedViews中对应的项。

基础实现

创建存储变量

vuex 中新建cache.js

const state = {
  visitedViews: [],
  cachedViews: []
}

const mutations = {}

const actions = {}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

设置 keep-alive

修改页面渲染的组件(我这里是创建了AppMain.vue组件进行页面渲染),使其支持缓存功能。

在组件中设置了两个计算属性:

  • cachedViews:当前已缓存的项。

  • key:用于标识路由,值设置为当前路由的fullPath

这里做一下补充,关于router-view的属性key,不同设置之间的区别:

  • 不设置key

    同一组件之间跳转时(例如:/comp/a => /comp/b/comp?param=a => /comp?param=b),将不再执行createdmounted,获取数据的操作需要放到beforeRouteUpdate方法中执行。

    钩子函数执行的顺序:beforeRouteUpdate

  • 设置key$route.path

    1. /comp/a => /comp/b:动态路由,$route.path的值不一样,所以组件不会复用。

      钩子函数执行的顺序:beforeRouteUpdate => created => mounted

    2. /comp?param=a => /comp?param=b:$route.path的值一样,所以此时和不设置key`的情况一致。

      钩子函数执行的顺序:beforeRouteUpdate

  • 设置key$route.fullPath

    /comp/a => /comp/b/comp?param=a => /comp?param=b:对应的$route.fullPath的值不一样,所以组件不会复用。

    钩子函数执行的顺序:beforeRouteUpdate => created => mounted

<template>
  <section class="app-main">
    <div class="app-main-content">
      <keep-alive :include="cachedViews">
        <router-view :key="key"/>
      </keep-alive>
    </div>
  </section>
</template>

<script>
import { mapState } from 'vuex'
export default {
  name: 'AppMain',
  computed: {
    ...mapState('cache', ['cachedViews']),
    key() {
      return this.$route.fullPath
    }
  },
}
</script>

设置 tabs

作用:作为打开过的页面的快捷访问入口。

新建组件TabsView,组件样式可以参照element-ui里的“tabs”标签页。我这里之所以自己写样式是因为我觉得这样组件的可控性更高,同时也方便以后业务方面的一些扩展。

<template>
  <div class="tabs-view-container">
    <div class="tabs-view-wrapper">
      <router-link
        v-for="tab in visitedViews"
        :key="tab.name"
        :class="`tabs-view-item ${isActive(tab) ? 'active' : ''}`"
        ref="tab"
        :to="tab.fullPath"
        tag="span"
      >
        {{tab.title}}
        <i class="el-icon-close" />
      </router-link>
    </div>
  </div>
</template>

<script>
export default {
  name: 'TabsView',
  data() {
    return {
      visitedViews: [{title: '概览', name: 'Dashboard'}, {title: '测试'}]
    }
  },
  methods: {
    isActive(route) {
      return route.name === this.$route.name
    },
  }
}
</script>

先来看一下这个组件。

这里先写死一个变量用来循环router-link,中间显示的内容是路由信息里面设置的title。通过函数 isActive判断当前激活的项,并设置相应的类名(因为要传入参数,所以用的是函数而不是计算属性)。

此时系统的显示情况如下图所示:

添加缓存页面

前面的准备工作基本是已经做好了,下面进入核心阶段~

由于变量visitedViewscachedViews是放到vuex中进行管理的,所以数据的操作依赖对应的mutationaction

mutations对象中添加两个函数:

  • ADD_VISITED_VIEW:添加已访问的页面。
  • ADD_CACHED_VIEW:添加需要缓存的页面。
const mutations = {
  // 添加
  ADD_VISITED_VIEW(state, view) {
  	if (view.meta && view.meta.hidden) return
    if (state.visitedViews.some(v => v.name === view.name)) return
    state.visitedViews.push(Object.assign({}, view, {
      title: view.meta.title || 'no-name'
    }))
  },
  // 如果设置了 noCache,则不需要缓存(可自定义条件)。
  ADD_CACHED_VIEW(state, view) {
    if (view.meta && view.meta.hidden) return
    if (state.cachedViews.includes(view.name)) return
    if (!view.meta.noCache) {
      state.cachedViews.push(view.name)
    }
  }
}

actions对象中添加三个函数:

  • addView:分发addVisitedViewaddCachedView
  • addVisitedView:提交ADD_VISITED_VIEW
  • addCachedView:提交ADD_CACHED_VIEW
const actions = {
  // 添加
  addView({ dispatch }, view) {
    dispatch('addVisitedView', view)
    dispatch('addCachedView', view)
  },
  addVisitedView({ commit }, view) {
    commit('ADD_VISITED_VIEW', view)
  },
  addCachedView({ commit }, view) {
    commit('ADD_CACHED_VIEW', view)
  }
}

修改TabsViewscript

简单分析一下:

修改的部分主要是将上面写死的visitedViews的值改成了通过addTabs函数动态添加的值。在初始化组件和路由切换的时候执行该函数,同时通过变量selectedTab存储当前显示页面的路由信息(后面会用到哦)。

<script>
import { mapState, mapActions } from 'vuex'

export default {
  name: 'TabsView',
  data() {
    return {
      selectedTab: {}, // 记录当前 tab
    }
  },
  computed: {
    ...mapState('cache', ['visitedViews']),
  },
  watch: {
    $route() {
      this.addTabs()
    }
  },
  created() {
    this.addTabs()
  },
  methods: {
    ...mapActions('cache', ['addView']),
    isActive(route) {
      return route.name === this.$route.name
    },
    // 添加 缓存页
    addTabs() {
      if (this.$route.name) {
        this.addView(this.$route)
        this.selectedTab = this.$route
      }
      return false
    },
  }
}
</script>

测试一下:现在访问各个页面,tabs标签栏会将未添加过的页面添加上;刷新页面,会清空tabs标签栏并将当前页面作为初始页面(如果需要刷新的时候保留前面打开过的项,可以把数据同步存储在 sessionStorage里,初始化的时候读取 sessionStorage里的值。这个功能这里就不讲啦,逻辑还是很简单的)。

有了添加,下面就是删除了~

删除缓存页面

删除缓存页面的实现方式和上一节基本一致,但需要注意的是,删除的项如果是当前显示的页面,则需要进行页面跳转。

mutations对象中添加两个函数:

  • DEL_VISITED_VIEW:删除已访问的页面。
  • DEL_CACHED_VIEW:删除缓存中的记录。
const mutations = {
  // ...
  // 删除单个 [i, v] => [0, {name: 'xxx'}]
  DEL_VISITED_VIEW(state, view) {
    for (const [i, v] of state.visitedViews.entries()) {
      if (v.name === view.name) {
        state.visitedViews.splice(i, 1)
        break
      }
    }
  },
  DEL_CACHED_VIEW(state, view) {
    const index = state.cachedViews.indexOf(view.name)
    index > -1 && state.cachedViews.splice(index, 1)
  }
}

actions对象中添加三个函数:

  • delView:分发delVisitedViewdelCachedView
  • delVisitedView:提交DEL_VISITED_VIEW
  • delCachedView:提交DEL_CACHED_VIEW
const actions = {
  // ...
  // 删除
  delView({ dispatch }, view) {
    dispatch('delVisitedView', view)
    dispatch('delCachedView', view)
  },
  delVisitedView({ commit }, view) {
    commit('DEL_VISITED_VIEW', view)
  },
  delCachedView({ commit }, view) {
    commit('DEL_CACHED_VIEW', view)
  },
}

修改TabsViewscript部分:

分析:

删除缓存页面我是从以下几个方面去思考的(执行删除动作后):

  • visitedViews数组有值。

    两种情况:

    1. 触发删除的项不是当前显示的页面,正常删除即可。
    2. 触发删除的项当前显示的页面,则删除后需要切换当前激活的页面(本文是切换为最后一项)。
  • visitedViews数组没有值。

    两种情况:

    1. 触发删除的项不是概览页面,则跳转到概览页面。
    2. 触发删除的项概览页面,则重新加载概览页面。
<template>
  <!-- ... -->
  <i class="el-icon-close" @click.prevent.stop="closeSelectedTab(tab)" />
  <!-- ... -->
<template/>
<script>
import { mapState, mapActions } from 'vuex'

export default {
  name: 'TabsView',
  
  // ...
  
  methods: {
    ...mapActions('cache', ['addView', 'delView']),
    
    // ...
    
    closeSelectedTab(tab) {
       // 删除 tab
      this.delView(tab)
      // 更新视图
      this.toUpdateView(tab)
    },
    // 更新视图
    toUpdateView(tab) {
      const latestView = this.visitedViews.slice(-1)[0]
      if (latestView) { // 当前还存在访问过的页面
        // 删除项的name 和 当前路由的name 一致时
        if (tab.name === this.$route.name) this.$router.push(latestView.fullPath)
      } else { 
        if (tab.name === 'Dashboard') { // 当最后一个删除的项是概览页时,需要 "重新加载" 概览页
          // TODO 临时方案
          this.$router.push({
            path: '/common',
            query: {
              test: Math.random() * 1000
            }
          })
        } else this.$router.push('/common')
      }
    }
  }
}
</script>

这里只贴了修改的部分(没有全贴出来是为了一方面突出修改的部分,另一方面是避免篇幅太长,所以小伙伴们就先凑合看吧~)。

这里预留了一个功能,在重新加载概览页面的地方。由于现在还没有做“重置当前”的功能,所以就先用了临时方案:页面跳转的方式,实现的“重新加载”功能。

根据上面的分析思路进行测试,当然,我们还需要测试一下页面切换是否可以实现缓存。

随便写个输入框进行测试,执行下面两条“用例”:

  • 输入内容后跳转其他页面再返回,内容还在;
  • 关闭页面再打开,内容清空;

至此,基本的添加、删除功能已经实现了~

下面我们来进行功能扩展。

功能扩展

添加tab右键菜单,提供以下功能:重置当前、关闭当前、关闭其他、关闭左侧、关闭右侧、全部关闭。

右键菜单

实现这些扩展功能之前,要先在TabsView组件中实现右键菜单,主要包括菜单的:样式和内容、显示/隐藏、位置、点击事件的功能。

样式和内容

  • menuVisible:控制右键菜单显示/隐藏;

  • menuPos:控制菜单位置;

  • menuConfig:控制当前tab是否有 关闭左侧和关闭右侧 的功能;

  • menuMinWidthtab DOM 宽度的最小值(后面用于计算菜单的位置);

<template>
  <div class="tabs-view-container">
    <!-- ... -->
    <ul v-show="menuVisible" :style="menuPos" class="contextmenu">
      <li @click="refreshSelectedTab">重置当前</li>
      <li @click="closeSelectedTab(selectedTab)">关闭当前</li>
      <li @click="closeOthersTabs">关闭其他</li>
      <li :class="{'not-allowed': menuConfig.left}" @click="closeDirTabs('left')">关闭左侧</li>
      <li :class="{'not-allowed': menuConfig.right}" @click="closeDirTabs('right')">关闭右侧</li>
      <li @click="closeAllTabs">全部关闭</li>
    </ul>
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex'

export default {
  name: 'TabsView',
  data() {
    return {
      selectedTab: {}, // 记录当前 tab
      menuVisible: false,
      menuPos: {},
      menuConfig: {
        left: true,
        right: true,
      },
      menuMinWidth: 100,
    }
  },
  
  // ...
  
  methods: {
    // ...
    
    // 重置当前页
    refreshSelectedTab() {},
    // 关闭当前 缓存页
    closeSelectedTab(tab) {},
    // 关闭其他 缓存页
    closeOthersTabs() {},
    // 删除 左侧/右侧 缓存页
    closeDirTabs() {},
    // 关闭所有 缓存页
    closeAllTabs() {}
  }
}
</script>

显示和隐藏

实现步骤:

  • router-link标签上添加右键事件:

    @contextmenu.prevent.native="openMenu($event, tab)"

  • 实现打开、关闭方法:

    1. 当点击tab时,触发openMenu,打开菜单并确定菜单的位置。

    2. 设置watch监听menuVisible变量,当菜单显示时,给body绑定关闭菜单的事件,触发点击后,关闭菜单。当菜单隐藏时,移除监听的事件。

<template>
  <div class="tabs-view-container">
    <div class="tabs-view-wrapper">
      <router-link
        v-for="tab in visitedViews"
        :key="tab.name"
        :class="`tabs-view-item ${isActive(tab) ? 'active' : ''}`"
        ref="tab"
        :to="tab"
        tag="span"
        @contextmenu.prevent.native="openMenu($event, tab)"
      >
        {{tab.title}}
        <i class="el-icon-close" @click.prevent.stop="closeSelectedTab(tab)" />
      </router-link>
    </div>
    <!-- ... -->
  </div>
</template>
<script>
  import { mapState, mapActions } from 'vuex'

export default {
  // ...
  watch: {
    // ...
    menuVisible(boolean) {
      if (boolean) document.body.addEventListener('click', this.closeMenu)
      else document.body.removeEventListener('click', this.closeMenu)
    }
  },
  methods: {
    // ...
    // 打开 缓存页签 右键菜单
    openMenu(e, tag) {
      this.selectedTab = tab
    },
    // 关闭 缓存页签 右键菜单
    closeMenu() {
      this.menuVisible = false
    },
  }
}
</script>

openMenu函数的实现:

openMenu(e, tab) {
  this.menuConfig = {
    left: this.visitedViews[0].name === tab.name,
    right: this.visitedViews[this.visitedViews.length - 1].name === tab.name
  }
  const offsetWidth = this.$el.offsetWidth
  // 右键菜单 left 最大值
  const maxLeft = offsetWidth - this.menuMinWidth
  const left = e.clientX - 200
  const top = e.offsetY

  this.menuVisible = true
  this.menuPos = {
    left: `${left > maxLeft ? maxLeft : left}px`,
    top: `${top}px`
  }
  this.selectedTab = tab
}

这个函数做了三个事情:

  • 判断菜单功能(是否需要关闭左侧/关闭右侧)

    当前触发右键事件的项的name等于visitedViews第一项name时,表示触发项为第一项,则不需要“关闭左侧”的功能(左侧没有页面需要关闭);

    当前触发右键事件的项的name等于visitedViews最后一项name时,表示触发项为最后一项,则不需要“关闭右侧”的功能(右侧没有页面需要关闭)。

  • 控制菜单位置

    这里控制位置的时候需要注意的是,计算过程会受样式的影响(菜单相对于谁来定位会影响计算方式。我这里是相对于 div.tabs-view-container进行定位的)。

    offsetWidthdiv.tabs-view-container的宽度。

    maxLeft:设置菜单的left的最大值。

    e.clientX:事件发生时鼠标指针距离客户端左边的水平坐标。

    menuMinWidth:菜单DOM的最小宽度(这里默认是100)(也可以理解成是:当菜单处于最右侧时,优化的偏移量)。

    结合下图,应该还是很容易理解菜单位置的计算逻辑的~

  • 打开菜单

    将变量menuVisible置为true

测试一下,现在菜单可以正常打开了。

下面开始完善菜单功能~

重置当前

实现思路是:当前页面 -> 进入中转页面 -> 调用路由的replace方法,从而实现页面刷新。

新建中转页面:Transfer.vue

<script>
export default {
  created() {
    const { params, query } = this.$route
    const { path } = params
    this.$router.replace({
      path: '/' + path,
      query
    })
  },

  render() {
    return (<div></div>)
  }
}
</script>

添加路由:

{
  path: '/transfer/:path(.*)',
  name: 'transfer',
  component: Transfer
}

修改TabsView组件:

  • 添加重置页面函数reloadPage
  • 修改toUpdateView,将之前的临时跳转方案替换成调用reloadPage
  • 修改refreshSelectedTab:先删除缓存项,再调用reloadPage
<script>
  methods: {
    ...mapActions('tagsView', ['addView', 'delView', 'delCachedView']),
    // ...
    // 重新加载目标页
    reloadPage(tab) {
      this.$router.replace({
        path: `/transfer${tab.fullPath}`,
        query: {
          ...tab.params,
        }
      })
    },
    // 更新视图
    toUpdateView(tab) {
      const latestView = this.visitedViews.slice(-1)[0]
      if (latestView) { // 当前还存在访问过的页面
        // 删除项的name 和 当前路由的name 一致时,需要切换当前激活的 tab 为最后一项;反之,则不需要进行额外操作。
        if (tab.name === this.$route.name) this.$router.push(latestView.fullPath)
      } else {
        // 当最后一个删除的项是概览页时,需要 "重新加载" 概览页,不能直接跳转
        if (tab.name === 'Dashboard') this.reloadPage(tab) // 重新加载
        else this.$router.push('/common')
      }
    },
    // 重置当前页
    refreshSelectedTab() {
      let tab = this.selectedTab
      this.delCachedView(tab)
      this.reloadPage(tab)
    },
    // ...
  }
</script>

关闭当前

直接调用 基础实现 -> 删除缓存页方法closeSelectedTab)。

关闭其他

分析两种情况:

  • 触发项当前页面:直接关闭其他页面。
  • 触发项不是当前页面:关闭其他页面后,需要跳转到触发项对应的页面。

mutations对象中添加两个函数:

  • DEL_OTHERS_VISITED_VIEWS:过滤触发项的路由信息。
  • DEL_OTHERS_CACHED_VIEWS:过滤触发项的name
const mutations = {
  // ...
  // 删除其他
  DEL_OTHERS_VISITED_VIEWS(state, view) {
    state.visitedViews = state.visitedViews.filter(v => v.name === view.name)
  },
  DEL_OTHERS_CACHED_VIEWS(state, view) {
    state.cachedViews = state.cachedViews.filter(name => name === view.name)
  },
}

actions对象中添加三个函数:

  • delOthersViews:分发delOthersVisitedViewsdelOthersCachedViews
  • delOthersVisitedViews:提交DEL_OTHERS_VISITED_VIEWS
  • delOthersCachedViews:提交DEL_OTHERS_CACHED_VIEWS
const actions = {
  // ...
  // 删除其他
  delOthersViews({ dispatch }, view) {
    dispatch('delOthersVisitedViews', view)
    dispatch('delOthersCachedViews', view)
  },
  delOthersVisitedViews({ commit }, view) {
    commit('DEL_OTHERS_VISITED_VIEWS', view)
  },
  delOthersCachedViews({ commit }, view) {
    commit('DEL_OTHERS_CACHED_VIEWS', view)
  },
}

修改TabsViewcloseOthersTags方法:

<script>
  methods: {
    ...mapActions('cache', ['addView', 'delView', 'delCachedView', 'delOthersViews']),
    // ...
    // 关闭其他 缓存页
    closeOthersTabs() {
      let tab = this.selectedTab
      this.delOthersViews(tab)
      if (this.$route.name !== tab.name) this.$router.push(tab.fullPath)
    },
  }
</script>

按照上面的两种情况进行测试,没问题咱们就继续(每一步的测试还是挺重要的,确保每个小功能没问题,有问题也能及时定位)~

关闭全部

“关闭全部”功能还是很简单的,直接清空visitedViewscachedViews,然后重新加载概览页面。

mutations对象中添加两个函数:

  • DEL_ALL_VISITED_VIEWS:清空已访问页面。
  • DEL_ALL_CACHED_VIEWS:清空已缓存页面的name
const mutations = {
  // ...
  // 删除所有
  DEL_ALL_VISITED_VIEWS(state) {
    state.visitedViews = []
  },
  DEL_ALL_CACHED_VIEWS(state) {
    state.cachedViews = []
  },
}

actions对象中添加三个函数:

  • delAllViews:分发delAllVisitedViewsdelAllCachedViews
  • delAllVisitedViews:提交DEL_ALL_VISITED_VIEWS
  • delAllCachedViews:提交DEL_ALL_CACHED_VIEWS
const actions = {
  // ...
  // 删除所有
  delAllViews({ dispatch }, view) {
    dispatch('delAllVisitedViews', view)
    dispatch('delAllCachedViews', view)
  },
  delAllVisitedViews({ commit }) {
    commit('DEL_ALL_VISITED_VIEWS')
  },
  delAllCachedViews({ commit }) {
    commit('DEL_ALL_CACHED_VIEWS')
  },
}

修改TabsViewcloseAllTabs

<script>
  methods: {
    ...mapActions('tagsView', ['addView', 'delView', 'delCachedView', 'delOthersViews', 'delAllViews']),
    // ...
    // 关闭所有 缓存页
    closeAllTags() {
      this.delAllViews()
      this.reloadPage({ fullPath: '/common' })
    },
  }
</script>

继续测试~

一切正常~

关闭左侧/关闭右侧

分析:

关闭左侧/关闭右侧,最关键的就是拿到当前项的下标(index),然后处理数组。

  • 关闭左侧:通过数组slice方法,截取从index到数组末尾的元素,然后重新赋值。
  • 关闭右侧:通过数组splice方法,删除从index到数组末尾的元素。

mutations对象中添加两个函数:

  • DEL_DIR_VISITED_VIEWS:删除触发项左侧或右侧的已访问页面。
  • DEL_DIR_CACHED_VIEWS:删除触发项左侧或右侧的已缓存页面的name
const mutations = {
  // ...
  // 删除 左侧/右侧
  DEL_DIR_VISITED_VIEWS(state, { view, dir }) {
    let visitedViews = [...state.visitedViews]
    for (const [i, v] of visitedViews.entries()) {
      if (v.name === view.name) {
        if (dir === 'right') {
          let len = visitedViews.length - (i + 1) // 右边还有 len 个项
          visitedViews.splice(i + 1, len)
        } else visitedViews = visitedViews.slice(i)
        break
      }
    }
    state.visitedViews = visitedViews
  },
  DEL_DIR_CACHED_VIEWS(state, { view, dir }) {
    const index = state.cachedViews.indexOf(view.name)
    let cachedViews = [...state.cachedViews]
    if (index > -1) {
      if (dir === 'right') {
        let len = cachedViews.length - (index + 1) // 右边还有 len 个项
        cachedViews.splice(index + 1, len)
      } else cachedViews = cachedViews.slice(index)
    }
    state.cachedViews = cachedViews
  },
}

actions对象中添加三个函数:

  • delDirViews:分发delDirVisitedViewsdelDirCachedViews
  • delDirVisitedViews:提交DEL_DIR_VISITED_VIEWS
  • delDirCachedViews:提交DEL_DIR_CACHED_VIEWS
const actions = {
  // ...
  // 删除 左侧/右侧
  delDirViews({ dispatch }, { view, dir }) {
    dispatch('delDirVisitedViews', { view, dir })
    dispatch('delDirCachedViews', { view, dir })
  },
  delDirVisitedViews({ commit }, { view, dir }) {
    commit('DEL_DIR_VISITED_VIEWS', { view, dir })
  },
  delDirCachedViews({ commit }, { view, dir }) {
    commit('DEL_DIR_CACHED_VIEWS', { view, dir })
  },
}

修改TabsViewcloseDirTabs方法:

  1. 判断是否有可删除的“左侧/右侧”,有则执行删除函数,反之则直接return

  2. 判断当前路由对应的页面是否包含在删除项内(执行循环,拿当前路由的name进行匹配),如果包含,则跳转到最后一项对应的页面。

<script>
  methods: {
    ...mapActions('tagsView', ['addView', 'delView', 'delCachedView', 'delOthersViews', 'delAllViews', 'delDirViews']),
    // ...
    // 删除 左侧/右侧 缓存页
    closeDirTabs(dir) {
      if (this.menuConfig[dir]) return
      let tab = this.selectedTab
      this.delDirViews({
        view: tab,
        dir: dir
      })
      let needUpdateVisited = true
      for (const [, v] of this.visitedViews.entries()) {
        if (this.$route.name === v.name) {
          needUpdateVisited = false
          break
        }
      }
      if (needUpdateVisited) this.$router.push(this.visitedViews.slice(-1)[0].fullPath)
    },
  }
</script>

测试~

“小”功告成~

至此整个缓存组件的既定功能已经完成了,但是还有两个需要优化的地方:

  • 页面切换动画:现在的页面切换太过生硬,需要加上过渡动画。
  • 标签栏滚动:当打开的页面较多时,可能会出现换行,所以我们需要在外层添加一个可滚动的“盒子”,防止样式错乱。

页面过渡动画

还得记得之前一开始的AppMain组件吗,我们稍微对它进行一些修改:

添加transition标签,并指定namefade,过渡模式modeout-in(当前元素先进行过渡,完成之后新元素过渡进入)。

<template>
  <section class="app-main">
    <div class="app-main-content">
      <transition name="fade" mode="out-in">
        <keep-alive :include="cachedViews">
          <router-view :key="key"/>
        </keep-alive>
      </transition>
    </div>
  </section>
</template>

新建样式文件:transition.scss,并添加过渡样式:

/* global transition css */

/* fade */
.fade-leave-active,
.fade-enter-active {
  transition: all .3s;
}

.fade-enter {
  opacity: 0;
  transform: translateX(-30px);
}

.fade-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

在入口文件main.js中引入transition.scss

import '@/style/transition'

实现滚动

为什么要单独开一个菜单写滚动呢?

是因为我今天无意中看到了el-scrollbar这个组件(由element-ui提供),虽然文档里面没有相关的说明,但是可以直接使用,所以我就在这里进行了一下测试(主要是对滚动条的样式做了统一处理)~

修改TabsView

<template>
  <div class="tabs-view-container">
    <el-scrollbar class="tabs-view-scroll">
      <div class="tabs-view-wrapper">
        <router-link
                v-for="tab in visitedViews"
                :key="tab.name"
                :class="`tabs-view-item ${isActive(tab) ? 'active' : ''}`"
                ref="tab"
                :to="tab"
                tag="span"
                @contextmenu.prevent.native="openMenu($event, tab)"
        >
          {{tab.title}}
          <i class="el-icon-close" @click.prevent.stop="closeSelectedTab(tab)" />
        </router-link>
      </div>
    </el-scrollbar>
    <!-- ... -->
  </div>
</template>

效果图:

效果还是棒棒哒💯(感谢element团队)!

“大”功告成!

总结

本文的技术栈主要涉及了vuevuexvue-routerkeep-alivetransitionelement-ui,在组件的实现过程中,也算是回顾了它们的一些基础知识和用法。当然,有时候会有更复杂的业务场景,但是我觉得大部分都可以在路由里面配置不同的参数,然后进行相应的处理(emmm...目前我还没遇到很特别的情况,有的话欢迎一起讨论~)。

另外再聊聊为什么会想到写这个组件。

最近在改一个重构的项目,看了之前同事封装的一些组件,有所收获。所以结合自己的想法把一部分组件进行了重写,同时计划 把思考的过程记录下来。不足之处还请指教~

最后,祝看官大大们周末愉快,顺便给个👍(赞)吧~