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.
@@ -8,6 +8,8 @@ import useTabPanel from './useTabPanel'
8
8
  import { parseUrl } from '../utils/urlParser'
9
9
  import { useTabEmitter } from './useTabEventBus'
10
10
 
11
+ import { createPageId } from '../utils/tabInfoEncoder'
12
+
11
13
  /** StackTabs 监听此事件切换前进动画 */
12
14
  const ROUTER_EVENT_FORWARD = 'FORWARD'
13
15
  /** StackTabs 监听此事件切换后退动画 */
@@ -17,93 +19,206 @@ const ROUTER_EVENT_BACKWARD = 'BACKWARD'
17
19
  * useTabRouter - 标签内路由 Hook
18
20
  *
19
21
  * 职责:在单个标签内实现「前进/后退」栈式导航,以及滚动位置的记录与恢复
20
- * 使用范围:仅在标签内的页面组件中使用,依赖 router-view 传入的 tId、pId
22
+ * 使用范围:仅在标签内的页面组件中使用,依赖 StackTabs 通过 cloneVNode 注入的 tId / pId
21
23
  *
22
24
  * 主要 API:
23
- * - forward(to) 前进:push 新页面到该标签的 pages 栈
24
- * - backward(to, backQuery?) 后退:pop 栈顶、markCacheForEviction,跳转到新栈顶
25
- * - addScrollTarget(...selectors) 注册需要记录滚动位置的选择器
25
+ * - forward(to) 前进:push 新页面到该标签的 pages 栈
26
+ * - backward(to, backQuery) 后退:pop 栈顶、驱逐缓存,跳转到新栈顶
27
+ * - addScrollTarget(...) 注册需要记录滚动位置的选择器
26
28
  */
