Bootstrap

vue实现多标签页,并实现可拖动、可刷新的标签栏

前言

最近用vue做后台管理,需要多标签页功能,模仿浏览器的标签栏,可拖动,右键菜单有关闭标签、刷新标签功能。
网上相关教程很多,基本上就是用keep-alive缓存router-view,用include、exclude控制缓存,但我这里路由用的是异步加载的匿名组件,而且有可能多个路由共用一个组件,include、exclude不能匹配匿名组件。
我网上翻阅了很多相关文章,最终找到了 Vue匿名组件使用keep-alive后动态清除缓存这篇文章。参考此文章,基本实现了需求。

标签栏组件

TabBar.vue

<template>
  <div class="panel-bar" ref="tab-bar" onselectstart="return false;" :style="{width: panelBarWidth + 'px'}">
    <template v-for="(tab, index) in tabList">
      <!--由于v-for更新时不会根据数据顺序调整dom顺序, 需要添加唯一key解决此问题-->
      <div
          :key="tab.path"
          :ref="'tab_' + index"
          class='panel panel-transition'
          :style="{left: index*panelWidth + 'px', width: panelWidth + 'px'}" :url="tab.path"
      >
        <div
            :class="['panel-content', {'panel-selected ': tab.path === currentTab.path}]"
            @mousedown.left="mousedownLeft($event, index)"
            @click.middle.stop="closeTab(index, 1)"
            @mousedown.right.stop="mouseRightDown"
            @mouseup.right.stop="mouseRightUp(index, $event)"
        >
          <span class="tab-text" :title="tab.name">{{ tab.name }}</span>
          <span class="panel-icon-wrapper" @click.stop="closeTab(index, 1)" @mousedown.left.stop="">
            <i class="fa fa-close"></i>
          </span>
        </div>
      </div>
    </template>

    <!--右键菜单-->
    <div class="right-menu"
         ref="right-menu"
         v-if="rightMenu.showRightMenu"
         :style="{left: rightMenu.rightMenuLeft + 'px', top: rightMenu.rightMenuTop + 'px', width: rightMenu.rightMenuWidth + 'px'}"
         v-click-outside="closeRightMenu"
    >
      <div @click="refreshPage(rightMenu.currentIndex);closeRightMenu();" class="right-menu-item">刷新当前标签</div>
      <div @click="refreshAllPage();closeRightMenu();" class="right-menu-item">刷新所有标签</div>
      <div @click="closeTab(0, tabList.length);closeRightMenu();" class="right-menu-item">关闭所有标签</div>
      <div @click="closeTab(rightMenu.currentIndex, 1);closeRightMenu();" class="right-menu-item">关闭当前标签</div>
      <div @click="closeTab(0, rightMenu.currentIndex);closeRightMenu();" v-if="rightMenu.currentIndex > 0" class="right-menu-item">关闭左侧标签</div>
      <div @click="closeTab(rightMenu.currentIndex + 1, tabList.length - rightMenu.currentIndex - 1);closeRightMenu();" v-if="rightMenu.currentIndex < tabList.length - 1"
           class="right-menu-item">关闭右侧标签
      </div>
    </div>


  </div>
</template>

<script>
import ClickOutside from "@/directives/clickoutside";
import './IndexTabBarConfig'


