vue-stack-tabs 2.2.0 → 2.3.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.
Files changed (42) hide show
  1. package/dist/StackTabs.vue.d.ts +2 -2
  2. package/dist/components/StackKeepAlive/StackCacheRenderer.vue.d.ts +0 -2
  3. package/dist/hooks/stackTabsContext.d.ts +0 -1
  4. package/dist/hooks/tabPanel/evict.d.ts +2 -2
  5. package/dist/hooks/tabPanel/scroll.d.ts +0 -2
  6. package/dist/hooks/tabPanel/state.d.ts +0 -1
  7. package/dist/hooks/useIframeManager.d.ts +1 -1
  8. package/dist/hooks/useScrollShadow.d.ts +11 -2
  9. package/dist/hooks/useTabEventBus.d.ts +5 -0
  10. package/dist/model/TabModel.d.ts +12 -5
  11. package/dist/nuxt/module.mjs +1 -1
  12. package/dist/nuxt/runtime/plugin.mjs +8 -1598
  13. package/dist/style.css +1 -1
  14. package/dist/utils/scrollUtils.d.ts +0 -2
  15. package/dist/utils/tabInfoEncoder.d.ts +0 -2
  16. package/dist/utils/urlParser.d.ts +6 -7
  17. package/dist/vue-stack-tabs.es.js +1 -1
  18. package/package.json +1 -1
  19. package/src/lib/StackTabs.vue +16 -1
  20. package/src/lib/assets/style/stack-tab.scss +12 -6
  21. package/src/lib/components/PageLoading.vue +10 -9
  22. package/src/lib/components/StackKeepAlive/StackCacheRenderer.vue +1 -5
  23. package/src/lib/components/TabHeader/TabHeaderScroll.vue +164 -90
  24. package/src/lib/components/TabHeader/index.vue +51 -10
  25. package/src/lib/hooks/stackTabsContext.ts +0 -1
  26. package/src/lib/hooks/tabPanel/evict.ts +14 -6
  27. package/src/lib/hooks/tabPanel/pageComponentFactory.tsx +211 -42
  28. package/src/lib/hooks/tabPanel/scroll.ts +4 -15
  29. package/src/lib/hooks/tabPanel/session.ts +11 -4
  30. package/src/lib/hooks/tabPanel/state.ts +0 -2
  31. package/src/lib/hooks/tabPanel/tabStore.ts +28 -10
  32. package/src/lib/hooks/useIframeManager.ts +11 -15
  33. package/src/lib/hooks/useScrollShadow.ts +74 -19
  34. package/src/lib/hooks/useTabActions.ts +10 -4
  35. package/src/lib/hooks/useTabEventBus.ts +3 -0
  36. package/src/lib/hooks/useTabPanel.tsx +46 -34
  37. package/src/lib/model/TabModel.ts +14 -5
  38. package/src/lib/nuxt/module.ts +3 -1
  39. package/src/lib/nuxt/runtime/plugin.ts +7 -3
  40. package/src/lib/utils/scrollUtils.ts +0 -18
  41. package/src/lib/utils/tabInfoEncoder.ts +21 -2
  42. package/src/lib/utils/urlParser.ts +40 -37
@@ -5,15 +5,17 @@
5
5
  * 使 useTabPanel 只负责领域状态与路由协调。
6
6
  */
7
7
  import {
8
- Transition,
9
8
  cloneVNode,
10
9
  defineComponent,
10
+ nextTick,
11
11
  onActivated,
12
+ onBeforeUnmount,
12
13
  onDeactivated,
13
14
  onMounted,
14
15
  onUnmounted,
15
16
  ref,
16
- shallowRef
17
+ shallowRef,
18
+ watch
17
19
  } from 'vue'
18
20
  import type { DefineComponent, PropType, VNode } from 'vue'
19
21
  import type { ITabBase } from '../../model/TabModel'
