今天把TagsView部分代码看了一遍,动手搞了下,遇到的问题不多。主要是挺多前端的API不熟悉。
TagsView
没有使用Tag 标签,运用keep-alive和router-view的结合。根据vue-element-admin文档中介绍tags-view 维护了两个数组:
- visitedViews : 用户访问过的页面 就是标签栏导航显示的一个个 tag 数组集合
- cachedViews : 实际 keep-alive 的路由。可以在配置路由的时候通过 meta.noCache 来设置是否需要缓存这个路由 默认都缓存
参考文档中设计,(文档地址)[https://panjiachen.gitee.io/vue-element-admin-site/zh/guide/essentials/tags-view.html#visitedviews-cachedviews]:
由于目前 keep-alive 和 router-view 是强耦合的,而且查看文档和源码不难发现 keep-alive 的 include 默认是优先匹配组件的 name ,所以在编写路由 router 和路由对应的 view component 的时候一定要确保 两者的 name 是完全一致的。(切记 name 命名时候尽量保证唯一性 切记不要和某些组件的命名重复了,不然会递归引用最后内存溢出等问题)。一定要保证两着的名字相同,切记写重或者写错。默认如果不写 name 就不会被缓存,详情见issue
缓存不适合场景
目前缓存的方案对于某些业务是不适合的,比如文章详情页这种 /article/1 /article/2,他们的路由不同但对应的组件却是一样的,所以他们的组件 name 就是一样的,就如前面提到的,keep-alive的 include 只能根据组件名来进行缓存,所以这样就出问题了。目前有两种解决方案:
- 不使用 keep-alive 的 include 功能 ,直接是用 keep-alive 缓存所有组件,这样子是支持前面所说的业务情况的。 前往@/layout/components/AppMain.vue文件下,移除include相关代码即可。当然直接使用 keep-alive 也是有弊端的,他并不能动态的删除缓存,你最多只能帮它设置一个最大缓存实例的个数 limit。相关 issue
- 使用 localStorage 等浏览器缓存方案,自己进行缓存处理
标签的左键切换到对应的菜单,中键判断如果是不是固定的标签就关闭,鼠标右键为刷新关闭其他等功能
v-on
- .stop - 调用 event.stopPropagation()。
- .prevent - 调用 event.preventDefault()。
- .capture - 添加事件侦听器时使用 capture 模式。
- .self - 只当事件是从侦听器绑定的元素本身触发时才触发回调。
- .{keyCode | keyAlias} - 只当事件是从特定键触发时才触发回调。
- .native - 监听组件根元素的原生事件。
- .once - 只触发一次回调。
- .left - (2.2.0) 只当点击鼠标左键时触发。
- .right - (2.2.0) 只当点击鼠标右键时触发。
- .middle - (2.2.0) 只当点击鼠标中键时触发。
- .passive - (2.3.0) 以 { passive: true } 模式添加侦听器
右键事件绑定在contextmenu属性上
当Tags过多超过页面时,通过滚轮的wheel事件来滚动加载TagsView的左右滚动
参考vue-element-admin中源码,这里还是使用了Scrollbar这个组件。并且使用了VUE的内置组件slot。slot组件可以理解为父组件中使用子组件时,组建内涵盖的元素会被插入到子组件的slot位置。举例说明,有父组件root.vue,子组件child.vue,代码如下
root.vue
<div>
<h1>父组件</h1>
<child>
<p>行1</p>
<p>行2</p>
</child>
</div>
child.vue
<div>
<h2>子组件</h2>
<slot/>
</div>
最终结果
<div>
<h1>父组件</h1>
<div>
<h2>子组件</h2>
<p>行1</p>
<p>行2</p>
</div>
</div>
在src>layouts>components下新建TagsView目录,并新建一个ScrollPane.vue。代码如下,样式省略
<template>
<el-scrollbar
ref="scrollContainer"
:vertical="false"
class="scroll-container"
@wheel.native.prevent="handleScroll"
>
<slot />
</el-scrollbar>
</template>
<script>
const tagAndTagSpacing = 4; // tagAndTagSpacing
export default {
name: "ScrollPane",
data() {
return {
left: 0
};
},
computed: {
scrollWrapper() {
return this.$refs.scrollContainer.$refs.wrap;
}
},
methods: {
handleScroll(e) {
const eventDelta = e.wheelDelta || -e.deltaY * 40;
const $scrollWrapper = this.scrollWrapper;
$scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4;
},
moveToTarget(currentTag) {
const $container = this.$refs.scrollContainer.$el;
const $containerWidth = $container.offsetWidth;
const $scrollWrapper = this.scrollWrapper;
const tagList = this.$parent.$refs.tag;
let firstTag = null;
let lastTag = null;
// find first tag and last tag
if (tagList.length > 0) {
firstTag = tagList[0];
lastTag = tagList[tagList.length - 1];
}
if (firstTag === currentTag) {
$scrollWrapper.scrollLeft = 0;
} else if (lastTag === currentTag) {
$scrollWrapper.scrollLeft =
$scrollWrapper.scrollWidth - $containerWidth;
} else {
// find preTag and nextTag
const currentIndex = tagList.findIndex(item => item === currentTag);
const prevTag = tagList[currentIndex - 1];
const nextTag = tagList[currentIndex + 1];
// the tag's offsetLeft after of nextTag
const afterNextTagOffsetLeft =
nextTag.$el.offsetLeft + nextTag.$el.offsetWidth + tagAndTagSpacing;
// the tag's offsetLeft before of prevTag
const beforePrevTagOffsetLeft =
prevTag.$el.offsetLeft - tagAndTagSpacing;
if (
afterNextTagOffsetLeft >
$scrollWrapper.scrollLeft + $containerWidth
) {
$scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth;
} else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
$scrollWrapper.scrollLeft = beforePrevTagOffsetLeft;
}
}
}
}
};
</script>
因为配置了国际化, 需要转换一下自定义的显示,所以要新建了一个工具的方法,用来转换。在src>utils新建i18n.js,代码如下
// translate router.meta.title, be used in breadcrumb sidebar tagsview
export function generateTitle(title) {
const hasKey = this.$te("route." + title);
if (hasKey) {
// $t :this method from vue-i18n, inject in @/lang/index.js
const translatedTitle = this.$t("route." + title);
return translatedTitle;
}
return title;
}
按照vue-element-admin的思路,Tags都是router-link,而鼠标右键的事件绑定了页面可见性。同时要看route中是否配置了Affix属性,如果配置了则不会被删除,Tag就会固定。而针对Tag的操作,均在vuex中完成。在src>store>modules下新建一个tagsView.js,代码如下,并注册相关代码。同时在getters.js中配置visitedViews和cachedViews的获取。
tagsView.js
const state = {
visitedViews: [],
cachedViews: []
};
const mutations = {
ADD_VISITED_VIEW: (state, view) => {
if (state.visitedViews.some(v => v.path === view.path)) return;
state.visitedViews.push(
Object.assign({}, view, {
title: view.meta.title || "no-name"
})
);
},
ADD_CACHED_VIEW: (state, view) => {
if (state.cachedViews.includes(view.name)) return;
if (!view.meta.noCache) {
state.cachedViews.push(view.name);
}
},
DEL_VISITED_VIEW: (state, view) => {
for (const [i, v] of state.visitedViews.entries()) {
if (v.path === view.path) {
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);
},
DEL_OTHERS_VISITED_VIEWS: (state, view) => {
state.visitedViews = state.visitedViews.filter(v => {
return v.meta.affix || v.path === view.path;
});
},
DEL_OTHERS_CACHED_VIEWS: (state, view) => {
const index = state.cachedViews.indexOf(view.name);
if (index > -1) {
state.cachedViews = state.cachedViews.slice(index, index + 1);
} else {
// if index = -1, there is no cached tags
state.cachedViews = [];
}
},
DEL_ALL_VISITED_VIEWS: state => {
// keep affix tags
const affixTags = state.visitedViews.filter(tag => tag.meta.affix);
state.visitedViews = affixTags;
},
DEL_ALL_CACHED_VIEWS: state => {
state.cachedViews = [];
},
UPDATE_VISITED_VIEW: (state, view) => {
for (let v of state.visitedViews) {
if (v.path === view.path) {
v = Object.assign(v, view);
break;
}
}
}
};
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);
},
delView({ dispatch, state }, view) {
return new Promise(resolve => {
dispatch("delVisitedView", view);
dispatch("delCachedView", view);
resolve({
visitedViews: [...state.visitedViews],
cachedViews: [...state.cachedViews]
});
});
},
delVisitedView({ commit, state }, view) {
return new Promise(resolve => {
commit("DEL_VISITED_VIEW", view);
resolve([...state.visitedViews]);
});
},
delCachedView({ commit, state }, view) {
return new Promise(resolve => {
commit("DEL_CACHED_VIEW", view);
resolve([...state.cachedViews]);
});
},
delOthersViews({ dispatch, state }, view) {
return new Promise(resolve => {
dispatch("delOthersVisitedViews", view);
dispatch("delOthersCachedViews", view);
resolve({
visitedViews: [...state.visitedViews],
cachedViews: [...state.cachedViews]
});
});
},
delOthersVisitedViews({ commit, state }, view) {
return new Promise(resolve => {
commit("DEL_OTHERS_VISITED_VIEWS", view);
resolve([...state.visitedViews]);
});
},
delOthersCachedViews({ commit, state }, view) {
return new Promise(resolve => {
commit("DEL_OTHERS_CACHED_VIEWS", view);
resolve([...state.cachedViews]);
});
},
delAllViews({ dispatch, state }, view) {
return new Promise(resolve => {
dispatch("delAllVisitedViews", view);
dispatch("delAllCachedViews", view);
resolve({
visitedViews: [...state.visitedViews],
cachedViews: [...state.cachedViews]
});
});
},
delAllVisitedViews({ commit, state }) {
return new Promise(resolve => {
commit("DEL_ALL_VISITED_VIEWS");
resolve([...state.visitedViews]);
});
},
delAllCachedViews({ commit, state }) {
return new Promise(resolve => {
commit("DEL_ALL_CACHED_VIEWS");
resolve([...state.cachedViews]);
});
},
updateVisitedView({ commit }, view) {
commit("UPDATE_VISITED_VIEW", view);
}
};
export default {
namespaced: true,
state,
mutations,
actions
};
TagsView组件的代码如下,大体思路已有,具体的方法也相对简单.
<template>
<div id="tags-view-container" class="tags-view-container">
<scroll-pane ref="scrollPane" class="tags-view-wrapper">
<router-link
v-for="tag in visitedViews"
ref="tag"
:key="tag.path"
:class="isActive(tag) ? 'active' : ''"
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
tag="span"
class="tags-view-item"
@click.middle.native="!isAffix(tag) ? closeSelectedTag(tag) : ''"
@contextmenu.prevent.native="openMenu(tag, $event)"
>
{{ generateTitle(tag.title) }}
<span
v-if="!isAffix(tag)"
class="el-icon-close"
@click.prevent.stop="closeSelectedTag(tag)"
/>
</router-link>
</scroll-pane>
<ul
v-show="visible"
:style="{ left: left + 'px', top: top + 'px' }"
class="contextmenu"
>
<li @click="refreshSelectedTag(selectedTag)">
{{ $t("tagsView.refresh") }}
</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
{{ $t("tagsView.close") }}
</li>
<li @click="closeOthersTags">{{ $t("tagsView.closeOthers") }}</li>
<li @click="closeAllTags(selectedTag)">{{ $t("tagsView.closeAll") }}</li>
</ul>
</div>
</template>
<script>
import ScrollPane from "./ScrollPane";
import { generateTitle } from "@/utils/i18n";
import path from "path";
export default {
components: { ScrollPane },
data() {
return {
visible: false,
top: 0,
left: 0,
selectedTag: {},
affixTags: []
};
},
computed: {
visitedViews() {
return this.$store.state.tagsView.visitedViews;
},
routes() {
return this.$store.state.permission.routes;
}
},
watch: {
$route() {
this.addTags();
this.moveToCurrentTag();
},
visible(value) {
if (value) {
document.body.addEventListener("click", this.closeMenu);
} else {
document.body.removeEventListener("click", this.closeMenu);
}
}
},
mounted() {
this.initTags();
this.addTags();
},
methods: {
generateTitle, // generateTitle by vue-i18n
isActive(route) {
return route.path === this.$route.path;
},
isAffix(tag) {
return tag.meta && tag.meta.affix;
},
filterAffixTags(routes, basePath = "/") {
let tags = [];
routes.forEach(route => {
if (route.meta && route.meta.affix) {
const tagPath = path.resolve(basePath, route.path);
tags.push({
fullPath: tagPath,
path: tagPath,
name: route.name,
meta: { ...route.meta }
});
}
if (route.children) {
const tempTags = this.filterAffixTags(route.children, route.path);
if (tempTags.length >= 1) {
tags = [...tags, ...tempTags];
}
}
});
return tags;
},
initTags() {
const affixTags = (this.affixTags = this.filterAffixTags(this.routes));
for (const tag of affixTags) {
// Must have tag name
if (tag.name) {
this.$store.dispatch("tagsView/addVisitedView", tag);
}
}
},
addTags() {
const { name } = this.$route;
if (name) {
this.$store.dispatch("tagsView/addView", this.$route);
}
return false;
},
moveToCurrentTag() {
const tags = this.$refs.tag;
this.$nextTick(() => {
for (const tag of tags) {
if (tag.to.path === this.$route.path) {
this.$refs.scrollPane.moveToTarget(tag);
// when query is different then update
if (tag.to.fullPath !== this.$route.fullPath) {
this.$store.dispatch("tagsView/updateVisitedView", this.$route);
}
break;
}
}
});
},
refreshSelectedTag(view) {
this.$store.dispatch("tagsView/delCachedView", view).then(() => {
const { fullPath } = view;
this.$nextTick(() => {
this.$router.replace({
path: "/redirect" + fullPath
});
});
});
},
closeSelectedTag(view) {
this.$store
.dispatch("tagsView/delView", view)
.then(({ visitedViews }) => {
if (this.isActive(view)) {
this.toLastView(visitedViews, view);
}
});
},
closeOthersTags() {
this.$router.push(this.selectedTag);
this.$store
.dispatch("tagsView/delOthersViews", this.selectedTag)
.then(() => {
this.moveToCurrentTag();
});
},
closeAllTags(view) {
this.$store.dispatch("tagsView/delAllViews").then(({ visitedViews }) => {
if (this.affixTags.some(tag => tag.path === view.path)) {
return;
}
this.toLastView(visitedViews, view);
});
},
toLastView(visitedViews, view) {
const latestView = visitedViews.slice(-1)[0];
if (latestView) {
this.$router.push(latestView.fullPath);
} else {
// now the default is to redirect to the home page if there is no tags-view,
// you can adjust it according to your needs.
if (view.name === "Dashboard") {
// to reload home page
this.$router.replace({ path: "/redirect" + view.fullPath });
} else {
this.$router.push("/");
}
}
},
openMenu(tag, e) {
const menuMinWidth = 105;
const offsetLeft = this.$el.getBoundingClientRect().left; // container margin left
const offsetWidth = this.$el.offsetWidth; // container width
const maxLeft = offsetWidth - menuMinWidth; // left boundary
const left = e.clientX - offsetLeft + 15; // 15: margin right
if (left > maxLeft) {
this.left = maxLeft;
} else {
this.left = left;
}
this.top = e.clientY;
this.visible = true;
this.selectedTag = tag;
},
closeMenu() {
this.visible = false;
}
}
};
</script>
最后需要注意的是如果需要缓存,还得得AppMain.vue中将缓存加上,示例如下:
<keep-alive :include="cachedViews">
<router-view :key="key" />
</keep-alive>