@finesoft/front 0.2.0 → 0.4.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/dist/browser-BIetJHp_.mjs +2 -0
- package/dist/browser-DMw76sEo.d.mts +2731 -0
- package/dist/browser.d.mts +2 -2
- package/dist/browser.mjs +1 -1
- package/dist/index.d.mts +149 -2
- package/dist/index.mjs +22 -22
- package/docs/04-rendering-and-hydration.md +71 -1
- package/docs/11-navigation.md +355 -0
- package/docs/12-session-restoration.md +219 -0
- package/docs/zh/04-rendering-and-hydration.md +71 -1
- package/docs/zh/11-navigation.md +355 -0
- package/docs/zh/12-session-restoration.md +219 -0
- package/package.json +3 -3
- package/dist/server-data-DQzknR97.d.mts +0 -1573
- package/dist/start-app-S4DH-40i.mjs +0 -2
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# 12. 会话恢复
|
|
2
|
+
|
|
3
|
+
框架已经能恢复**首屏**:SSR 把 prefetch 出来的 intent 结果经 `PrefetchedIntents` 注入 HTML,浏览器在首次导航时复用它们;结构化导航的当前树也会随 `history.state` 走前进 / 后退。
|
|
4
|
+
|
|
5
|
+
但有一类状态以上手段**全都救不回**:用户**硬重载 / 标签崩溃 / 关掉再回来**时「当时在干什么」—— 他在哪一屏(或哪个栈深、哪个 tab、split 哪一列)、表单里打了一半的草稿、列表滚到哪里。内存里的 `history.state` map 整页重载即清空,`PrefetchedIntents` 只覆盖服务端渲染的那一屏。
|
|
6
|
+
|
|
7
|
+
**会话恢复**填这个缺口:把一份带版本、JSON 安全的**会话快照**(导航位置 + 应用注册的状态切片 + 导航作用域的逐屏状态)序列化到可插拔 `Storage`,并在全新加载时重水化。框架**不含任何 UI** —— 它只恢复**状态**,应用据此自行重渲染。
|
|
8
|
+
|
|
9
|
+
它完全可选:从不向 `startBrowserApp` 传 `session` 的应用**逐位等价**于原行为,毫无变化。
|
|
10
|
+
|
|
11
|
+
## 两种作用域
|
|
12
|
+
|
|
13
|
+
一份快照捕获两层状态,一起序列化、跨重载一起恢复:
|
|
14
|
+
|
|
15
|
+
| 作用域 | 存放于 | 键 | 生命周期 | SwiftUI 对标 |
|
|
16
|
+
| ------------------ | -------- | -------------- | -------------------------------------- | --------------- |
|
|
17
|
+
| **全局切片** | `slices` | `provider.key` | 整个会话(主题、跨屏向导草稿…) | `@SceneStorage` |
|
|
18
|
+
| **导航作用域状态** | `scoped` | `entryKey` | 绑定到某个导航条目 —— 条目离树即被丢弃 | `@State` |
|
|
19
|
+
|
|
20
|
+
- **全局切片**是 app-wide 的。每个切片注册一个 `SessionStateProvider`;框架编排*何时*捕获与落盘,但从不解释内容 —— 它只搬运。
|
|
21
|
+
- **导航作用域状态**绑定到某个*导航条目*,对标 SwiftUI 视图 `@State` 的位置作用域生命周期(见下)。
|
|
22
|
+
|
|
23
|
+
## 全局切片:`SessionStateProvider`
|
|
24
|
+
|
|
25
|
+
应用为每个切片注册一个 provider。`capture()` 返回 JSON 安全的同步值;`restore(data)` 把它放回(应用自行 `setState` / 回填表单 / 滚动):
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import type { SessionStateProvider } from "@finesoft/front";
|
|
29
|
+
|
|
30
|
+
const themeSlice: SessionStateProvider<string> = {
|
|
31
|
+
key: "theme",
|
|
32
|
+
capture: () => getCurrentTheme(),
|
|
33
|
+
restore: (theme) => applyTheme(theme),
|
|
34
|
+
};
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
框架原样搬运值、从不窥探 —— 所以捕获什么由**你**决定。敏感字段就在 `capture()` 里自行排除;不注册的切片永不被捕获。
|
|
38
|
+
|
|
39
|
+
## 导航作用域状态:SwiftUI `@State` 生命周期
|
|
40
|
+
|
|
41
|
+
导航作用域状态是更有意思的一半。它按**条目身份**而非可见性建键,遵循与 SwiftUI 视图 `@State` 相同的位置作用域生命周期:
|
|
42
|
+
|
|
43
|
+
> `A` → push `B` → 返回(pop `B`)到 `A`:**`B` 的状态被丢弃,`A` 的状态仍在。**
|
|
44
|
+
|
|
45
|
+
机制:每个条目的状态袋按 `entryKey = intent + " " + stableStringify(params)` 存放 —— 与导航 controller 给目标用的身份同源,故**跨重载稳定**。每次导航提交后,框架把 scoped map **prune** 到树中**实际存在**的条目 —— 注意是*存在*,不是*可见*。条目已不在树中的键即被丢弃。
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { sessionEntryKey } from "@finesoft/front";
|
|
49
|
+
|
|
50
|
+
// 渲染某屏时,用该条目的键读写它的作用域袋:
|
|
51
|
+
const key = sessionEntryKey("post", { id: 7 });
|
|
52
|
+
store.scope.set(key, { scroll: 240, draft: "评论打了一半" });
|
|
53
|
+
const bag = store.scope.get(key); // -> { scroll: 240, draft: "..." } | undefined
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
逐步走一遍生命周期:
|
|
57
|
+
|
|
58
|
+
- **push `B`** → 树 `[A, B]`,present `{A, B}` → `A` 的状态**保留**(`A` 仍在栈中、只是不可见),`B` 拿到自己的作用域。
|
|
59
|
+
- **pop `B`** → 树 `[A]`,present `{A}` → **`B` 的作用域被 prune 丢弃**,`A` 的原样保留;返回 `A` 按保留态渲染。
|
|
60
|
+
- **切 TabView 的 tab** → 其它分支仍在树中 → 其状态保活(与 SwiftUI 让未激活 tab 保持挂载一致)。
|
|
61
|
+
- **跨重载** → `scoped` 随快照序列化;重载后每个仍在树的条目恢复各自作用域,之后 pop 照常丢弃。
|
|
62
|
+
|
|
63
|
+
`store.scope` 是 store 持有的 `NavigationScopedState` 实例 —— `get` / `set` / `delete` / `keys`,外加框架替你调用的 `prune(presentKeys)`。用高层 `startBrowserApp({ session })` 时无需直接持有 store:`mount` 回调(context)交给你的 `SessionHandle` 上的 `handle.scope` 就是同一个实例(restore 重建后仍指向最新),照样 `handle.scope.get(entryKey)` / `set(entryKey, data)`。
|
|
64
|
+
|
|
65
|
+
### 扁平 vs 结构化:保留语义**本质就是栈**
|
|
66
|
+
|
|
67
|
+
「把 `A` 留在 `B` 底下、pop 时丢 `B`、恢复 `A`」这套行为,按定义就是**栈语义** —— 所以它只在**结构化导航**里成立,那里栈 / 树能持有*存在但不可见*的条目。
|
|
68
|
+
|
|
69
|
+
**扁平单页没有栈**:`A → B` 是整页替换,故 `presentKeys()` 恒为单条目(当前 URL)。一离开某屏,其作用域即被 prune,浏览器**返回**是 fresh 重渲染。
|
|
70
|
+
|
|
71
|
+
两种模式都支持「当前屏作用域 + 跨重载恢复」。要「返回时保留上一屏」,就把它建成结构化栈 —— 用 push 而非 replace。这正是 `NavigationStack` 的*意义*,不是扁平模式的缺陷。
|
|
72
|
+
|
|
73
|
+
## 快照
|
|
74
|
+
|
|
75
|
+
`createSessionStore(options)` 返回 `SessionStore` 编排器。`capture()` 组装快照但不落盘;快照模型为:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
interface SessionSnapshot {
|
|
79
|
+
readonly version: number;
|
|
80
|
+
readonly navigation?: SerializedNavigation | SessionUrlLocation; // 结构化树 | { url }
|
|
81
|
+
readonly slices: Readonly<Record<string, unknown>>; // provider.key -> capture()
|
|
82
|
+
readonly scoped: Readonly<Record<string, unknown>>; // entryKey -> 状态袋
|
|
83
|
+
readonly capturedAt: number; // epoch ms,用于 maxAgeMs 过期判断
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`navigation` 用一个轻判别区分:`SerializedNavigation` 始终带 `kind`(leaf/stack/tabs/split),扁平的 `SessionUrlLocation` 带 `url`。用 `isUrlLocation(nav)` 区分二者。
|
|
88
|
+
|
|
89
|
+
store 暴露:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
interface SessionStore {
|
|
93
|
+
register(provider: SessionStateProvider): () => void; // 返回反注册函数
|
|
94
|
+
readonly scope: NavigationScopedState;
|
|
95
|
+
capture(): SessionSnapshot; // 组装(nav + slices + scoped),无 I/O
|
|
96
|
+
persist(snapshot?: SessionSnapshot): void; // 省略则先 capture(),再落盘
|
|
97
|
+
load(): SessionSnapshot | undefined; // 读取 + 校验(version / maxAge / 结构)
|
|
98
|
+
restore(snapshot?: SessionSnapshot): void | Promise<void>; // 省略则先 load(),再应用
|
|
99
|
+
clear(): void; // 清除持久化快照
|
|
100
|
+
save(): void; // capture + persist —— 手动逃生口
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`load()` 会丢弃版本不符、`capturedAt` 超过 `maxAgeMs`、或结构畸形的快照 —— 返回 `undefined`,绝不向应用抛错。`capture()` / `restore()` 抛错的 provider 被隔离:跳过其切片、错误走 `onError`,快照其余部分照常存活。
|
|
105
|
+
|
|
106
|
+
## 持久化:默认 `sessionStorage`,可替换
|
|
107
|
+
|
|
108
|
+
快照经稳定 stringify 编码,作为一条 `storage.set(key, ...)` 写入。`Storage` 是 core 既有的依赖接口,所以 durability 由**你**决定:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { createWebStorage } from "@finesoft/front";
|
|
112
|
+
|
|
113
|
+
createWebStorage("session"); // sessionStorage —— 标签级,关闭即清(默认)
|
|
114
|
+
createWebStorage("local"); // localStorage —— 跨标签、跨重启持久
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`createWebStorage` 把 `get`/`set`/`delete` 映射到 `getItem`/`setItem`/`removeItem`,写入时吞掉配额错(会话恢复是尽力而为,绝不打断导航),选定的 Web Storage 不可用时(如隐私模式 `SecurityError`)降级为安全 no-op。
|
|
118
|
+
|
|
119
|
+
因为它就是 `Storage` 接口,你可以塞**任意**实现 —— 测试用内存版,或服务端同步的 `Storage` 实现跨设备恢复。框架 v1 不内建服务端端点,但接缝是开放的。
|
|
120
|
+
|
|
121
|
+
## 接入浏览器
|
|
122
|
+
|
|
123
|
+
向 `startBrowserApp` 传可选的 `session`。存在时,框架构建 `SessionStore`、注册你的 providers、装配 `SessionBridge`(导航变更自动捕获 + `pagehide`/`visibilitychange`),在首次导航后跑 boot 恢复,并把 `SessionHandle` 交给你:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
// src/main.ts
|
|
127
|
+
import { startBrowserApp } from "@finesoft/front";
|
|
128
|
+
import { bootstrap } from "./bootstrap";
|
|
129
|
+
import { themeSlice, draftSlice } from "./lib/session";
|
|
130
|
+
|
|
131
|
+
startBrowserApp({
|
|
132
|
+
bootstrap,
|
|
133
|
+
callbacks,
|
|
134
|
+
session: {
|
|
135
|
+
providers: [themeSlice, draftSlice],
|
|
136
|
+
// storage 缺省为 createWebStorage("session")
|
|
137
|
+
maxAgeMs: 1000 * 60 * 60 * 24, // 丢弃超过一天的快照(可选)
|
|
138
|
+
},
|
|
139
|
+
mount(target, { session, app }) {
|
|
140
|
+
// session: SessionHandle(save/clear/scope/…);app:统一的 nav+session 句柄。
|
|
141
|
+
// 自动捕获/恢复已在跑;用 session.save() / session.clear() 作逃生口。
|
|
142
|
+
// ... 把 UI 挂载到 target,将 app(或 session)传给组件 ...
|
|
143
|
+
return () => undefined;
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`session` **缺省**时,以上整段不运行,原有 `startBrowserApp` 路径逐位不变。
|
|
149
|
+
|
|
150
|
+
### 扁平 vs 结构化接线(自动)
|
|
151
|
+
|
|
152
|
+
`startBrowserApp` 替你挑选导航适配器:
|
|
153
|
+
|
|
154
|
+
- **有** `navigation` 配置 → 结构化 `createNavigationSessionAdapter(controller)`:序列化整棵树,恢复时 `hydrate` 回去。自动捕获由导航 handle 的 `subscribe` 驱动。
|
|
155
|
+
- **无** `navigation`(扁平单页)→ 接 `framework.perform(makeFlowAction(url))` 的 `createUrlSessionAdapter`:捕获 `{ url }`,恢复时导航过去。
|
|
156
|
+
|
|
157
|
+
只有自己装配 store 时(如在服务端、或测试里)才需要直接选适配器。
|
|
158
|
+
|
|
159
|
+
## 句柄:手动 save / clear / dispose
|
|
160
|
+
|
|
161
|
+
`SessionHandle`(通过 mount context 交付)给你逃生口 —— 自动捕获已在跑,但你可强制落盘、清快照、或整体拆除。统一的 `app` 句柄把导航命令与 session 的 `save`/`clear`/`scope` 合并,组件拿一个对象即可,免自己拼 controller:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
interface SessionHandle {
|
|
165
|
+
restore(currentUrl: string): void | Promise<void>; // boot 恢复(已替你调过)
|
|
166
|
+
save(): void; // 立即强制落盘
|
|
167
|
+
clear(): void; // 丢弃持久化快照(如登出时)
|
|
168
|
+
dispose(): void; // 反订阅导航 + 解绑 pagehide/visibilitychange + 清定时器
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
登出时调 `handle.clear()`,下个用户就不会继承陈旧会话;自己拆除应用实例时调 `handle.dispose()`。
|
|
173
|
+
|
|
174
|
+
### 何时捕获?
|
|
175
|
+
|
|
176
|
+
你很少需要调 `save()` —— 捕获是自动的:
|
|
177
|
+
|
|
178
|
+
- **导航变更时**:bridge **先**把 scoped map prune 到 `adapter.presentKeys()`(这正是「pop `B` 丢掉 `B` 状态」的落点),再**防抖**落盘(默认 `SESSION_DEFAULT_DEBOUNCE_MS` = 500 ms,合并连续导航)。用 `session.debounceMs` 调。
|
|
179
|
+
- **`pagehide` 与 `visibilitychange`(hidden)时**:**立即**落盘并取消挂起的防抖 —— 比 `beforeunload` 在移动端更可靠(标签切后台 / 被回收前能抓到末态)。
|
|
180
|
+
|
|
181
|
+
## 深链策略:`shouldRestore`
|
|
182
|
+
|
|
183
|
+
boot 时 bridge 读快照,**仅当** `shouldRestore(snapshot, currentUrl)` 通过才应用 —— 整份 `nav + slices` 恢复共用一个布尔门。默认的 `defaultShouldRestore` 遵循**显式深链优先于陈旧会话**:
|
|
184
|
+
|
|
185
|
+
| 快照 `navigation` | 恢复当且仅当… |
|
|
186
|
+
| ------------------------------------ | --------------------------------------------------------------- |
|
|
187
|
+
| **扁平**(`SessionUrlLocation`) | `currentUrl === snapshot.navigation.url` **或**当前路径为根 `/` |
|
|
188
|
+
| **结构化**(`SerializedNavigation`) | 当前路径为根 `/` |
|
|
189
|
+
| **无**(仅切片) | 总恢复(与 URL 无关) |
|
|
190
|
+
|
|
191
|
+
于是重载同页(或全新进入 `/`)会恢复;打开不同深链 `/x` 则**不会**被旧会话覆盖。「根」判定为路径 `=== "/"`(剥离 query/hash)。带 base path 的应用应覆盖该门:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
session: {
|
|
195
|
+
providers: [themeSlice],
|
|
196
|
+
shouldRestore: (snapshot, currentUrl) => currentUrl.startsWith("/app/"),
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
恢复到与 SSR'd URL 不同的态会产生一次客户端跳变(SSR 渲染 URL 那屏,客户端再恢复)。该时机经 bridge 暴露给你掌控;纯 CSR 应用可在首次绘制前恢复、完全避免它。
|
|
201
|
+
|
|
202
|
+
## 哪些**不**被捕获
|
|
203
|
+
|
|
204
|
+
- **你没注册的 DOM。** 框架从不扫描 DOM。状态切片就是你的 provider `capture()` 出来的那些 —— 仅此而已。
|
|
205
|
+
- **不注册任何 provider 时的一切。** 只注册导航(或什么都不注册)时,捕获实质为零 —— 隐私默认。
|
|
206
|
+
- **你排除的敏感字段。** `capture()` 是你的过滤器;token、PII 之类在此剥掉。
|
|
207
|
+
- **陈旧 / 过期 / 畸形的快照。** `load()` 返回 `undefined`,而非为恢复坏态崩掉应用。
|
|
208
|
+
|
|
209
|
+
## 向后兼容
|
|
210
|
+
|
|
211
|
+
- 不向 `startBrowserApp` 传 `session` 的应用走**原路径**、零行为变化 —— 整个特性被那一个字段门控。
|
|
212
|
+
- 会话恢复对服务端无任何要求。提供自己的 `Storage` 即可实现服务端同步快照,但框架不内建任何东西。
|
|
213
|
+
- 框架恢复**状态**、绝不恢复 UI。你的 `Page` 模型与渲染方式原封不动。
|
|
214
|
+
|
|
215
|
+
## 下一步
|
|
216
|
+
|
|
217
|
+
- [导航](./11-navigation.md) —— 其条目为逐屏状态划定作用域的结构化树
|
|
218
|
+
- [渲染与 Hydration](./04-rendering-and-hydration.md) —— 首屏如何已经通过 prefetch 结果被恢复
|
|
219
|
+
- [DI 容器](./07-di-container.md) —— 会话恢复落盘所经的 `Storage` 依赖
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@finesoft/front",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Full-stack framework: router, DI, actions, SSR, and server — all in one package",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
7
|
+
"dist/browser-BIetJHp_.mjs",
|
|
8
|
+
"dist/browser-DMw76sEo.d.mts",
|
|
7
9
|
"dist/browser.d.mts",
|
|
8
10
|
"dist/browser.mjs",
|
|
9
11
|
"dist/index.d.mts",
|
|
10
12
|
"dist/index.mjs",
|
|
11
|
-
"dist/server-data-DQzknR97.d.mts",
|
|
12
|
-
"dist/start-app-S4DH-40i.mjs",
|
|
13
13
|
"docs",
|
|
14
14
|
"README.md"
|
|
15
15
|
],
|