@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. Session restoration
|
|
2
|
+
|
|
3
|
+
The framework already restores the **first screen**: SSR injects the prefetched intent results through `PrefetchedIntents`, and the browser reuses them on the first navigation. Structured navigation also carries the current tree across back/forward via `history.state`.
|
|
4
|
+
|
|
5
|
+
But one class of state survives **none** of that: what the user was actually _doing_ when they **hard-reloaded, crashed the tab, or closed and came back** — which screen (or stack depth, or tab, or split column) they were on, the half-typed draft in a form, how far a list was scrolled. The in-memory `history.state` map is wiped by a full reload; `PrefetchedIntents` only covers the one server-rendered screen.
|
|
6
|
+
|
|
7
|
+
**Session restoration** fills that gap: it serializes a versioned, JSON-safe **session snapshot** (navigation position + app-registered state slices + navigation-scoped per-screen state) to a pluggable `Storage`, and rehydrates it on a fresh load. The framework ships **no UI** — it restores **state**, and your app re-renders from it however you like.
|
|
8
|
+
|
|
9
|
+
It is entirely opt-in: an app that never passes `session` to `startBrowserApp` is **byte-for-byte unchanged**.
|
|
10
|
+
|
|
11
|
+
## The two scopes
|
|
12
|
+
|
|
13
|
+
A snapshot captures two layers of state, serialized together and restored together across a reload:
|
|
14
|
+
|
|
15
|
+
| Scope | Lives in | Keyed by | Lifetime | SwiftUI analogue |
|
|
16
|
+
| --------------------------- | -------- | -------------- | ----------------------------------------------------------------------- | ---------------- |
|
|
17
|
+
| **Global slices** | `slices` | `provider.key` | The whole session (theme, a cross-screen wizard draft…) | `@SceneStorage` |
|
|
18
|
+
| **Navigation-scoped state** | `scoped` | `entryKey` | Bound to one navigation entry — dropped when that entry leaves the tree | `@State` |
|
|
19
|
+
|
|
20
|
+
- **Global slices** are app-wide. You register a `SessionStateProvider` per slice; the framework orchestrates _when_ it is captured and persisted. It never interprets the contents — it only moves them.
|
|
21
|
+
- **Navigation-scoped state** is bound to a _navigation entry_, mirroring the position-scoped lifecycle of a SwiftUI view's `@State` (covered below).
|
|
22
|
+
|
|
23
|
+
## Global slices: `SessionStateProvider`
|
|
24
|
+
|
|
25
|
+
An app registers one provider per slice. `capture()` returns a JSON-safe synchronous value; `restore(data)` puts it back (your app calls `setState` / refills the form / scrolls):
|
|
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
|
+
The framework moves the value verbatim and never inspects it — so **you** decide what to capture. Exclude sensitive fields right here in `capture()`; a slice you never register is never captured.
|
|
38
|
+
|
|
39
|
+
## Navigation-scoped state: the SwiftUI `@State` lifecycle
|
|
40
|
+
|
|
41
|
+
Navigation-scoped state is the interesting half. It is keyed by **entry identity**, not by visibility, and it follows the same position-scoped lifecycle as a SwiftUI view's `@State`:
|
|
42
|
+
|
|
43
|
+
> `A` → push `B` → go back (pop `B`) to `A`: **`B`'s state is discarded, `A`'s state is still there.**
|
|
44
|
+
|
|
45
|
+
The mechanism: each entry's state bag is stored under `entryKey = intent + " " + stableStringify(params)` — the same identity the navigation controller uses for a destination, so it is **stable across a reload**. After every committed navigation, the framework **prunes** the scoped map down to the entries **actually present in the tree** — note _present_, not _visible_. Any key whose entry is no longer in the tree is dropped.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { sessionEntryKey } from "@finesoft/front";
|
|
49
|
+
|
|
50
|
+
// When you render a screen, read/write its scoped bag with the entry's key:
|
|
51
|
+
const key = sessionEntryKey("post", { id: 7 });
|
|
52
|
+
store.scope.set(key, { scroll: 240, draft: "half a comment" });
|
|
53
|
+
const bag = store.scope.get(key); // -> { scroll: 240, draft: "..." } | undefined
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Walking through the lifecycle:
|
|
57
|
+
|
|
58
|
+
- **push `B`** → tree `[A, B]`, present `{A, B}` → `A`'s state is **kept** (`A` is still on the stack, just not visible) and `B` gets its own scope.
|
|
59
|
+
- **pop `B`** → tree `[A]`, present `{A}` → **`B`'s scope is pruned away**, `A`'s is kept intact; going back to `A` renders with its retained state.
|
|
60
|
+
- **switch a TabView tab** → the other branches are still in the tree → their state is kept alive (exactly like SwiftUI keeping inactive tabs mounted).
|
|
61
|
+
- **across a reload** → `scoped` is serialized into the snapshot; after reload, every entry still in the tree gets its scope back, and a later pop discards it as usual.
|
|
62
|
+
|
|
63
|
+
`store.scope` is the `NavigationScopedState` instance held by the store — `get` / `set` / `delete` / `keys`, plus the `prune(presentKeys)` the framework calls for you. With the high-level `startBrowserApp({ session })` path you don't hold the store directly: the `SessionHandle` handed to your `mount` callback (context) exposes the same instance as `handle.scope` (still live after a restore rebuilds it), so you `handle.scope.get(entryKey)` / `set(entryKey, data)` the same way.
|
|
64
|
+
|
|
65
|
+
### Flat vs structured: retention _is_ a stack
|
|
66
|
+
|
|
67
|
+
That "keep `A` under `B`, drop `B` on pop, restore `A`" behavior is, by definition, **stack semantics** — so it only exists in **structured navigation**, where a stack/tree can hold entries that are _present but not visible_.
|
|
68
|
+
|
|
69
|
+
A **flat single page has no stack**: `A → B` is a full-page replacement, so `presentKeys()` is always a single entry (the current URL). The moment you leave a screen its scope is pruned, and a browser **Back** re-renders it fresh.
|
|
70
|
+
|
|
71
|
+
Both modes support "current-screen scope + restore-across-reload". If you want "go Back and keep the previous screen", build it as a structured stack — push instead of replace. That is precisely what `NavigationStack` is _for_; it is not a shortcoming of flat mode.
|
|
72
|
+
|
|
73
|
+
## The snapshot
|
|
74
|
+
|
|
75
|
+
`createSessionStore(options)` returns the `SessionStore` orchestrator. `capture()` assembles a snapshot without persisting; the snapshot model is:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
interface SessionSnapshot {
|
|
79
|
+
readonly version: number;
|
|
80
|
+
readonly navigation?: SerializedNavigation | SessionUrlLocation; // structured tree | { url }
|
|
81
|
+
readonly slices: Readonly<Record<string, unknown>>; // provider.key -> capture()
|
|
82
|
+
readonly scoped: Readonly<Record<string, unknown>>; // entryKey -> state bag
|
|
83
|
+
readonly capturedAt: number; // epoch ms, for maxAgeMs expiry
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`navigation` is discriminated with a light guard: a `SerializedNavigation` always carries a `kind` (leaf/stack/tabs/split); a flat `SessionUrlLocation` carries a `url`. `isUrlLocation(nav)` tells them apart.
|
|
88
|
+
|
|
89
|
+
The store exposes:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
interface SessionStore {
|
|
93
|
+
register(provider: SessionStateProvider): () => void; // returns a disposer
|
|
94
|
+
readonly scope: NavigationScopedState;
|
|
95
|
+
capture(): SessionSnapshot; // assemble (nav + slices + scoped), no I/O
|
|
96
|
+
persist(snapshot?: SessionSnapshot): void; // capture() if omitted, then write
|
|
97
|
+
load(): SessionSnapshot | undefined; // read + validate (version / maxAge / shape)
|
|
98
|
+
restore(snapshot?: SessionSnapshot): void | Promise<void>; // load() if omitted, then apply
|
|
99
|
+
clear(): void; // remove the persisted snapshot
|
|
100
|
+
save(): void; // capture + persist — the manual escape hatch
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`load()` discards a snapshot whose version mismatches, whose `capturedAt` is older than `maxAgeMs`, or whose shape is malformed — it returns `undefined` rather than ever throwing into your app. A provider that throws in `capture()` / `restore()` is isolated: its slice is skipped, the error goes to `onError`, and the rest of the snapshot survives.
|
|
105
|
+
|
|
106
|
+
## Persistence: `sessionStorage` by default, swappable
|
|
107
|
+
|
|
108
|
+
The snapshot is encoded with a stable stringify and written as a single `storage.set(key, ...)`. `Storage` is the existing core dependency interface, so durability is **your** choice:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { createWebStorage } from "@finesoft/front";
|
|
112
|
+
|
|
113
|
+
createWebStorage("session"); // sessionStorage — tab-scoped, cleared when the tab closes (default)
|
|
114
|
+
createWebStorage("local"); // localStorage — survives across tabs and restarts
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`createWebStorage` maps `get`/`set`/`delete` onto `getItem`/`setItem`/`removeItem`, swallows quota errors on write (session restoration is best-effort — it never interrupts navigation), and degrades to a safe no-op when the chosen Web Storage is unavailable (e.g. private mode `SecurityError`).
|
|
118
|
+
|
|
119
|
+
Because it is just the `Storage` interface, you can supply **any** implementation — an in-memory store for tests, or a server-synced `Storage` for cross-device restoration. The framework v1 ships no built-in server endpoint, but the seam is open.
|
|
120
|
+
|
|
121
|
+
## Wiring it into the browser
|
|
122
|
+
|
|
123
|
+
Pass an optional `session` to `startBrowserApp`. When present, the framework builds a `SessionStore`, registers your providers, wires a `SessionBridge` (auto-capture on navigation + `pagehide`/`visibilitychange`), runs the boot restore after the first navigation, and hands you a `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 defaults to createWebStorage("session")
|
|
137
|
+
maxAgeMs: 1000 * 60 * 60 * 24, // discard snapshots older than a day (optional)
|
|
138
|
+
},
|
|
139
|
+
mount(target, { session, app }) {
|
|
140
|
+
// session: SessionHandle (save/clear/scope/...); app: unified nav+session handle.
|
|
141
|
+
// Auto-capture/restore already run; use session.save() / session.clear() as escape hatches.
|
|
142
|
+
// ... mount your UI; pass `app` (or `session`) to components ...
|
|
143
|
+
return () => undefined;
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
When `session` is **absent**, none of this runs and the original `startBrowserApp` path is byte-for-byte unchanged.
|
|
149
|
+
|
|
150
|
+
### Flat vs structured wiring (automatic)
|
|
151
|
+
|
|
152
|
+
`startBrowserApp` picks the navigation adapter for you:
|
|
153
|
+
|
|
154
|
+
- **With** a `navigation` config → the structured `createNavigationSessionAdapter(controller)`: it serializes the whole tree, and on restore `hydrate`s it back. Auto-capture is driven by the navigation handle's `subscribe`.
|
|
155
|
+
- **Without** `navigation` (flat single page) → the `createUrlSessionAdapter` bound to `framework.perform(makeFlowAction(url))`: it captures `{ url }` and navigates on restore.
|
|
156
|
+
|
|
157
|
+
You only choose the adapter directly if you are assembling the store yourself (e.g. on the server, or in tests).
|
|
158
|
+
|
|
159
|
+
## The handle: manual save / clear / dispose
|
|
160
|
+
|
|
161
|
+
The `SessionHandle` (delivered in the mount context) gives you the escape hatches — auto-capture already runs, but you can force a write, clear the snapshot, or tear everything down. The unified `app` handle merges navigation commands with session `save`/`clear`/`scope`, so components can hold a single object instead of assembling their own controller:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
interface SessionHandle {
|
|
165
|
+
restore(currentUrl: string): void | Promise<void>; // boot restore (already called for you)
|
|
166
|
+
save(): void; // force an immediate persist
|
|
167
|
+
clear(): void; // drop the persisted snapshot (e.g. on logout)
|
|
168
|
+
dispose(): void; // unsubscribe navigation + remove pagehide/visibilitychange + clear timers
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Call `handle.clear()` on logout so the next user doesn't inherit a stale session; call `handle.dispose()` if you tear down the app instance yourself.
|
|
173
|
+
|
|
174
|
+
### When does it capture?
|
|
175
|
+
|
|
176
|
+
You rarely call `save()` — capture is automatic:
|
|
177
|
+
|
|
178
|
+
- **On navigation change**: the bridge first prunes the scoped map to `adapter.presentKeys()` (this is where "pop `B` drops `B`'s state" actually lands), then **debounces** a write (default `SESSION_DEFAULT_DEBOUNCE_MS` = 500 ms, coalescing rapid navigations). Tune with `session.debounceMs`.
|
|
179
|
+
- **On `pagehide` and `visibilitychange` (hidden)**: it persists **immediately** and cancels any pending debounce — more reliable than `beforeunload` on mobile (the last state is captured before the tab is backgrounded or reclaimed).
|
|
180
|
+
|
|
181
|
+
## Deep-link policy: `shouldRestore`
|
|
182
|
+
|
|
183
|
+
On boot the bridge reads the snapshot and applies it **only if** `shouldRestore(snapshot, currentUrl)` passes — a single boolean gate for the whole `nav + slices` restore. The default, `defaultShouldRestore`, honors **explicit deep links over a stale session**:
|
|
184
|
+
|
|
185
|
+
| Snapshot `navigation` | Restores when… |
|
|
186
|
+
| --------------------------------------- | ---------------------------------------------------------------------------- |
|
|
187
|
+
| **Flat** (`SessionUrlLocation`) | `currentUrl === snapshot.navigation.url` **or** the current path is root `/` |
|
|
188
|
+
| **Structured** (`SerializedNavigation`) | the current path is root `/` |
|
|
189
|
+
| **None** (slices only) | always (URL-independent) |
|
|
190
|
+
|
|
191
|
+
So reloading the same page (or entering fresh at `/`) restores; opening a different deep link `/x` does **not** get overwritten by an old session. "Root" is the path `=== "/"` (query/hash stripped). Apps served under a base path should override the gate:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
session: {
|
|
195
|
+
providers: [themeSlice],
|
|
196
|
+
shouldRestore: (snapshot, currentUrl) => currentUrl.startsWith("/app/"),
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Restoring to a different state than the SSR'd URL produces one client-side jump (SSR renders the URL's screen, then the client restores). That timing is exposed through the bridge so you can control it; a pure-CSR app can restore before first paint and avoid it entirely.
|
|
201
|
+
|
|
202
|
+
## What is _not_ captured
|
|
203
|
+
|
|
204
|
+
- **DOM you didn't register.** The framework never scans the DOM. State slices are whatever your providers `capture()` — nothing more.
|
|
205
|
+
- **Anything when you register no providers.** With only navigation (or nothing) registered, capture is effectively zero — the privacy default.
|
|
206
|
+
- **Sensitive fields you exclude.** `capture()` is your filter; strip tokens, PII, and the like there.
|
|
207
|
+
- **A stale, expired, or malformed snapshot.** `load()` returns `undefined` instead of crashing the app to restore a bad state.
|
|
208
|
+
|
|
209
|
+
## Backward compatibility
|
|
210
|
+
|
|
211
|
+
- An app that doesn't pass `session` to `startBrowserApp` runs the **original path** with zero behavior change — the entire feature is gated behind that one field.
|
|
212
|
+
- Session restoration adds no requirement on the server. A server-synced snapshot is possible by supplying your own `Storage`, but nothing is built in.
|
|
213
|
+
- The framework restores **state**, never UI. Your `Page` models and how you render them are untouched.
|
|
214
|
+
|
|
215
|
+
## Next
|
|
216
|
+
|
|
217
|
+
- [Navigation](./11-navigation.md) — the structured tree whose entries scope per-screen state
|
|
218
|
+
- [Rendering & hydration](./04-rendering-and-hydration.md) — how the first screen is already restored via prefetched results
|
|
219
|
+
- [DI container](./07-di-container.md) — the `Storage` dependency that session restoration persists through
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 4. 渲染与 Hydration
|
|
2
2
|
|
|
3
|
-
页面从 Controller 输出到字节流再回到活着的浏览器应用要走的路。本章覆盖 SSR、CSR、prerender
|
|
3
|
+
页面从 Controller 输出到字节流再回到活着的浏览器应用要走的路。本章覆盖 SSR、CSR、prerender,它们正交组合的第二根轴 —— **应用架构**(扁平单页 vs 结构化导航 + islands)—— 以及把两端绑在一起的 `PrefetchedIntents` 机制。
|
|
4
4
|
|
|
5
5
|
## 三种模式并排比
|
|
6
6
|
|
|
@@ -15,6 +15,25 @@
|
|
|
15
15
|
|
|
16
16
|
模式是**按路由**配置,自由混合。
|
|
17
17
|
|
|
18
|
+
## 两根轴:渲染模式 × 应用架构
|
|
19
|
+
|
|
20
|
+
渲染模式是一根轴。**应用架构**是正交的第二根轴:
|
|
21
|
+
|
|
22
|
+
- **扁平单页** —— 服务端 `createSSRRender`,客户端单次挂载、每次导航重渲。一个 root、一个可见页。(见下方 [SSR 管线](#ssr-管线)。)
|
|
23
|
+
- **结构化导航 + islands** —— 服务端 `createSSRNavigationRender`,客户端按目标挂为 _islands_:独立 root,切 tab / 栈时保活不销毁。(见 [导航](./11-navigation.md) 与下方 [Islands SSR](#islands-ssr结构化架构方案-c)。)
|
|
24
|
+
|
|
25
|
+
两轴组合成矩阵 —— 渲染模式决定 HTML _何时/何地_ 产出,架构决定应用 _怎么组织_:
|
|
26
|
+
|
|
27
|
+
| | 扁平单页 | 结构化导航 + islands(方案 C) |
|
|
28
|
+
| ------------- | ----------------------- | --------------------------------- |
|
|
29
|
+
| **ssr** | ✅ `svelte-minimal` | ✅ `vue-minimal`、`react-minimal` |
|
|
30
|
+
| **csr** | ◐ 空壳 → 单 client root | ◐ 空壳 → islands 客户端挂 |
|
|
31
|
+
| **prerender** | ◐ 缓存扁平 SSR | ◐ 缓存方案 C SSR |
|
|
32
|
+
|
|
33
|
+
✅ 有 starter 模板实证 · ◐ 设计上成立,暂无 starter 模板。
|
|
34
|
+
|
|
35
|
+
**islands 是 SSR 还是 CSR,是模式的结果、不是独立选项**:`ssr`/`prerender` 下框架服务端渲出每个可见 island、客户端 _收养并水合_;`csr` 下无服务端 HTML,每个 island 都在客户端新挂。每种模式的子维度仍叠加其上 —— CSR 有两个触发点([见下](#csr客户端渲染)),prerender 有构建期静态与运行时 ISR 两种形态([见下](#prerender静态--isr))。会话恢复 + DOM 恢复是再一层正交(客户端、post-hydration),可叠加在任意格上 —— 见 [会话恢复](./12-session-restoration.md)。
|
|
36
|
+
|
|
18
37
|
## SSR 管线
|
|
19
38
|
|
|
20
39
|
```
|
|
@@ -83,6 +102,57 @@ Vite 插件和 adapter 替你调用 `render(url, options)`。你返回 `{ html,
|
|
|
83
102
|
- 根据 `deny()` / `redirect()` / `rewrite()` 结果设置 HTTP status
|
|
84
103
|
- `afterLoad` 发出 rewrite 信号时加 `Content-Location` 头
|
|
85
104
|
|
|
105
|
+
## Islands SSR(结构化架构,"方案 C")
|
|
106
|
+
|
|
107
|
+
结构化架构把 **chrome**(tab bar、header —— 持久外框)和 **island 内容**(当前活动页)渲为**独立的水合 root**,作为挂载节点下的 sibling:
|
|
108
|
+
|
|
109
|
+
```html
|
|
110
|
+
<div id="app">
|
|
111
|
+
<div data-fs-chrome><!-- chrome 渲在这 --></div>
|
|
112
|
+
<main data-fs-outlet><!-- 每个可见 island 渲在这 --></main>
|
|
113
|
+
</div>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**服务端** —— `renderApp` 渲 chrome;`renderIslandsHtml(snapshot, renderEntry)` 把每个可见目标渲进 outlet,带上共享标记(`data-fs-entry` / `data-fs-intent` / `data-fs-key`)供客户端匹配:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// src/ssr.ts —— 结构化入口(createSSRNavigationRender)
|
|
120
|
+
async renderApp(page, _framework, snapshot) {
|
|
121
|
+
const chromeHtml = await renderToString(createSSRApp(App, { snapshot }));
|
|
122
|
+
const islandsHtml = await renderIslandsHtml(snapshot, (entry) =>
|
|
123
|
+
renderToString(createSSRApp(VIEWS[entry.intent], { page: entry.page })),
|
|
124
|
+
);
|
|
125
|
+
return {
|
|
126
|
+
html: `<div data-fs-chrome>${chromeHtml}</div><main data-fs-outlet>${islandsHtml}</main>`,
|
|
127
|
+
head: `<title>${page.title}</title>`,
|
|
128
|
+
css: "",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**客户端** —— `resolveIslandsShell(target)` 找到(或创建)chrome/outlet sibling,并报告 chrome 是否服务端渲过(`hydrate`)。island 编排器按 `data-fs-key` 收养每个 SSR'd 容器,调你的 `mountEntry(entry, container)` 时置 `entry.hydrate = true`,让你水合已有 DOM 而非新建:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
// src/main.ts
|
|
137
|
+
const mountEntry = (entry, container) => {
|
|
138
|
+
const factory = entry.hydrate ? createSSRApp : createApp; // 水合 SSR'd vs 新建(客户端导航)
|
|
139
|
+
const app = factory(VIEWS[entry.intent], { page: entry.page, controller: ctx.app });
|
|
140
|
+
app.mount(container);
|
|
141
|
+
return { unmount: () => app.unmount() };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
startBrowserApp({
|
|
145
|
+
bootstrap,
|
|
146
|
+
mount,
|
|
147
|
+
callbacks,
|
|
148
|
+
navigation: { ...navigation.toBrowserConfig(), mountEntry },
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
> **同步挂载契约。** `mountEntry` 返回后,island 的 DOM 必须已就绪:框架在下一帧回填 `data-restore-root` 字段(见 [会话恢复](./12-session-restoration.md))。Vue/Svelte 的 `.mount()` 同步满足。**React** 异步提交 DOM,故须把**客户端新挂**路径用 `flushSync(() => root.render(view))` 包住 —— 仅客户端新挂的 island 需要(SSR'd island 的 DOM 已来自服务端)。见 `templates/react-minimal/src/main.tsx`。
|
|
153
|
+
|
|
154
|
+
完整示例:`templates/vue-minimal` 与 `templates/react-minimal`(均为 `ssr` + 结构化导航 + islands + 会话恢复)。
|
|
155
|
+
|
|
86
156
|
## CSR(客户端渲染)
|
|
87
157
|
|
|
88
158
|
`renderMode: "csr"` 的路由,服务端返回最小空壳:
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# 11. 导航
|
|
2
|
+
|
|
3
|
+
第 2–4 章讲的是**扁平单页**生命周期:一个 URL → 一个 intent → 一个页面。本章补上**结构化导航** —— 一棵递归的、与 UI 无关的导航树,对标 SwiftUI 的 `NavigationStack`、`TabView`、`NavigationSplitView`。
|
|
4
|
+
|
|
5
|
+
框架持有导航的**状态**、URL/history 接线、以及对每个目标的 intent 派发。它**不含任何 UI**。你的 `Page` 模型与之前一样保持内容无关 —— tabs、stack、split 怎么画,由你用 Svelte / React / Vue 自行决定。
|
|
6
|
+
|
|
7
|
+
单个叶子树**逐位等价**于扁平单页,因此本特性完全可选:从不调用 `defineNavigation` 的应用行为不变。
|
|
8
|
+
|
|
9
|
+
## 心智模型
|
|
10
|
+
|
|
11
|
+
导航状态是一棵由四种节点构成的树:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
NavigationNode = LeafNode | StackNode | TabsNode | SplitNode
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| 节点 | 持有 | 语义 | SwiftUI |
|
|
18
|
+
| ----------- | ------------------------------- | ---------------------------------------------------------------------- | --------------------- |
|
|
19
|
+
| `LeafNode` | `intent` + `params` | 一个目标(一次 intent 派发) | 一个 destination view |
|
|
20
|
+
| `StackNode` | 有序 `entries[]` | 一条路径:`entries[0]` 是根,末尾是可见的栈顶 | `NavigationStack` |
|
|
21
|
+
| `TabsNode` | `active` 键 + `branches` | 并列分支;**仅激活分支可见** | `TabView` |
|
|
22
|
+
| `SplitNode` | `columns[]` + 可选 `visibility` | 多列并存;可见集**默认全部列**,可收窄为 `detailOnly` / `doubleColumn` | `NavigationSplitView` |
|
|
23
|
+
|
|
24
|
+
叶子持有 `intent` + `params`,**不是** `Page`。树是纯粹的、可序列化的数据,描述「要去哪」;「那里是什么」(`Page`)由 controller 在解析时产出,并随快照交回。这正是树能进 URL / history 的原因。
|
|
25
|
+
|
|
26
|
+
内部节点递归嵌套 —— 一个由 `NavigationStack` 组成的 `TabView`、detail 列是 stack 的 split,等等。
|
|
27
|
+
|
|
28
|
+
## 声明一棵树
|
|
29
|
+
|
|
30
|
+
构造器与其它一切一样从 `@finesoft/front` 导入:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { leaf, stack, tabs, split } from "@finesoft/front";
|
|
34
|
+
|
|
35
|
+
// 一个目标 —— 等价于今天的扁平单页
|
|
36
|
+
leaf("home");
|
|
37
|
+
leaf("product", { id: 42 });
|
|
38
|
+
|
|
39
|
+
// 栈:只有根,或根 + 已 push 的 entry
|
|
40
|
+
stack(leaf("feed"));
|
|
41
|
+
stack([leaf("feed"), leaf("post", { id: 7 })]);
|
|
42
|
+
|
|
43
|
+
// Tabs:每个分支自成一个栈
|
|
44
|
+
tabs({
|
|
45
|
+
active: "home",
|
|
46
|
+
branches: {
|
|
47
|
+
home: stack(leaf("home")),
|
|
48
|
+
search: stack(leaf("search")),
|
|
49
|
+
me: stack(leaf("me")),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Split:sidebar + detail,detail 是一个栈
|
|
54
|
+
split([
|
|
55
|
+
{ id: "sidebar", content: leaf("folders") },
|
|
56
|
+
{ id: "detail", content: stack(leaf("folder", { id: "inbox" })) },
|
|
57
|
+
]);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`tabs()` 缺省 `order` 时按 `branches` 的插入顺序推导稳定 tab 顺序。`stack()` 接受单个根节点或一个 entries 数组。
|
|
61
|
+
|
|
62
|
+
## 由 NavigationStack 组成的 TabView
|
|
63
|
+
|
|
64
|
+
最常见的形态:底部标签栏,每个 tab 保留自己的导航深度。
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// src/bootstrap.ts
|
|
68
|
+
import { type Framework, defineRoutes, defineNavigation, leaf, stack, tabs } from "@finesoft/front";
|
|
69
|
+
import { HomeController } from "./lib/controllers/home";
|
|
70
|
+
import { SearchController } from "./lib/controllers/search";
|
|
71
|
+
import { ProfileController } from "./lib/controllers/profile";
|
|
72
|
+
import { PostController } from "./lib/controllers/post";
|
|
73
|
+
|
|
74
|
+
export function bootstrap(framework: Framework): void {
|
|
75
|
+
defineRoutes(framework, [
|
|
76
|
+
{ path: "/", intentId: "home", controller: new HomeController() },
|
|
77
|
+
{ path: "/search", intentId: "search", controller: new SearchController() },
|
|
78
|
+
{ path: "/me", intentId: "me", controller: new ProfileController() },
|
|
79
|
+
{ path: "/posts/:id", intentId: "post", controller: new PostController() },
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 导航结构,只声明一次
|
|
84
|
+
export const navigation = defineNavigation({
|
|
85
|
+
initial: tabs({
|
|
86
|
+
active: "home",
|
|
87
|
+
branches: {
|
|
88
|
+
home: stack(leaf("home")),
|
|
89
|
+
search: stack(leaf("search")),
|
|
90
|
+
me: stack(leaf("me")),
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`defineNavigation` 返回一个规范化的定义,附带两个适配器 —— `toBrowserConfig()` 给 CSR、`toSSRDefinition()` 给 SSR —— 因此你只声明**一次**树,就能把各自需要的形态交给对应 runner。
|
|
97
|
+
|
|
98
|
+
### 接入浏览器
|
|
99
|
+
|
|
100
|
+
`startBrowserApp` 新增一个可选 `navigation` 字段;存在时,`NavigationHandle`(以及统一的 `app` 句柄)会在 `mount` 回调的 context 里交给你,挂载时即可直接使用:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// src/main.ts
|
|
104
|
+
import { startBrowserApp } from "@finesoft/front";
|
|
105
|
+
import { bootstrap, navigation } from "./bootstrap";
|
|
106
|
+
|
|
107
|
+
startBrowserApp({
|
|
108
|
+
bootstrap,
|
|
109
|
+
callbacks,
|
|
110
|
+
navigation: navigation.toBrowserConfig(),
|
|
111
|
+
mount(target, { navigation: nav, app }) {
|
|
112
|
+
// nav/app 在 mount 时已就绪,无需等待回调。
|
|
113
|
+
// 快照变更时重渲染:
|
|
114
|
+
nav?.subscribe((snapshot) => mountNavigation(snapshot));
|
|
115
|
+
if (nav) mountNavigation(nav.getSnapshot());
|
|
116
|
+
// ... 把 UI 挂载到 target,将 app 传给组件 ...
|
|
117
|
+
return () => undefined;
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
提供 `navigation` 时,框架会构建 `NavigationController` 和 history 桥、解析首屏,并在 mount context 中把 handle 交给你。缺省时 `startBrowserApp` 走原有扁平单页路径,行为不变。
|
|
123
|
+
|
|
124
|
+
## 驱动导航
|
|
125
|
+
|
|
126
|
+
`NavigationHandle` 暴露各操作。每个都返回 `Promise<NavigationSnapshot>`(提交后的树 + 每个可见目标解析出的 `Page`),并在浏览器侧把新状态写入 history/URL。
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
// 在激活栈压入一个目标
|
|
130
|
+
await handle.push("post", { id: 7 });
|
|
131
|
+
|
|
132
|
+
// 弹回
|
|
133
|
+
await handle.pop(); // 一层
|
|
134
|
+
await handle.pop(2); // 两层 —— 绝不越过栈根
|
|
135
|
+
await handle.popToRoot();
|
|
136
|
+
|
|
137
|
+
// 替换当前栈顶(如 登录 → dashboard 且不留返回步)
|
|
138
|
+
await handle.replaceTop("dashboard");
|
|
139
|
+
|
|
140
|
+
// 切换激活 tab —— 其它 tab 保留各自栈深
|
|
141
|
+
await handle.selectTab("search");
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`pop` 绝不弹到栈根之下。不显式给 target 时,栈操作作用于**最深的激活栈**(当前可见的那个),`selectTab` 作用于**最外层**的 tabs 节点 —— 这正是「标签栏驱动聚焦栈」想要的。
|
|
145
|
+
|
|
146
|
+
### 读取结果
|
|
147
|
+
|
|
148
|
+
`NavigationSnapshot` 就是你拿来渲染的东西:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
const snapshot = handle.getSnapshot();
|
|
152
|
+
snapshot.tree; // 当前 NavigationNode 树
|
|
153
|
+
snapshot.destinations; // ResolvedDestination[]:{ intent, params, page, status? }
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`destinations` 的顺序与 `collectVisibleDestinations(tree)` 一致:tabs 节点**只**贡献激活分支,split **每个**非空列都贡献。这个顺序也正是服务端预取的内容。
|
|
157
|
+
|
|
158
|
+
你的视图层遍历 `snapshot.tree` 排布外壳(有哪些 tab、每个栈多深),从 `snapshot.destinations` 读页面内容。框架从不告诉你**怎么**画。
|
|
159
|
+
|
|
160
|
+
## NavigationSplitView
|
|
161
|
+
|
|
162
|
+
split 视图同时展示多列 —— 经典的 sidebar + detail(+ sub-detail)布局。一列的选择驱动下一列。
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
export const navigation = defineNavigation({
|
|
166
|
+
initial: split([
|
|
167
|
+
{ id: "sidebar", content: leaf("mailboxes") },
|
|
168
|
+
{ id: "list", content: undefined }, // 稍后选择
|
|
169
|
+
{ id: "detail", content: undefined },
|
|
170
|
+
]),
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
用 `selectColumn(columnId, intent, params?)` 设置某列内容:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
// 选一个邮箱 → 填充 "list" 列
|
|
178
|
+
await handle.selectColumn("list", "messages", { mailbox: "inbox" });
|
|
179
|
+
|
|
180
|
+
// 选一封邮件 → 填充 "detail" 列
|
|
181
|
+
await handle.selectColumn("detail", "message", { id: 1024 });
|
|
182
|
+
|
|
183
|
+
// 重选邮箱 → 清空 "list" 与 "detail"(它之后的所有列)
|
|
184
|
+
await handle.selectColumn("list", "messages", { mailbox: "archive" });
|
|
185
|
+
|
|
186
|
+
// 给 intent 传 undefined 显式清空某列
|
|
187
|
+
await handle.selectColumn("detail", undefined);
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
设置某列会**清空它之后的所有列**。重选 sidebar 会正确作废已打开的 detail,于是你绝不会渲染出「旧 detail 配新 sidebar」的错配。
|
|
191
|
+
|
|
192
|
+
默认所有列都可见,快照的 `destinations` 里**每个非空列**各一条 —— 框架会派发(服务端则预取)它们每一个。
|
|
193
|
+
|
|
194
|
+
### 列可见性
|
|
195
|
+
|
|
196
|
+
对标 SwiftUI 的 `NavigationSplitViewVisibility`,split 带一个可选的 **visibility** —— 这是**可绑定、可序列化的导航状态**(不是样式),它决定哪些列算可见,进而决定服务端预取什么:
|
|
197
|
+
|
|
198
|
+
| `visibility` | 可见列 |
|
|
199
|
+
| -------------------------- | ------------------------- |
|
|
200
|
+
| `automatic`(缺省)/ `all` | 全部列 |
|
|
201
|
+
| `doubleColumn` | 首列 + 末列(隐藏中间列) |
|
|
202
|
+
| `detailOnly` | 仅末列(detail) |
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import { SPLIT_VISIBILITIES, visibleSplitColumns } from "@finesoft/front";
|
|
206
|
+
|
|
207
|
+
// 声明时即指定(例如深链直达 detail)
|
|
208
|
+
split(
|
|
209
|
+
[
|
|
210
|
+
{ id: "sidebar", content: leaf("mailboxes") },
|
|
211
|
+
{ id: "detail", content: leaf("message", { id: 7 }) },
|
|
212
|
+
],
|
|
213
|
+
SPLIT_VISIBILITIES.DETAIL_ONLY,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// 或运行时切换 —— 新变可见的列会被派发,隐藏的列从快照中移除
|
|
217
|
+
await handle.setVisibility(SPLIT_VISIBILITIES.DETAIL_ONLY); // 只剩 detail 目标
|
|
218
|
+
await handle.setVisibility(SPLIT_VISIBILITIES.ALL); // 重新预取 sidebar + list
|
|
219
|
+
|
|
220
|
+
// 无需自己重实现映射,直接拿可见列渲染
|
|
221
|
+
for (const col of visibleSplitColumns(splitNode)) renderColumn(col);
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`detailOnly` 深链在服务端**只**解析并预取 detail 列 —— 隐藏列在显示前不耗成本。compact 窗口塌缩成单栈(SwiftUI 的 `preferredCompactColumn`)是视口反应式的纯渲染,框架不碰,完全交给你:读 `getPlatform()` / 视口,自行把 split 塌成栈视图。
|
|
225
|
+
|
|
226
|
+
## 定位嵌套容器
|
|
227
|
+
|
|
228
|
+
当一棵树里有不止一个 stack/tabs/split 时,传一个显式的 `target` 路径来操作更深的那个。路径是从根出发的步骤序列:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import type { NavigationPath } from "@finesoft/front";
|
|
232
|
+
|
|
233
|
+
// split 的 detail 列里那个栈
|
|
234
|
+
const detailStack: NavigationPath = [
|
|
235
|
+
{ kind: "column", id: "detail" },
|
|
236
|
+
{ kind: "stack-entry", index: 0 },
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
await handle.push("attachment", { id: 3 }, { target: detailStack });
|
|
240
|
+
await handle.selectTab("photos", someTabsPath);
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
不给 `target` 时,操作默认作用于激活路径 —— 绝大多数情况下这都是对的。
|
|
244
|
+
|
|
245
|
+
## 纯操作(不需要 controller)
|
|
246
|
+
|
|
247
|
+
上面这一切都由纯粹、不可变的树函数支撑,你可以直接用 —— 写测试、做乐观计算、或自建 controller:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
import {
|
|
251
|
+
push,
|
|
252
|
+
pop,
|
|
253
|
+
selectTab,
|
|
254
|
+
collectVisibleDestinations,
|
|
255
|
+
resolveActivePath,
|
|
256
|
+
} from "@finesoft/front";
|
|
257
|
+
|
|
258
|
+
const next = push(tree, leaf("post", { id: 7 })); // 返回一棵新树
|
|
259
|
+
const visible = collectVisibleDestinations(next); // readonly LeafNode[]
|
|
260
|
+
const activePath = resolveActivePath(next);
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
它们绝不修改输入 —— 只有被改动路径上的节点会重建,树的其余部分按引用复用。非法 target(如对非 tabs 节点 `selectTab`、对空栈 target 执行 pop)会抛 `NavigationError`。
|
|
264
|
+
|
|
265
|
+
## 服务端渲染
|
|
266
|
+
|
|
267
|
+
SSR 预取**所有**可见目标并把它们 —— 连同树本身 —— 序列化进 HTML,于是浏览器首屏直接复用服务端结果、不再取数。多列 split 视图天然预取多个 intent。
|
|
268
|
+
|
|
269
|
+
用 `createSSRNavigationRender` 配合 SSR 适配器:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
// src/ssr.ts
|
|
273
|
+
import { createSSRNavigationRender } from "@finesoft/front";
|
|
274
|
+
import { bootstrap, navigation } from "./bootstrap";
|
|
275
|
+
import { renderApp } from "./lib/render";
|
|
276
|
+
|
|
277
|
+
export const render = createSSRNavigationRender({
|
|
278
|
+
bootstrap,
|
|
279
|
+
getErrorPage: (status, message) => ({
|
|
280
|
+
id: `error-${status}`,
|
|
281
|
+
pageType: "error",
|
|
282
|
+
title: message,
|
|
283
|
+
}),
|
|
284
|
+
renderApp, // (page, framework, snapshot) => { html, head, css }
|
|
285
|
+
navigation: navigation.toSSRDefinition(),
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
`renderApp` 收三个参数:**主目标**页面(激活叶子的结果 —— 与扁平 SSR 的 `renderApp` 签名兼容)、framework、以及完整的多区域 `snapshot`,让你渲染 tabs/split 布局:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
function renderApp(page, framework, snapshot) {
|
|
293
|
+
// page → 聚焦目标(如用于 <title>、status)
|
|
294
|
+
// snapshot.tree → 要画哪些 tab / 列
|
|
295
|
+
// snapshot.destinations → 每个可见区域的 Page
|
|
296
|
+
return renderYourFramework(snapshot);
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
`renderApp` 具体要搭的 islands 外壳 —— chrome + 按目标的 islands 作为独立水合 root,以及客户端 `mountEntry` / `resolveIslandsShell` 如何收养并水合它们 —— 见 [Islands SSR](./04-rendering-and-hydration.md#islands-ssr结构化架构方案-c)。
|
|
301
|
+
|
|
302
|
+
底层原理:每个可见目标经**既有的** `PrefetchedIntents` 通道序列化为一条普通的 `{ intent, data: page }`,再额外挂一条承载序列化树的哨兵条目。`@finesoft/server` **零改动** —— 它经同一个 `#serialized-server-data` 脚本透传哨兵。hydration 时浏览器桥从 history state(或哨兵)读回树,并复用预取的页面。
|
|
303
|
+
|
|
304
|
+
若某请求没有结构化深链、应用也没提供骨架,SSR 回退到 `Router.resolve(url)` → 单个叶子 —— 即今天的扁平单页(含其 `renderMode`)。404 路径不变。
|
|
305
|
+
|
|
306
|
+
## 用 `createFullStateCodec` 做深链
|
|
307
|
+
|
|
308
|
+
默认情况下,**激活叶子**驱动 URL(`/posts/7`),完整的树通过 history state 旁路传输 —— 聚焦目标拥有干净、可分享的 URL。若要把**整棵**树编码进 URL 以支持完整深链(分享一个能还原 tab、栈深、split 选择的链接),改用 `createFullStateCodec`:
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
import { createFullStateCodec } from "@finesoft/front";
|
|
312
|
+
|
|
313
|
+
export const navigation = defineNavigation({
|
|
314
|
+
initial: tabs({
|
|
315
|
+
active: "home",
|
|
316
|
+
branches: { home: stack(leaf("home")), me: stack(leaf("me")) },
|
|
317
|
+
}),
|
|
318
|
+
codec: createFullStateCodec(), // 整树 → "?__nav=..." query 参数
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
此时 URL 形如 `/me?__nav=<编码后的树>`,粘贴它即可在 SSR 与浏览器两侧还原完整导航状态。编码紧凑(base64url)、稳定(key 排序,相同树永远产出相同串)、无损。传 `createFullStateCodec({ param: "nav" })` 可重命名保留 query 参数。
|
|
323
|
+
|
|
324
|
+
如需自定义 URL 方案,你也可以实现自己的 `NavigationCodec` —— 两个内置实现仅依赖 router 的 `getRoutes()`(和可选的 `reverse()`),别无其它。
|
|
325
|
+
|
|
326
|
+
## 守卫照常生效
|
|
327
|
+
|
|
328
|
+
导航级 `beforeLoad` / `afterLoad` 守卫在每次导航时对**主目标**(激活叶子)执行,`redirect` / `rewrite` / `deny` 语义与[第 3 章](./03-middleware.md)一致:
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
export const navigation = defineNavigation({
|
|
332
|
+
initial: tabs({
|
|
333
|
+
active: "home",
|
|
334
|
+
branches: { home: stack(leaf("home")), me: stack(leaf("me")) },
|
|
335
|
+
}),
|
|
336
|
+
beforeLoad: [authGuard],
|
|
337
|
+
});
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
- `redirect` → 当作 SPA 内跳处理(浏览器复用 FlowAction 管线);该目标不派发。
|
|
341
|
+
- `rewrite` → 用新 URL 重新解析出该目标的 intent/params。
|
|
342
|
+
- `deny` → 给目标打上 deny status,不派发其 intent。
|
|
343
|
+
|
|
344
|
+
单个目标的 dispatch 失败绝不会从操作里抛出 —— 它在该目标上记一个 `status` 和一张兜底页(与 controller 一样的 `fallback` 安全网),于是某一列失败不会让整屏空白。
|
|
345
|
+
|
|
346
|
+
## 向后兼容
|
|
347
|
+
|
|
348
|
+
- 不向 `startBrowserApp` / `createSSRRender` 传 `navigation` 的应用走**原有扁平路径**,行为零变化。
|
|
349
|
+
- 单叶子树等价于扁平单页:一个可见目标、一次 resolve/dispatch、一对 before/after。SSR 仅在 `serverData` 多挂一条树哨兵(在抵达 `PrefetchedIntents` 前被剔除)。
|
|
350
|
+
- `Page` 保持内容无关。导航在你的页面**周围**加结构,从不规定页面形状或你怎么渲染它。
|
|
351
|
+
|
|
352
|
+
## 下一步
|
|
353
|
+
|
|
354
|
+
- [中间件](./03-middleware.md) —— 导航复用的守卫语义
|
|
355
|
+
- [渲染与 Hydration](./04-rendering-and-hydration.md) —— 渲染模式 × 架构矩阵、islands SSR 外壳,以及预取结果如何跨越 SSR → CSR 边界
|