@@ -35,6 +37,36 @@ interface PageComponentFactoryOptions {
35
37
  removeScroller: (pageId: string) => void
36
38
  }
37
39
 
40
+ const parseTimeList = (value: string): number[] =>
41
+ value.split(',').map((item) => Number.parseFloat(item.trim()) || 0)
42
+
43
+ /** 读取元素当前 transition 的总时长(秒转毫秒),用于等待离场/入场结束。 */
44
+ const getTransitionDurationMs = (element: HTMLElement): number => {
45
+ if (typeof window === 'undefined') return 0
46
+ const style = window.getComputedStyle(element)
47
+ const durations = parseTimeList(style.transitionDuration || '0s')
48
+ const delays = parseTimeList(style.transitionDelay || '0s')
49
+ if (durations.length === 0) return 0
50
+ return (
51
+ Math.max(
52
+ ...durations.map((duration, index) => duration + (delays[index % delays.length] ?? 0))
53
+ ) * 1000
54
+ )
55
+ }
56
+
57
+ /** 等待下一帧,让刚添加的 transition 起始样式先生效。 */
58
+ const nextFrame = (): Promise<void> =>
59
+ new Promise((resolve) => {
60
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => resolve())
61
+ else setTimeout(resolve, 16)
62
+ })
63
+
64
+ /** 用户开启「减少动态效果」时直接切换页面,不执行转场。 */
65
+ const prefersReducedMotion = (): boolean =>
66
+ typeof window !== 'undefined' &&
67
+ typeof window.matchMedia === 'function' &&
68
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
69
+
38
70
  export const createPageComponentFactory = (options: PageComponentFactoryOptions) => {
39
71
  const {
40
72
  runtimeContext,
@@ -59,8 +91,9 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
59
91
  }
60
92
 
61
93
  /**
62
- * 页面壳组件:作为 Transition 的直接子节点,承接刷新时的 key 变化,
94
+ * 页面壳组件:承接刷新时的 key 变化,让页面重建发生在缓存 wrapper 内部,
63
95
  * 避免直接修改 router-view 页面 vnode 的 key 破坏 keep-alive 的实例复用。
96
+ * 不额外渲染元素,页面可以是单根,也可以是多根节点(Fragment)。
64
97
  */
65
98
  const PageShell = defineComponent({
66
99
  name: `${cacheName}-shell`,
@@ -110,24 +143,156 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
110
143
  const lastVnode = shallowRef<VNode | null | undefined>(null)
111
144
  const lastCloned = shallowRef<VNode | null>(null)
112
145
  const lastBackParams = shallowRef<Record<string, unknown> | null>(null)
113
- const lastRefreshVersion = shallowRef<number>(0)
114
146
  const pageRoot = ref<HTMLElement | null>(null)
115
147
  /**
116
- * 转场期间临时把 wrapper 的 overflow 锁为 hidden。
117
- * 内部 Transition 的 translateX 作用于页面根元素,若 wrapper 是 overflow:auto,
118
- * 位移会让内容短暂溢出并闪出滚动条。
148
+ * 实际渲染的刷新版本。
149
+ *
150
+ * 刷新时先把 wrapper(真实元素,兼容多根节点页面)作为动画主体执行离场,
151
+ * 离场结束再提升该值重建页面,最后执行入场;因此它会短暂落后于
152
+ * props.refreshVersion。外层 keep-alive key 保持不变,避免实例累积。
119
153
  */
120
- const isTransitioning = ref(false)
121
- let transitionCount = 0
122
- const beginTransition = () => {
123
- transitionCount++
124
- isTransitioning.value = true
154
+ const renderedRefreshVersion = ref(props.refreshVersion ?? 0)
155
+
156
+ let refreshDisposed = false
157
+ let isFlushingRefresh = false
158
+ let queuedRefreshVersion: number | null = null
159
+ let refreshTimer: ReturnType<typeof setTimeout> | null = null
160
+ let cancelRefreshWait: (() => void) | null = null
161
+ let appliedTransitionName = ''
162
+
163
+ const clearRefreshTimer = () => {
164
+ if (refreshTimer === null) return
165
+ clearTimeout(refreshTimer)
166
+ refreshTimer = null
125
167
  }
126
- const endTransition = () => {
127
- transitionCount = Math.max(0, transitionCount - 1)
128
- if (transitionCount === 0) isTransitioning.value = false
168
+
169
+ const removeRefreshClasses = () => {
170
+ const element = pageRoot.value
171
+ const name = appliedTransitionName
172
+ if (!element || !name) return
173
+ element.classList.remove(
174
+ `${name}-leave-active`,
175
+ `${name}-leave-to`,
176
+ `${name}-enter-active`,
177
+ `${name}-enter-from`
178
+ )
179
+ appliedTransitionName = ''
129
180
  }
130
181
 
182
+ /**
183
+ * 等待一段 transition 结束;transitionend 不可用时用时长兜底。
184
+ * 元素可能同时有多个过渡属性(opacity / transform),任意一个结束即可。
185
+ */
186
+ const waitForRefreshEnd = (duration: number): Promise<void> =>
187
+ new Promise((resolve) => {
188
+ const element = pageRoot.value
189
+ if (!element || duration <= 0) {
190
+ resolve()
191
+ return
192
+ }
193
+ let settled = false
194
+ const finish = () => {
195
+ if (settled) return
196
+ settled = true
197
+ cancelRefreshWait = null
198
+ clearRefreshTimer()
199
+ element.removeEventListener('transitionend', handleEnd)
200
+ resolve()
201
+ }
202
+ const handleEnd = (event: TransitionEvent) => {
203
+ if (event.target === element) finish()
204
+ }
205
+ cancelRefreshWait = finish
206
+ element.addEventListener('transitionend', handleEnd)
207
+ refreshTimer = setTimeout(finish, duration + 80)
208
+ })
209
+
210
+ /**
211
+ * 执行一次完整的刷新转场:旧内容离场 → 提升版本重建页面 → 新内容入场。
212
+ *
213
+ * 动画类名沿用 Vue Transition 的约定(`${name}-leave-active` 等),
214
+ * 因此自定义页面过渡 CSS 仍然有效;动画主体是缓存 wrapper 本身,
215
+ * 页面是单根还是多根(Fragment)都不影响。
216
+ */
217
+ const runRefreshAnimation = async (targetVersion: number): Promise<void> => {
218
+ const element = pageRoot.value
219
+ const name = props.transitionName
220
+ if (!element || !name || prefersReducedMotion()) {
221
+ renderedRefreshVersion.value = targetVersion
222
+ return
223
+ }
224
+
225
+ appliedTransitionName = name
226
+ element.classList.add(`${name}-leave-active`)
227
+ const leaveDuration = getTransitionDurationMs(element)
228
+ if (leaveDuration <= 0) {
229
+ removeRefreshClasses()
230
+ renderedRefreshVersion.value = targetVersion
231
+ return
232
+ }
233
+
234
+ // 先让 leave-active 生效,再添加 leave-to,触发离场过渡。
235
+ await nextFrame()
236
+ if (refreshDisposed) {
237
+ removeRefreshClasses()
238
+ return
239
+ }
240
+ element.classList.add(`${name}-leave-to`)
241
+ await waitForRefreshEnd(leaveDuration)
242
+ removeRefreshClasses()
243
+ if (refreshDisposed) return
244
+
245
+ renderedRefreshVersion.value = targetVersion
246
+ await nextTick()
247
+ if (refreshDisposed) return
248
+
249
+ appliedTransitionName = name
250
+ // 先把元素落到入场起点(不加 transition,避免从离场终点反向缓动),
251
+ // 再启用 enter-active,下一帧移除 enter-from,形成标准的入场过渡。
252
+ element.classList.add(`${name}-enter-from`)
253
+ void element.offsetWidth
254
+ element.classList.add(`${name}-enter-active`)
255
+ const enterDuration = getTransitionDurationMs(element)
256
+ if (enterDuration <= 0) {
257
+ removeRefreshClasses()
258
+ return
259
+ }
260
+
261
+ await nextFrame()
262
+ if (refreshDisposed) {
263
+ removeRefreshClasses()
264
+ return
265
+ }
266
+ element.classList.remove(`${name}-enter-from`)
267
+ await waitForRefreshEnd(enterDuration)
268
+ removeRefreshClasses()
269
+ }
270
+
271
+ const flushRefreshQueue = async (): Promise<void> => {
272
+ if (isFlushingRefresh) return
273
+ isFlushingRefresh = true
274
+ try {
275
+ while (!refreshDisposed && queuedRefreshVersion !== null) {
276
+ const targetVersion = queuedRefreshVersion
277
+ queuedRefreshVersion = null
278
+ if (targetVersion === renderedRefreshVersion.value) continue
279
+ await runRefreshAnimation(targetVersion)
280
+ }
281
+ } finally {
282
+ isFlushingRefresh = false
283
+ }
284
+ }
285
+
286
+ watch(
287
+ () => props.refreshVersion ?? 0,
288
+ (nextVersion) => {
289
+ if (nextVersion === renderedRefreshVersion.value && queuedRefreshVersion === null)
290
+ return
291
+ queuedRefreshVersion = nextVersion
292
+ void flushRefreshQueue()
293
+ }
294
+ )
295
+
131
296
  const checkAndConsumeBackParams = () => {
132
297
  localBackParams.value = consumeBackParams(tabInfo.id!, cacheName)
133
298
  }
@@ -135,67 +300,71 @@ export const createPageComponentFactory = (options: PageComponentFactoryOptions)
135
300
  onMounted(() => {
136
301
  context.emit('onLoaded')
137
302
  addPageScroller(cacheName, `#W-${cacheName}`)
303
+ // 新页面经 Transition(out-in) 挂载时,setup 后的 microtask 可能早于 ref 赋值;
304
+ // 挂载后重新 sync,确保 scroll shadow 监听与阴影类目标正确绑定。
305
+ scrollShadow.sync()
138
306
  checkAndConsumeBackParams()
139
307
  })
140
- onDeactivated(() => saveScroller(cacheName))
308
+ onDeactivated(() => {
309
+ scrollShadow.clear()
310
+ saveScroller(cacheName)
311
+ })
141
312
  onActivated(() => {
142
313
  context.emit('onLoaded')
143
314
  restoreScroller(cacheName)
315
+ scrollShadow.sync()
144
316
  evictMarkedCaches()
145
317
  clearTabEvictionMarks()
146
318
  checkAndConsumeBackParams()
147
319
  })
320
+ onBeforeUnmount(() => {
321
+ refreshDisposed = true
322
+ queuedRefreshVersion = null
323
+ cancelRefreshWait?.()
324
+ clearRefreshTimer()
325
+ removeRefreshClasses()
326
+ })
148
327
  onUnmounted(() => removeScroller(cacheName))
149
328
 
150
- // 给 .stack-tab__cache-page-wrapper 挂滚动阴影,确保 keep-alive 的 suspended/deactivated 生命周期下表现一致
151
- useScrollShadow({ target: pageRoot })
329
+ // 阴影类挂到不被刷新动画 transform 的 panel 上;滚动监听仍绑定 wrapper。
330
+ // 否则 ::after 的绝对定位包含块会随 transform 变化,顶部阴影会错位/消失。
331
+ const scrollShadow = useScrollShadow({
332
+ target: pageRoot,
333
+ classTarget: () => pageRoot.value?.parentElement ?? null
334
+ })
152
335
 
153
336
  return () => {
154
337
  const vnode = props.vnode
155
338
  const backParams = localBackParams.value
156
- const refreshVersion = props.refreshVersion ?? 0
157
- if (
158
- vnode !== lastVnode.value ||
159
- backParams !== lastBackParams.value ||
160
- refreshVersion !== lastRefreshVersion.value
161
- ) {
162
- const cloned = vnode
339
+ if (vnode !== lastVnode.value || backParams !== lastBackParams.value) {
340
+ lastCloned.value = vnode
163
341
  ? cloneVNode(vnode, {
164
342
  tId: tabInfo.id,
165
343
  pId: cacheName,
166
344
  ...(backParams ? { _back: backParams } : {})
167
345
  })
168
346
  : null
169
- lastCloned.value = cloned
170
347
  lastVnode.value = vnode
171
348
  lastBackParams.value = backParams
172
- lastRefreshVersion.value = refreshVersion
173
349
  }
350
+
174
351
  return (
175
352
  <div
176
353
  ref={pageRoot}
177
354
  class="stack-tab__cache-page-wrapper"
178
355
  id={`W-${cacheName}`}
179
356
  style={[
180
- runtimeContext.useGlobalScroll.value && !isTransitioning.value
181
- ? 'overflow:auto'
182
- : 'overflow:hidden',
357
+ // 动画作用于 wrapper 本身,页面内容不会相对滚动容器位移,
358
+ // 因此无需临时隐藏 overflow,避免动画期间滚动条消失导致内容宽度跳动。
359
+ runtimeContext.useGlobalScroll.value ? 'overflow:auto' : 'overflow:hidden',
183
360
  'height: 100%'
184
361
  ]}
185
362
  >
186
363
  {lastCloned.value ? (
187
- <Transition
188
- name={props.transitionName}
189
- mode="out-in"
190
- onBeforeEnter={beginTransition}
191
- onAfterEnter={endTransition}
192
- onEnterCancelled={endTransition}
193
- onBeforeLeave={beginTransition}
194
- onAfterLeave={endTransition}
195
- onLeaveCancelled={endTransition}
196
- >
197
- <PageShell key={`${cacheName}-${refreshVersion}`} vnode={lastCloned.value} />
198
- </Transition>
364
+ <PageShell
365
+ key={`${cacheName}-${renderedRefreshVersion.value}`}
366
+ vnode={lastCloned.value}
367
+ />
199
368
  ) : null}
200
369
  <PageLoading tabId={tabInfo.id!} />
201
370
  </div>
@@ -12,8 +12,6 @@ export interface TabPanelScrollApi {
12
12
  saveScroller: (pageCacheId: string) => void
13
13
  removeScroller: (pageCacheId: string) => void
14
14
  addPageScroller: (pageCacheId: string, ...selectorIds: string[]) => void
15
- /** 主动失效某页面的滚动元素缓存,例如外部强制刷新缓存页时 */
16
- invalidateAutoScrollCache: (pageCacheId: string) => void
17
15
  }
18
16
 
19
17
  const resolveScrollElement = (selector: string): HTMLElement | null =>
@@ -124,9 +122,9 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
124
122
  })
125
123
 
126
124
  for (const selector of positions.keys()) {
127
- const element = selector.startsWith(AUTO_SCROLLER_PREFIX)
128
- ? resolveAutoScrollElement(pageCacheId, selector)
129
- : resolveScrollElement(selector)
125
+ // auto key 已在上一步按 DOM 顺序写入,无需再次解析。
126
+ if (selector.startsWith(AUTO_SCROLLER_PREFIX)) continue
127
+ const element = resolveScrollElement(selector)
130
128
  positions.set(selector, {
131
129
  top: element?.scrollTop ?? 0,
132
130
  left: element?.scrollLeft ?? 0
@@ -158,19 +156,10 @@ export const createTabPanelScroll = (context: StackTabsRuntimeContext): TabPanel
158
156
  }
159
157
  }
160
158
 
161
- const invalidateAutoScrollCache = (pageCacheId: string): void => {
162
- const cache = autoScrollCaches.get(pageCacheId)
163
- if (!cache) return
164
- cache.elements = []
165
- cache.observer?.disconnect()
166
- autoScrollCaches.delete(pageCacheId)
167
- }
168
-
169
159
  return {
170
160
  restoreScroller,
171
161
  saveScroller,
172
162
  removeScroller,
173
- addPageScroller,
174
- invalidateAutoScrollCache
163
+ addPageScroller
175
164
  }
176
165
  }
@@ -32,19 +32,23 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
32
32
  let pendingWrite: { tab: ITabItem; version: number } | null = null
33
33
  let writeVersion = 0
34
34
  let writeScheduled = false
35
+ /** 最近一次成功写入的内容;与待写入内容相同时跳过 setItem。 */
36
+ let lastPersistedJson: string | null = null
35
37
 
36
38
  /**
37
39
  * 写入 sessionStorage,捕获隐私模式(QuotaExceededError)等异常,
38
40
  * 避免在受限浏览器环境下抛出未处理错误。SSR 环境无 window 时直接跳过。
39
41
  */
40
- const safeSetItem = (key: string, value: string): void => {
41
- if (typeof window === 'undefined') return
42
+ const safeSetItem = (key: string, value: string): boolean => {
43
+ if (typeof window === 'undefined') return false
42
44
  try {
43
45
  window.sessionStorage.setItem(key, value)
46
+ return true
44
47
  } catch (error) {
45
48
  if (!import.meta.env.PROD) {
46
49
  console.warn('[vue-stack-tabs] Failed to write sessionStorage:', error)
47
50
  }
51
+ return false
48
52
  }
49
53
  }
50
54
 
@@ -64,7 +68,9 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
64
68
  const write = pendingWrite
65
69
  pendingWrite = null
66
70
  if (!write || write.version !== writeVersion) return
67
- safeSetItem(getSessionKey(), JSON.stringify(write.tab))
71
+ const serialized = JSON.stringify(write.tab)
72
+ if (serialized === lastPersistedJson) return
73
+ if (safeSetItem(getSessionKey(), serialized)) lastPersistedJson = serialized
68
74
  }
69
75
 
70
76
  const invalidatePendingWrite = () => {
@@ -83,6 +89,7 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
83
89
  const clearSession = (): void => {
84
90
  invalidatePendingWrite()
85
91
  safeRemoveItem(getSessionKey())
92
+ lastPersistedJson = null
86
93
  }
87
94
 
88
95
  const restoreActiveTabSession = (storedJson: string | null): void => {
@@ -91,7 +98,7 @@ export const createTabPanelSession = (context: StackTabsRuntimeContext): TabPane
91
98
  return
92
99
  }
93
100
  invalidatePendingWrite()
94
- safeSetItem(getSessionKey(), storedJson)
101
+ if (safeSetItem(getSessionKey(), storedJson)) lastPersistedJson = storedJson
95
102
  }
96
103
 
97
104
  const restoreTabFromSession = (storedJson: string | null): ITabItem | null => {
@@ -15,7 +15,6 @@ export interface ScrollPosition {
15
15
 
16
16
  export interface TabPanelRuntimeState {
17
17
  tabs: Ref<ITabItem[]>
18
- defaultTabs: Ref<ITabItem[]>
19
18
  caches: ShallowRef<string[]>
20
19
  components: Map<string, DefineComponent>
21
20
  cacheIdsToEvict: Set<string>
@@ -34,7 +33,6 @@ export const SESSION_TAB_NAME = 'stacktab-active-tab'
34
33
 
35
34
  export const createTabPanelRuntimeState = (): TabPanelRuntimeState => ({
36
35
  tabs: ref<ITabItem[]>([]) as Ref<ITabItem[]>,
37
- defaultTabs: ref<ITabItem[]>([]) as Ref<ITabItem[]>,
38
36
  caches: shallowRef<string[]>([]),
39
37
  components: new Map<string, DefineComponent>(),
40
38
  cacheIdsToEvict: new Set<string>(),
@@ -121,6 +121,9 @@ interface TabRecord {
121
121
 
122
122
  interface PageStackAccess {
123
123
  getPages: (tabId: string) => readonly ITabPage[]
124
+ getPageIds: (tabId: string) => readonly string[]
125
+ getPageAt: (tabId: string, index: number) => ITabPage | undefined
126
+ getPageCount: (tabId: string) => number
124
127
  getTopPage: (tabId: string) => ITabPage | null
125
128
  /** 读取任意页面的刷新版本;渲染层据此只重建对应页面 */
126
129
  getPageRefreshVersion: (pageId: string) => number
@@ -156,15 +159,16 @@ class PageStackView extends Stack<ITabPage> {
156
159
  }
157
160
 
158
161
  override at(index: number): ITabPage | undefined {
159
- return this.access.getPages(this.tabId)[index]
162
+ // 只取单个元素时不必构造整条页面数组。
163
+ return this.access.getPageAt(this.tabId, index)
160
164
  }
161
165
 
162
166
  override isEmpty(): boolean {
163
- return this.access.getPages(this.tabId).length === 0
167
+ return this.access.getPageCount(this.tabId) === 0
164
168
  }
165
169
 
166
170
  override size(): number {
167
- return this.access.getPages(this.tabId).length
171
+ return this.access.getPageCount(this.tabId)
168
172
  }
169
173
 
170
174
  override clear(): void {
@@ -251,6 +255,15 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
251
255
  return pagesById.get(ids[ids.length - 1] as string) ?? null
252
256
  }
253
257
 
258
+ const getPageCount = (tabId: string): number => pageIdsByTabId.get(tabId)?.length ?? 0
259
+
260
+ /** 按下标读取页面;越界(含负数)返回 undefined,与数组下标语义一致 */
261
+ const getPageAt = (tabId: string, index: number): ITabPage | undefined => {
262
+ const ids = pageIdsByTabId.get(tabId)
263
+ if (!ids || index < 0 || index >= ids.length) return undefined
264
+ return pagesById.get(ids[index] as string)
265
+ }
266
+
254
267
  const getPageRefreshVersion = (pageId: string): number =>
255
268
  pagesById.get(pageId)?.refreshVersion ?? 0
256
269
 
@@ -310,8 +323,12 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
310
323
  }
311
324
 
312
325
  const activateTab = (id: string): ITabItem | null => {
313
- activeTabId.value = tabsById.has(id) ? id : ''
314
- syncTabsView()
326
+ const nextId = tabsById.has(id) ? id : ''
327
+ // 重复激活同一标签(页面内导航经常走到这里)无需重建 tabs 数组。
328
+ if (nextId !== activeTabId.value) {
329
+ activeTabId.value = nextId
330
+ syncTabsView()
331
+ }
315
332
  return getTab(id)
316
333
  }
317
334
 
@@ -416,12 +433,13 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
416
433
  }
417
434
 
418
435
  // ---- 页面栈写入 ----
436
+ // 页面栈只修改 pageIdsByTabId / pagesById(两者都是响应式 Map),
437
+ // 不重建 tabs 数组:标签栏、iframe 与刷新版本计算都无需因入栈/出栈重新求值。
419
438
  const appendPage = (tabId: string, page: ITabPage): void => {
420
439
  if (!tabsById.has(tabId)) return
421
440
  const ids = pageIdsByTabId.get(tabId) ?? []
422
441
  pageIdsByTabId.set(tabId, [...ids, page.id])
423
442
  pagesById.set(page.id, page)
424
- syncTabsView()
425
443
  }
426
444
 
427
445
  const popPages = (tabId: string, steps: number): ITabPage[] => {
@@ -437,7 +455,6 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
437
455
  pagesById.delete(pageId)
438
456
  }
439
457
  pageIdsByTabId.set(tabId, ids)
440
- syncTabsView()
441
458
  return popped
442
459
  }
443
460
 
@@ -452,14 +469,12 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
452
469
  nextPageIds.push(cloned.id)
453
470
  }
454
471
  pageIdsByTabId.set(tabId, nextPageIds)
455
- syncTabsView()
456
472
  }
457
473
 
458
474
  const clearPages = (tabId: string): void => {
459
475
  if (!tabsById.has(tabId)) return
460
476
  for (const pageId of getPageIds(tabId)) pagesById.delete(pageId)
461
477
  pageIdsByTabId.set(tabId, [])
462
- syncTabsView()
463
478
  }
464
479
 
465
480
  const applyPagePatch = (
@@ -512,8 +527,8 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
512
527
  const top = getTopPage(tabId)
513
528
  if (!top || top.id !== pageId || !top._backParams) return null
514
529
  const backParams = { ...top._backParams }
530
+ // _backParams 是页面对象上的响应式字段,删除后读取方会自行更新。
515
531
  delete top._backParams
516
- syncTabsView()
517
532
  return backParams
518
533
  }
519
534
 
@@ -548,6 +563,9 @@ const createTabStore = (context: StackTabsRuntimeContext): TabStoreApi => {
548
563
  // ---- 兼容视图 ----
549
564
  const pageAccess: PageStackAccess = {
550
565
  getPages,
566
+ getPageIds,
567
+ getPageAt,
568
+ getPageCount,
551
569
  getTopPage,
552
570
  getPageRefreshVersion,
553
571
  appendPage,
@@ -19,7 +19,7 @@ interface IframeLoadState {
19
19
  export interface UseIframeManagerOptions {
20
20
  tabs: Ref<ITabItem[]>
21
21
  iframeRefreshKeys: Ref<Record<string, number>>
22
- iframeLoadTimeout: number
22
+ iframeLoadTimeout: Ref<number>
23
23
  t: (key: string) => string
24
24
  }
25
25
 
@@ -106,7 +106,7 @@ export function useIframeManager(options: UseIframeManagerOptions) {
106
106
  message: t('VueStackTab.iframeLoadTimeout')
107
107
  }
108
108
  }
109
- }, iframeLoadTimeout)
109
+ }, iframeLoadTimeout.value)
110
110
  )
111
111
  }
112
112
 
@@ -176,20 +176,16 @@ export function useIframeManager(options: UseIframeManagerOptions) {
176
176
  )
177
177
 
178
178
  // refreshKey 变化时重新触发对应 iframe 的加载状态
179
- watch(
180
- iframeRefreshKeys,
181
- (keys, previousKeys = {}) => {
182
- for (const frame of iframeTabs.value) {
183
- if (!frame.active) continue
184
- const nextKey = keys[frame.id] ?? 0
185
- const previousKey = previousKeys[frame.id] ?? 0
186
- if (nextKey !== previousKey) {
187
- setIframeLoading(frame.id, { force: true })
188
- }
179
+ watch(iframeRefreshKeys, (keys, previousKeys = {}) => {
180
+ for (const frame of iframeTabs.value) {
181
+ if (!frame.active) continue
182
+ const nextKey = keys[frame.id] ?? 0
183
+ const previousKey = previousKeys[frame.id] ?? 0
184
+ if (nextKey !== previousKey) {
185
+ setIframeLoading(frame.id, { force: true })
189
186
  }
190
- },
191
- { deep: true }
192
- )
187
+ }
188
+ })
193
189
 
194
190
  const handleIframeLoad = (frame: { id: string; url?: string }, event: Event) => {
195
191
  const iframe = event.currentTarget as HTMLIFrameElement | null