标签视图管理 (tagsView)
介绍
标签视图管理模块提供类似浏览器多标签页的页面管理体验。通过 useLayout Composable 中的 tagsViewMethods 实现,支持标签的添加、删除、批量操作、keep-alive 缓存管理和 iframe 页面支持。
核心特性:
- 多标签导航 - 类似浏览器的多标签页体验,支持标签添加、删除、切换和右键菜单操作
- 智能缓存 - 基于 keep-alive 的页面状态缓存,自动管理组件生命周期
- 固定标签 - 支持重要页面设置为固定标签,不可关闭且始终显示
- 批量操作 - 关闭其他、关闭左侧、关闭右侧、关闭全部等批量操作
- 动态路由支持 - 智能识别带参数的动态路由,避免错误清理缓存
- iframe 集成 - 支持内嵌外部页面,独立管理 iframe 生命周期
状态定义
TagsViewState 接口
interface TagsViewState {
/** 已访问的视图列表 - 标签栏显示的所有页面 */
visitedViews: RouteLocationNormalized[]
/** 缓存的视图名称列表 - keep-alive 缓存的组件名称 */
cachedViews: string[]
/** iframe 视图列表 - 内嵌外部链接的页面 */
iframeViews: RouteLocationNormalized[]
}2
3
4
5
6
7
8
9
10
三种状态数组
| 数组 | 类型 | 说明 |
|---|---|---|
visitedViews | RouteLocationNormalized[] | 存储完整路由信息,用于标签栏渲染 |
cachedViews | string[] | 存储组件名称,供 keep-alive 使用 |
iframeViews | RouteLocationNormalized[] | 存储外部链接页面路由信息 |
缓存规则:
- 组件必须定义
name属性 - 组件
name必须与路由name一致 - 路由
meta.noCache为true时不缓存 - 动态路由特殊处理,避免缓存冲突
核心方法
添加视图
// 同时添加到已访问列表和缓存列表(最常用)
addView(view: RouteLocationNormalized)
// 仅添加到已访问列表(标签栏显示)
addVisitedView(view: RouteLocationNormalized)
// 仅添加到缓存列表(keep-alive)
addCachedView(view: RouteLocationNormalized)
// 添加 iframe 视图
addIframeView(view: RouteLocationNormalized)2
3
4
5
6
7
8
9
10
11
使用示例:
import { useLayout } from '@/composables/useLayout'
import { useRoute } from 'vue-router'
const layout = useLayout()
const route = useRoute()
// 监听路由变化,自动添加标签
watch(
() => route.path,
() => {
if (route.name) {
layout.addView(route)
}
},
{ immediate: true }
)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
删除视图
// 删除指定视图(标签 + 缓存)
async delView(view: RouteLocationNormalized): Promise<ViewsResult>
// 仅删除已访问视图
async delVisitedView(view: RouteLocationNormalized): Promise<RouteLocationNormalized[]>
// 删除缓存视图(不传参则清空所有)
async delCachedView(view?: RouteLocationNormalized): Promise<string[]>
// 删除 iframe 视图
async delIframeView(view: RouteLocationNormalized): Promise<RouteLocationNormalized[]>2
3
4
5
6
7
8
9
10
11
关闭标签示例:
const closeCurrentTag = async () => {
const { visitedViews } = await layout.delView(route)
// 如果关闭的是当前页,跳转到最后一个标签
if (route.path === currentRoute.path) {
const latestView = visitedViews[visitedViews.length - 1]
router.push(latestView || '/')
}
}2
3
4
5
6
7
8
9
批量操作
// 删除其他视图(保留当前和固定标签)
async delOthersViews(view: RouteLocationNormalized): Promise<ViewsResult>
// 删除所有视图(保留固定标签)
async delAllViews(): Promise<ViewsResult>
// 删除右侧标签
async delRightTags(view: RouteLocationNormalized): Promise<RouteLocationNormalized[]>
// 删除左侧标签
async delLeftTags(view: RouteLocationNormalized): Promise<RouteLocationNormalized[]>2
3
4
5
6
7
8
9
10
11
工具方法
// 更新已访问视图信息
updateVisitedView(view: RouteLocationNormalized)
// 判断是否为动态路由
isDynamicRoute(view: RouteLocationNormalized): boolean
// 获取视图列表副本
getVisitedViews(): RouteLocationNormalized[]
getCachedViews(): string[]
getIframeViews(): RouteLocationNormalized[]2
3
4
5
6
7
8
9
10
缓存机制
Keep-Alive 集成
<template>
<router-view v-slot="{ Component, route }">
<transition name="fade-transform" mode="out-in">
<keep-alive :include="cachedViews">
<component :is="Component" :key="route.path" />
</keep-alive>
</transition>
</router-view>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useLayout } from '@/composables/useLayout'
const layout = useLayout()
const cachedViews = computed(() => layout.cachedViews.value)
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
缓存生效条件
<!-- 组件必须设置 name,且与路由 name 一致 -->
<script lang="ts" setup>
defineOptions({
name: 'UserManagement' // 与路由 name 相同
})
</script>2
3
4
5
6
// 路由配置
{
path: '/user',
name: 'UserManagement', // 与组件 name 相同
component: () => import('@/views/user/index.vue'),
meta: {
title: '用户管理'
// noCache: true // 设为 true 则不缓存
}
}2
3
4
5
6
7
8
9
10
生命周期钩子
<script lang="ts" setup>
import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'
// 首次进入(只调用一次)
onMounted(() => {
initData()
})
// 每次切换回来都调用
onActivated(() => {
refreshData()
})
// 每次离开都调用
onDeactivated(() => {
saveFormDraft()
})
// 组件销毁(标签关闭时)
onUnmounted(() => {
cleanup()
})
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
固定标签
配置方式
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: {
title: '首页',
affix: true // 设置为固定标签
}
}2
3
4
5
6
7
8
9
固定标签特性
| 特性 | 说明 |
|---|---|
| 不可关闭 | 不显示关闭按钮,无法被用户关闭 |
| 始终显示 | 应用启动时自动添加到标签栏 |
| 批量保留 | 关闭全部/关闭其他时自动保留 |
初始化固定标签:
const initAffixTags = () => {
const routes = permissionStore.routes
const filterAffixTags = (routes: RouteRecordRaw[]): RouteLocationNormalized[] => {
return routes
.filter(route => route.meta?.affix)
.map(route => ({
path: route.path,
name: route.name,
meta: { ...route.meta }
} as RouteLocationNormalized))
}
filterAffixTags(routes).forEach(tag => {
if (tag.name) layout.addVisitedView(tag)
})
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Iframe 视图
配置示例
{
path: '/external/docs',
name: 'ExternalDocs',
component: () => import('@/views/system/InnerLink.vue'),
meta: {
title: '在线文档',
link: 'https://docs.example.com' // 外部链接
}
}2
3
4
5
6
7
8
9
InnerLink 组件
<template>
<iframe
v-if="iframeSrc"
:src="iframeSrc"
frameborder="0"
width="100%"
height="100%"
/>
</template>
<script lang="ts" setup>
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useLayout } from '@/composables/useLayout'
const route = useRoute()
const layout = useLayout()
const iframeSrc = computed(() => route.meta?.link as string)
onMounted(() => {
if (iframeSrc.value) {
layout.addIframeView(route)
}
})
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
使用示例
标签栏组件核心逻辑
<template>
<div class="tags-view-container">
<router-link
v-for="tag in visitedViews"
:key="tag.path"
:to="{ path: tag.path, query: tag.query }"
:class="{ active: isActive(tag) }"
class="tags-view-item"
@contextmenu.prevent="openMenu(tag, $event)"
>
<span>{{ tag.meta.title }}</span>
<el-icon v-if="!isAffix(tag)" @click.prevent.stop="closeTag(tag)">
<Close />
</el-icon>
</router-link>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLayout } from '@/composables/useLayout'
const route = useRoute()
const router = useRouter()
const layout = useLayout()
const visitedViews = computed(() => layout.visitedViews.value)
const isActive = (tag) => tag.path === route.path
const isAffix = (tag) => tag.meta?.affix === true
const closeTag = async (tag) => {
const { visitedViews } = await layout.delView(tag)
if (isActive(tag)) {
const latestView = visitedViews.slice(-1)[0]
router.push(latestView || '/')
}
}
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
右键菜单操作
// 刷新当前页面(通过 redirect 实现)
const refresh = () => {
router.replace({ path: '/redirect' + tag.path, query: tag.query })
}
// 关闭其他
const closeOthers = async () => {
await layout.delOthersViews(tag)
if (route.path !== tag.path) router.push(tag)
}
// 关闭左侧
const closeLeft = async () => {
await layout.delLeftTags(tag)
if (route.path !== tag.path) router.push(tag)
}
// 关闭右侧
const closeRight = async () => {
await layout.delRightTags(tag)
if (route.path !== tag.path) router.push(tag)
}
// 关闭全部
const closeAll = async () => {
const { visitedViews } = await layout.delAllViews()
if (!tag.meta?.affix) {
router.push(visitedViews.slice(-1)[0] || '/')
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
性能优化
标签数量限制
const MAX_TAGS = 10
const addViewWithLimit = (view) => {
layout.addView(view)
const views = layout.visitedViews.value
if (views.length > MAX_TAGS) {
const firstNonAffix = views.find(v => !v.meta?.affix)
if (firstNonAffix) layout.delView(firstNonAffix)
}
}2
3
4
5
6
7
8
9
10
11
缓存上限控制
<!-- max 属性限制最多缓存的组件数 -->
<keep-alive :include="cachedViews" :max="10">
<router-view />
</keep-alive>2
3
4
数据刷新策略
<script lang="ts" setup>
const lastFetchTime = ref(0)
const REFRESH_INTERVAL = 5 * 60 * 1000 // 5分钟
onActivated(() => {
const elapsed = Date.now() - lastFetchTime.value
if (elapsed > REFRESH_INTERVAL) {
fetchList()
lastFetchTime.value = Date.now()
}
})
</script>2
3
4
5
6
7
8
9
10
11
12
API 参考
状态属性
| 属性 | 类型 | 说明 |
|---|---|---|
visitedViews | Ref<RouteLocationNormalized[]> | 已访问视图列表 |
cachedViews | Ref<string[]> | 缓存视图名称列表 |
iframeViews | Ref<RouteLocationNormalized[]> | iframe 视图列表 |
标签管理方法
| 方法 | 说明 |
|---|---|
addView(view) | 添加视图到已访问和缓存列表 |
addVisitedView(view) | 添加视图到已访问列表 |
addCachedView(view) | 添加视图到缓存列表 |
addIframeView(view) | 添加 iframe 视图 |
删除方法
| 方法 | 说明 |
|---|---|
delView(view) | 删除指定视图 |
delVisitedView(view) | 删除已访问视图 |
delCachedView(view?) | 删除缓存视图 |
delIframeView(view) | 删除 iframe 视图 |
批量操作方法
| 方法 | 说明 |
|---|---|
delOthersViews(view) | 删除其他视图 |
delAllViews() | 删除所有视图 |
delRightTags(view) | 删除右侧标签 |
delLeftTags(view) | 删除左侧标签 |
工具方法
| 方法 | 说明 |
|---|---|
updateVisitedView(view) | 更新已访问视图信息 |
isDynamicRoute(view) | 判断是否为动态路由 |
getVisitedViews() | 获取已访问视图列表副本 |
getCachedViews() | 获取缓存视图列表副本 |
getIframeViews() | 获取 iframe 视图列表副本 |
类型定义
interface ViewsResult {
visitedViews: RouteLocationNormalized[]
cachedViews: string[]
}2
3
4
最佳实践
1. 组件命名规范
<!-- 组件 name 必须与路由 name 一致 -->
<script lang="ts" setup>
defineOptions({ name: 'UserManagement' })
</script>
<!-- 路由配置 -->
{ path: '/user', name: 'UserManagement', ... }2
3
4
5
6
7
2. 缓存控制策略
// 表单页面不缓存
{ path: '/user/create', meta: { noCache: true } }
// 列表页面缓存(保持筛选条件)
{ path: '/user/list', meta: { /* 默认缓存 */ } }
// 实时数据不缓存
{ path: '/monitor', meta: { noCache: true } }2
3
4
5
6
7
8
3. 动态路由标题更新
watch(() => userDetail.value, (detail) => {
if (detail) {
layout.updateVisitedView({
...route,
meta: { ...route.meta, title: `用户详情 - ${detail.name}` }
})
}
})2
3
4
5
6
7
8
4. 清理资源防止内存泄漏
<script lang="ts" setup>
let timer: number | null = null
onMounted(() => {
timer = setInterval(fetchData, 5000)
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
window.removeEventListener('resize', handleResize)
})
</script>2
3
4
5
6
7
8
9
10
11
12
13
常见问题
1. 页面缓存不生效
问题描述:
页面已添加到标签栏,但切换回来时数据丢失,表单内容清空,滚动位置重置,组件重新初始化。
问题原因:
- 组件未定义
name属性或名称不正确 - 组件
name与路由name不一致 - 路由配置了
meta.noCache: true - keep-alive 的
include列表未正确配置 - 使用了异步组件但未正确处理
解决方案:
<!-- ❌ 错误:未定义组件名称 -->
<script lang="ts" setup>
// 缺少 defineOptions 或组件名
import { ref } from 'vue'
</script>
<!-- ✅ 正确:定义与路由一致的组件名称 -->
<script lang="ts" setup>
defineOptions({
name: 'UserManagement' // 必须与路由 name 完全一致
})
import { ref } from 'vue'
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
路由配置检查:
// 路由配置
const routes = [
{
path: '/user/management',
name: 'UserManagement', // ✅ 与组件 name 一致
component: () => import('@/views/user/management.vue'),
meta: {
title: '用户管理',
// noCache: true // ⚠️ 如果设为 true 则不缓存
}
}
]2
3
4
5
6
7
8
9
10
11
12
缓存调试工具:
// composables/useTagsViewDebug.ts
export function useTagsViewDebug() {
const layout = useLayout()
// 检查缓存状态
const checkCacheStatus = (routeName: string) => {
const isCached = layout.cachedViews.value.includes(routeName)
const isVisited = layout.visitedViews.value.some(v => v.name === routeName)
console.group(`[TagsView Debug] ${routeName}`)
console.log('是否在已访问列表:', isVisited)
console.log('是否在缓存列表:', isCached)
console.log('当前缓存列表:', layout.cachedViews.value)
console.groupEnd()
return { isCached, isVisited }
}
// 验证组件名称配置
const validateComponentName = (route: RouteLocationNormalized) => {
const routeName = route.name as string
const componentName = route.matched[route.matched.length - 1]?.components?.default?.name
if (routeName !== componentName) {
console.warn(
`[TagsView] 组件名称不匹配!\n` +
`路由 name: ${routeName}\n` +
`组件 name: ${componentName || '未定义'}\n` +
`请确保两者一致以启用缓存`
)
return false
}
return true
}
return { checkCacheStatus, validateComponentName }
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
异步组件缓存处理:
// 异步组件需要确保正确导出名称
const UserManagement = defineAsyncComponent({
loader: () => import('@/views/user/management.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200,
timeout: 30000
})
// 或者在组件内部确保 name 被正确定义
// views/user/management.vue
<script lang="ts" setup>
defineOptions({
name: 'UserManagement'
})
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
keep-alive 配置验证:
<template>
<router-view v-slot="{ Component, route }">
<transition name="fade-transform" mode="out-in">
<keep-alive :include="cachedViewNames">
<component
:is="Component"
:key="getViewKey(route)"
/>
</keep-alive>
</transition>
</router-view>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useLayout } from '@/composables/useLayout'
const layout = useLayout()
// 确保返回的是组件名称数组
const cachedViewNames = computed(() => {
const names = layout.cachedViews.value
console.log('[keep-alive] 当前缓存组件:', names)
return names
})
// 为动态路由生成唯一 key
const getViewKey = (route: RouteLocationNormalized) => {
// 对于动态路由,使用完整路径作为 key
if (route.meta?.dynamicRoute) {
return route.fullPath
}
return route.path
}
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
2. 动态路由参数变化时缓存失效或状态混乱
问题描述:
访问 /user/1 后再访问 /user/2,两个标签页显示相同的数据;或者参数变化后页面没有刷新数据。
问题原因:
- 动态路由使用同一组件实例,Vue 会复用组件
router-view的 key 设置不当- 缓存策略未考虑动态参数场景
- 组件内部未监听路由参数变化
解决方案:
<!-- 方案一:使用完整路径作为 key -->
<template>
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedViews">
<component
:is="Component"
:key="route.fullPath"
/>
</keep-alive>
</router-view>
</template>2
3
4
5
6
7
8
9
10
11
// 方案二:动态路由特殊处理的缓存逻辑
// stores/tagsView.ts
interface DynamicRouteCache {
baseName: string
instances: Map<string, { params: Record<string, string>, timestamp: number }>
}
const dynamicRouteCache = new Map<string, DynamicRouteCache>()
// 添加动态路由视图
const addDynamicView = (view: RouteLocationNormalized) => {
const baseName = view.name as string
const fullPath = view.fullPath
const params = { ...view.params }
// 生成唯一缓存名称
const cacheName = `${baseName}_${Object.values(params).join('_')}`
// 添加到缓存列表
if (!cachedViews.value.includes(cacheName)) {
cachedViews.value.push(cacheName)
}
// 更新动态路由缓存记录
if (!dynamicRouteCache.has(baseName)) {
dynamicRouteCache.set(baseName, {
baseName,
instances: new Map()
})
}
dynamicRouteCache.get(baseName)!.instances.set(fullPath, {
params,
timestamp: Date.now()
})
return cacheName
}
// 清理动态路由缓存
const clearDynamicRouteCache = (baseName: string) => {
const cache = dynamicRouteCache.get(baseName)
if (cache) {
cache.instances.forEach((_, key) => {
const cacheName = `${baseName}_${key}`
const index = cachedViews.value.indexOf(cacheName)
if (index > -1) {
cachedViews.value.splice(index, 1)
}
})
dynamicRouteCache.delete(baseName)
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<!-- 方案三:在组件内监听路由变化 -->
<script lang="ts" setup>
import { watch, onActivated } from 'vue'
import { useRoute } from 'vue-router'
defineOptions({
name: 'UserDetail'
})
const route = useRoute()
const userId = computed(() => route.params.id as string)
const userData = ref<UserInfo | null>(null)
// 监听路由参数变化
watch(
() => route.params.id,
async (newId, oldId) => {
if (newId && newId !== oldId) {
await loadUserData(newId as string)
}
},
{ immediate: true }
)
// 从缓存恢复时也检查数据
onActivated(() => {
// 检查当前显示的数据是否与路由参数匹配
if (userData.value?.id !== userId.value) {
loadUserData(userId.value)
}
})
const loadUserData = async (id: string) => {
try {
userData.value = await userApi.getDetail(id)
} catch (error) {
console.error('加载用户数据失败:', error)
}
}
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 方案四:动态路由标签标题个性化
const updateDynamicRouteTitle = (
route: RouteLocationNormalized,
title: string
) => {
const layout = useLayout()
// 更新标签标题
layout.updateVisitedView({
...route,
meta: {
...route.meta,
title
}
})
}
// 使用示例
watch(userData, (user) => {
if (user) {
updateDynamicRouteTitle(route, `用户详情 - ${user.name}`)
}
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
动态路由缓存管理器:
// composables/useDynamicRouteCache.ts
export function useDynamicRouteCache() {
const layout = useLayout()
const route = useRoute()
// 缓存实例数据
const instanceCache = new Map<string, unknown>()
// 保存当前实例状态
const saveInstanceState = <T>(key: string, state: T) => {
const cacheKey = `${route.name}_${route.fullPath}_${key}`
instanceCache.set(cacheKey, state)
}
// 恢复实例状态
const restoreInstanceState = <T>(key: string, defaultValue: T): T => {
const cacheKey = `${route.name}_${route.fullPath}_${key}`
return (instanceCache.get(cacheKey) as T) ?? defaultValue
}
// 清除当前实例缓存
const clearInstanceState = () => {
const prefix = `${route.name}_${route.fullPath}_`
for (const key of instanceCache.keys()) {
if (key.startsWith(prefix)) {
instanceCache.delete(key)
}
}
}
return {
saveInstanceState,
restoreInstanceState,
clearInstanceState
}
}
// 使用示例
const { saveInstanceState, restoreInstanceState } = useDynamicRouteCache()
// 保存表单状态
watch(formData, (data) => {
saveInstanceState('formData', data)
}, { deep: true })
// 恢复表单状态
onActivated(() => {
const savedData = restoreInstanceState('formData', defaultFormData)
Object.assign(formData, savedData)
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
3. 关闭标签后路由跳转异常或页面空白
问题描述:
关闭当前标签后页面变成空白,或者跳转到了错误的页面;有时关闭标签后页面闪烁或出现短暂的白屏。
问题原因:
- 未判断关闭的是否为当前激活标签
- 跳转目标计算错误
- 异步删除操作未正确等待
- 路由守卫与标签删除逻辑冲突
- 固定标签处理不当
解决方案:
// 完善的标签关闭逻辑
const closeTag = async (targetView: RouteLocationNormalized) => {
const layout = useLayout()
const router = useRouter()
const route = useRoute()
try {
// 1. 执行删除操作
const { visitedViews } = await layout.delView(targetView)
// 2. 判断是否关闭的是当前页面
const isCurrentView = targetView.path === route.path
if (isCurrentView) {
// 3. 计算跳转目标
const nextView = findNextView(visitedViews, targetView)
// 4. 执行跳转
if (nextView) {
await router.push({
path: nextView.path,
query: nextView.query
})
} else {
// 没有可跳转的页面,回到首页
await router.push('/')
}
}
} catch (error) {
console.error('关闭标签失败:', error)
// 错误恢复:刷新标签列表
layout.refreshViews()
}
}
// 查找下一个要显示的视图
const findNextView = (
views: RouteLocationNormalized[],
closedView: RouteLocationNormalized
): RouteLocationNormalized | null => {
// 优先选择最后访问的非固定标签
const lastView = views[views.length - 1]
if (lastView) return lastView
// 如果有固定标签,返回第一个固定标签
const affixView = views.find(v => v.meta?.affix)
if (affixView) return affixView
return null
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
批量关闭的跳转处理:
// 关闭其他标签
const closeOtherTags = async (currentView: RouteLocationNormalized) => {
const layout = useLayout()
const router = useRouter()
const route = useRoute()
const { visitedViews } = await layout.delOthersViews(currentView)
// 如果当前路由不是保留的视图,跳转到保留视图
const isCurrentRetained = visitedViews.some(v => v.path === route.path)
if (!isCurrentRetained) {
await router.push(currentView)
}
}
// 关闭所有标签
const closeAllTags = async () => {
const layout = useLayout()
const router = useRouter()
const { visitedViews } = await layout.delAllViews()
// 找到要跳转的目标(固定标签或首页)
const affixView = visitedViews.find(v => v.meta?.affix)
await router.push(affixView || '/')
}
// 关闭左侧/右侧标签
const closeSideTags = async (
direction: 'left' | 'right',
baseView: RouteLocationNormalized
) => {
const layout = useLayout()
const router = useRouter()
const route = useRoute()
const result = direction === 'left'
? await layout.delLeftTags(baseView)
: await layout.delRightTags(baseView)
// 检查当前路由是否被关闭
const isCurrentClosed = !result.some(v => v.path === route.path)
if (isCurrentClosed) {
await router.push(baseView)
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
防止页面闪烁的过渡处理:
<template>
<div class="tags-view-wrapper">
<ScrollPane ref="scrollPane">
<TransitionGroup name="tags-fade" tag="div" class="tags-container">
<router-link
v-for="tag in visitedViews"
:key="tag.fullPath"
:to="{ path: tag.path, query: tag.query }"
:class="['tag-item', { active: isActive(tag) }]"
@click.middle="!isAffix(tag) && closeTag(tag)"
>
<span class="tag-title">{{ tag.meta?.title }}</span>
<el-icon
v-if="!isAffix(tag)"
class="tag-close"
@click.prevent.stop="closeTag(tag)"
>
<Close />
</el-icon>
</router-link>
</TransitionGroup>
</ScrollPane>
</div>
</template>
<style lang="scss" scoped>
.tags-fade-enter-active,
.tags-fade-leave-active {
transition: all 0.3s ease;
}
.tags-fade-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.tags-fade-leave-to {
opacity: 0;
transform: translateX(30px);
}
// 防止删除时的布局抖动
.tags-fade-leave-active {
position: absolute;
}
</style>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
4. 固定标签被意外删除或不显示
问题描述:
设置了 affix: true 的固定标签被关闭了,或者刷新页面后固定标签消失不见。
问题原因:
- 批量删除方法实现不正确
- 直接操作
visitedViews数组绕过了保护逻辑 - 应用初始化时未正确加载固定标签
- 路由配置变更导致固定标签失效
解决方案:
// stores/tagsView.ts - 正确的批量删除实现
const delOthersViews = async (view: RouteLocationNormalized) => {
// 保留固定标签和当前视图
const retainedViews = visitedViews.value.filter(v => {
return v.meta?.affix === true || v.path === view.path
})
// 更新已访问列表
visitedViews.value = retainedViews
// 清理缓存(固定标签和当前视图的缓存保留)
const retainedNames = retainedViews
.map(v => v.name as string)
.filter(Boolean)
cachedViews.value = cachedViews.value.filter(name =>
retainedNames.includes(name)
)
return {
visitedViews: [...visitedViews.value],
cachedViews: [...cachedViews.value]
}
}
const delAllViews = async () => {
// 只保留固定标签
const affixViews = visitedViews.value.filter(v => v.meta?.affix === true)
visitedViews.value = affixViews
// 只保留固定标签的缓存
const affixNames = affixViews.map(v => v.name as string).filter(Boolean)
cachedViews.value = cachedViews.value.filter(name =>
affixNames.includes(name)
)
return {
visitedViews: [...visitedViews.value],
cachedViews: [...cachedViews.value]
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
初始化固定标签:
// composables/useTagsView.ts
export function useTagsViewInit() {
const layout = useLayout()
const permissionStore = usePermissionStore()
const router = useRouter()
// 初始化固定标签
const initAffixTags = () => {
const affixRoutes = findAffixRoutes(permissionStore.routes)
affixRoutes.forEach(route => {
// 确保有 name 才添加
if (route.name) {
const tag: RouteLocationNormalized = {
path: route.path,
name: route.name,
fullPath: route.path,
params: {},
query: {},
hash: '',
matched: [],
meta: { ...route.meta },
redirectedFrom: undefined
}
layout.addVisitedView(tag)
}
})
}
// 递归查找所有固定路由
const findAffixRoutes = (routes: RouteRecordRaw[], basePath = ''): RouteRecordRaw[] => {
const affixRoutes: RouteRecordRaw[] = []
routes.forEach(route => {
const fullPath = basePath + '/' + route.path
const normalizedPath = fullPath.replace(/\/+/g, '/').replace(/\/$/, '')
if (route.meta?.affix) {
affixRoutes.push({
...route,
path: normalizedPath
})
}
if (route.children?.length) {
affixRoutes.push(...findAffixRoutes(route.children, normalizedPath))
}
})
return affixRoutes
}
// 应用启动时初始化
const initialize = () => {
// 确保路由已加载完成
router.isReady().then(() => {
initAffixTags()
})
}
return { initialize, initAffixTags }
}
// App.vue 中调用
const { initialize } = useTagsViewInit()
onMounted(() => {
initialize()
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
固定标签保护检查:
// 删除视图前的保护检查
const safeDelView = async (view: RouteLocationNormalized) => {
// 检查是否为固定标签
if (view.meta?.affix === true) {
console.warn('[TagsView] 固定标签不可删除:', view.name)
return null
}
return layout.delView(view)
}
// 右键菜单中禁用固定标签的关闭选项
const getContextMenuItems = (tag: RouteLocationNormalized) => {
const isAffix = tag.meta?.affix === true
return [
{
label: '刷新',
icon: 'Refresh',
action: () => refresh(tag),
disabled: false
},
{
label: '关闭',
icon: 'Close',
action: () => closeTag(tag),
disabled: isAffix // 固定标签禁用关闭
},
{
label: '关闭其他',
icon: 'Remove',
action: () => closeOtherTags(tag),
disabled: false
},
// ...
]
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
5. 标签页状态丢失或表单数据未保存
问题描述:
切换标签后返回,表单填写的内容消失了;编辑中的数据没有自动保存;页面滚动位置丢失。
问题原因:
- 组件未正确使用 keep-alive 缓存
- 表单数据存储在局部变量而非响应式状态
- 未使用
onActivated/onDeactivated处理恢复逻辑 - 滚动位置未记录和恢复
解决方案:
<!-- 完善的表单状态保持 -->
<script lang="ts" setup>
import {
ref,
reactive,
watch,
onActivated,
onDeactivated,
onMounted,
onUnmounted
} from 'vue'
defineOptions({
name: 'UserForm'
})
// 表单数据
const formData = reactive({
name: '',
email: '',
phone: '',
department: '',
remark: ''
})
// 表单草稿 key
const DRAFT_KEY = 'user_form_draft'
// 保存草稿到 sessionStorage
const saveDraft = () => {
const draft = {
data: { ...formData },
timestamp: Date.now(),
path: route.fullPath
}
sessionStorage.setItem(DRAFT_KEY, JSON.stringify(draft))
}
// 恢复草稿
const restoreDraft = () => {
try {
const draftStr = sessionStorage.getItem(DRAFT_KEY)
if (draftStr) {
const draft = JSON.parse(draftStr)
// 检查是否为同一路径且未过期(1小时)
if (
draft.path === route.fullPath &&
Date.now() - draft.timestamp < 3600000
) {
Object.assign(formData, draft.data)
return true
}
}
} catch (error) {
console.warn('恢复草稿失败:', error)
}
return false
}
// 清除草稿
const clearDraft = () => {
sessionStorage.removeItem(DRAFT_KEY)
}
// 监听表单变化,自动保存草稿
watch(
() => formData,
() => {
saveDraft()
},
{ deep: true }
)
// 页面激活时恢复状态
onActivated(() => {
// 如果是从缓存恢复,表单数据会自动保持
// 额外检查是否需要从草稿恢复
console.log('[UserForm] 页面激活')
})
// 页面离开时保存状态
onDeactivated(() => {
saveDraft()
console.log('[UserForm] 页面休眠,已保存草稿')
})
// 提交成功后清除草稿
const handleSubmit = async () => {
try {
await submitForm(formData)
clearDraft()
ElMessage.success('提交成功')
} catch (error) {
ElMessage.error('提交失败')
}
}
// 首次加载时尝试恢复草稿
onMounted(() => {
if (restoreDraft()) {
ElMessage.info('已恢复上次编辑的内容')
}
})
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
滚动位置保持:
// composables/useScrollRestore.ts
export function useScrollRestore(containerRef: Ref<HTMLElement | null>) {
const route = useRoute()
const scrollPositions = new Map<string, number>()
// 保存滚动位置
const saveScrollPosition = () => {
if (containerRef.value) {
scrollPositions.set(route.fullPath, containerRef.value.scrollTop)
}
}
// 恢复滚动位置
const restoreScrollPosition = () => {
const position = scrollPositions.get(route.fullPath)
if (containerRef.value && position !== undefined) {
nextTick(() => {
containerRef.value!.scrollTop = position
})
}
}
onDeactivated(() => {
saveScrollPosition()
})
onActivated(() => {
restoreScrollPosition()
})
return { saveScrollPosition, restoreScrollPosition }
}
// 使用示例
const containerRef = ref<HTMLElement | null>(null)
const { saveScrollPosition, restoreScrollPosition } = useScrollRestore(containerRef)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
表格选择状态保持:
// composables/useTableStateRestore.ts
export function useTableStateRestore(tableKey: string) {
const route = useRoute()
interface TableState {
currentPage: number
pageSize: number
selectedKeys: string[]
filters: Record<string, unknown>
sorter: { field: string; order: 'asc' | 'desc' } | null
}
const stateKey = computed(() => `table_state_${tableKey}_${route.fullPath}`)
const saveState = (state: Partial<TableState>) => {
const existing = loadState()
const merged = { ...existing, ...state }
sessionStorage.setItem(stateKey.value, JSON.stringify(merged))
}
const loadState = (): TableState => {
try {
const stored = sessionStorage.getItem(stateKey.value)
return stored ? JSON.parse(stored) : getDefaultState()
} catch {
return getDefaultState()
}
}
const getDefaultState = (): TableState => ({
currentPage: 1,
pageSize: 10,
selectedKeys: [],
filters: {},
sorter: null
})
const clearState = () => {
sessionStorage.removeItem(stateKey.value)
}
return { saveState, loadState, clearState }
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
6. iframe 页面加载失败或显示空白
问题描述:
内嵌的 iframe 页面显示空白,或加载时报跨域错误;切换标签后 iframe 重新加载;iframe 页面无法与主应用通信。
问题原因:
- 目标网站设置了
X-Frame-Options禁止嵌入 - iframe 的
src设置时机不正确 - 跨域限制阻止了资源加载
- iframe 视图未正确管理导致重复创建
解决方案:
<!-- 增强的 InnerLink 组件 -->
<template>
<div class="inner-link-container">
<!-- 加载状态 -->
<div v-if="loading" class="loading-overlay">
<el-icon class="loading-icon"><Loading /></el-icon>
<span>正在加载...</span>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-overlay">
<el-icon class="error-icon"><WarningFilled /></el-icon>
<h3>页面加载失败</h3>
<p>{{ error }}</p>
<el-button type="primary" @click="retry">重新加载</el-button>
<el-button @click="openExternal">在新窗口打开</el-button>
</div>
<!-- iframe 内容 -->
<iframe
v-show="!loading && !error"
ref="iframeRef"
:src="iframeSrc"
frameborder="0"
width="100%"
height="100%"
@load="handleLoad"
@error="handleError"
:sandbox="sandboxRules"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useLayout } from '@/composables/useLayout'
defineOptions({
name: 'InnerLink'
})
const route = useRoute()
const layout = useLayout()
const iframeRef = ref<HTMLIFrameElement | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const loadAttempts = ref(0)
const MAX_ATTEMPTS = 3
// 从路由获取链接
const iframeSrc = computed(() => {
const link = route.meta?.link as string
if (!link) {
error.value = '未配置有效链接'
return ''
}
return link
})
// 沙箱规则(根据需要调整)
const sandboxRules = computed(() => {
// 严格模式
// return 'allow-scripts allow-same-origin'
// 宽松模式(允许更多功能)
return 'allow-scripts allow-same-origin allow-forms allow-popups allow-modals'
})
const handleLoad = () => {
loading.value = false
error.value = null
loadAttempts.value = 0
// 注册到 iframe 视图列表
layout.addIframeView(route)
}
const handleError = (e: Event) => {
loadAttempts.value++
if (loadAttempts.value < MAX_ATTEMPTS) {
// 重试
setTimeout(() => {
if (iframeRef.value) {
iframeRef.value.src = iframeSrc.value
}
}, 1000 * loadAttempts.value)
} else {
loading.value = false
error.value = '页面加载失败,可能是网络问题或该网站禁止嵌入'
}
}
const retry = () => {
loading.value = true
error.value = null
loadAttempts.value = 0
if (iframeRef.value) {
iframeRef.value.src = ''
nextTick(() => {
iframeRef.value!.src = iframeSrc.value
})
}
}
const openExternal = () => {
window.open(iframeSrc.value, '_blank')
}
// iframe 通信
const handleMessage = (event: MessageEvent) => {
// 验证消息来源
const trustedOrigins = ['https://trusted-site.com']
if (!trustedOrigins.includes(event.origin)) return
// 处理消息
const { type, data } = event.data
switch (type) {
case 'NAVIGATION':
// 处理导航请求
break
case 'NOTIFICATION':
ElMessage.info(data.message)
break
}
}
onMounted(() => {
window.addEventListener('message', handleMessage)
})
onUnmounted(() => {
window.removeEventListener('message', handleMessage)
// 清理 iframe 视图
layout.delIframeView(route)
})
</script>
<style lang="scss" scoped>
.inner-link-container {
position: relative;
width: 100%;
height: 100%;
iframe {
display: block;
width: 100%;
height: 100%;
}
}
.loading-overlay,
.error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--el-bg-color);
}
.loading-icon {
font-size: 48px;
animation: rotating 2s linear infinite;
}
.error-icon {
font-size: 48px;
color: var(--el-color-warning);
}
</style>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
iframe 视图管理优化:
// stores/tagsView.ts - iframe 视图特殊处理
const iframeViews = ref<RouteLocationNormalized[]>([])
// 添加 iframe 视图(避免重复)
const addIframeView = (view: RouteLocationNormalized) => {
const exists = iframeViews.value.some(v => v.path === view.path)
if (!exists && view.meta?.link) {
iframeViews.value.push({ ...view })
}
}
// 删除 iframe 视图
const delIframeView = async (view: RouteLocationNormalized) => {
const index = iframeViews.value.findIndex(v => v.path === view.path)
if (index > -1) {
iframeViews.value.splice(index, 1)
}
return [...iframeViews.value]
}
// 获取 iframe 视图(用于多 iframe 同时显示)
const getIframeViews = () => [...iframeViews.value]
// iframe 预加载
const preloadIframe = (url: string) => {
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'document'
link.href = url
document.head.appendChild(link)
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
7. 标签页过多导致性能下降或内存泄漏
问题描述:
打开大量标签页后页面变卡顿,内存占用持续升高,切换标签变慢,浏览器崩溃。
问题原因:
- 无限制地添加标签和缓存
- 组件未正确清理定时器、事件监听等资源
- keep-alive 缓存过多组件
- 大数据组件(如大表格、图表)未释放内存
解决方案:
// composables/useTagsViewLimit.ts
export function useTagsViewLimit(options: {
maxTags?: number
maxCache?: number
excludeAffix?: boolean
} = {}) {
const {
maxTags = 15,
maxCache = 10,
excludeAffix = true
} = options
const layout = useLayout()
// 添加视图时检查限制
const addViewWithLimit = async (view: RouteLocationNormalized) => {
// 先添加视图
layout.addView(view)
// 检查标签数量
await checkTagsLimit()
await checkCacheLimit()
}
// 检查并清理超出的标签
const checkTagsLimit = async () => {
const views = layout.visitedViews.value
const closableViews = excludeAffix
? views.filter(v => !v.meta?.affix)
: views
if (closableViews.length > maxTags) {
// 找出最早的非固定标签
const oldestView = closableViews[0]
await layout.delView(oldestView)
// 提示用户
ElMessage.info({
message: `已自动关闭较早的标签「${oldestView.meta?.title}」`,
duration: 2000
})
}
}
// 检查并清理超出的缓存
const checkCacheLimit = async () => {
const cached = layout.cachedViews.value
if (cached.length > maxCache) {
// 获取需要保留的缓存名称(当前显示的标签)
const visibleNames = layout.visitedViews.value
.map(v => v.name as string)
.filter(Boolean)
// 清理不在显示列表中的缓存
const toRemove = cached.filter(name => !visibleNames.includes(name))
toRemove.slice(0, cached.length - maxCache).forEach(name => {
const index = layout.cachedViews.value.indexOf(name)
if (index > -1) {
layout.cachedViews.value.splice(index, 1)
}
})
}
}
return { addViewWithLimit }
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
组件资源清理模板:
<script lang="ts" setup>
import {
ref,
onMounted,
onUnmounted,
onDeactivated,
onActivated
} from 'vue'
defineOptions({
name: 'HeavyComponent'
})
// 资源引用
let timer: ReturnType<typeof setInterval> | null = null
let observer: IntersectionObserver | null = null
let abortController: AbortController | null = null
const eventHandlers = new Map<string, EventListener>()
// 初始化资源
const initResources = () => {
// 定时器
timer = setInterval(refreshData, 30000)
// 观察器
observer = new IntersectionObserver(handleIntersection)
// 事件监听
const resizeHandler = debounce(handleResize, 200)
window.addEventListener('resize', resizeHandler)
eventHandlers.set('resize', resizeHandler)
// AbortController 用于取消请求
abortController = new AbortController()
}
// 清理资源
const cleanupResources = () => {
// 清理定时器
if (timer) {
clearInterval(timer)
timer = null
}
// 清理观察器
if (observer) {
observer.disconnect()
observer = null
}
// 清理事件监听
eventHandlers.forEach((handler, event) => {
window.removeEventListener(event, handler)
})
eventHandlers.clear()
// 取消未完成的请求
if (abortController) {
abortController.abort()
abortController = null
}
}
// 暂停资源(标签切走时)
const pauseResources = () => {
if (timer) {
clearInterval(timer)
timer = null
}
}
// 恢复资源(标签切回时)
const resumeResources = () => {
if (!timer) {
timer = setInterval(refreshData, 30000)
}
}
onMounted(() => {
initResources()
})
onUnmounted(() => {
cleanupResources()
})
onDeactivated(() => {
// 标签切走时暂停,节省资源
pauseResources()
})
onActivated(() => {
// 标签切回时恢复
resumeResources()
})
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
内存监控工具:
// utils/memoryMonitor.ts
export function createMemoryMonitor() {
let monitoring = false
let lastHeapSize = 0
const checkMemory = () => {
if (!('memory' in performance)) {
console.warn('Memory API 不可用')
return
}
const memory = (performance as any).memory
const heapSize = memory.usedJSHeapSize / 1024 / 1024
// 检测内存增长
const growth = heapSize - lastHeapSize
if (growth > 50) {
console.warn(`[Memory] 内存增长 ${growth.toFixed(2)} MB`)
}
// 检测内存过高
if (heapSize > 500) {
console.error(`[Memory] 内存使用过高: ${heapSize.toFixed(2)} MB`)
// 触发清理
triggerCleanup()
}
lastHeapSize = heapSize
}
const triggerCleanup = () => {
// 清理过期缓存
const layout = useLayout()
const views = layout.visitedViews.value
const cached = layout.cachedViews.value
// 只保留当前显示的标签的缓存
const visibleNames = views.map(v => v.name as string)
cached.forEach(name => {
if (!visibleNames.includes(name)) {
const index = layout.cachedViews.value.indexOf(name)
if (index > -1) {
layout.cachedViews.value.splice(index, 1)
}
}
})
}
const start = () => {
if (monitoring) return
monitoring = true
setInterval(checkMemory, 10000)
}
const stop = () => {
monitoring = false
}
return { start, stop, checkMemory }
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
8. 多浏览器窗口或标签下状态不同步
问题描述:
在浏览器新窗口/标签页打开应用,两个窗口的标签状态不同步;一个窗口关闭标签后另一个窗口仍然显示。
问题原因:
- Pinia 状态只存在于单个页面内存中
- 浏览器多窗口间无法共享 JavaScript 状态
- localStorage/sessionStorage 未用于持久化
- 未使用跨窗口通信机制
解决方案:
// stores/tagsView.ts - 添加持久化支持
import { watch } from 'vue'
const STORAGE_KEY = 'tags_view_state'
// 保存状态到 localStorage
const persistState = () => {
const state = {
visitedViews: visitedViews.value.map(v => ({
path: v.path,
fullPath: v.fullPath,
name: v.name,
meta: v.meta,
query: v.query
})),
cachedViews: cachedViews.value,
timestamp: Date.now()
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}
// 从 localStorage 恢复状态
const restoreState = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const state = JSON.parse(stored)
// 检查时效性(1小时内有效)
if (Date.now() - state.timestamp < 3600000) {
visitedViews.value = state.visitedViews
cachedViews.value = state.cachedViews
return true
}
}
} catch (error) {
console.warn('恢复标签状态失败:', error)
}
return false
}
// 监听状态变化并持久化
watch(
[visitedViews, cachedViews],
() => {
persistState()
},
{ deep: true }
)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
跨窗口同步 (BroadcastChannel):
// composables/useTagsViewSync.ts
export function useTagsViewSync() {
const layout = useLayout()
let channel: BroadcastChannel | null = null
// 消息类型
type SyncMessage =
| { type: 'ADD_VIEW'; view: SerializedView }
| { type: 'DEL_VIEW'; path: string }
| { type: 'DEL_ALL'; affixPaths: string[] }
| { type: 'SYNC_REQUEST' }
| { type: 'SYNC_RESPONSE'; views: SerializedView[] }
interface SerializedView {
path: string
fullPath: string
name: string
meta: Record<string, unknown>
query: Record<string, string>
}
// 初始化同步
const initSync = () => {
if (!('BroadcastChannel' in window)) {
console.warn('BroadcastChannel 不可用,跨窗口同步已禁用')
return
}
channel = new BroadcastChannel('tags_view_sync')
channel.onmessage = (event: MessageEvent<SyncMessage>) => {
handleMessage(event.data)
}
// 请求同步现有状态
channel.postMessage({ type: 'SYNC_REQUEST' })
}
// 处理同步消息
const handleMessage = (message: SyncMessage) => {
switch (message.type) {
case 'ADD_VIEW':
syncAddView(message.view)
break
case 'DEL_VIEW':
syncDelView(message.path)
break
case 'DEL_ALL':
syncDelAll(message.affixPaths)
break
case 'SYNC_REQUEST':
respondWithState()
break
case 'SYNC_RESPONSE':
mergeViews(message.views)
break
}
}
// 发送同步消息
const broadcastAddView = (view: RouteLocationNormalized) => {
channel?.postMessage({
type: 'ADD_VIEW',
view: serializeView(view)
})
}
const broadcastDelView = (path: string) => {
channel?.postMessage({
type: 'DEL_VIEW',
path
})
}
// 序列化视图对象
const serializeView = (view: RouteLocationNormalized): SerializedView => ({
path: view.path,
fullPath: view.fullPath,
name: view.name as string,
meta: { ...view.meta },
query: { ...view.query } as Record<string, string>
})
// 同步添加视图
const syncAddView = (serialized: SerializedView) => {
const exists = layout.visitedViews.value.some(v => v.path === serialized.path)
if (!exists) {
layout.visitedViews.value.push(deserializeView(serialized))
}
}
// 同步删除视图
const syncDelView = (path: string) => {
const index = layout.visitedViews.value.findIndex(v => v.path === path)
if (index > -1) {
layout.visitedViews.value.splice(index, 1)
}
}
// 同步删除全部
const syncDelAll = (affixPaths: string[]) => {
layout.visitedViews.value = layout.visitedViews.value.filter(v =>
affixPaths.includes(v.path)
)
}
// 响应同步请求
const respondWithState = () => {
channel?.postMessage({
type: 'SYNC_RESPONSE',
views: layout.visitedViews.value.map(serializeView)
})
}
// 合并其他窗口的视图
const mergeViews = (views: SerializedView[]) => {
views.forEach(view => {
syncAddView(view)
})
}
// 反序列化视图
const deserializeView = (serialized: SerializedView): RouteLocationNormalized => ({
path: serialized.path,
fullPath: serialized.fullPath,
name: serialized.name,
meta: serialized.meta,
query: serialized.query,
params: {},
hash: '',
matched: [],
redirectedFrom: undefined
})
// 清理
const cleanup = () => {
channel?.close()
channel = null
}
return {
initSync,
cleanup,
broadcastAddView,
broadcastDelView
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
使用 SharedWorker 实现更强大的同步:
// workers/tagsViewWorker.ts
const connections: MessagePort[] = []
let sharedState: { visitedViews: unknown[]; cachedViews: string[] } = {
visitedViews: [],
cachedViews: []
}
self.onconnect = (event: MessageEvent) => {
const port = event.ports[0]
connections.push(port)
port.onmessage = (e: MessageEvent) => {
const { type, data } = e.data
switch (type) {
case 'UPDATE_STATE':
sharedState = data
// 广播到所有连接
connections.forEach(conn => {
if (conn !== port) {
conn.postMessage({ type: 'STATE_UPDATED', data: sharedState })
}
})
break
case 'GET_STATE':
port.postMessage({ type: 'CURRENT_STATE', data: sharedState })
break
}
}
port.start()
// 发送当前状态给新连接
port.postMessage({ type: 'CURRENT_STATE', data: sharedState })
}
// composables/useSharedTagsView.ts
export function useSharedTagsView() {
let worker: SharedWorker | null = null
const init = () => {
if (!('SharedWorker' in window)) {
console.warn('SharedWorker 不可用')
return
}
worker = new SharedWorker(
new URL('../workers/tagsViewWorker.ts', import.meta.url)
)
worker.port.onmessage = (event) => {
const { type, data } = event.data
if (type === 'STATE_UPDATED' || type === 'CURRENT_STATE') {
// 更新本地状态
applySharedState(data)
}
}
worker.port.start()
}
const syncState = (state: unknown) => {
worker?.port.postMessage({ type: 'UPDATE_STATE', data: state })
}
return { init, syncState }
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
总结
标签视图管理系统通过 useLayout Composable 提供完整的多标签页管理能力:
- 完整的标签管理: 15+ 个标签操作方法
- 智能缓存系统: 基于 keep-alive 的页面状态保持
- 灵活的批量操作: 关闭其他、关闭左侧、关闭右侧、关闭全部
- 固定标签支持: 重要页面不可关闭
- Iframe 集成: 无缝集成第三方系统
使用建议:遵循命名规范、合理控制缓存、正确使用生命周期钩子。
