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
|
@@ -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
|
-
* 使用范围:仅在标签内的页面组件中使用,依赖
|
|
22
|
+
* 使用范围:仅在标签内的页面组件中使用,依赖 StackTabs 通过 cloneVNode 注入的 tId / pId
|
|
21
23
|
*
|
|
22
24
|
* 主要 API:
|
|
23
|
-
* - forward(to)
|
|
24
|
-
* - backward(to, backQuery
|
|
25
|
-
* - addScrollTarget(...
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
50
|
+
const { getTab, evictPageCache, addPageScroller } = useTabPanel()
|
|
39
51
|
|
|
40
|
-
/**
|
|
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
|
|
57
|
-
*
|
|
58
|
-
* @param
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -22,21 +22,11 @@ describe('tabInfoEncoder', () => {
|
|
|
22
22
|
expect(decoded.refreshable).toBe(true)
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
it('createPageId
|
|
26
|
-
const id1 = createPageId(
|
|
27
|
-
const id2 = createPageId(
|
|
28
|
-
expect(id1).
|
|
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 =
|
|
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
|
-
*
|
|
40
|
-
*
|
|
40
|
+
* 生成页面缓存 ID (CacheName)
|
|
41
|
+
* 采用全局唯一的 ULID 确保同一个路由在栈内打开多少次都是绝对隔离的不同实例。
|
|
41
42
|
*/
|
|
42
|
-
export const createPageId = (
|
|
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()
|