export default {
  name: "IndexTabBar",
  directives: {ClickOutside},
  data() {
    return {
      tabList: this.$store.state.indexTabBarStore.indexTabBar.list,
      currentTab: this.$store.state.indexTabBarStore.indexTabBar.current,
      panelWidth: 160,
      list: [],  //本地保存的list数据, 区分props入参tabList, 因为在鼠标拖动标签时需要实时记录和修改list数据, 这时直接修改props入参会引发双向绑定的dom重新渲染, 引起一些问题
      panelBarWidth: 0,
      rightMenu: {
        showRightMenu: false,//是否显示右键菜单
        rightMenuLeft: 0,
        rightMenuTop: 0,
        rightMenuWidth: 100,
        currentIndex: -1
      }

    }
  },
  watch: {
    '$store.state.indexTabBarStore.indexTabBar.list': function (newValue, oldValue) {
      this.tabList = this.$store.state.indexTabBarStore.indexTabBar.list;
    },
    '$store.state.indexTabBarStore.indexTabBar.current': function (newValue, oldValue) {
      this.currentTab = this.$store.state.indexTabBarStore.indexTabBar.current;
    }
  },
  updated() {
    this.initData();
  },
  mounted() {
    this.initData();
    if (this.list.length === 0) {
      this.$router.push('/home').catch(() => {
      });
    }
  },
  beforeDestroy() {

  },
  methods: {
    mouseRightDown: function () { //屏蔽浏览器右键菜单
      document.oncontextmenu = () => {
        return false;
      }
    },
    mouseRightUp: function (index, event) {
      this.rightMenu.showRightMenu = true;
      this.rightMenu.currentIndex = index;
      this.rightMenu.rightMenuTop = event.clientY;
      if (event.clientX + this.rightMenu.rightMenuWidth > window.innerWidth) {
        this.rightMenu.rightMenuLeft = event.clientX - this.rightMenu.rightMenuWidth;
      } else {
        this.rightMenu.rightMenuLeft = event.clientX;
      }

      window.setTimeout(() => { //恢复浏览器右键菜单
        document.oncontextmenu = () => {
          return true;
        }
      })
    },
    closeRightMenu: function () {
      this.$nextTick(() => {  //如果不用$nextTick, 关闭标签时就没有右侧标签向左移动的动画, 原因未知
        this.rightMenu.showRightMenu = false;
      })
    },
    /*
    * 初始化本地list数据
    * */
    initData: function () {
      this.panelBarWidth = this.tabList.length * this.panelWidth;
      let list = JSON.parse(JSON.stringify(this.tabList));
      let that = this;
      list.forEach((item, index) => {
        item.left = index * that.panelWidth;
        item.el = that.$refs['tab_' + index][0];
      });
      this.list = list;
    },
    closeTab: function (index, howmany) { // todo 清除keep-alive缓存
      let closeSelectedTab = false;//是否关闭当前页面标签
      for (let i = index; i < index + howmany; i++) {
        let item = this.list[i];
        this.$store.commit('indexTabBarStore/removeIndexTabBarCache', item.path);  //清除keep-alive缓存
        if (item.path === this.currentTab.path) {
          closeSelectedTab = true;
          break;
        }
      }
      this.list.splice(index, howmany); //删除标签数据
      this.$store.commit('indexTabBarStore/refreshIndexData', {list: this.list});
      if (closeSelectedTab) { //如果被关闭的标签是当前页面标签, 那么关闭后打开最后一个标签
        let tab = this.list[this.list.length - 1];
        if (!tab) { //如果已经关闭了所有标签 那么回到index空白页
          tab = {
            name: '',
            path: '/'
          }
        }
        this.$nextTick(() => {
          this.$router.push(tab.path).catch(() => {
          });
          this.$store.commit('indexTabBarStore/selectThisPanel', tab);
        })
      }
    },
    refreshPage: function (index) {
      this.$store.commit('indexTabBarStore/removeIndexTabBarCache', this.list[index].path);
      if (this.$route.path === this.list[index].path) {
        this.$router.push('/');
        this.$nextTick(() => {
          this.$router.push(this.list[index].path);
        })
      }
    },
    refreshAllPage: function () {
      this.$store.commit('indexTabBarStore/clearAllCache');
      let path = this.$route.path;
      this.$router.push('/');
      this.$nextTick(() => {
        this.$router.push(path);
      })
    },
    //按下鼠标左键
    mousedownLeft: function (event, index) {
      let that = this;
      let currentItem = this.list[index];
      this.$nextTick(() => {
        this.$router.push(currentItem.path).catch(() => {
        });
        this.$store.commit('indexTabBarStore/selectThisPanel', currentItem);
      })
      let startPosition = event.screenX;  //记录鼠标点击标签时的起始位置
      let ele = this.list[index].el;//获取当前点击的标签的元素
      ele.classList.remove('panel-transition');//点击的标签不能有transition
      ele.style.zIndex = 9;  //当前标签可以覆盖其他标签
      let offsetLeft = currentItem.left;  //当前标签的left值
      let targetIndex = index;  //标签所在位置, 起始位置为list位置
      let moveReturn = false; //开始拖动鼠标时有5px的延迟, 防止出现点击误拖动, 拖动后拖回原位置时屏蔽这个延迟以防止出现抖动
      //-----------------------------
      let mousemove = (event) => {
        let screenX = event.screenX;  //鼠标左键点击后移动的位置
        let offset = screenX - startPosition;//计算鼠标移动的距离
        if (!moveReturn && offset < 5 && offset > -5) {  //防止点击操作时出现移动
          return
        } else {
          moveReturn = true;
        }
        if (offsetLeft + offset > 0) {//标签不能在左边超出标签栏
          ele.style.left = offsetLeft + offset + 'px';
          targetIndex = this.positionCompute(this.list[targetIndex].left + offset, targetIndex);//计算标签当前位置,并移动旁边的标签
        } else if (offset + offsetLeft <= 0) {
          ele.style.left = 0 + 'px';
          targetIndex = this.positionCompute(0, targetIndex);
        }
      };
      //---------------------鼠标左键松开
      let mouseup = () => {
        ele.classList.add('panel-transition');//恢复过渡
        ele.style.zIndex = 'auto';//z-index恢复
        ele.style.left = this.panelWidth * targetIndex + 'px';//定位到最终位置
        document.removeEventListener('mousemove', mousemove);//移除mousemove实际
        document.removeEventListener('mouseup', mouseup);//移除mouseup事件
        //把调整过的list数据, 传递给父组件
        window.setTimeout(() => {
          that.$store.commit('indexTabBarStore/refreshIndexData', {list: this.list});
        }, 200)
      };

      //添加mousemove和mouseup监听
      document.addEventListener('mousemove', mousemove);
      //鼠标离开页面时无法获取mouseup事件, 需要用js事件委托,绑定到document对象上
      document.addEventListener('mouseup', mouseup);

    }
    ,
    positionCompute: function (left, index) {
      let currentItem = this.list[index];
      let targetIndex = Math.round(left / this.panelWidth);//计算标签位置, 四舍五入
      if (targetIndex < 0) {
        targetIndex = 0;
      }
      if (targetIndex > this.list.length - 1) {//标签位置最大为标签总数减一
        targetIndex = this.list.length - 1;
      }
      if (targetIndex !== index) {//标签移动到其他标签的位置, 则这个其他标签移动到当前标签原来位置
        this.list[targetIndex].el.style.left = index * this.panelWidth + 'px';
        this.list[index] = this.list[targetIndex];
        this.list[targetIndex] = currentItem;
      }
      return targetIndex;
    }
  }
}
</script>

