@finesoft/front 0.5.1 → 0.5.3
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 +4 -4
- package/dist/Outlet.svelte +39 -0
- package/dist/Outlet.svelte.d.ts +7 -0
- package/dist/browser-CR5vhgXg.mjs +1317 -0
- package/dist/browser-kFMjlLGT.d.mts +262 -0
- package/dist/browser.d.mts +7 -2
- package/dist/browser.mjs +9 -1
- package/dist/controller-types-CgmJ6-le.d.mts +16 -0
- package/dist/cookies-BiRlUanX.d.mts +786 -0
- package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
- package/dist/host-guard-DDWxLpFL.mjs +222 -0
- package/dist/http-B6CJqDyf.d.mts +46 -0
- package/dist/http-CaxrMD1A.d.mts +1 -0
- package/dist/http-D70PL72H.mjs +257 -0
- package/dist/http.d.mts +3 -0
- package/dist/http.mjs +2 -0
- package/dist/index-node.d.mts +15 -0
- package/dist/index-node.mjs +15 -0
- package/dist/index.d.mts +48 -697
- package/dist/index.mjs +44 -261
- package/dist/load-node.d.mts +5 -0
- package/dist/load-node.mjs +10 -0
- package/dist/load-portable.d.mts +5 -0
- package/dist/load-portable.mjs +8 -0
- package/dist/lru-map-BKoUAySU.mjs +50 -0
- package/dist/messages-CAt2QdGr.mjs +140 -0
- package/dist/native-contract-DuR25hYB.d.mts +14 -0
- package/dist/native-contract.d.mts +2 -0
- package/dist/native-contract.mjs +1 -0
- package/dist/node-D9hB4dsz.d.mts +35 -0
- package/dist/node.d.mts +2 -0
- package/dist/node.mjs +59 -0
- package/dist/path-CGFl2w7D.mjs +113 -0
- package/dist/path-CXT6xGPO.d.mts +261 -0
- package/dist/portable-CaxrMD1A.d.mts +1 -0
- package/dist/portable.d.mts +11 -0
- package/dist/portable.mjs +12 -0
- package/dist/proxy-2dSWO-Xw.d.mts +53 -0
- package/dist/proxy-z02VvGIj.mjs +7520 -0
- package/dist/public-types-BcJM-AYc.mjs +835 -0
- package/dist/react-DhwBRw01.d.mts +16 -0
- package/dist/react.d.mts +3 -0
- package/dist/react.mjs +29 -0
- package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
- package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
- package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
- package/dist/session-DnB4ZC3x.d.mts +1279 -0
- package/dist/src-BQBfaaPO.mjs +3825 -0
- package/dist/src-Ftl_0rhu.mjs +28 -0
- package/dist/ssr-BLzYP4wU.d.mts +207 -0
- package/dist/ssr-Tn4YkuxM.mjs +357 -0
- package/dist/ssr.d.mts +3 -0
- package/dist/ssr.mjs +3 -0
- package/dist/svelte-Dr5to3SE.d.mts +16 -0
- package/dist/svelte.d.mts +3 -0
- package/dist/svelte.mjs +13 -0
- package/dist/typegen-C-WeJCtf.d.mts +12 -0
- package/dist/typegen-cli.d.mts +1 -0
- package/dist/typegen-cli.mjs +11 -0
- package/dist/typegen.d.mts +3 -0
- package/dist/typegen.mjs +2 -0
- package/dist/types-B1BT0N3t.mjs +402 -0
- package/dist/undici-CPfL25Hr.mjs +22262 -0
- package/dist/vite-Cj4SPA8D.d.mts +277 -0
- package/dist/vite.d.mts +4 -0
- package/dist/vite.mjs +2354 -0
- package/dist/vue-DGmzuKho.d.mts +32 -0
- package/dist/vue.d.mts +3 -0
- package/dist/vue.mjs +56 -0
- package/dist/web.d.mts +6 -0
- package/dist/web.mjs +8 -0
- package/dist/worker.d.mts +2 -0
- package/dist/worker.mjs +2 -0
- package/docs/01-getting-started.md +67 -199
- package/docs/02-routing-and-controllers.md +163 -241
- package/docs/03-middleware.md +10 -212
- package/docs/04-rendering-and-hydration.md +7 -332
- package/docs/05-i18n.md +7 -237
- package/docs/06-http-client.md +20 -263
- package/docs/07-di-container.md +23 -257
- package/docs/08-observability.md +6 -286
- package/docs/09-server-and-deployment.md +59 -219
- package/docs/10-features-platform-pwa.md +7 -231
- package/docs/11-navigation.md +143 -288
- package/docs/12-session-restoration.md +6 -214
- package/docs/README.md +7 -7
- package/docs/advanced/custom-action-handler.md +29 -229
- package/docs/advanced/custom-adapter.md +7 -259
- package/docs/advanced/custom-event-recorder.md +7 -312
- package/docs/advanced/inline-proxy-codegen.md +6 -185
- package/docs/advanced/multi-tenant-scopes.md +10 -323
- package/docs/engineering/ci-release-flow.md +35 -222
- package/docs/engineering/project-structure.md +34 -277
- package/docs/engineering/testing.md +8 -310
- package/docs/pitfalls/container-scope-leak.md +2 -214
- package/docs/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/pitfalls/ssr-hydration-mismatch.md +9 -155
- package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/docs/zh/01-getting-started.md +67 -199
- package/docs/zh/02-routing-and-controllers.md +166 -244
- package/docs/zh/03-middleware.md +10 -212
- package/docs/zh/04-rendering-and-hydration.md +7 -332
- package/docs/zh/05-i18n.md +7 -237
- package/docs/zh/06-http-client.md +20 -263
- package/docs/zh/07-di-container.md +23 -257
- package/docs/zh/08-observability.md +6 -283
- package/docs/zh/09-server-and-deployment.md +59 -219
- package/docs/zh/10-features-platform-pwa.md +7 -231
- package/docs/zh/11-navigation.md +130 -290
- package/docs/zh/12-session-restoration.md +6 -214
- package/docs/zh/README.md +4 -4
- package/docs/zh/advanced/custom-action-handler.md +29 -229
- package/docs/zh/advanced/custom-adapter.md +7 -259
- package/docs/zh/advanced/custom-event-recorder.md +7 -312
- package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
- package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
- package/docs/zh/engineering/ci-release-flow.md +35 -222
- package/docs/zh/engineering/project-structure.md +34 -277
- package/docs/zh/engineering/testing.md +8 -310
- package/docs/zh/pitfalls/container-scope-leak.md +2 -214
- package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +9 -155
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/package.json +118 -20
- package/dist/browser-BHhVWXik.mjs +0 -2
- package/dist/browser-BV2BBXm7.d.mts +0 -2811
|
@@ -1,182 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 语言包体积
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
静态导入所有语言会增加浏览器包体积。在应用声明中使用生成的语言 loader。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
## 根因
|
|
8
|
-
|
|
9
|
-
翻译被打进了主客户端 chunk 而不是按 locale 拆。要么:
|
|
10
|
-
|
|
11
|
-
- 你在模块顶层直接 import 了 `src/locales/*.json`:
|
|
12
|
-
|
|
13
|
-
```ts
|
|
14
|
-
import zh from "../locales/zh-Hans.json";
|
|
15
|
-
import en from "../locales/en-US.json";
|
|
16
|
-
import ja from "../locales/ja-JP.json";
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
三个 locale 都进了每个用户的 bundle,即使每个用户只看到一种。
|
|
20
|
-
|
|
21
|
-
- 你在模块顶层构造了一个 `Translator`,所有 messages 都内联:
|
|
22
|
-
|
|
23
|
-
```ts
|
|
24
|
-
const t = new SimpleTranslator({
|
|
25
|
-
locale: "en-US",
|
|
26
|
-
messages: { ...zhMessages, ...enMessages, ...jaMessages },
|
|
27
|
-
});
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
- 你通过 `serializeServerData` 把翻译序列化进了 HTML,每个 SSR 页面响应都带全字典。
|
|
31
|
-
|
|
32
|
-
## 修法
|
|
33
|
-
|
|
34
|
-
### 用 `messagesDir` 而不是静态 import
|
|
35
|
-
|
|
36
|
-
配置 Vite 插件:
|
|
5
|
+
## Loader / 加载器
|
|
37
6
|
|
|
38
7
|
```ts
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
});
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
插件生成按 locale 的 loader。服务端从磁盘读;浏览器端动态 import 对应 chunk。Vite 把每个 locale 的 JSON 拆成独立 chunk,只有匹配解析后 locale 的 chunk 被请求。
|
|
45
|
-
|
|
46
|
-
```
|
|
47
|
-
dist/client/assets/
|
|
48
|
-
├── index-abc123.js ← 主 bundle(无翻译)
|
|
49
|
-
├── locale-en-US-def456.js ← 只有 en-US 访客加载
|
|
50
|
-
├── locale-zh-Hans-789.js ← 只有 zh-Hans 访客加载
|
|
51
|
-
└── locale-ja-JP-xyz.js ← 只有 ja-JP 访客加载
|
|
8
|
+
import { loadMessages } from "virtual:finesoft-front/i18n-loader";
|
|
9
|
+
// defineWebApp({ ..., loadMessages })
|
|
52
10
|
```
|
|
53
11
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
框架**故意不**把字典放进 `PrefetchedIntents`。浏览器和初始渲染并行拉自己的 locale chunk。
|
|
57
|
-
|
|
58
|
-
如果你在用自己的机制手动注入翻译进页面,停下:
|
|
59
|
-
|
|
60
|
-
```html
|
|
61
|
-
<!-- 不好 —— 每个 SSR 响应都带字典 -->
|
|
62
|
-
<script>
|
|
63
|
-
window.__TRANSLATIONS__ = { hello: "你好" /* 几百个 key */ };
|
|
64
|
-
</script>
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
```ts
|
|
68
|
-
// 好 —— 框架作为独立 chunk 加载
|
|
69
|
-
// (用 messagesDir 时自动处理)
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
### 检查实际发了什么
|
|
73
|
-
|
|
74
|
-
```bash
|
|
75
|
-
vp build
|
|
76
|
-
ls -lah dist/client/assets/locale-*
|
|
77
|
-
ls -lah dist/client/assets/index-*
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
加新 locale JSON 时 index chunk 应该不变。变了就有问题。
|
|
81
|
-
|
|
82
|
-
可视化拆解:
|
|
83
|
-
|
|
84
|
-
```bash
|
|
85
|
-
vp build --analyze
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
打开交互式 bundle treemap。locale chunk 应该小(KB 级)、独立、有名字。
|
|
89
|
-
|
|
90
|
-
## 「多大算太大」
|
|
91
|
-
|
|
92
|
-
首屏关键 JS(index chunk)大致阈值:
|
|
93
|
-
|
|
94
|
-
- 静态营销站:<50 KB gzipped
|
|
95
|
-
- 标准 SPA:<150 KB gzipped
|
|
96
|
-
- 重 dashboard:<300 KB gzipped
|
|
97
|
-
|
|
98
|
-
翻译把 index chunk 推过这些,就该拆开。按 locale 的 10-50 KB chunk 正常,不用担心。
|
|
99
|
-
|
|
100
|
-
## 服务端:字典被缓存,不打包
|
|
101
|
-
|
|
102
|
-
服务端框架第一次请求时从磁盘读 locale JSON 并缓存:
|
|
103
|
-
|
|
104
|
-
```
|
|
105
|
-
请求 1(zh-Hans):磁盘读 zh-Hans.json,缓存
|
|
106
|
-
请求 2(zh-Hans):从缓存返回
|
|
107
|
-
请求 3(en-US):磁盘读 en-US.json,缓存
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
你也不发巨大的 SSR bundle —— `tsdown` 打包服务端入口,但 locale JSON 是运行时从磁盘读的,没嵌入。
|
|
111
|
-
|
|
112
|
-
这意味着:
|
|
113
|
-
|
|
114
|
-
- ✅ 冷启动成本:每个 locale 一次磁盘读,每个 worker 一次
|
|
115
|
-
- ✅ 稳态:零开销 —— locale 留在 `Map` 里
|
|
116
|
-
- ❌ 可变性:编辑 JSON,服务器保持缓存的旧版本直到重启
|
|
117
|
-
|
|
118
|
-
可变性问题通常不是问题 —— 翻译入源代码控制,重新部署就重新加载。运行时更新翻译的话,用自定义 `loadMessages` 回调从服务拉。
|
|
119
|
-
|
|
120
|
-
## 字典确实巨大怎么办
|
|
121
|
-
|
|
122
|
-
单 locale 字典是几 MB(罕见 —— 大多数应用 <100 KB):
|
|
123
|
-
|
|
124
|
-
### 按 namespace 拆
|
|
125
|
-
|
|
126
|
-
```
|
|
127
|
-
src/locales/
|
|
128
|
-
├── en-US/
|
|
129
|
-
│ ├── common.json
|
|
130
|
-
│ ├── checkout.json
|
|
131
|
-
│ ├── admin.json
|
|
132
|
-
│ └── help-center.json
|
|
133
|
-
└── zh-Hans/
|
|
134
|
-
└── ...
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
写自定义 `loadMessages` 只加载某页面需要的 namespace:
|
|
138
|
-
|
|
139
|
-
```ts
|
|
140
|
-
createSSRRender({
|
|
141
|
-
bootstrap,
|
|
142
|
-
async loadMessages(locale) {
|
|
143
|
-
// 只 eager 加载 "common";其他按需懒加载
|
|
144
|
-
return import(`./locales/${locale}/common.json`);
|
|
145
|
-
},
|
|
146
|
-
async renderApp(page) {
|
|
147
|
-
/* ... */
|
|
148
|
-
},
|
|
149
|
-
});
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
视图层渲染 admin 字符串前调 `await translator.loadNamespace("admin")`。
|
|
153
|
-
|
|
154
|
-
### 视图挂载时懒加载
|
|
155
|
-
|
|
156
|
-
非常大的可选字典(帮助内容、错误码消息),从视图层按需拉,而不是框架启动时。框架不需要知道 —— 它们只是数据。
|
|
157
|
-
|
|
158
|
-
## 网络侧优化
|
|
159
|
-
|
|
160
|
-
正确拆分后还能加速 locale 拉取:
|
|
161
|
-
|
|
162
|
-
- 给 locale chunk 设长 `Cache-Control`(Vite 内容 hash 文件名让这安全)
|
|
163
|
-
- 预加载用户的 locale chunk:
|
|
164
|
-
```html
|
|
165
|
-
<link rel="preload" href="/assets/locale-en-US-def456.js" as="script" crossorigin />
|
|
166
|
-
```
|
|
167
|
-
- 高流量应用,让 locale chunk 走主 JS 同一 HTTP/2 连接 push
|
|
168
|
-
|
|
169
|
-
## 为什么不直接把翻译放 HTML
|
|
170
|
-
|
|
171
|
-
因为:
|
|
172
|
-
|
|
173
|
-
- 每个页面响应都带完整字典 —— 包括用户从不访问的页面的内容
|
|
174
|
-
- 当 HTML 按 locale 变化并含字典时,CDN 层无法缓存
|
|
175
|
-
- SSR 延迟随字典大小线性增长
|
|
176
|
-
|
|
177
|
-
按 locale 的 chunk 是正确权衡:发一次、永久缓存、只对用户实际拥有的 locale 发。
|
|
178
|
-
|
|
179
|
-
## 参考
|
|
180
|
-
|
|
181
|
-
- [第 5 章:i18n](../05-i18n.md) —— locale 处理全貌
|
|
182
|
-
- Vite 插件源:`packages/server/src/vite-plugin.ts`(搜 `messagesDir`)
|
|
12
|
+
使用相同路由、构建设置检查生产分块及 gzip 总量。入口变小可能只是字节移到别的分块。水合前 SSR 与客户端语言需一致。
|
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
|
|
21
21
|
PNG 以 `0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A` 开头 —— 开头的 `0x89` 不是合法 UTF-8,变成 `0xEF 0xBF 0xBD`。浏览器的图片解码从第 0 字节起看到垃圾,直接放弃。
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
当前实现按块读取并执行 10 MiB 上限,最终合并为 `ArrayBuffer`,字节原样转发:
|
|
24
24
|
|
|
25
25
|
```ts
|
|
26
26
|
// packages/server/src/proxy.ts
|
|
27
|
-
const body = await resp
|
|
27
|
+
const body = await readProxyBody(resp);
|
|
28
|
+
if (!body) return c.text("Proxy response too large", 502);
|
|
28
29
|
return c.newResponse(body, resp.status, respHeaders);
|
|
29
30
|
```
|
|
30
31
|
|
|
@@ -61,7 +62,7 @@ curl -s http://localhost:3000/api/image.png | sha256sum
|
|
|
61
62
|
|
|
62
63
|
## 什么情况下会撞到
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
当前版本(按原始字节读取)不会撞到。这条陷阱主要作为以下场景的历史参照:
|
|
65
66
|
|
|
66
67
|
- **从旧版本升级** —— 升级后验证二进制端点
|
|
67
68
|
- **写自己的自定义 proxy 逻辑** —— 抄旧示例会把 bug 引回来
|
|
@@ -69,7 +70,7 @@ curl -s http://localhost:3000/api/image.png | sha256sum
|
|
|
69
70
|
|
|
70
71
|
## 自定义 proxy —— 写对
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
自定义代理也必须保留原始字节;以下 `arrayBuffer` 示例仅适用于已有独立体积限制的响应。对不可信上游优先使用框架的有界读取实现:
|
|
73
74
|
|
|
74
75
|
```ts
|
|
75
76
|
// 好
|
|
@@ -108,14 +109,9 @@ app.all("/api/*", async (c) => {
|
|
|
108
109
|
|
|
109
110
|
`resp.body` 是 `ReadableStream`。直接返回它流式转字节不缓冲。但你失去大小守卫 —— 只在信任上游时这么干。
|
|
110
111
|
|
|
111
|
-
##
|
|
112
|
+
## 一套实现执行两次大小检查
|
|
112
113
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
1. **运行时**(`registerProxyRoutes`):先查 `Content-Length` 头,再查 fetch 后的 `body.byteLength`
|
|
116
|
-
2. **生成代码**(`generateProxyCode`):serverless 内联版本发同样两条检查
|
|
117
|
-
|
|
118
|
-
改一处大小限制,两处都改。测试 `generated proxy code embeds the same response size limit as runtime (parity)` 强制这点。
|
|
114
|
+
框架在 `registerProxyRoutes` 中执行 `MAX_RESPONSE_SIZE = 10 * 1024 * 1024`(10 MB)限制:先查 `Content-Length` 头,再按块累计实际字节;超过限制立即取消读取。`generateProxyCode` 只生成对同一实现的注册调用,开发环境和部署产物不会各自维护不同的限制。测试实际执行生成的注册代码与构建后的部署入口,覆盖二进制载荷及超限拒绝。
|
|
119
115
|
|
|
120
116
|
## 为什么 `Content-Length` 和 `byteLength` 都要
|
|
121
117
|
|
|
@@ -124,7 +120,7 @@ app.all("/api/*", async (c) => {
|
|
|
124
120
|
两次检查覆盖两种:
|
|
125
121
|
|
|
126
122
|
- 声明的 `Content-Length` 触发快速拒绝,避免下载 100MB 再拒
|
|
127
|
-
-
|
|
123
|
+
- 实际收到的字节数超限时立即拒绝,防 `Content-Length` 缺失或撒谎
|
|
128
124
|
|
|
129
125
|
## 参考
|
|
130
126
|
|
|
@@ -1,163 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 水合不一致
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
浏览器与 SSR 共享同一页面声明、App 和 Outlet 视图表。SSR 使用 `createSSRRender({ definition, render: app => nativeRender(app) })`;浏览器根据 `app.shouldHydrate` 选择原生 hydrate 或 mount,完成挂载后再等待 `app.ready`。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
先水合服务器快照,再恢复持久化状态。避免首次渲染读取随机数、时间或浏览器独有全局。不要在挂载前等待 ready,也不要手动改动 Outlet 的子树。
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
[Vue warn]: Hydration node mismatch — server rendered "<div>Loading...</div>" but client expected "<div>Welcome, Alice</div>"
|
|
9
|
-
```
|
|
7
|
+
显式声明公开数据投影;嵌套对象需要嵌套声明或 codec。wire/buildId 不匹配时重新加载。排查时同时检查实际浏览器警告、页面 DOM、网络请求和 entry 身份。
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
整站语言应在 `defineWebApp({ configuration: { locale: "en" }, ... })` 中声明,让 SSR 与浏览器使用同一个值。SSR 组装器在 `<html>` 上输出 `lang`、`dir`;浏览器还会在所选应用容器上设置这两个属性和 `data-fs-app`,使嵌入应用能使用自己的语言而不改动宿主文档。仅在静态模板中写 `<html lang="en">`,不会配置服务端运行时的 locale。
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
`injectSSRContent` 会在数据脚本上附带其父容器的 DOM 校验值。`createBrowserApp` 消费有效的 SSR 数据后,在返回原生挂载句柄前核对校验值,检测属性、文本、注释和结构变化,不识别具体浏览器插件。比较对象是解析后的 HTML,不是原始 HTML 字符串;自定义元素的宿主属性及内部内容由组件自己管理,不进入比较。根容器自身的属性和应用外部节点也不在检查范围内。
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
DOM 已变化且可以安全替换时,`shouldHydrate` 返回 false,现有 React/Vue/Svelte 模板使用 SSR 页面数据重新挂载原生根,不重新执行页面控制器。宿主记录 `source: "hydration", code: "dom-changed", recovery: "native-mount"`。没有插件名单、全局日志过滤或反复重建循环。正常 DOM 继续水合,SPA 导航不重复检查。
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
- 在服务端读 `window` / `localStorage` / `document.cookie`(都是 `undefined`)
|
|
19
|
-
- 在浏览器读 `process.env`(打包后是 `undefined`)
|
|
20
|
-
- SSR 看不到真实 UA 时却做了 UA 相关的渲染
|
|
21
|
-
- 异步竞争:Controller 的 `execute()` 每次返回不同数据
|
|
15
|
+
存在焦点、选区、已修改表单、滚动、可编辑内容、活动媒体、自定义元素、嵌套应用或不透明的浏览器上下文时,宿主继续原生水合,记录 `recovery: "deferred"`,保留原生水合行为及诊断。这避免框架额外强制重建,但原生渲染器自身水合失败时仍不能保证状态保留。手工组装 HTML 未附带校验值,或数据脚本不直接位于所选 target 内时,保持通常的原生水合流程。
|
|
22
16
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
## 根因(较不常见)
|
|
26
|
-
|
|
27
|
-
`PrefetchedIntents` 的 key(intentId + 稳定字符串化的 params)在两端不匹配:
|
|
28
|
-
|
|
29
|
-
- params 对象含不能确定性序列化的值(Map、Set、类实例、Symbol)
|
|
30
|
-
- Controller 原地改 `params` —— dispatch key 是从原始 params 算的,Controller 看到的是改过的
|
|
31
|
-
|
|
32
|
-
## 诊断
|
|
33
|
-
|
|
34
|
-
```ts
|
|
35
|
-
// 在 view 里两端都 log page:
|
|
36
|
-
console.log("[hydration]", typeof window === "undefined" ? "SSR" : "CSR", page);
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
对比两份 log。第一个不同的字段就是根因。
|
|
40
|
-
|
|
41
|
-
`PrefetchedIntents` 调试,浏览器里 log 缓存状态:
|
|
42
|
-
|
|
43
|
-
```ts
|
|
44
|
-
startBrowserApp({
|
|
45
|
-
bootstrap,
|
|
46
|
-
onBeforeStart(framework) {
|
|
47
|
-
console.log("[prefetched]", framework.prefetchedIntents.dump());
|
|
48
|
-
},
|
|
49
|
-
mount: /* ... */,
|
|
50
|
-
});
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
dump 里有 intent 但 **params 跟浏览器首次 dispatch 不同**就是 key 不匹配。
|
|
54
|
-
|
|
55
|
-
## 修法
|
|
56
|
-
|
|
57
|
-
### 别在模块顶层读平台专属全局
|
|
58
|
-
|
|
59
|
-
```ts
|
|
60
|
-
// 不好
|
|
61
|
-
const userId = localStorage.getItem("uid"); // SSR 抛错
|
|
62
|
-
const isDarkMode = matchMedia("(prefers-color-scheme: dark)").matches; // SSR 抛错
|
|
63
|
-
const csrfToken = document.querySelector("meta[name=csrf]")?.content; // SSR 是 null
|
|
64
|
-
|
|
65
|
-
export class HomeController extends BaseController {
|
|
66
|
-
/* 用 userId */
|
|
67
|
-
}
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
```ts
|
|
71
|
-
// 好
|
|
72
|
-
export class HomeController extends BaseController {
|
|
73
|
-
async execute(_params, container) {
|
|
74
|
-
// 从 DI resolve;请求 scope 里两端都有正确的值
|
|
75
|
-
const session = container.resolve<Session>("session");
|
|
76
|
-
return { kind: "home", userId: session.userId };
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
cookie 两端都能通过 `container.resolve("session")` 拿到(注册之后)。`localStorage` 只在浏览器 —— SSR 也要同一个值时,通过 cookie 或 query 参数暴露。
|
|
82
|
-
|
|
83
|
-
### `execute()` 里别用随机性/时间相关逻辑
|
|
84
|
-
|
|
85
|
-
```ts
|
|
86
|
-
// 不好 —— 服务端和浏览器算不同的值
|
|
87
|
-
async execute() {
|
|
88
|
-
return { kind: "home", randomGreeting: pick(greetings) };
|
|
89
|
-
}
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
需要随机性的话在服务端算一次,让客户端通过 `PrefetchedIntents` 复用(它会自动复用)。别尝试「在客户端重新随机」—— 那正是 hydration mismatch 的成因。
|
|
93
|
-
|
|
94
|
-
时间相关逻辑在服务端决定后送出结果:
|
|
95
|
-
|
|
96
|
-
```ts
|
|
97
|
-
async execute() {
|
|
98
|
-
const isOfficeHours = new Date().getHours() >= 9 && new Date().getHours() < 17;
|
|
99
|
-
return { kind: "home", isOfficeHours };
|
|
100
|
-
}
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
两端都看到同一个 `isOfficeHours: true`,因为浏览器从缓存读,不重新评估。
|
|
104
|
-
|
|
105
|
-
### `params` 用纯 JSON 类型
|
|
106
|
-
|
|
107
|
-
```ts
|
|
108
|
-
// 不好 —— dispatchAction 带非可序列化 params
|
|
109
|
-
framework.dispatch({
|
|
110
|
-
intentId: "search",
|
|
111
|
-
params: {
|
|
112
|
-
query: "widget",
|
|
113
|
-
filters: new Set(["red", "small"]), // Set JSON.stringify 不好
|
|
114
|
-
startDate: new Date(), // 变 ISO string,能用,但...
|
|
115
|
-
validator: new Validator(), // 类实例 —— 不会留下
|
|
116
|
-
},
|
|
117
|
-
});
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
```ts
|
|
121
|
-
// 好 —— 仅原语 + 纯对象
|
|
122
|
-
framework.dispatch({
|
|
123
|
-
intentId: "search",
|
|
124
|
-
params: {
|
|
125
|
-
query: "widget",
|
|
126
|
-
filters: ["red", "small"],
|
|
127
|
-
startDate: "2026-05-14",
|
|
128
|
-
},
|
|
129
|
-
});
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
`PrefetchedIntents` 缓存用**稳定字符串化** —— 同 key 不同顺序产出相同 key,能检测循环引用。但非 JSON 值会被强转为字符串或静默丢弃。
|
|
133
|
-
|
|
134
|
-
### 别改 `params`
|
|
135
|
-
|
|
136
|
-
```ts
|
|
137
|
-
// 不好
|
|
138
|
-
async execute(params, container) {
|
|
139
|
-
params.userId = container.resolve("session").userId; // 改了
|
|
140
|
-
return loadFor(params);
|
|
141
|
-
}
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
```ts
|
|
145
|
-
// 好
|
|
146
|
-
async execute(params, container) {
|
|
147
|
-
const effective = { ...params, userId: container.resolve("session").userId };
|
|
148
|
-
return loadFor(effective);
|
|
149
|
-
}
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
dispatcher 用原始 `params` 算了 cache key。改了之后,下次同样形状的 dispatch 就缓存未命中。
|
|
153
|
-
|
|
154
|
-
## 为什么 `stableStringify` 重要
|
|
155
|
-
|
|
156
|
-
框架的 `stableStringify`(在 `packages/core/src/prefetched-intents/stable-stringify.ts`)处理对象键顺序。它用 `seen` Set + `try/finally` 清理来支持 DAG(同一对象被多次引用)—— 没有清理的话 DAG 会被误判为循环引用,key 在两端会悄悄不同。
|
|
157
|
-
|
|
158
|
-
如果 SSR 期间看到 "Circular reference detected" warning 但数据确实是 DAG,提 bug —— 清理本该处理这个。
|
|
159
|
-
|
|
160
|
-
## 参考
|
|
161
|
-
|
|
162
|
-
- [陷阱:SSR vs CSR 全局变量](./ssr-vs-csr-globals.md) —— 平台专属全局住在哪
|
|
163
|
-
- [第 4 章:渲染与 Hydration](../04-rendering-and-hydration.md) —— `PrefetchedIntents` 怎么工作
|
|
17
|
+
这是启动恢复边界,不是插件隔离或安全校验。校验值变化不能确定来源:插件、应用脚本及 HTML 改写服务都可能修改 DOM。它不修复 head 中的样式、组件自己的内部结构、检查之后发生的改动或扩展自身运行环境中的错误。服务端 DOM 未变化时,应用自身的水合错误仍正常报告。
|
|
@@ -1,176 +1,15 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 运行环境边界
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
全部 API 从 `@finesoft/front` 导入,各自的运行环境要求仍然保留。包条件与编译器选择内部实现,不加载未使用的 UI 或平台依赖。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
```
|
|
8
|
-
ReferenceError: window is not defined
|
|
9
|
-
at /src/lib/foo.ts:3:13
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
或者更隐蔽:
|
|
13
|
-
|
|
14
|
-
```
|
|
15
|
-
TypeError: Cannot read properties of undefined (reading 'getItem')
|
|
16
|
-
at /src/lib/storage.ts:5:34
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
浏览器专属全局(`window`、`document`、`localStorage`、`navigator`、`matchMedia`、`IntersectionObserver` 等)在 Node 上不存在 —— Node 没有。
|
|
20
|
-
|
|
21
|
-
## 根因
|
|
22
|
-
|
|
23
|
-
你在被 SSR 入口导入的文件**模块求值时**读了浏览器专属全局。即使你只在客户端用它,模块图也把它拖进来了。
|
|
24
|
-
|
|
25
|
-
常见入口:
|
|
26
|
-
|
|
27
|
-
- `controllers/foo.ts` 导入 `lib/analytics.ts`,后者顶层用 `window.gtag`
|
|
28
|
-
- `lib/storage.ts` 工厂在 import 时调 `localStorage.getItem`
|
|
29
|
-
- 动画库 import 时自动跑 `requestAnimationFrame`
|
|
30
|
-
|
|
31
|
-
反过来浏览器也会遇到同样问题:
|
|
32
|
-
|
|
33
|
-
- 服务端专属代码(`process.env.X`、Node `fs`、`path`)被浏览器 bundle 拖进来的东西 import 了
|
|
34
|
-
- Vite 把大多数 tree-shake 掉,但不是全部,动态 import 也可能让 tree-shake 失效
|
|
35
|
-
|
|
36
|
-
## 诊断
|
|
37
|
-
|
|
38
|
-
SSR 入口崩时错误信息含文件。从上往下读 —— 第一条 `import` 链触到浏览器全局的就是元凶。
|
|
39
|
-
|
|
40
|
-
预先找浏览器专属代码,grep:
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
rg -n '\b(window|document|localStorage|sessionStorage|navigator|matchMedia|location)\b' src/
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
交叉对照从 `src/ssr.ts` 传递可达的东西。从 `ssr.ts` 可达的任何代码都必须 SSR 安全。
|
|
47
|
-
|
|
48
|
-
## 修法
|
|
49
|
-
|
|
50
|
-
### 用环境检查守卫
|
|
51
|
-
|
|
52
|
-
```ts
|
|
53
|
-
// 好 —— 两端都安全
|
|
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"` 是 SSR 检查的标准写法。比 `typeof process !== "undefined"` 更安全,因为有些打包器在客户端 polyfill `process`。
|
|
61
|
-
|
|
62
|
-
### 移到生命周期 hook
|
|
63
|
-
|
|
64
|
-
```ts
|
|
65
|
-
// 不好 —— import 时跑
|
|
66
|
-
const analytics = createAnalytics(window.location.host);
|
|
67
|
-
export function track(event: string) {
|
|
68
|
-
analytics.send(event);
|
|
69
|
-
}
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
```ts
|
|
73
|
-
// 好 —— 浏览器里 framework 启动之后跑
|
|
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
|
-
或用 `startBrowserApp` 的 `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
|
-
然后在 Controller / view 里从 DI resolve —— 共享代码里永远别直接碰 `window`。
|
|
99
|
-
|
|
100
|
-
### 条件 import
|
|
101
|
-
|
|
102
|
-
import 时崩 Node 的库(动画库、音频库),只在浏览器动态 import:
|
|
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
|
-
或在 `onBeforeStart` 里 import:
|
|
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
|
-
### 用框架的抽象
|
|
128
|
-
|
|
129
|
-
框架提供两端都能用的 DI key:
|
|
130
|
-
|
|
131
|
-
- `DEP_KEYS.PLATFORM` —— 服务端是解析的 user-agent,客户端是 navigator 派生
|
|
132
|
-
- `DEP_KEYS.STORAGE` —— 客户端 `localStorage`,服务端内存 map
|
|
133
|
-
- `DEP_KEYS.LOCALE` —— 两端解析后的 locale
|
|
134
|
-
|
|
135
|
-
用这些替代直接读全局。它们就是为跨平台设计的。
|
|
136
|
-
|
|
137
|
-
## 症状:本地能跑,生产构建挂
|
|
138
|
-
|
|
139
|
-
有时 dev server 容忍某个全局访问(Vite 的懒求值),但生产构建崩。原因通常是某个模块 dev 下被 tree-shake 掉而 prod 下没被,或者反过来。
|
|
140
|
-
|
|
141
|
-
部署前测生产构建:
|
|
142
|
-
|
|
143
|
-
```bash
|
|
144
|
-
pnpm build
|
|
145
|
-
pnpm preview
|
|
146
|
-
# 访问 SSR 路由
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
`vp preview` 跑生产同一代码路径 —— 它不崩,部署也不会崩(至少不会因为这类 bug)。
|
|
150
|
-
|
|
151
|
-
## 症状:生产能跑,dev 里空白页
|
|
152
|
-
|
|
153
|
-
反向问题 —— 服务端专属代码漏进了客户端 bundle,浏览器 hydration 之前就崩了。
|
|
154
|
-
|
|
155
|
-
打开浏览器 devtools,看 console 里有没有 `process is not defined` / `require is not defined`。修法同前:用 `typeof window === "undefined"`(反过来用 `typeof window !== "undefined"`)守卫,或移到生命周期 hook。
|
|
156
|
-
|
|
157
|
-
## 为什么 import 重要,不是「不调函数」
|
|
158
|
-
|
|
159
|
-
你可能想「不调那个函数」而不是守卫 import:
|
|
5
|
+
## Boundaries / 边界
|
|
160
6
|
|
|
161
7
|
```ts
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
8
|
+
import { defineOperation } from "@finesoft/front";
|
|
9
|
+
// createBrowserApp: browser
|
|
10
|
+
// startNodeHandler: Node
|
|
11
|
+
// createHttpHandler: Request/Response host (Node or Worker)
|
|
12
|
+
// finesoftFrontViteConfig: build configuration
|
|
167
13
|
```
|
|
168
14
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
修被 import 的模块让它 import 安全,不是只让 call 安全。
|
|
172
|
-
|
|
173
|
-
## 参考
|
|
174
|
-
|
|
175
|
-
- [陷阱:SSR Hydration 不匹配](./ssr-hydration-mismatch.md) —— SSR 跑了但产出与 CSR 不同
|
|
176
|
-
- [DI 容器](../07-di-container.md) —— 注册跨平台服务
|
|
15
|
+
共享声明模块加载时勿读取 window/document/storage。UI 绑定只选择需要的框架。Node DNS、文件系统不能进入浏览器/Worker 图。使用实际安装产物验证对应运行环境,源码别名可能掩盖缺失产物。
|