@finesoft/front 0.1.76 → 0.1.78
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/docs/01-getting-started.md +230 -0
- package/docs/02-routing-and-controllers.md +203 -0
- package/docs/03-middleware.md +220 -0
- package/docs/04-rendering-and-hydration.md +271 -0
- package/docs/05-i18n.md +243 -0
- package/docs/06-http-client.md +286 -0
- package/docs/07-di-container.md +264 -0
- package/docs/08-observability.md +290 -0
- package/docs/09-server-and-deployment.md +242 -0
- package/docs/10-features-platform-pwa.md +238 -0
- package/docs/README.md +72 -0
- package/docs/advanced/custom-action-handler.md +248 -0
- package/docs/advanced/custom-adapter.md +264 -0
- package/docs/advanced/custom-event-recorder.md +318 -0
- package/docs/advanced/inline-proxy-codegen.md +200 -0
- package/docs/advanced/multi-tenant-scopes.md +330 -0
- package/docs/engineering/ci-release-flow.md +244 -0
- package/docs/engineering/project-structure.md +296 -0
- package/docs/engineering/testing.md +317 -0
- package/docs/pitfalls/container-scope-leak.md +215 -0
- package/docs/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/docs/zh/01-getting-started.md +230 -0
- package/docs/zh/02-routing-and-controllers.md +203 -0
- package/docs/zh/03-middleware.md +220 -0
- package/docs/zh/04-rendering-and-hydration.md +271 -0
- package/docs/zh/05-i18n.md +243 -0
- package/docs/zh/06-http-client.md +286 -0
- package/docs/zh/07-di-container.md +264 -0
- package/docs/zh/08-observability.md +287 -0
- package/docs/zh/09-server-and-deployment.md +242 -0
- package/docs/zh/10-features-platform-pwa.md +238 -0
- package/docs/zh/README.md +72 -0
- package/docs/zh/advanced/custom-action-handler.md +248 -0
- package/docs/zh/advanced/custom-adapter.md +264 -0
- package/docs/zh/advanced/custom-event-recorder.md +318 -0
- package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
- package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
- package/docs/zh/engineering/ci-release-flow.md +244 -0
- package/docs/zh/engineering/project-structure.md +296 -0
- package/docs/zh/engineering/testing.md +317 -0
- package/docs/zh/pitfalls/container-scope-leak.md +215 -0
- package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/package.json +2 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Pitfall: SSR vs CSR globals
|
|
2
|
+
|
|
3
|
+
## Symptom
|
|
4
|
+
|
|
5
|
+
The build succeeds. The dev server starts. The first request to any SSR route crashes with:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
ReferenceError: window is not defined
|
|
9
|
+
at /src/lib/foo.ts:3:13
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or, more subtly:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
TypeError: Cannot read properties of undefined (reading 'getItem')
|
|
16
|
+
at /src/lib/storage.ts:5:34
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The browser-only global (`window`, `document`, `localStorage`, `navigator`, `matchMedia`, `IntersectionObserver`, ...) doesn't exist on Node — Node has none of them.
|
|
20
|
+
|
|
21
|
+
## Root cause
|
|
22
|
+
|
|
23
|
+
You're reading a browser-only global at **module evaluation time** in a file imported by your SSR entry. The module graph dragged it in even though you only use it on the client.
|
|
24
|
+
|
|
25
|
+
Common entry points:
|
|
26
|
+
|
|
27
|
+
- A `controllers/foo.ts` that imports a `lib/analytics.ts` with `window.gtag` at module top
|
|
28
|
+
- A `lib/storage.ts` factory that calls `localStorage.getItem` at import time
|
|
29
|
+
- An animation library auto-running `requestAnimationFrame` on import
|
|
30
|
+
|
|
31
|
+
The same problem in reverse hits the browser:
|
|
32
|
+
|
|
33
|
+
- Server-only code (`process.env.X`, Node `fs`, `path`) imported by something the browser bundle pulled in
|
|
34
|
+
- Vite tree-shakes most, but not all, and dynamic imports can defeat tree-shaking
|
|
35
|
+
|
|
36
|
+
## Diagnosis
|
|
37
|
+
|
|
38
|
+
When the SSR entry crashes, the error message includes the file. Read top-to-bottom — the first `import` chain that touches a browser global is the offender.
|
|
39
|
+
|
|
40
|
+
To find browser-only code preemptively, grep:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
rg -n '\b(window|document|localStorage|sessionStorage|navigator|matchMedia|location)\b' src/
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Cross-reference with what's imported transitively from `src/ssr.ts`. Anything reachable from `ssr.ts` must be SSR-safe.
|
|
47
|
+
|
|
48
|
+
## Fix
|
|
49
|
+
|
|
50
|
+
### Guard with an environment check
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// GOOD — safe on both sides
|
|
54
|
+
function getStoredTheme(): "light" | "dark" {
|
|
55
|
+
if (typeof window === "undefined") return "light";
|
|
56
|
+
return (localStorage.getItem("theme") as "light" | "dark") ?? "light";
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`typeof window === "undefined"` is the canonical SSR check. It's safer than `typeof process !== "undefined"` because some bundlers polyfill `process` on the client.
|
|
61
|
+
|
|
62
|
+
### Move to lifecycle hooks
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// BAD — runs at import time
|
|
66
|
+
const analytics = createAnalytics(window.location.host);
|
|
67
|
+
export function track(event: string) {
|
|
68
|
+
analytics.send(event);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
// GOOD — runs after framework start in the browser
|
|
74
|
+
let analytics: Analytics | null = null;
|
|
75
|
+
|
|
76
|
+
export function track(event: string) {
|
|
77
|
+
if (!analytics) {
|
|
78
|
+
if (typeof window === "undefined") return;
|
|
79
|
+
analytics = createAnalytics(window.location.host);
|
|
80
|
+
}
|
|
81
|
+
analytics.send(event);
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or use `startBrowserApp`'s `onBeforeStart`:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
startBrowserApp({
|
|
89
|
+
bootstrap,
|
|
90
|
+
onBeforeStart(framework) {
|
|
91
|
+
const analytics = createAnalytics(window.location.host);
|
|
92
|
+
framework.container.register("analytics", () => analytics);
|
|
93
|
+
},
|
|
94
|
+
mount: /* ... */,
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Then resolve from DI in controllers/views — never touch `window` directly in shared code.
|
|
99
|
+
|
|
100
|
+
### Conditional import
|
|
101
|
+
|
|
102
|
+
For libraries that crash on import in Node (animation libs, audio libs), import dynamically only on the browser:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
let confetti: ((options?: any) => void) | null = null;
|
|
106
|
+
|
|
107
|
+
if (typeof window !== "undefined") {
|
|
108
|
+
import("canvas-confetti").then((m) => {
|
|
109
|
+
confetti = m.default;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function celebrate() {
|
|
114
|
+
confetti?.();
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Or register the import in `onBeforeStart`:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
onBeforeStart: async (framework) => {
|
|
122
|
+
const { default: confetti } = await import("canvas-confetti");
|
|
123
|
+
framework.container.register("confetti", () => confetti);
|
|
124
|
+
},
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Use the framework's abstractions
|
|
128
|
+
|
|
129
|
+
The framework provides DI keys that work on both sides:
|
|
130
|
+
|
|
131
|
+
- `DEP_KEYS.PLATFORM` — the parsed user-agent on the server, navigator-derived on the client
|
|
132
|
+
- `DEP_KEYS.STORAGE` — `localStorage` on the client, in-memory map on the server
|
|
133
|
+
- `DEP_KEYS.LOCALE` — resolved locale on both sides
|
|
134
|
+
|
|
135
|
+
Use these instead of reading globals directly. They're cross-platform by design.
|
|
136
|
+
|
|
137
|
+
## Symptom: works locally, fails in production build
|
|
138
|
+
|
|
139
|
+
Sometimes the dev server tolerates a global access (via Vite's lazy evaluation) but the production build crashes. The cause is usually a module that's tree-shaken in dev but not in prod, or vice versa.
|
|
140
|
+
|
|
141
|
+
Test the production build before deploying:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pnpm build
|
|
145
|
+
pnpm preview
|
|
146
|
+
# hit the SSR routes
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The `vp preview` server runs the same code path as production — if it doesn't crash, the deploy won't either (at least not from this class of bug).
|
|
150
|
+
|
|
151
|
+
## Symptom: works in production but blank page in dev
|
|
152
|
+
|
|
153
|
+
Inverse problem — server-only code leaked into the client bundle, and the browser crashed during hydration before the view layer mounted.
|
|
154
|
+
|
|
155
|
+
Open browser devtools, check the console for `process is not defined` / `require is not defined`. The fix is the same: guard with `typeof window === "undefined"` (inverted: guard with `typeof window !== "undefined"`) or move to a lifecycle hook.
|
|
156
|
+
|
|
157
|
+
## Why imports matter, not "code that runs"
|
|
158
|
+
|
|
159
|
+
You may be tempted to "just not call the function" instead of guarding the import:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
// import-time check
|
|
163
|
+
if (typeof window !== "undefined") {
|
|
164
|
+
// never actually called on SSR
|
|
165
|
+
setupAnalytics();
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
But the `import` itself runs the module's top-level code. If `lib/analytics.ts` calls `window.gtag` at module top-level (e.g., as part of `const analytics = window.gtag.bind(window)`), the crash happens **at import**, before your `if` check.
|
|
170
|
+
|
|
171
|
+
Fix the imported module to be import-safe, not just call-safe.
|
|
172
|
+
|
|
173
|
+
## Related
|
|
174
|
+
|
|
175
|
+
- [Pitfall: SSR hydration mismatch](./ssr-hydration-mismatch.md) — when SSR runs but produces different output than CSR
|
|
176
|
+
- [DI container](../07-di-container.md) — registering cross-platform services
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# 1. 快速开始
|
|
2
|
+
|
|
3
|
+
五分钟跑通一个 `@finesoft/front` 应用。读完你会有一个 Vue/React/Svelte 页面在服务端渲染、在浏览器 hydrate、并准备好部署。
|
|
4
|
+
|
|
5
|
+
## 先决条件
|
|
6
|
+
|
|
7
|
+
- Node.js `>= 22.12.0`
|
|
8
|
+
- 一个包管理器:pnpm(推荐)、npm 或 yarn
|
|
9
|
+
- 一个视图层:Vue 3、React 19 或 Svelte 5 —— 文档用 Vue 举例,但每个示例都能 1:1 翻译
|
|
10
|
+
|
|
11
|
+
## 脚手架创建新应用
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @finesoft/create-app my-app
|
|
15
|
+
cd my-app
|
|
16
|
+
pnpm install
|
|
17
|
+
pnpm dev
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
脚手架生成一个可运行的应用,路由、SSR、proxy 已经接好。打开 <http://localhost:5173>。
|
|
21
|
+
|
|
22
|
+
如果你想理解里面是怎么搭起来的,本页后面从零搭一份同样的配置。
|
|
23
|
+
|
|
24
|
+
## 加进已有项目
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pnpm add @finesoft/front
|
|
28
|
+
pnpm add -D vite hono
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Peer 依赖:`hono >= 4.0.0`。可选但推荐:`@hono/node-server`(Node 部署)、`vite >= 5.0.0`(dev server)。
|
|
32
|
+
|
|
33
|
+
## 最小项目布局
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
my-app/
|
|
37
|
+
├── src/
|
|
38
|
+
│ ├── bootstrap.ts # 路由 + Controller(SSR + CSR 共享)
|
|
39
|
+
│ ├── main.ts # 浏览器入口
|
|
40
|
+
│ ├── ssr.ts # SSR 入口
|
|
41
|
+
│ ├── App.vue # 根组件
|
|
42
|
+
│ └── lib/
|
|
43
|
+
│ └── controllers/
|
|
44
|
+
│ └── home.ts
|
|
45
|
+
├── index.html
|
|
46
|
+
├── vite.config.ts
|
|
47
|
+
├── package.json
|
|
48
|
+
└── tsconfig.json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Vite 配置
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// vite.config.ts
|
|
55
|
+
import { finesoftFrontViteConfig } from "@finesoft/front";
|
|
56
|
+
import vue from "@vitejs/plugin-vue";
|
|
57
|
+
import { defineConfig } from "vite";
|
|
58
|
+
|
|
59
|
+
export default defineConfig({
|
|
60
|
+
plugins: [
|
|
61
|
+
vue(),
|
|
62
|
+
finesoftFrontViteConfig({
|
|
63
|
+
ssr: { entry: "src/ssr.ts" },
|
|
64
|
+
i18n: { messagesDir: "src/locales" },
|
|
65
|
+
adapter: "auto",
|
|
66
|
+
}),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`finesoftFrontViteConfig` 加的东西:
|
|
72
|
+
|
|
73
|
+
- 基于 Hono 的 dev server,每个请求都跑你的 SSR 入口
|
|
74
|
+
- 从 `messagesDir` 自动加载 locale JSON
|
|
75
|
+
- 构建管线同时产出客户端 bundle 和服务端入口
|
|
76
|
+
- `adapter: "auto" | "node" | "vercel" | "cloudflare" | "netlify" | "static"` 自动接入平台 adapter
|
|
77
|
+
|
|
78
|
+
## Bootstrap(SSR 与 CSR 共享)
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// src/bootstrap.ts
|
|
82
|
+
import { type Framework, defineRoutes } from "@finesoft/front";
|
|
83
|
+
import { HomeController } from "./lib/controllers/home";
|
|
84
|
+
|
|
85
|
+
export function bootstrap(framework: Framework): void {
|
|
86
|
+
defineRoutes(framework, [{ path: "/", intentId: "home", controller: new HomeController() }]);
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
同一个 `bootstrap()` 在两端都跑。这就是 SSR 和 CSR 解析 URL 完全一致的保证。
|
|
91
|
+
|
|
92
|
+
## Controller
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// src/lib/controllers/home.ts
|
|
96
|
+
import { BaseController, type Container } from "@finesoft/front";
|
|
97
|
+
|
|
98
|
+
interface HomePage {
|
|
99
|
+
kind: "home";
|
|
100
|
+
title: string;
|
|
101
|
+
items: string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class HomeController extends BaseController<Record<string, string>, HomePage> {
|
|
105
|
+
readonly intentId = "home";
|
|
106
|
+
|
|
107
|
+
async execute(_params: Record<string, string>, _container: Container): Promise<HomePage> {
|
|
108
|
+
return {
|
|
109
|
+
kind: "home",
|
|
110
|
+
title: "Welcome",
|
|
111
|
+
items: ["one", "two", "three"],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fallback(_params: Record<string, string>, error: unknown): HomePage {
|
|
116
|
+
return { kind: "home", title: "Failed", items: [] };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`BaseController` 把 `execute()` 包在 `try/catch` 里,出错时调 `fallback()`。永远要写 `fallback` —— 原因见 [可观测性 · 错误处理](./08-observability.md#通过-fallback-处理错误)。
|
|
122
|
+
|
|
123
|
+
## SSR 入口
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
// src/ssr.ts
|
|
127
|
+
import { createSSRRender, serializeServerData } from "@finesoft/front";
|
|
128
|
+
import { createSSRApp } from "vue";
|
|
129
|
+
import { renderToString } from "vue/server-renderer";
|
|
130
|
+
import App from "./App.vue";
|
|
131
|
+
import { bootstrap } from "./bootstrap";
|
|
132
|
+
|
|
133
|
+
export const render = createSSRRender({
|
|
134
|
+
bootstrap,
|
|
135
|
+
getErrorPage: () => ({ kind: "error", title: "Error" }),
|
|
136
|
+
async renderApp(page) {
|
|
137
|
+
const html = await renderToString(createSSRApp(App, { page }));
|
|
138
|
+
return { html, head: `<title>${(page as { title: string }).title}</title>`, css: "" };
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
export { serializeServerData };
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`createSSRRender` 返回一个函数,接 URL 输出渲染后的 HTML + 序列化的 prefetched intent 数据。Vite 插件和 adapter 会替你调用,不需要手动调。
|
|
146
|
+
|
|
147
|
+
## 浏览器入口
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// src/main.ts
|
|
151
|
+
import { startBrowserApp } from "@finesoft/front/browser";
|
|
152
|
+
import { createSSRApp } from "vue";
|
|
153
|
+
import App from "./App.vue";
|
|
154
|
+
import { bootstrap } from "./bootstrap";
|
|
155
|
+
|
|
156
|
+
startBrowserApp({
|
|
157
|
+
bootstrap,
|
|
158
|
+
mount(target, { framework }) {
|
|
159
|
+
const app = createSSRApp(App, { framework });
|
|
160
|
+
app.mount(target);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`startBrowserApp` 从 DOM 读 SSR 注入的 `PrefetchedIntents`,创建 `Framework`,跑同样的 `bootstrap()`,并触发首屏页面。`mount()` 跑的时候,框架已经准备好了初始的 `Page`。
|
|
166
|
+
|
|
167
|
+
客户端从 `@finesoft/front/browser` 导入(不是 `@finesoft/front`),避免把服务端模块带进客户端 bundle。
|
|
168
|
+
|
|
169
|
+
## 根组件(Vue 示例)
|
|
170
|
+
|
|
171
|
+
```vue
|
|
172
|
+
<!-- src/App.vue -->
|
|
173
|
+
<script setup lang="ts">
|
|
174
|
+
import { computed } from "vue";
|
|
175
|
+
import type { Framework } from "@finesoft/front";
|
|
176
|
+
|
|
177
|
+
const props = defineProps<{ framework?: Framework; page?: { title: string; items: string[] } }>();
|
|
178
|
+
|
|
179
|
+
// SSR 直接收到 page;CSR 从 framework 取当前页面。
|
|
180
|
+
const page = computed(() => props.page ?? props.framework?.getCurrentPage());
|
|
181
|
+
</script>
|
|
182
|
+
|
|
183
|
+
<template>
|
|
184
|
+
<main>
|
|
185
|
+
<h1>{{ page?.title }}</h1>
|
|
186
|
+
<ul>
|
|
187
|
+
<li v-for="item in page?.items" :key="item">{{ item }}</li>
|
|
188
|
+
</ul>
|
|
189
|
+
</main>
|
|
190
|
+
</template>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## index.html
|
|
194
|
+
|
|
195
|
+
```html
|
|
196
|
+
<!doctype html>
|
|
197
|
+
<html lang="en">
|
|
198
|
+
<head>
|
|
199
|
+
<meta charset="UTF-8" />
|
|
200
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
201
|
+
<!--head-->
|
|
202
|
+
</head>
|
|
203
|
+
<body>
|
|
204
|
+
<div id="app"><!--ssr--></div>
|
|
205
|
+
<script type="module" src="/src/main.ts"></script>
|
|
206
|
+
</body>
|
|
207
|
+
</html>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`<!--head-->` 和 `<!--ssr-->` 是框架注入 head 片段和渲染 HTML 的占位。占位名也通过 `SSR_PLACEHOLDERS` 导出。
|
|
211
|
+
|
|
212
|
+
## 跑起来
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
pnpm dev # 带 HMR 的开发服务器
|
|
216
|
+
pnpm build # 生产构建
|
|
217
|
+
pnpm preview # 本地预览生产构建
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
完成。你现在有一个应用:
|
|
221
|
+
|
|
222
|
+
- 服务端渲染,带 prefetch 数据
|
|
223
|
+
- Hydrate 不再多发请求(SSR 数据通过 `PrefetchedIntents` 复用)
|
|
224
|
+
- 客户端路由无刷新
|
|
225
|
+
- 已准备好部署到任一支持的 adapter
|
|
226
|
+
|
|
227
|
+
## 下一步
|
|
228
|
+
|
|
229
|
+
- [路由与 Controller](./02-routing-and-controllers.md) —— 定义更多路由、控制渲染方式
|
|
230
|
+
- [项目结构](./engineering/project-structure.md) —— 应用长大后的推荐布局
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# 2. 路由与 Controller
|
|
2
|
+
|
|
3
|
+
框架的路由层把 URL 映射到 **intent**,把 intent 映射到 **Controller**,Controller 产出 **Page**。本章覆盖这三个概念。
|
|
4
|
+
|
|
5
|
+
## 心智模型
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
URL ──Router.resolve()──▶ RouteMatch { intent, renderMode, guards }
|
|
9
|
+
│
|
|
10
|
+
▼
|
|
11
|
+
IntentDispatcher.dispatch(intent)
|
|
12
|
+
│
|
|
13
|
+
▼
|
|
14
|
+
Controller.execute() → Page
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
一条路由定义包含:
|
|
18
|
+
|
|
19
|
+
- **路径模式**(`/products/:id`)
|
|
20
|
+
- **intent id**(操作的逻辑名;一个 intent 可以对应多条路由)
|
|
21
|
+
- **Controller 实例**(产出页面数据)
|
|
22
|
+
- 可选的**渲染模式**(`ssr` / `csr` / `prerender`)
|
|
23
|
+
- 可选的**守卫**(`beforeLoad` / `afterLoad`)
|
|
24
|
+
|
|
25
|
+
## 定义路由
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// src/bootstrap.ts
|
|
29
|
+
import { type Framework, defineRoutes } from "@finesoft/front";
|
|
30
|
+
import { HomeController } from "./lib/controllers/home";
|
|
31
|
+
import { ProductController } from "./lib/controllers/product";
|
|
32
|
+
import { authGuard } from "./lib/guards/auth";
|
|
33
|
+
|
|
34
|
+
export function bootstrap(framework: Framework): void {
|
|
35
|
+
defineRoutes(framework, [
|
|
36
|
+
// 普通 SSR 路由
|
|
37
|
+
{ path: "/", intentId: "home", controller: new HomeController() },
|
|
38
|
+
|
|
39
|
+
// 动态参数段
|
|
40
|
+
{ path: "/products/:id", intentId: "product", controller: new ProductController() },
|
|
41
|
+
|
|
42
|
+
// 仅 CSR(服务端只返回空壳)
|
|
43
|
+
{
|
|
44
|
+
path: "/dashboard",
|
|
45
|
+
intentId: "dashboard",
|
|
46
|
+
controller: new DashboardController(),
|
|
47
|
+
renderMode: "csr",
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// 构建期静态化
|
|
51
|
+
{
|
|
52
|
+
path: "/about",
|
|
53
|
+
intentId: "about",
|
|
54
|
+
controller: new AboutController(),
|
|
55
|
+
renderMode: "prerender",
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// 受保护路由 —— 复用 home intent,但加守卫
|
|
59
|
+
{
|
|
60
|
+
path: "/admin",
|
|
61
|
+
intentId: "home",
|
|
62
|
+
controller: new HomeController(),
|
|
63
|
+
beforeLoad: [authGuard],
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 路由选项
|
|
70
|
+
|
|
71
|
+
| 字段 | 类型 | 说明 |
|
|
72
|
+
| ------------ | -------------------------------- | -------------------------------------------------------------- |
|
|
73
|
+
| `path` | `string` | 路径模式,含 `:param` 占位。结尾的 `/` 会被归一化。 |
|
|
74
|
+
| `intentId` | `string` | 操作的逻辑名。用于注册 Controller。 |
|
|
75
|
+
| `controller` | `BaseController<TParams, TPage>` | 若 intent 已注册可省略。 |
|
|
76
|
+
| `renderMode` | `"ssr" \| "csr" \| "prerender"` | 默认 `"ssr"`。详见[第 4 章](./04-rendering-and-hydration.md)。 |
|
|
77
|
+
| `beforeLoad` | `BeforeLoadGuard[]` | Controller 之前执行。详见[第 3 章](./03-middleware.md)。 |
|
|
78
|
+
| `afterLoad` | `AfterLoadGuard[]` | Page 产出之后执行。 |
|
|
79
|
+
|
|
80
|
+
### 路径模式
|
|
81
|
+
|
|
82
|
+
- 静态:`/about`
|
|
83
|
+
- 带参数:`/products/:id`、`/users/:userId/posts/:postId`
|
|
84
|
+
- 尾部通配:`/files/*`
|
|
85
|
+
- **不**支持可选段 —— 写两条路由代替。
|
|
86
|
+
|
|
87
|
+
参数以字符串键对象的形式传给 `controller.execute(params, container)`。
|
|
88
|
+
|
|
89
|
+
## 写一个 Controller
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// src/lib/controllers/product.ts
|
|
93
|
+
import { BaseController, type Container, type HttpClient } from "@finesoft/front";
|
|
94
|
+
|
|
95
|
+
interface ProductPage {
|
|
96
|
+
kind: "product";
|
|
97
|
+
id: string;
|
|
98
|
+
name: string;
|
|
99
|
+
price: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class ProductController extends BaseController<{ id: string }, ProductPage> {
|
|
103
|
+
readonly intentId = "product";
|
|
104
|
+
|
|
105
|
+
async execute(params: { id: string }, container: Container): Promise<ProductPage> {
|
|
106
|
+
const http = container.resolve<HttpClient>("http");
|
|
107
|
+
const product = await http.get<{ name: string; price: number }>(
|
|
108
|
+
`/api/products/${params.id}`,
|
|
109
|
+
);
|
|
110
|
+
return {
|
|
111
|
+
kind: "product",
|
|
112
|
+
id: params.id,
|
|
113
|
+
name: product.name,
|
|
114
|
+
price: product.price,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fallback(params: { id: string }, _error: unknown): ProductPage {
|
|
119
|
+
return { kind: "product", id: params.id, name: "Not available", price: 0 };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Controller 契约
|
|
125
|
+
|
|
126
|
+
| 成员 | 必需 | 用途 |
|
|
127
|
+
| ---------- | ---- | --------------------------------------------------------------------------- |
|
|
128
|
+
| `intentId` | 是 | 必须与路由的 `intentId` 一致(或与 `IntentDispatcher.register` 调用一致)。 |
|
|
129
|
+
| `execute` | 是 | 产出页面。接受解析后的路径参数和请求级 DI 容器。 |
|
|
130
|
+
| `fallback` | 是 | `execute()` 抛错时返回降级页面。必须同步且总能返回。 |
|
|
131
|
+
|
|
132
|
+
`BaseController` 把 `execute()` 包在 `try/catch` 里,错误都走 `fallback()`。框架从不让 `dispatch()` 抛错 —— 你的 `fallback()` 是最后一道防线。
|
|
133
|
+
|
|
134
|
+
### 为什么 `fallback` 是必需的
|
|
135
|
+
|
|
136
|
+
SSR 期间 `execute()` 抛错本来会让整个请求崩 —— 要么 500,要么渲出空白文档。`fallback()` 让你返回一个结构化的「错误」`Page`,让视图层渲成优雅失败(banner、重试按钮等)。完整模式见 [可观测性 · 错误处理](./08-observability.md#通过-fallback-处理错误)。
|
|
137
|
+
|
|
138
|
+
## 渲染模式
|
|
139
|
+
|
|
140
|
+
| 模式 | 服务端返回的内容 | 适用场景 |
|
|
141
|
+
| ------------- | -------------------------------- | ------------------------------------------- |
|
|
142
|
+
| `"ssr"` | 完整渲染的 HTML + 序列化数据 | 默认。SEO 和 TTFB 敏感的页面。 |
|
|
143
|
+
| `"csr"` | 空壳 HTML;Controller 在浏览器跑 | 鉴权后的 dashboard、高度个性化的页面。 |
|
|
144
|
+
| `"prerender"` | 部署期生成的静态 HTML | 营销页、文档、博客。结合 ISR(见第 4 章)。 |
|
|
145
|
+
|
|
146
|
+
模式是**按路由**配置,可以自由混合。框架在构建期重生成 prerendered 路由;SSR 路由每次请求都执行。
|
|
147
|
+
|
|
148
|
+
## 不绑路由也能注册 Controller
|
|
149
|
+
|
|
150
|
+
可以为 intent 注册 Controller 但不暴露成路由。这对只通过 `dispatchAction` 触发的 intent 有用:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
framework.intentDispatcher.register("checkout", new CheckoutController());
|
|
154
|
+
|
|
155
|
+
// 别处:
|
|
156
|
+
const page = await framework.intentDispatcher.dispatch({
|
|
157
|
+
intentId: "checkout",
|
|
158
|
+
params: { cartId },
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
路由本质上就是按 URL 索引的 intent dispatch。
|
|
163
|
+
|
|
164
|
+
## 一个 intent 多个路由
|
|
165
|
+
|
|
166
|
+
同一个 intent 可以服务不同 URL:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
defineRoutes(framework, [
|
|
170
|
+
{ path: "/", intentId: "home", controller: new HomeController() },
|
|
171
|
+
{ path: "/welcome", intentId: "home" }, // 复用已注册的 HomeController
|
|
172
|
+
{ path: "/landing/:slug", intentId: "home" }, // 同 intent,参数不同
|
|
173
|
+
]);
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
避免只是 URL 表面不同就复制 Controller 实例。前面例子里 `/admin` 复用 `home` intent 也是同一个套路。
|
|
177
|
+
|
|
178
|
+
## 检查解析后的 match
|
|
179
|
+
|
|
180
|
+
诊断或自定义路由时,可以直接调 `Router.resolve()`:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
const match = framework.router.resolve("/products/42");
|
|
184
|
+
// {
|
|
185
|
+
// intent: { intentId: "product", params: { id: "42" } },
|
|
186
|
+
// action: { kind: "flow", url: "/products/42" },
|
|
187
|
+
// renderMode: "ssr",
|
|
188
|
+
// guards: { before: [...], after: [...] },
|
|
189
|
+
// }
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
未命中返回 `null` —— 在服务端 404 逻辑里处理它。
|
|
193
|
+
|
|
194
|
+
## 实时演示
|
|
195
|
+
|
|
196
|
+
下方注册了一个真实的 `Router` 实例(包含示例路由)。在左侧输入 URL,右侧实时显示 `Router.resolve()` 的 `RouteMatch` —— 与框架运行时走的是同一段代码。
|
|
197
|
+
|
|
198
|
+
<Ch02RouteResolver />
|
|
199
|
+
|
|
200
|
+
## 下一步
|
|
201
|
+
|
|
202
|
+
- [中间件](./03-middleware.md) —— 守卫导航、重定向、拒绝
|
|
203
|
+
- [渲染与 Hydration](./04-rendering-and-hydration.md) —— Controller 产出 Page 之后发生什么
|