<style scoped>
* {
  box-sizing: border-box;
}

.panel-bar {
  font-size: 0;
  position: relative;
  user-select: none;
  /*background-color: #E7EAED;*/
  height: 30px;
  display: inline-block;
}

.panel {
  display: inline-block;
  position: absolute;
  font-size: 13px;
  line-height: 30px;
  height: 30px;
  cursor: pointer;
  overflow: hidden; /*解决莫名其妙出现的纵向滚动条*/
  /*background-color: white;*/
}

.panel-content {
  position: relative;
  display: inline-block;
  background-color: #E7EAED;
  width: 100%;
  border: 1px solid lightgrey;
  padding-left: 10px;
  height: 30px;
  line-height: 30px;
  vertical-align: middle;
  border-radius: 3px;
}

.tab-text {
  display: inline-block;
  width: calc(100% - 30px);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.panel-icon-wrapper {
  display: inline-block;
  height: 28px;
  width: 30px;
  line-height: 30px;
  position: absolute;
  text-align: center;
  right: 0;
}

.panel-icon-wrapper:hover {
  color: red;
}

.panel-transition {
  transition: left 150ms linear;
}

.panel-selected {
  background-color: white !important;
}

.right-menu {
  position: fixed;
  /*width: 150px;*/
  background-color: white;
  box-shadow: 0 0 3px grey;
  font-size: 12px;
  line-height: 30px;
  text-align: center;
  border-radius: 3px;
  padding: 5px 0;
  z-index: 100;
}

.right-menu-item {
  cursor: pointer;
  width: 100px;
}

.right-menu-item:hover {
  background-color: #e8e7e7;
}
</style>

vuex配置

标签路由和缓存信息由vuex管理

/**
 * 标签栏vuex配置
 */
export const indexTabBarStore = {
    namespaced: true,
    state: {
        indexTabBar: {
            list: [
                // {
                //   name: '主页',
                //   path: '/home',
                //   // closeable: false
                // }
            ],
            current: {
                name: '主页',
                path: '/home'
            },
            cache: null,
            keys: null
        }
    },
    mutations: {
        //增加标签页
        addIndexBar: function (state, tabData) {
            //判断标签页是否已经存在
            let number = state.indexTabBar.list.findIndex((item) => {
                return tabData.path === item.path;
            });
            //只添加不存在的标签页
            if (number === -1) {
                // Object.assign(tabData, {selected: true});
                state.indexTabBar.list.push(tabData);
            }
            //设置新标签页为当前选中的标签页
            state.indexTabBar.current = tabData;
        },
        //刷新标签页, 其实就是list替换
        refreshIndexData: function (state, data) {
            state.indexTabBar.list = data.list;
        },
        //选中标签
        selectThisPanel: function (state, data) {
            state.indexTabBar.current = data;
        },
        //设置标签页缓存, 其实就是获取keep-alive组件的内部cache变量, 只需获取一次即可
        setIndexTabBarCache: function (state, cache) {
            if (!state.indexTabBar.cache) {
                state.indexTabBar.cache = cache.cache;
                state.indexTabBar.keys = cache.keys;
            }
        },
        //清除置顶标签页的缓存
        removeIndexTabBarCache: function (state, key) {
            if (state.indexTabBar.cache[key]) {
                state.indexTabBar.cache[key].componentInstance.$destroy();  //需要执行组件实例的$destroy(), 否则组件仍会占据内存
                delete state.indexTabBar.cache[key]
                let index = state.indexTabBar.keys.indexOf(key)
                if (index > -1) {
                    state.indexTabBar.keys.splice(index, 1)
                }
            }
        },
        //清空所有标签页缓存
        clearAllCache: function (state) {
            console.log(state.indexTabBar.cache);
            for (let key in state.indexTabBar.cache) {
                delete state.indexTabBar.cache[key].componentInstance.$destroy();
                delete state.indexTabBar.cache[key];
                let index = state.indexTabBar.keys.indexOf(key)
                if (index > -1) {
                    state.indexTabBar.keys.splice(index, 1)
                }
            }
        }
    },
    actions: {
        //增加标签页, 此处供全局前置守卫beforeEach调用
        addIndexBar: function (context, tabData) {
            context.commit('addIndexBar', tabData);
        }
    }
}

组件路由守卫

import Vue from 'vue';

/**
 * 刷新标签页需要清空keep-alive的缓存,因此需要获取keep-alive的cache, 且只需获取一次
 * 路由指向的组件的父级是keep-alive组件, 所以在其初次执行时获取其父组件即可获取页面缓存
 */
Vue.mixin({
    beforeRouteEnter(to, from, next) {
        next((vm) => {
            if (vm.$vnode.parent) {
                vm.$store.commit('indexTabBarStore/setIndexTabBarCache', {
                    cache: vm.$vnode.parent.componentInstance.cache,  //cache和keys未出现在官方文档中, 没准下一个版本就没了, 慎用!!!
                    keys: vm.$vnode.parent.componentInstance.keys
                });
            }
        })
    }
})

使用组件

多个路由公用一个组件时只能缓存一个,需要增加key属性区分不同路由

<keep-alive max="15">
  <router-view :key="$route.fullPath"/>
</keep-alive>
;