前言
最近用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>