27
29
  export default function useTabRouter() {
28
30
  const instance = getCurrentInstance() as ComponentInternalInstance
29
31
  const { attrs, props } = instance
30
- /** 当前标签 id,由 cache 组件的 dynamic-component 传入 */
32
+
33
+ /**
34
+ * 当前标签 ID。
35
+ * StackTabs 在 StackTabs.vue 的 tabWrapper 里通过 cloneVNode(Node, { tId, pId }) 注入,
36
+ * 未在子组件 setup 中声明为 prop 的属性会落入 attrs,
37
+ * 因此优先读 attrs.tId,再 fallback 到 props.tId。
38
+ */
31
39
  const tabId = (attrs.tId ?? props.tId) as string
32
- /** 当前页面缓存 id */
40
+
41
+ /**
42
+ * 当前页面的缓存 ID(即 cacheName / keep-alive include 中的名称)。
43
+ * 用于 addScrollTarget 向 useTabPanel 绑定该页面的可滚动容器选择器。
44
+ */
33
45
  const pageId = (attrs.pId ?? props.pId) as string
34
46
 
35
47
  const route = useRoute()
36
48
  const router = useRouter()
37
49
  const emitter = useTabEmitter()
38
- const { getTab, markCacheForEviction, getComponent, addPageScroller } = useTabPanel()
50
+ const { getTab, evictPageCache, addPageScroller } = useTabPanel()
39
51
 
40
- /** 当前标签的编码信息,用于 push 时保持 __tab */
52
+ /**
53
+ * 当前标签的编码信息字符串,存于 URL query.__tab。
54
+ * 每次 forward 时必须将此值一并带入新页面的 query,
55
+ * 确保 StackTabs 在 updatePageState 解析路由时能识别该请求归属于哪个标签,
56
+ * 而不是错误地新建一个陌生标签。
57
+ */
41
58
  const tabInfo = route.query.__tab as string
42
59
 
43
- /** 注册需记录滚动位置的容器选择器 */
60
+ /** 注册需记录滚动位置的容器选择器,供 useTabPanel 在 deactivated/activated 时保存/恢复 */
44
61
  const addScrollTarget = (...selectors: string[]) => {
45
62
  addPageScroller(pageId, ...selectors)
46
63
  }
47
64
 
48
- /** 前进:进栈,跳转到新页面 */
65
+ /**
66
+ * 前进:将新路由推入该标签的 pages 栈,并触发前进动画。
67
+ *
68
+ * 实现细节:
69
+ * 1. 通过 defu 将 __tab 合并进 query,确保目标页面也携带正确的标签标识
70
+ * (用户传入的 to.query 优先级更高,__tab 作为默认补全)
71
+ * 2. 发出 FORWARD 事件,StackTabs 切换为前进方向的转场动画
72
+ * 3. router.push 触发路由变更,StackTabs 的 tabWrapper → updatePageState
73
+ * 会在下次渲染时把目标页面压入 tab.pages 栈
74
+ */
49
75
  const forward = (to: RouteLocationPathRaw) => {
76
+ const tab = getTab(tabId)
77
+ const targetQuery = (to.query || {}) as Record<string, string>
78
+
79
+ if (tab) {
80
+ // 【核心重构:数据前置压栈】
81
+ // 生成崭新独立的 CacheName,并推入专属页面栈
82
+ const newPageId = createPageId()
83
+ tab.pages.push({
84
+ id: newPageId,
85
+ tabId: tab.id,
86
+ path: to.path,
87
+ query: targetQuery
88
+ })
89
+ }
90
+
50
91
  const query = defu({ __tab: tabInfo }, to.query)
51
92
  emitter.emit(ROUTER_EVENT_FORWARD)
52
93
  router.push({ path: to.path, query })
53
94
  }
54
95
 
55
96
  /**
56
- * 后退:从该标签的 pages 栈中 pop,并跳转到新栈顶
57
- * @param to 数字=退几步,字符串=回退到首个路径匹配的页面
58
- * @param backQuery 回退后传给目标页面的 props 数据(目标页通过 props 接收)
97
+ * 后退:从该标签的 pages 栈中弹出若干层页面,跳转到新栈顶。
98
+ *
99
+ * @param to 数字 = 退几步;字符串路径 = 回退到栈中首个路径匹配的页面
100
+ * @param backQuery 可选,回退后传递给目标页面的额外参数(目标页通过 props 接收)
101
+ *
102
+ * ───────────────────────────────────────────────────────
103
+ * ⚠️ 关键时序约束 — 为什么必须先导航再驱逐?
104
+ * ───────────────────────────────────────────────────────
105
+ * evictPageCache 会立即同步修改响应式数组 caches.value(即 <keep-alive :include>)。
106
+ * 这个修改会触发 Vue 将 StackTabs 组件标记为 "需要重渲染"。
107
+ *
108
+ * 由于 router.push 是异步的(微任务队列),重渲染在 push 完成前就可能发生。
109
+ * 此时路由对象仍然指向被退出的页面,tabWrapper 以旧路由调用 resolvePageComponent,
110
+ * 而 components 里对应的 cacheName 已被删除 → resolvePageComponent 误判为"新页面",
111
+ * 立刻重建组件 + 调用 addCache 将其加回 caches。
112
+ * 结果:驱逐了又原地复活,<keep-alive> 保留了旧实例(含用户输入状态)。
113
+ *
114
+ * ✅ 正确做法(本实现):
115
+ * 1. 仅从 pages 栈中 pop,不立即驱逐
116
+ * 2. router.push(target) — 导航到前一页,路由切换后重渲染以新路由为基础
117
+ * 3. .then() 回调里安全执行 evictPageCache
118
+ * 此时旧页面已不再是"当前渲染目标",不会被误重建
119
+ * ───────────────────────────────────────────────────────
59
120
  */
60
- const backward = (to: number | string, backQuery?: object) => {
121
+ const backward = (to: number | string, backQuery?: Record<string, unknown>) => {
61
122
  const tab = getTab(tabId)
62
- if (!tab) return
123
+ if (!tab) return false
63
124
 
64
125
  const stack = tab.pages
65
126
  let steps = 0
66
127
 
67
128
  if (typeof to === 'number') {
129
+ // 按步数退:取绝对值,防止调用方传入负数导致逻辑异常
68
130
  steps = Math.abs(to)
69
131
  } else {
132
+ // 按路径退:从栈顶向下扫描,找第一个 path 完全匹配的历史页面
70
133
  const parsed = parseUrl(to)
71
- backQuery = defu(parsed.query, backQuery)
134
+ const targetQuery = parsed.query || {}
72
135
  const pages = stack.list()
73
136
  let found = false
137
+
138
+ // 精简版的参数匹配:如果调用方指定的 to.query 为空,就只要 path 匹配即可
139
+ // 若调用方指定了 query,则该 query 的每个键值必须在历史页面的 query 中存在且相等
140
+ const isQueryMatch = (pageQuery?: Record<string, unknown>) => {
141
+ const pq = { ...(pageQuery || {}) }
142
+ const tq = { ...targetQuery }
143
+ delete pq.__tab
144
+ delete pq._back
145
+ delete tq.__tab
146
+ delete tq._back
147
+
148
+ const tqKeys = Object.keys(tq)
149
+ if (tqKeys.length === 0) return true // 回退意图只要路径对了即可,不管历史曾有啥参数
150
+
151
+ return tqKeys.every((k) => String(tq[k] ?? '') === String(pq[k] ?? ''))
152
+ }
153
+
154
+ // 从 size-2 开始(size-1 是当前页,不算在候选范围内)
74
155
  for (let i = stack.size() - 2; i >= 0; i--) {
75
156
  steps++
76
- if (pages[i]?.path === parsed.path) {
157
+ const page = pages[i]
158
+ const isSamePath = page?.path === parsed.path
159
+ const isSameQuery = isQueryMatch(page?.query)
160
+ console.log(
161
+ `[vue-stack-tabs backward] Checking pop candidate ${i}: path=${page?.path}, isSamePath=${isSamePath}, isSameQuery=${isSameQuery}`
162
+ )
163
+ if (isSamePath && isSameQuery) {
77
164
  found = true
78
165
  break
79
166
  }
80
167
  }
81
- if (!found) return
168
+ // 栈中找不到目标路径,无法后退,返回警告
169
+ if (!found) {
170
+ console.warn(
171
+ `[vue-stack-tabs] backward failed: target url '${to}' not found in history stack. Current stack: `,
172
+ JSON.parse(JSON.stringify(pages))
173
+ )
174
+ return false
175
+ }
176
+ }
177
+
178
+ // 边界保护:
179
+ // - steps <= 0:无效步数,不操作
180
+ if (steps <= 0) return false
181
+
182
+ // - stack.size() <= steps:栈深不足,如果步数超限,最多只能退到首页(即首个页面,清空其他)。
183
+ if (stack.size() <= steps) {
184
+ steps = stack.size() - 1 // 保留最后一层(首个推入的根页面)
82
185
  }
83
186
 
84
- if (steps <= 0 || stack.size() <= steps) return
187
+ // 若修复后退之后步数仍为0,说明已经在根页面无可后退
188
+ if (steps <= 0) return false
85
189
 
190
+ // 阶段 1:仅从 pages 栈弹出,收集 ID 入回收站
191
+ // (keep-alive 驱逐推迟到导航完成后,这就相当于您的回收站概念)
192
+ const recyclingBin: string[] = []
86
193
  for (let i = 0; i < steps; i++) {
87
194
  const popped = stack.pop()
88
- if (popped) markCacheForEviction(popped.id)
195
+ if (popped) recyclingBin.push(popped.id)
89
196
  }
90
197
 
198
+ // 弹出后,栈顶即为目标回退页
91
199
  const target = stack.peek()
92
- if (!target) return
200
+ if (!target) return false
93
201
 
94
- const component = getComponent(target.id)
95
- if (component && backQuery) {
96
- const comp = component as {
97
- components?: { DynamicComponent?: { props?: object } }
98
- }
99
- const dynamic = comp.components?.DynamicComponent
100
- if (dynamic?.props) {
101
- dynamic.props = defu(backQuery, dynamic.props)
102
- }
202
+ if (backQuery) {
203
+ // 按照需求,将回退参数挂载在 _backParams 这个只活在页表栈的内存变量上
204
+ // 绝对隔离与不再污染真实 route.query 和 URL 跳转。
205
+ target._backParams = backQuery
103
206
  }
104
207
 
105
208
  emitter.emit(ROUTER_EVENT_BACKWARD)
106
- router.push({ path: target.path, query: target.query })
209
+
210
+ // 阶段 2:先导航,再清理回收站
211
+ // 对于 targetQuery 只需要带一个能够给框架定锚的 __tab。其他参数其实已经在 target.query 里。
212
+ const targetQueryWithTab = defu({ __tab: tabInfo }, target.query || {})
213
+
214
+ // 用编程式导航发起回退路由
215
+ router.push({ path: target.path, query: targetQueryWithTab }).then(() => {
216
+ // 导航完成且微任务结束后,说明新目标已稳坐栈顶,此时清理回收站安全
217
+ for (const id of recyclingBin) {
218
+ evictPageCache(id)
219
+ }
220
+ })
221
+ return true
107
222
  }
108
223
 
109
224
  return {
@@ -81,6 +81,8 @@ export interface ITabPage {
81
81
  tabId: string
82
82
  path: string
83
83
  query?: Record<string, string>
84
+ /** 该页面从别的页面被后退唤醒时附带的临时闭包参数,不污染 URL */
85
+ _backParams?: Record<string, unknown>
84
86
  }
85
87
  /** 右键菜单项:图标、标题、回调、禁用条件 */
86
88
  export interface IContextMenu {
@@ -100,7 +102,7 @@ export interface IContextMenu {
100
102
  // }
101
103
  /** 使用 Map 实现的栈结构,用于标签内的 pages 页面栈 */
102
104
  export class Stack<T> {
103
- private items: Map<number, T>
105
+ protected items: Map<number, T>
104
106
 
105
107
  constructor(items?: T[]) {
106
108
  this.items = new Map()
@@ -160,32 +162,33 @@ export class Stack<T> {
160
162
  }
161
163
 
162
164
  /**
163
- * @description: 清空栈
165
+ * @description: 清空栈内存
164
166
  */
165
167
  clear() {
166
168
  this.items.clear()
167
169
  }
168
170
 
169
- list() {
171
+ /**
172
+ * @description: 将栈中的元素转换为数组
173
+ * @return {T[]}
174
+ */
175
+ list(): T[] {
170
176
  return Array.from(this.items.values())
171
177
  }
172
178
 
179
+ /**
180
+ * @description: 用于控制 JSON.stringify 时的序列化输出
181
+ * @return {T[]}
182
+ */
183
+ toJSON(): T[] {
184
+ return this.list()
185
+ }
186
+
173
187
  /**
174
188
  * @description: 覆盖Object默认的toString
175
189
  * @return {String}
176
190
  */
177
191
  toString(): string {
178
- if (this.isEmpty()) {
179
- return ''
180
- }
181
- let result: string = ''
182
- this.items.forEach((value, key) => {
183
- result = `${result}${key === 0 ? '' : ', '}${value}`
184
- })
185
- return result
186
- }
187
- toJSON() {
188
- // return Object.fromEntries(this.items)
189
- return Array.from(this.items.values())
192
+ return this.list().toString()
190
193
  }
191
194
  }
@@ -5,7 +5,5 @@ export default defineNuxtPlugin((nuxtApp) => {
5
5
  locale?: string
6
6
  }
7
7
 
8
- nuxtApp.vueApp.use(VueStackTabs, {
9
- locale: config?.locale ?? 'zh-CN'
10
- })
8
+ nuxtApp.vueApp.use(VueStackTabs, [{ locale: config?.locale ?? 'zh-CN' }])
11
9
  })
@@ -22,21 +22,11 @@ describe('tabInfoEncoder', () => {
22
22
  expect(decoded.refreshable).toBe(true)
23
23
  })
24
24
 
25
- it('createPageId 相同输入产生相同输出', () => {
26
- const id1 = createPageId('t1', '/path', { a: '1', b: '2' })
27
- const id2 = createPageId('t1', '/path', { b: '2', a: '1' })
28
- expect(id1).toBe(id2)
29
- })
30
-
31
- it('createPageId 不同 tabId 产生不同输出', () => {
32
- const id1 = createPageId('t1', '/path', {})
33
- const id2 = createPageId('t2', '/path', {})
25
+ it('createPageId 每次生成唯一 ULID', () => {
26
+ const id1 = createPageId()
27
+ const id2 = createPageId()
28
+ expect(id1).toBeTruthy()
29
+ expect(id2).toBeTruthy()
34
30
  expect(id1).not.toBe(id2)
35
31
  })
36
-
37
- it('createPageId 排除 __ 前缀的 query', () => {
38
- const withTab = createPageId('t1', '/path', { __tab: 'x', a: '1' })
39
- const withoutTab = createPageId('t1', '/path', { a: '1' })
40
- expect(withTab).toBe(withoutTab)
41
- })
42
32
  })
@@ -1,7 +1,5 @@
1
1
  import type { ITabBase } from '../model/TabModel'
2
2
  import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'
3
- import Hex from 'hex-encoding'
4
- import { blake3 } from '@noble/hashes/blake3.js'
5
3
 
6
4
  /**
7
5
  * 标签信息编解码器
@@ -15,7 +13,8 @@ import { blake3 } from '@noble/hashes/blake3.js'
15
13
  * @param tabData 标签基础数据
16
14
  */
17
15
  export const encodeTabInfo = (tabData: ITabBase): string => {
18
- const mode = (tabData as { iframeRefreshMode?: string }).iframeRefreshMode === 'reload' ? 'R' : 'P'
16
+ const mode =
17
+ (tabData as { iframeRefreshMode?: string }).iframeRefreshMode === 'reload' ? 'R' : 'P'
19
18
  const info = `${tabData.id}|${tabData.title}|${tabData.iframe ? 'Y' : 'N'}|${tabData.closable ? 'Y' : 'N'}|${tabData.refreshable ? 'Y' : 'N'}|${mode}`
20
19
  return compressToEncodedURIComponent(info)
21
20
  }
@@ -35,31 +34,10 @@ export const decodeTabInfo = (encoded: string): ITabBase => {
35
34
  }
36
35
  }
37
36
 
37
+ import { ulid } from 'ulid'
38
+
38
39
  /**
39
- * 根据 tabId + path + query 生成页面缓存 ID
40
- * 用于 keep-alive 的 include 和 exclude,需稳定且唯一
40
+ * 生成页面缓存 ID (CacheName)
41
+ * 采用全局唯一的 ULID 确保同一个路由在栈内打开多少次都是绝对隔离的不同实例。
41
42
  */
42
- export const createPageId = (tabId: string, path: string, query: object): string => {
43
- const queryEntries: { key: string; value: string }[] = []
44
- type ObjectKey = keyof typeof query
45
- for (const key in query) {
46
- if (!key.startsWith('__')) {
47
- queryEntries.push({
48
- key,
49
- value: query[key as ObjectKey]
50
- })
51
- }
52
- }
53
- queryEntries.sort((a, b) => {
54
- let result = 0
55
- if (a.key > b.key) {
56
- result = 1
57
- } else if (a.key < b.key) {
58
- result = -1
59
- }
60
- return result
61
- })
62
- const msg = new TextEncoder().encode(`${tabId}|${path}|${JSON.stringify(queryEntries)}`)
63
- const hash = blake3(msg)
64
- return Hex.encode(hash)
65
- }
43
+ export const createPageId = (): string => ulid()