vue-stack-tabs 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +255 -538
- package/dist/iframe-child.html +75 -0
- package/dist/src/lib/hooks/tabPanel/evict.d.ts +45 -7
- package/dist/src/lib/hooks/tabPanel/state.d.ts +2 -3
- package/dist/src/lib/hooks/useTabActions.d.ts +2 -2
- package/dist/src/lib/hooks/useTabPanel.d.ts +6 -4
- package/dist/src/lib/hooks/useTabRouter.d.ts +5 -5
- package/dist/src/lib/model/TabModel.d.ts +13 -3
- package/dist/src/lib/utils/tabInfoEncoder.d.ts +3 -3
- package/dist/style.css +1 -1
- package/dist/vue-stack-tabs.css +1 -1
- package/dist/vue-stack-tabs.es.js +1290 -1481
- package/dist/vue-stack-tabs.umd.js +1 -1
- package/package.json +1 -1
- package/src/lib/StackTabs.vue +17 -20
- package/src/lib/assets/style/stackTab.scss +2 -0
- package/src/lib/hooks/tabPanel/evict.ts +61 -14
- package/src/lib/hooks/tabPanel/scroll.ts +6 -2
- package/src/lib/hooks/tabPanel/state.ts +1 -1
- package/src/lib/hooks/useTabPanel.tsx +310 -61
- package/src/lib/hooks/useTabRouter.ts +146 -31
- package/src/lib/model/TabModel.ts +18 -15
- package/src/lib/nuxt/runtime/plugin.client.ts +1 -3
- package/src/lib/utils/tabInfoEncoder.spec.ts +5 -15
- package/src/lib/utils/tabInfoEncoder.ts +7 -29
|
@@ -12,7 +12,15 @@
|
|
|
12
12
|
* 使用范围:内部 hook,由 StackTabs、useTabActions、useTabRouter、ContextMenu 调用
|
|
13
13
|
*/
|
|
14
14
|
import type { ITabBase, ITabData, ITabItem, ITabPage } from '../model/TabModel'
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
defineComponent,
|
|
17
|
+
onActivated,
|
|
18
|
+
onDeactivated,
|
|
19
|
+
onMounted,
|
|
20
|
+
onUnmounted,
|
|
21
|
+
ref,
|
|
22
|
+
cloneVNode
|
|
23
|
+
} from 'vue'
|
|
16
24
|
import type { DefineComponent, VNode } from 'vue'
|
|
17
25
|
import { useRouter } from 'vue-router'
|
|
18
26
|
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
|
@@ -32,7 +40,6 @@ import {
|
|
|
32
40
|
components,
|
|
33
41
|
tabIdsToEvict,
|
|
34
42
|
refreshKey,
|
|
35
|
-
excludedCacheIdsForRefresh,
|
|
36
43
|
iframeRefreshKeys,
|
|
37
44
|
isInitialized,
|
|
38
45
|
maxTabCount,
|
|
@@ -48,7 +55,8 @@ import {
|
|
|
48
55
|
markTabPagesForEvictionOnly,
|
|
49
56
|
addCache,
|
|
50
57
|
removeCache,
|
|
51
|
-
evictMarkedCaches
|
|
58
|
+
evictMarkedCaches,
|
|
59
|
+
evictPageCache
|
|
52
60
|
} from './tabPanel/evict'
|
|
53
61
|
import { restoreScroller, saveScroller, removeScroller, addPageScroller } from './tabPanel/scroll'
|
|
54
62
|
import {
|
|
@@ -97,7 +105,7 @@ export default () => {
|
|
|
97
105
|
refreshable: true,
|
|
98
106
|
iframe: false
|
|
99
107
|
})
|
|
100
|
-
const cacheName = createPageId(
|
|
108
|
+
const cacheName = createPageId()
|
|
101
109
|
const page: ITabPage = {
|
|
102
110
|
id: cacheName,
|
|
103
111
|
tabId,
|
|
@@ -112,7 +120,10 @@ export default () => {
|
|
|
112
120
|
closable: config.closable,
|
|
113
121
|
refreshable: config.refreshable,
|
|
114
122
|
iframe: config.iframe,
|
|
115
|
-
iframeRefreshMode:
|
|
123
|
+
iframeRefreshMode:
|
|
124
|
+
(config as { iframeRefreshMode?: string }).iframeRefreshMode === 'reload'
|
|
125
|
+
? 'reload'
|
|
126
|
+
: 'postMessage',
|
|
116
127
|
active: false,
|
|
117
128
|
pages
|
|
118
129
|
}
|
|
@@ -126,18 +137,25 @@ export default () => {
|
|
|
126
137
|
const restoredTab = restoreTabFromSession(storedTabJson)
|
|
127
138
|
if (restoredTab && !tabs.value.some((t) => t.id === restoredTab!.id)) {
|
|
128
139
|
if (restoredTab.iframe && restoredTab.url && restoredTab.iframeRefreshMode === undefined) {
|
|
129
|
-
restoredTab.iframeRefreshMode = isCrossOriginUrl(restoredTab.url)
|
|
130
|
-
|
|
131
|
-
|
|
140
|
+
restoredTab.iframeRefreshMode = isCrossOriginUrl(restoredTab.url) ? 'reload' : 'postMessage'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 核心修复:刷新恢复后,不仅要把 Tab 塞入列表,
|
|
144
|
+
// 更要把里面所有的 Page 的 ULID 重新注册进 keep-alive 的 includes,防止彻底沦为白板无缓存组件
|
|
145
|
+
if (restoredTab.pages && typeof restoredTab.pages.list === 'function') {
|
|
146
|
+
const pages = restoredTab.pages.list()
|
|
147
|
+
pages.forEach((page) => {
|
|
148
|
+
if (page && page.id) addCache(page.id)
|
|
149
|
+
})
|
|
132
150
|
}
|
|
151
|
+
|
|
133
152
|
tabs.value.push(restoredTab)
|
|
134
153
|
}
|
|
135
154
|
isInitialized.value = true
|
|
136
155
|
}
|
|
137
156
|
|
|
138
157
|
const hasTab = (id: string) => tabs.value.some((t) => t.id === id)
|
|
139
|
-
const canAddTab = () =>
|
|
140
|
-
maxTabCount <= 0 || (maxTabCount > 0 && maxTabCount > tabs.value.length)
|
|
158
|
+
const canAddTab = () => maxTabCount <= 0 || (maxTabCount > 0 && maxTabCount > tabs.value.length)
|
|
141
159
|
|
|
142
160
|
const addTab = (tab: ITabItem) => {
|
|
143
161
|
tabs.value.push(tab)
|
|
@@ -160,7 +178,18 @@ export default () => {
|
|
|
160
178
|
return tabInfo
|
|
161
179
|
}
|
|
162
180
|
|
|
163
|
-
/**
|
|
181
|
+
/**
|
|
182
|
+
* 查找或创建目标标签,并同步更新 active 状态与 pages 栈。
|
|
183
|
+
*
|
|
184
|
+
* 算法:
|
|
185
|
+
* 1. 遍历所有 tab,将所有 tab.active 置为 false(全部失活)
|
|
186
|
+
* 2. 同时查找 id 匹配的目标 tab(targetTab)
|
|
187
|
+
* 3-a. 若目标 tab 不存在:说明是首次打开该标签,创建新的 ITabItem,初始化 pages 栈并推入当前 page
|
|
188
|
+
* 3-b. 若目标 tab 已存在:
|
|
189
|
+
* - 设为 active
|
|
190
|
+
* - 判断当前 page 是否已在栈顶(避免重复入栈)
|
|
191
|
+
* 当路由在同一标签内多次重渲染(响应式触发)时,page.id 与 peek().id 相同,跳过 push
|
|
192
|
+
*/
|
|
164
193
|
const findOrCreateTargetTab = (
|
|
165
194
|
tabInfo: ITabBase,
|
|
166
195
|
route: RouteLocationNormalizedLoaded,
|
|
@@ -174,6 +203,7 @@ export default () => {
|
|
|
174
203
|
}
|
|
175
204
|
|
|
176
205
|
if (targetTab === null) {
|
|
206
|
+
// 全新标签:初始化 pages 栈、从 query.__src 获取 iframe url
|
|
177
207
|
const pages = new Stack<ITabPage>()
|
|
178
208
|
pages.push(page)
|
|
179
209
|
const src = route.query.__src
|
|
@@ -187,12 +217,14 @@ export default () => {
|
|
|
187
217
|
url: decodeURIComponent(src as string),
|
|
188
218
|
active: true,
|
|
189
219
|
pages
|
|
190
|
-
}
|
|
220
|
+
} as unknown as ITabItem
|
|
191
221
|
addTab(targetTab).then(() => {
|
|
192
222
|
emitter.emit(TabEventType.TAB_ACTIVE, { id: tabInfo.id!, isRoute: false })
|
|
193
223
|
})
|
|
194
224
|
} else {
|
|
195
225
|
targetTab.active = true
|
|
226
|
+
// 幂等保护:page.id 与当前栈顶相同时不重复入栈
|
|
227
|
+
// 响应式重渲染(如 caches 变更)会重复调用 tabWrapper,需过滤此类重入
|
|
196
228
|
if (targetTab.pages.isEmpty() || targetTab.pages.peek()!.id !== page.id) {
|
|
197
229
|
targetTab.pages.push(page)
|
|
198
230
|
}
|
|
@@ -201,8 +233,25 @@ export default () => {
|
|
|
201
233
|
}
|
|
202
234
|
|
|
203
235
|
/**
|
|
204
|
-
*
|
|
205
|
-
|
|
236
|
+
* 暴露给外部使用,用于获取指定路由对应的 cacheName
|
|
237
|
+
*/
|
|
238
|
+
const getCacheName = () => {
|
|
239
|
+
return createPageId()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 根据当前路由更新/创建标签与页面状态(核心路由适配器)。
|
|
244
|
+
*
|
|
245
|
+
* 该函数在每次 tabWrapper 被调用时执行(即每次 <router-view> 渲染或响应式重渲染时)。
|
|
246
|
+
* 返回三种可能的结果:
|
|
247
|
+
* - { empty: true } → 目标标签已被标记为驱逐(关闭态),返回空占位组件
|
|
248
|
+
* - { recovered, cacheName } → 页面曾被软标记驱逐(刷新场景),原组件仍存活,直接恢复
|
|
249
|
+
* - { cacheName, tabInfo, targetTab } → 正常流程,返回完整上下文供 resolvePageComponent 使用
|
|
250
|
+
*
|
|
251
|
+
* cacheName 生成算法:
|
|
252
|
+
* createPageId(tabId, normalizedPath, queryObject)
|
|
253
|
+
* 使用 tabId + 路径 + query(含 __tab)生成确定性哈希,
|
|
254
|
+
* 同一标签同一路由的 cacheName 始终相同,用作 cacheComponent.name 及 keep-alive include 的键。
|
|
206
255
|
*/
|
|
207
256
|
const updatePageState = (
|
|
208
257
|
route: RouteLocationNormalizedLoaded
|
|
@@ -214,62 +263,210 @@ export default () => {
|
|
|
214
263
|
if (tabIdsToEvict.has(tabInfo.id!)) return { empty: true }
|
|
215
264
|
|
|
216
265
|
const path = normalizePathForCache(route)
|
|
217
|
-
|
|
266
|
+
|
|
267
|
+
// 核心重构:一切以数据栈为准,不再计算 URL 哈希
|
|
268
|
+
let targetTab: ITabItem | null = tabInfo.id ? (getTab(tabInfo.id) as unknown as ITabItem) : null
|
|
269
|
+
let cacheName: string
|
|
270
|
+
let page: ITabPage | null = null
|
|
271
|
+
|
|
272
|
+
if (targetTab && !targetTab.pages.isEmpty()) {
|
|
273
|
+
const top = targetTab.pages.peek()
|
|
274
|
+
if (!top) throw new Error('[vue-stack-tabs] Stack is not empty but peek() returned undefined')
|
|
275
|
+
|
|
276
|
+
// 数据栈是唯一真相源,只比对 path 即可判断当前路由是否与栈顶一致
|
|
277
|
+
if (top.path === path) {
|
|
278
|
+
cacheName = top.id
|
|
279
|
+
} else {
|
|
280
|
+
// [边界/修复] "空降兵"或"浏览器原生后退"拦截
|
|
281
|
+
// URL 和栈顶对不上!我们被外部改变了。
|
|
282
|
+
const pagesList = targetTab.pages.list()
|
|
283
|
+
let foundIndex = -1
|
|
284
|
+
// 从栈顶往下找,有没有曾经来过的同路由(以此判定是否为 PopState 后退)
|
|
285
|
+
// 只比对 path,因为数据栈才是唯一真相源
|
|
286
|
+
for (let i = pagesList.length - 2; i >= 0; i--) {
|
|
287
|
+
const pastPage = pagesList[i]
|
|
288
|
+
if (pastPage && pastPage.path === path) {
|
|
289
|
+
foundIndex = i
|
|
290
|
+
break
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (foundIndex !== -1) {
|
|
295
|
+
// 找到了!这是一次浏览器的原生后退行为,因为路由事件触发了所以栈还没来得及退。
|
|
296
|
+
// 手动执行类似 `backward` 的栈回退逻辑以亡羊补牢。
|
|
297
|
+
console.warn(
|
|
298
|
+
'[vue-stack-tabs] Detected native browser back navigation, repairing page stack...'
|
|
299
|
+
)
|
|
300
|
+
const stepsToPop = pagesList.length - 1 - foundIndex
|
|
301
|
+
for (let i = 0; i < stepsToPop; i++) {
|
|
302
|
+
const popped = targetTab.pages.pop()
|
|
303
|
+
// 立即驱逐被错误保留在内存的那些未来组件(在 router 的钩子里也可以,但这里既然已经渲染就直接驱)
|
|
304
|
+
if (popped) evictPageCache(popped.id)
|
|
305
|
+
}
|
|
306
|
+
// 因为我们把不要的都出栈了,现在的栈顶就是以前的这一个记录
|
|
307
|
+
const newTop = targetTab.pages.peek()
|
|
308
|
+
cacheName = newTop ? newTop.id : createPageId()
|
|
309
|
+
|
|
310
|
+
if (!newTop) {
|
|
311
|
+
page = {
|
|
312
|
+
id: cacheName,
|
|
313
|
+
tabId: tabInfo.id!,
|
|
314
|
+
path: route.path, // 保持原 query 等参数
|
|
315
|
+
query: route.query as Record<string, string>
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
} else {
|
|
319
|
+
// 在历史栈完全没找到,真的是被强行推入的“空降兵”新路由,直接补发一个新的栈记录。
|
|
320
|
+
cacheName = createPageId()
|
|
321
|
+
page = {
|
|
322
|
+
id: cacheName,
|
|
323
|
+
tabId: tabInfo.id!,
|
|
324
|
+
path: route.path, // 保持原 query 等参数
|
|
325
|
+
query: route.query as Record<string, string>
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
// 这是一个全新创建的 Tab(被外部用空链接无中生有)
|
|
331
|
+
cacheName = createPageId()
|
|
332
|
+
page = {
|
|
333
|
+
id: cacheName,
|
|
334
|
+
tabId: tabInfo.id!,
|
|
335
|
+
path: route.path,
|
|
336
|
+
query: route.query as Record<string, string>
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 刷新恢复场景:依旧支持软驱逐和组件复用
|
|
218
341
|
if (cacheIdsToEvict.has(cacheName)) {
|
|
219
342
|
cacheIdsToEvict.delete(cacheName)
|
|
220
343
|
const cached = components.get(cacheName)
|
|
221
344
|
if (cached) return { recovered: cached, cacheName }
|
|
222
345
|
}
|
|
223
346
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
347
|
+
// 将 page (如果上面判断它为全新的时候)塞入栈中或建新 Tab
|
|
348
|
+
if (page) {
|
|
349
|
+
targetTab = findOrCreateTargetTab(tabInfo, route, cacheName, page)
|
|
350
|
+
} else {
|
|
351
|
+
// 否则说明这只是同页渲染复用(或者是刷新回来的无变动栈),把 targetTab 直接提拔为 active
|
|
352
|
+
for (const t of tabs.value) t.active = false
|
|
353
|
+
targetTab!.active = true
|
|
354
|
+
if (!cacheName && targetTab && !targetTab.pages.isEmpty()) {
|
|
355
|
+
cacheName = targetTab.pages.peek()!.id
|
|
356
|
+
}
|
|
229
357
|
}
|
|
230
|
-
|
|
231
|
-
saveActiveTabToSession(targetTab)
|
|
232
|
-
return { cacheName, tabInfo, targetTab }
|
|
358
|
+
|
|
359
|
+
saveActiveTabToSession(targetTab!)
|
|
360
|
+
return { cacheName, tabInfo, targetTab: targetTab! }
|
|
233
361
|
}
|
|
234
362
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
363
|
+
/**
|
|
364
|
+
* 获取或创建与 cacheName 对应的 cacheComponent(keep-alive 包装组件)。
|
|
365
|
+
*
|
|
366
|
+
* 设计要点:
|
|
367
|
+
*
|
|
368
|
+
* 1. 「组件定义与 VNode 解耦」
|
|
369
|
+
* 旧版实现在 defineComponent 时通过 components: { DynamicComponent: component }
|
|
370
|
+
* 将页面 VNode 锁定为闭包,导致:
|
|
371
|
+
* - 组件定义本身持有旧 VNode 引用,即使路由参数变化也不更新
|
|
372
|
+
* - evictPageCache 后重建新定义,但 keep-alive 以 name 为键仍激活旧实例
|
|
373
|
+
* 新版改为通过 props: ['vnode'] + StackTabs.vue 的 :vnode="Component" 动态传入,
|
|
374
|
+
* 每次渲染从父层获取最新 VNode,彻底消除闭包缓存残留。
|
|
375
|
+
*
|
|
376
|
+
* 2. 「cloneVNode 注入 tId / pId」
|
|
377
|
+
* render 函数执行时,通过 cloneVNode(Node, { tId, pId }) 克隆页面 VNode 并附加标签标识属性。
|
|
378
|
+
* 被渲染的页面组件(如 InternalRouteDetail)通过 attrs.tId / attrs.pId 读取这两个值,
|
|
379
|
+
* 供 useTabRouter 内部定位所属标签与缓存 ID。
|
|
380
|
+
* (未声明在 defineProps 里的属性自动进入 attrs)
|
|
381
|
+
*
|
|
382
|
+
* 3. 「onActivated 延迟驱逐」
|
|
383
|
+
* 标签切换(非 backward)导致旧页面失活时,旧页面 ID 已由 markCacheForEviction 标记,
|
|
384
|
+
* 等新页面的 cacheComponent onActivated 触发,再批量执行 evictMarkedCaches 是安全的——
|
|
385
|
+
* 此时新页面已就绪,不存在重渲染重建的竞态问题。
|
|
386
|
+
*/
|
|
387
|
+
const resolvePageComponent = (ctx: {
|
|
388
|
+
cacheName: string
|
|
389
|
+
tabInfo: ITabBase
|
|
390
|
+
targetTab: ITabItem
|
|
391
|
+
}): DefineComponent => {
|
|
239
392
|
const { cacheName, tabInfo } = ctx
|
|
393
|
+
|
|
394
|
+
// 已存在的组件定义直接复用(keep-alive 会在内部找到缓存实例),无需重建
|
|
240
395
|
if (components.has(cacheName)) {
|
|
241
|
-
addCache(cacheName)
|
|
396
|
+
addCache(cacheName) // 确保再次激活时仍在 include 列表中
|
|
242
397
|
return components.get(cacheName)!
|
|
243
398
|
}
|
|
399
|
+
|
|
400
|
+
// 首次进入该缓存页:创建专属 cacheComponent 包装组件
|
|
244
401
|
const cacheComponent = defineComponent({
|
|
402
|
+
// name 与 cacheName 保持一致,作为 keep-alive include 的匹配键
|
|
245
403
|
name: cacheName,
|
|
246
|
-
|
|
404
|
+
// vnode 由 StackTabs.vue 的 :vnode="Component" 动态传入(解耦闭包)
|
|
405
|
+
props: {
|
|
406
|
+
vnode: {
|
|
407
|
+
type: Object as () => VNode,
|
|
408
|
+
required: false,
|
|
409
|
+
default: undefined
|
|
410
|
+
}
|
|
411
|
+
},
|
|
247
412
|
emits: ['onLoaded'],
|
|
248
|
-
setup(
|
|
413
|
+
setup(props, context) {
|
|
414
|
+
// 用于在当前组件活跃期内存下回退参数,离开或重返时将更新或清理,真正实现生命周期同步!
|
|
415
|
+
const localBackParams = ref<Record<string, unknown> | null>(null)
|
|
416
|
+
|
|
417
|
+
const checkAndConsumeBackParams = () => {
|
|
418
|
+
try {
|
|
419
|
+
const tab = getTab(tabInfo.id!)
|
|
420
|
+
const top = tab?.pages.peek()
|
|
421
|
+
if (top && top.id === cacheName && top._backParams) {
|
|
422
|
+
localBackParams.value = { ...top._backParams }
|
|
423
|
+
// 取出后阅后即焚,防止从别处循环回退时拿到过期老参
|
|
424
|
+
delete top._backParams
|
|
425
|
+
} else {
|
|
426
|
+
localBackParams.value = null
|
|
427
|
+
}
|
|
428
|
+
} catch {
|
|
429
|
+
localBackParams.value = null
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
249
433
|
onMounted(() => {
|
|
250
434
|
context.emit('onLoaded')
|
|
251
|
-
addPageScroller(cacheName,
|
|
435
|
+
addPageScroller(cacheName, `#W-${cacheName}`)
|
|
436
|
+
checkAndConsumeBackParams()
|
|
252
437
|
})
|
|
438
|
+
|
|
253
439
|
onDeactivated(() => saveScroller(cacheName))
|
|
440
|
+
|
|
254
441
|
onActivated(() => {
|
|
255
442
|
context.emit('onLoaded')
|
|
256
443
|
restoreScroller(cacheName)
|
|
257
444
|
evictMarkedCaches()
|
|
258
445
|
tabIdsToEvict.clear()
|
|
259
|
-
|
|
260
|
-
component.props = null
|
|
446
|
+
checkAndConsumeBackParams()
|
|
261
447
|
})
|
|
448
|
+
|
|
262
449
|
onUnmounted(() => removeScroller(cacheName))
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
<
|
|
271
|
-
|
|
272
|
-
|
|
450
|
+
|
|
451
|
+
return () => {
|
|
452
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
453
|
+
const Node = (props as any).vnode as VNode
|
|
454
|
+
const dynamicBackProps = localBackParams.value ? { _back: localBackParams.value } : {}
|
|
455
|
+
|
|
456
|
+
return (
|
|
457
|
+
<div
|
|
458
|
+
class="cache-page-wrapper"
|
|
459
|
+
id={`W-${cacheName}`}
|
|
460
|
+
style={[useGlobalScroll ? 'overflow:auto' : 'overflow:hidden', 'height: 100%']}
|
|
461
|
+
>
|
|
462
|
+
{/* cloneVNode 附加 tId / pId 以及每次动态截留的 _back 回退参数 */}
|
|
463
|
+
{Node
|
|
464
|
+
? cloneVNode(Node, { tId: tabInfo.id, pId: cacheName, ...(dynamicBackProps || {}) })
|
|
465
|
+
: null}
|
|
466
|
+
<PageLoading tabId={tabInfo.id!} />
|
|
467
|
+
</div>
|
|
468
|
+
)
|
|
469
|
+
}
|
|
273
470
|
}
|
|
274
471
|
}) as DefineComponent
|
|
275
472
|
components.set(cacheName, cacheComponent)
|
|
@@ -277,24 +474,37 @@ export default () => {
|
|
|
277
474
|
return cacheComponent
|
|
278
475
|
}
|
|
279
476
|
|
|
280
|
-
|
|
281
|
-
excludedCacheIdsForRefresh.value = excludedCacheIdsForRefresh.value.filter((c) => c !== cacheId)
|
|
282
|
-
}
|
|
477
|
+
// 刷新隔离名单功能已由全新 ID 替换法替代
|
|
283
478
|
|
|
284
479
|
/**
|
|
285
|
-
*
|
|
286
|
-
*
|
|
480
|
+
* 将路由对应的组件注册为缓存页面(由 StackTabs.vue 的 tabWrapper 调用)。
|
|
481
|
+
*
|
|
482
|
+
* 流程:
|
|
483
|
+
* 1. 若 component 为空(router-view 未匹配到路由),返回空占位组件
|
|
484
|
+
* 2. updatePageState 解析路由状态:
|
|
485
|
+
* - empty → 标签已关闭,返回空占位
|
|
486
|
+
* - recovered → 刷新恢复,直接 addCache 并返回原组件
|
|
487
|
+
* - 正常 → 调用 resolvePageComponent 获取/创建 cacheComponent
|
|
488
|
+
*
|
|
489
|
+
* @param component StackTabs.vue 传入的原始 VNode(来自 <router-view> 的 Component 插槽)
|
|
490
|
+
* @returns 用于 <component :is> 的 cacheComponent 包装定义
|
|
287
491
|
*/
|
|
492
|
+
/** 当前正在渲染的页面的缓存 Key(ULID),供 StackTabs.vue 的 :key 绑定使用 */
|
|
493
|
+
const activeCacheKey = ref<string>('')
|
|
494
|
+
|
|
288
495
|
const addPage = (route: RouteLocationNormalizedLoaded, component: VNode): DefineComponent => {
|
|
289
496
|
if (!component) return EmptyPlaceholderComponent as DefineComponent
|
|
290
497
|
|
|
291
498
|
const state = updatePageState(route)
|
|
292
499
|
if ('empty' in state) return EmptyPlaceholderComponent as DefineComponent
|
|
293
500
|
if ('recovered' in state) {
|
|
501
|
+
// 刷新场景:组件定义仍存活,只需重新加入 include 列表即可
|
|
294
502
|
addCache(state.cacheName)
|
|
503
|
+
activeCacheKey.value = state.cacheName
|
|
295
504
|
return state.recovered
|
|
296
505
|
}
|
|
297
|
-
|
|
506
|
+
activeCacheKey.value = state.cacheName
|
|
507
|
+
return resolvePageComponent(state)
|
|
298
508
|
}
|
|
299
509
|
|
|
300
510
|
const renewTab = (tab: ITabData) => {
|
|
@@ -430,22 +640,35 @@ export default () => {
|
|
|
430
640
|
[id]: (iframeRefreshKeys.value[id] ?? 0) + 1
|
|
431
641
|
}
|
|
432
642
|
} else {
|
|
433
|
-
;(emitter as { emit: (t: string, p: string) => void }).emit(
|
|
643
|
+
;(emitter as { emit: (t: string, p: string) => void }).emit(
|
|
644
|
+
'REFRESH_IFRAME_POSTMESSAGE',
|
|
645
|
+
id
|
|
646
|
+
)
|
|
434
647
|
}
|
|
435
648
|
return
|
|
436
649
|
}
|
|
437
650
|
|
|
438
|
-
const
|
|
439
|
-
if (!
|
|
651
|
+
const currentPage = tab.pages.peek()
|
|
652
|
+
if (!currentPage) return
|
|
653
|
+
|
|
654
|
+
// 新架构:真正的刷新 = 把栈顶的旧组件数据连根拔起并注销,让它换个新 ID 重新走生命周期
|
|
655
|
+
const oldId = currentPage.id
|
|
656
|
+
evictPageCache(oldId) // 清除旧缓存
|
|
657
|
+
|
|
658
|
+
// 生成全新 ID,相当于把原页面配方注入了一个崭新的组件身份
|
|
659
|
+
currentPage.id = createPageId()
|
|
660
|
+
addCache(currentPage.id)
|
|
440
661
|
|
|
441
|
-
excludedCacheIdsForRefresh.value = [...excludedCacheIdsForRefresh.value, currentPageId]
|
|
442
662
|
if (tab.active) {
|
|
663
|
+
// 激活状态下,增加 refreshKey 强制触发当前 Router 的重排,
|
|
664
|
+
// 由于 `<keep-alive>` 中之前记录的名字已经被驱逐出境,它会自动使用新 ID 创建一个全新页面。
|
|
443
665
|
refreshKey.value++
|
|
444
666
|
}
|
|
445
667
|
}
|
|
446
668
|
|
|
447
669
|
const refreshAllTabs = () => {
|
|
448
|
-
|
|
670
|
+
let needsActiveRefresh = false
|
|
671
|
+
|
|
449
672
|
for (const tab of tabs.value) {
|
|
450
673
|
if (!tab?.refreshable) continue
|
|
451
674
|
if (tab.iframe) {
|
|
@@ -455,15 +678,27 @@ export default () => {
|
|
|
455
678
|
[tab.id]: (iframeRefreshKeys.value[tab.id] ?? 0) + 1
|
|
456
679
|
}
|
|
457
680
|
} else {
|
|
458
|
-
;(emitter as { emit: (t: string, p: string) => void }).emit(
|
|
681
|
+
;(emitter as { emit: (t: string, p: string) => void }).emit(
|
|
682
|
+
'REFRESH_IFRAME_POSTMESSAGE',
|
|
683
|
+
tab.id
|
|
684
|
+
)
|
|
459
685
|
}
|
|
460
686
|
} else {
|
|
461
|
-
const
|
|
462
|
-
if (
|
|
687
|
+
const currentPage = tab.pages.peek()
|
|
688
|
+
if (currentPage) {
|
|
689
|
+
const oldId = currentPage.id
|
|
690
|
+
evictPageCache(oldId)
|
|
691
|
+
currentPage.id = createPageId()
|
|
692
|
+
addCache(currentPage.id)
|
|
693
|
+
|
|
694
|
+
if (tab.active) {
|
|
695
|
+
needsActiveRefresh = true
|
|
696
|
+
}
|
|
697
|
+
}
|
|
463
698
|
}
|
|
464
699
|
}
|
|
465
|
-
|
|
466
|
-
|
|
700
|
+
|
|
701
|
+
if (needsActiveRefresh) {
|
|
467
702
|
refreshKey.value++
|
|
468
703
|
}
|
|
469
704
|
}
|
|
@@ -489,7 +724,19 @@ export default () => {
|
|
|
489
724
|
tabs.value = tabs.value.slice()
|
|
490
725
|
if (route) {
|
|
491
726
|
const top = target.pages.peek()
|
|
492
|
-
if (top)
|
|
727
|
+
if (top) {
|
|
728
|
+
// 切换 Tab 走路由时,必须将 __tab 编码信息注入 query,
|
|
729
|
+
// 否则 parseTabInfoFromRoute 找不到对应 Tab,会新建匿名标签!
|
|
730
|
+
const __tab = encodeTabInfo({
|
|
731
|
+
id: target.id,
|
|
732
|
+
title: target.title,
|
|
733
|
+
closable: target.closable,
|
|
734
|
+
refreshable: target.refreshable,
|
|
735
|
+
iframe: target.iframe
|
|
736
|
+
})
|
|
737
|
+
const query = defu({ __tab }, top.query || {})
|
|
738
|
+
router.push({ path: top.path, query })
|
|
739
|
+
}
|
|
493
740
|
}
|
|
494
741
|
}
|
|
495
742
|
|
|
@@ -509,13 +756,14 @@ export default () => {
|
|
|
509
756
|
tabs,
|
|
510
757
|
caches,
|
|
511
758
|
refreshKey,
|
|
512
|
-
|
|
759
|
+
activeCacheKey,
|
|
513
760
|
isInitialized,
|
|
514
761
|
setMaxSize: setMaxTabCount,
|
|
515
762
|
canAddTab,
|
|
516
763
|
initialize,
|
|
517
764
|
size,
|
|
518
765
|
active,
|
|
766
|
+
getCacheName,
|
|
519
767
|
destroy,
|
|
520
768
|
reset,
|
|
521
769
|
addCache,
|
|
@@ -535,6 +783,7 @@ export default () => {
|
|
|
535
783
|
removeComponent: (id: string) => components.delete(id),
|
|
536
784
|
markCacheForEviction,
|
|
537
785
|
evictMarkedCaches,
|
|
786
|
+
evictPageCache,
|
|
538
787
|
refreshTab,
|
|
539
788
|
refreshAllTabs,
|
|
540
789
|
iframeRefreshKeys,
|