@maiyunnet/kebab 8.6.5 → 9.0.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/doc/kebab-rag.md +1040 -697
- package/index.d.ts +1 -9
- package/index.js +1 -1
- package/lib/core.d.ts +12 -0
- package/lib/core.js +93 -22
- package/lib/cron.js +84 -33
- package/lib/db.d.ts +2 -0
- package/lib/db.js +3 -1
- package/lib/ratelimit.d.ts +47 -0
- package/lib/ratelimit.js +88 -0
- package/package.json +20 -13
- package/sys/child.js +2 -0
- package/sys/cmd.js +153 -0
- package/sys/ctr.d.ts +46 -5
- package/sys/ctr.js +135 -11
- package/sys/master.js +96 -2
- package/sys/route.d.ts +6 -0
- package/sys/route.js +24 -3
- package/www/example/ctr/test.d.ts +34 -0
- package/www/example/ctr/test.js +140 -0
- package/www/example/route.json +2 -1
- package/www/example/stc/view/react-page.bundle.js +97 -0
- package/www/example/stc/view/react-page.css +2 -0
- package/www/example/stc/view/react-page.d.ts +50 -0
- package/www/example/stc/view/react-page.js +136 -0
- package/www/example/stc/view/react-page.tsx +448 -0
- package/www/example/stc/view/react-router-page.bundle.js +81 -0
- package/www/example/stc/view/react-router-page.css +2 -0
- package/www/example/stc/view/react-router-page.d.ts +58 -0
- package/www/example/stc/view/react-router-page.js +142 -0
- package/www/example/stc/view/react-router-page.tsx +426 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* --- Kebab React 全页面示例组件 ---
|
|
3
|
+
*
|
|
4
|
+
* 【使用方式】
|
|
5
|
+
* 在 Ctr 方法中调用 _loadReactPage('view/react-page', { ...props }),
|
|
6
|
+
* 框架自动注入 _importMapJson / _hydrateScript / _propsJson 三个内部 props,
|
|
7
|
+
* 组件在 <head> 和 <body> 末尾渲染对应的 <script> 标签即可,其余部分与普通 React 组件无异。
|
|
8
|
+
*
|
|
9
|
+
* 【多页面 / 共用组件】
|
|
10
|
+
* 将公共 UI(如 Button/Card/Badge)放在 stc/lib/ 目录,各页面 import 进来。
|
|
11
|
+
* tsc watch 会自动将所有 .tsx 编译为同路径的 .js,无需打包工具。
|
|
12
|
+
*
|
|
13
|
+
* 【编译方式】
|
|
14
|
+
* 本文件 (.tsx) 已集成到 source/tsconfig.json 的 include 中,
|
|
15
|
+
* 开启 `tsc: watch - source/tsconfig.json` 即可自动编译,无需额外命令。
|
|
16
|
+
* 编译输出:stc/view/react-page.js(由 tsc 覆盖写入,勿手动编辑 .js 文件)
|
|
17
|
+
*
|
|
18
|
+
* 【运行时机】
|
|
19
|
+
* - 服务端(Node.js):_loadReactPage() 调用 renderToString(),产出完整 HTML 字符串。
|
|
20
|
+
* - 客户端(浏览器):import map 将 bare import 解析到 esm.sh,hydrateRoot 接管 document。
|
|
21
|
+
* - 两端使用同一份 JS 文件:服务端从磁盘读,浏览器从静态 URL 下载。
|
|
22
|
+
*
|
|
23
|
+
* 【限制】
|
|
24
|
+
* - 不能包含 Node.js 专属代码(fs/path/lDb 等),数据必须通过 props 传入。
|
|
25
|
+
* - 等价于 Next.js 的 Client Component,而非 Server Component。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { useState, useEffect, type ReactNode } from 'react';
|
|
29
|
+
import { MemoryRouter, Routes, Route, Link, useParams, useNavigate } from 'react-router-dom';
|
|
30
|
+
|
|
31
|
+
// --- 组件接收的 props 接口,_urlXxx/_staticVer 由框架自动注入 ---
|
|
32
|
+
interface IProps {
|
|
33
|
+
'title': string;
|
|
34
|
+
'serverTime': string;
|
|
35
|
+
'node': string;
|
|
36
|
+
'_urlBase': string;
|
|
37
|
+
'_urlStc': string;
|
|
38
|
+
'_urlFull': string;
|
|
39
|
+
'_staticVer': string;
|
|
40
|
+
/** --- 框架注入:import map JSON 字符串,<head> 中以 type="importmap" <script> 渲染 --- */
|
|
41
|
+
'_importMapJson'?: string;
|
|
42
|
+
/**
|
|
43
|
+
* --- 框架注入:水合 JS 代码字符串,</body> 前以 type="module" <script> 渲染 ---
|
|
44
|
+
* --- 内容:import hydrateRoot + import App + hydrateRoot(document, createElement(App, p)) ---
|
|
45
|
+
*/
|
|
46
|
+
'_hydrateScript'?: string;
|
|
47
|
+
/**
|
|
48
|
+
* --- 框架注入:fullProps 的 JSON 序列化(不含 _propsJson 本身)---
|
|
49
|
+
* --- 客户端水合脚本读取此值重建 props,suppressHydrationWarning 处理服务端/客户端内容差异 ---
|
|
50
|
+
*/
|
|
51
|
+
'_propsJson'?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── shadcn/ui 风格基础控件 ───────────────────────────────────────────────────
|
|
55
|
+
// 以下组件是 Tailwind + React 的轻量实现,与 shadcn/ui 源码结构完全一致。
|
|
56
|
+
// 生产环境可直接替换为 shadcn/ui 官方组件,import map 会通过 esm.sh 自动
|
|
57
|
+
// 解析 Radix UI 等所有依赖,无需本地 npm install 也无需打包工具。
|
|
58
|
+
|
|
59
|
+
/** --- 卡片容器 --- */
|
|
60
|
+
function Card({ children, className = '' }: { 'children': ReactNode; 'className'?: string }) {
|
|
61
|
+
return (
|
|
62
|
+
<div className={`bg-white rounded-xl shadow-sm border border-slate-200 p-6 ${className}`}>
|
|
63
|
+
{children}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** --- 标签徽章 --- */
|
|
69
|
+
function Badge({ children, variant = 'default' }: {
|
|
70
|
+
'children': string;
|
|
71
|
+
'variant'?: 'default' | 'success' | 'warn';
|
|
72
|
+
}) {
|
|
73
|
+
const styles = {
|
|
74
|
+
'default': 'bg-blue-100 text-blue-700',
|
|
75
|
+
'success': 'bg-green-100 text-green-700',
|
|
76
|
+
'warn': 'bg-amber-100 text-amber-700',
|
|
77
|
+
};
|
|
78
|
+
return (
|
|
79
|
+
<span className={`inline-flex px-2.5 py-0.5 rounded-md text-xs font-semibold ${styles[variant]}`}>
|
|
80
|
+
{children}
|
|
81
|
+
</span>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** --- 按钮 --- */
|
|
86
|
+
function Btn({ children, onClick, disabled = false, outline = false }: {
|
|
87
|
+
'children': string;
|
|
88
|
+
'onClick'?: () => void;
|
|
89
|
+
'disabled'?: boolean;
|
|
90
|
+
'outline'?: boolean;
|
|
91
|
+
}) {
|
|
92
|
+
/** --- 填充/轮廓两种样式 --- */
|
|
93
|
+
const style = outline
|
|
94
|
+
? 'border border-slate-300 text-slate-700 hover:bg-slate-50'
|
|
95
|
+
: 'bg-blue-500 hover:bg-blue-600 text-white';
|
|
96
|
+
return (
|
|
97
|
+
<button
|
|
98
|
+
onClick={onClick}
|
|
99
|
+
disabled={disabled}
|
|
100
|
+
className={`inline-flex items-center px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 cursor-pointer ${style}`}
|
|
101
|
+
>
|
|
102
|
+
{children}
|
|
103
|
+
</button>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Router 演示子页面 ─────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/** --- 路由 Home 页 --- */
|
|
110
|
+
function RouterHome() {
|
|
111
|
+
return (
|
|
112
|
+
<div>
|
|
113
|
+
<p className="text-slate-700 text-sm font-medium mb-3">当前路由:<code className="bg-slate-100 px-1 rounded">/</code></p>
|
|
114
|
+
<div className="flex gap-2 flex-wrap">
|
|
115
|
+
<Link to="/about" className="inline-flex items-center px-3 py-1.5 rounded-lg bg-blue-500 hover:bg-blue-600 text-white text-xs font-medium">
|
|
116
|
+
→ /about
|
|
117
|
+
</Link>
|
|
118
|
+
<Link to="/user/42" className="inline-flex items-center px-3 py-1.5 rounded-lg border border-slate-300 hover:bg-slate-50 text-slate-700 text-xs font-medium">
|
|
119
|
+
→ /user/42
|
|
120
|
+
</Link>
|
|
121
|
+
<Link to="/user/hello" className="inline-flex items-center px-3 py-1.5 rounded-lg border border-slate-300 hover:bg-slate-50 text-slate-700 text-xs font-medium">
|
|
122
|
+
→ /user/hello
|
|
123
|
+
</Link>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** --- 路由 About 页 --- */
|
|
130
|
+
function RouterAbout() {
|
|
131
|
+
const navigate = useNavigate();
|
|
132
|
+
return (
|
|
133
|
+
<div>
|
|
134
|
+
<p className="text-slate-700 text-sm font-medium mb-3">当前路由:<code className="bg-slate-100 px-1 rounded">/about</code></p>
|
|
135
|
+
<p className="text-slate-500 text-xs mb-3">useNavigate() 演示编程导航(不使用 Link 组件):</p>
|
|
136
|
+
<div className="flex gap-2">
|
|
137
|
+
<button
|
|
138
|
+
onClick={() => navigate('/')}
|
|
139
|
+
className="inline-flex items-center px-3 py-1.5 rounded-lg border border-slate-300 hover:bg-slate-50 text-slate-700 text-xs font-medium cursor-pointer"
|
|
140
|
+
>
|
|
141
|
+
← 返回 /
|
|
142
|
+
</button>
|
|
143
|
+
<button
|
|
144
|
+
onClick={() => navigate('/user/99')}
|
|
145
|
+
className="inline-flex items-center px-3 py-1.5 rounded-lg bg-blue-500 hover:bg-blue-600 text-white text-xs font-medium cursor-pointer"
|
|
146
|
+
>
|
|
147
|
+
→ /user/99
|
|
148
|
+
</button>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** --- 路由 User 动态参数页 --- */
|
|
155
|
+
function RouterUser() {
|
|
156
|
+
const { id } = useParams<{ 'id': string }>();
|
|
157
|
+
const navigate = useNavigate();
|
|
158
|
+
return (
|
|
159
|
+
<div>
|
|
160
|
+
<p className="text-slate-700 text-sm font-medium mb-1">
|
|
161
|
+
当前路由:<code className="bg-slate-100 px-1 rounded">/user/:id</code>
|
|
162
|
+
</p>
|
|
163
|
+
<p className="text-slate-500 text-xs mb-3">
|
|
164
|
+
useParams() 读取动态段:<code className="bg-slate-100 px-1 rounded">id = "{id}"</code>
|
|
165
|
+
</p>
|
|
166
|
+
<button
|
|
167
|
+
onClick={() => navigate('/')}
|
|
168
|
+
className="inline-flex items-center px-3 py-1.5 rounded-lg border border-slate-300 hover:bg-slate-50 text-slate-700 text-xs font-medium cursor-pointer"
|
|
169
|
+
>
|
|
170
|
+
← 返回 /
|
|
171
|
+
</button>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── 页面主组件 ────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/** --- Kebab React 全页面演示 --- */
|
|
179
|
+
export default function ReactPage({ title, serverTime, node, _urlBase, _urlStc, _importMapJson, _hydrateScript, _propsJson }: IProps) {
|
|
180
|
+
|
|
181
|
+
// --- useState 在 SSR 阶段使用初始值渲染,客户端水合后变为可交互状态 ---
|
|
182
|
+
const [count, setCount] = useState(0);
|
|
183
|
+
const [tab, setTab] = useState<'overview' | 'routing' | 'fetch'>('overview');
|
|
184
|
+
const [fetchResult, setFetchResult] = useState<string | null>(null);
|
|
185
|
+
const [isFetching, setIsFetching] = useState(false);
|
|
186
|
+
/** --- 仅客户端为 true,用于展示水合已完成 --- */
|
|
187
|
+
const [hydrated, setHydrated] = useState(false);
|
|
188
|
+
|
|
189
|
+
// --- useEffect 在 SSR 阶段不执行,只在浏览器端水合完成后触发 ---
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
setHydrated(true);
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* --- 发起 fetch 请求并更新结果状态 ---
|
|
196
|
+
* @param url 请求地址
|
|
197
|
+
*/
|
|
198
|
+
function doFetch(url: string): void {
|
|
199
|
+
setIsFetching(true);
|
|
200
|
+
setFetchResult(null);
|
|
201
|
+
fetch(url)
|
|
202
|
+
.then(r => r.json())
|
|
203
|
+
.then((data: unknown) => {
|
|
204
|
+
setFetchResult(JSON.stringify(data, null, 2));
|
|
205
|
+
setIsFetching(false);
|
|
206
|
+
})
|
|
207
|
+
.catch((e: Error) => {
|
|
208
|
+
setFetchResult(`Error: ${e.message}`);
|
|
209
|
+
setIsFetching(false);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
// --- 组件渲染完整 HTML 文档,无需外部 EJS 模板 ---
|
|
215
|
+
<html lang="en">
|
|
216
|
+
<head>
|
|
217
|
+
{/*
|
|
218
|
+
meta/title 放在 <head> 最前,与 renderToString 实际输出顺序保持一致,
|
|
219
|
+
避免客户端水合时虚拟 DOM 与浏览器 DOM 的顺序不匹配(#418)。
|
|
220
|
+
*/}
|
|
221
|
+
<meta charSet="UTF-8" />
|
|
222
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
223
|
+
<title>{title}</title>
|
|
224
|
+
{/*
|
|
225
|
+
import map 由 _loadReactPage 通过 props 传入,使 bare import 在浏览器端通过
|
|
226
|
+
esm.sh 自动解析。必须在所有 <script type="module"> 之前出现。
|
|
227
|
+
服务端:_importMapJson 有值 → 正常渲染。
|
|
228
|
+
客户端:同一值读自 __kebab_props__ → 渲染相同 → 水合匹配。
|
|
229
|
+
*/}
|
|
230
|
+
{_importMapJson && (
|
|
231
|
+
<script type="importmap" dangerouslySetInnerHTML={{ '__html': _importMapJson }} />
|
|
232
|
+
)}
|
|
233
|
+
{/*
|
|
234
|
+
Tailwind CSS CDN:开发和演示可直接使用。
|
|
235
|
+
生产环境建议用 `npx tailwindcss build` 输出 purged CSS,
|
|
236
|
+
然后改为:<link rel="stylesheet" href="/stc/css/app.css" />
|
|
237
|
+
*/}
|
|
238
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
239
|
+
</head>
|
|
240
|
+
<body className="bg-slate-50 min-h-screen font-sans">
|
|
241
|
+
<div className="max-w-3xl mx-auto px-4 py-10 space-y-6">
|
|
242
|
+
|
|
243
|
+
{/* ── 标题区(展示 SSR/水合状态) ── */}
|
|
244
|
+
<div>
|
|
245
|
+
<div className="flex gap-2 mb-2">
|
|
246
|
+
<Badge>SSR · Kebab</Badge>
|
|
247
|
+
{/* hydrated 在 SSR 阶段为 false,水合完成后 useEffect 将其设为 true */}
|
|
248
|
+
<Badge variant={hydrated ? 'success' : 'warn'}>
|
|
249
|
+
{hydrated ? 'Hydrated ✓' : 'Rendering...'}
|
|
250
|
+
</Badge>
|
|
251
|
+
</div>
|
|
252
|
+
<h1 className="text-2xl font-bold text-slate-900">{title}</h1>
|
|
253
|
+
<p className="text-slate-500 text-sm mt-1">{serverTime} · {node}</p>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{/* ── Tab 导航(客户端状态路由,无页面刷新) ── */}
|
|
257
|
+
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
|
|
258
|
+
{(['overview', 'routing', 'fetch'] as const).map(t => (
|
|
259
|
+
<button
|
|
260
|
+
key={t}
|
|
261
|
+
onClick={() => setTab(t)}
|
|
262
|
+
className={[
|
|
263
|
+
'px-4 py-2 rounded-md text-sm font-medium transition-colors cursor-pointer',
|
|
264
|
+
tab === t ? 'bg-white shadow text-slate-900' : 'text-slate-500 hover:text-slate-900',
|
|
265
|
+
].join(' ')}
|
|
266
|
+
>
|
|
267
|
+
{t.charAt(0).toUpperCase() + t.slice(1)}
|
|
268
|
+
</button>
|
|
269
|
+
))}
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{/* ══ Overview Tab ══ */}
|
|
273
|
+
{tab === 'overview' && (
|
|
274
|
+
<div className="space-y-4">
|
|
275
|
+
|
|
276
|
+
{/* useState 计数器 */}
|
|
277
|
+
<Card>
|
|
278
|
+
<h2 className="font-semibold text-slate-900 mb-1">Counter(useState + hydration)</h2>
|
|
279
|
+
<p className="text-slate-500 text-xs mb-4">
|
|
280
|
+
SSR 渲染初始值 0,props 序列化为内联 JSON。
|
|
281
|
+
水合完成后 state 变为可交互,点击按钮测试:
|
|
282
|
+
</p>
|
|
283
|
+
<div className="flex items-center gap-4">
|
|
284
|
+
<button
|
|
285
|
+
onClick={() => setCount(c => c - 1)}
|
|
286
|
+
className="w-10 h-10 rounded-lg border border-slate-300 hover:bg-slate-50 font-bold text-slate-700 text-xl cursor-pointer"
|
|
287
|
+
>−</button>
|
|
288
|
+
<span className="text-3xl font-bold text-slate-900 w-10 text-center tabular-nums">{count}</span>
|
|
289
|
+
<button
|
|
290
|
+
onClick={() => setCount(c => c + 1)}
|
|
291
|
+
className="w-10 h-10 rounded-lg bg-blue-500 hover:bg-blue-600 text-white font-bold text-xl cursor-pointer"
|
|
292
|
+
>+</button>
|
|
293
|
+
</div>
|
|
294
|
+
</Card>
|
|
295
|
+
|
|
296
|
+
{/* Server Props 展示 */}
|
|
297
|
+
<Card>
|
|
298
|
+
<h2 className="font-semibold text-slate-900 mb-3">Server Props(来自 Ctr 方法)</h2>
|
|
299
|
+
<p className="text-slate-500 text-xs mb-3">
|
|
300
|
+
通过{' '}
|
|
301
|
+
<code className="bg-slate-100 px-1 rounded">_loadReactPage(path, props)</code>{' '}
|
|
302
|
+
传入,框架自动注入 _urlBase 等常量,
|
|
303
|
+
整体序列化为内联 JSON,客户端水合时直接复用,无需二次请求。
|
|
304
|
+
</p>
|
|
305
|
+
<div className="space-y-2">
|
|
306
|
+
{([
|
|
307
|
+
['serverTime', serverTime],
|
|
308
|
+
['node', node],
|
|
309
|
+
['_urlBase', _urlBase],
|
|
310
|
+
['_urlStc', _urlStc],
|
|
311
|
+
] as const).map(([k, v]) => (
|
|
312
|
+
<div key={k} className="flex text-xs gap-4">
|
|
313
|
+
<span className="text-slate-400 font-mono w-28 shrink-0">{k}</span>
|
|
314
|
+
<span className="font-mono text-slate-700 truncate">{v}</span>
|
|
315
|
+
</div>
|
|
316
|
+
))}
|
|
317
|
+
</div>
|
|
318
|
+
</Card>
|
|
319
|
+
|
|
320
|
+
{/* shadcn/ui 兼容说明 */}
|
|
321
|
+
<Card>
|
|
322
|
+
<h2 className="font-semibold text-slate-900 mb-2">shadcn/ui 兼容性</h2>
|
|
323
|
+
<p className="text-slate-500 text-xs mb-3">
|
|
324
|
+
shadcn/ui = Tailwind + Radix UI。本页的 Card/Badge/Btn 是等效的轻量实现。
|
|
325
|
+
使用官方 shadcn/ui 组件时,其 Radix UI 依赖由 esm.sh 递归解析,
|
|
326
|
+
与打包工具的处理结果完全等价,无需本地 npm install。
|
|
327
|
+
</p>
|
|
328
|
+
<div className="flex flex-wrap gap-2">
|
|
329
|
+
<Badge variant="success">Card ✓</Badge>
|
|
330
|
+
<Badge variant="success">Badge ✓</Badge>
|
|
331
|
+
<Badge variant="success">Btn ✓</Badge>
|
|
332
|
+
<Badge>Radix UI Dialog 同样支持</Badge>
|
|
333
|
+
</div>
|
|
334
|
+
</Card>
|
|
335
|
+
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
|
|
339
|
+
{/* ══ Routing Tab ══ */}
|
|
340
|
+
{tab === 'routing' && (
|
|
341
|
+
/*
|
|
342
|
+
MemoryRouter:路由状态保存在内存中,服务端 SSR 和客户端水合均可运行,
|
|
343
|
+
无需服务端配置 catch-all 路由,适合在页面内嵌入独立的路由演示。
|
|
344
|
+
若要整页接管 URL,改用 BrowserRouter(需服务端对所有子路径返回同一页面)。
|
|
345
|
+
*/
|
|
346
|
+
<MemoryRouter>
|
|
347
|
+
<Card>
|
|
348
|
+
<h2 className="font-semibold text-slate-900 mb-1">React Router 演示</h2>
|
|
349
|
+
<p className="text-slate-500 text-xs mb-4">
|
|
350
|
+
使用 <code className="bg-slate-100 px-1 rounded">MemoryRouter</code>
|
|
351
|
+
{' '}演示路由跳转、动态参数(useParams)和编程导航(useNavigate),
|
|
352
|
+
服务端 SSR 与客户端水合均可运行,无需额外服务端配置。
|
|
353
|
+
</p>
|
|
354
|
+
{/* ── 路由定义 ── */}
|
|
355
|
+
<Routes>
|
|
356
|
+
<Route path="/" element={<RouterHome />} />
|
|
357
|
+
<Route path="/about" element={<RouterAbout />} />
|
|
358
|
+
<Route path="/user/:id" element={<RouterUser />} />
|
|
359
|
+
</Routes>
|
|
360
|
+
</Card>
|
|
361
|
+
|
|
362
|
+
{/* 整页 BrowserRouter 配置说明 */}
|
|
363
|
+
<Card className="mt-4">
|
|
364
|
+
<h2 className="font-semibold text-slate-900 mb-2">整页 BrowserRouter 配置</h2>
|
|
365
|
+
<p className="text-slate-500 text-xs mb-3">
|
|
366
|
+
若要让 React Router 接管整个页面的真实 URL(如 /app、/app/about),
|
|
367
|
+
将 MemoryRouter 替换为 BrowserRouter,并在服务端将所有子路径路由到同一 Ctr 方法:
|
|
368
|
+
</p>
|
|
369
|
+
<pre className="bg-slate-50 border border-slate-200 rounded-lg p-4 text-xs overflow-auto leading-relaxed text-slate-700">{`// 1. route.json:将所有子路径指向同一 Ctr 方法
|
|
370
|
+
{
|
|
371
|
+
"app": "ctr/app@reactPage",
|
|
372
|
+
"app\\/.*": "ctr/app@reactPage"
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 2. 客户端(浏览器):BrowserRouter 自动读取当前 URL
|
|
376
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
377
|
+
hydrateRoot(document,
|
|
378
|
+
createElement(BrowserRouter, { basename: '/app' },
|
|
379
|
+
createElement(App, props)));
|
|
380
|
+
|
|
381
|
+
// 3. 服务端 SSR:StaticRouter 使用请求 URL,避免水合差异
|
|
382
|
+
import { StaticRouter } from 'react-router-dom/server';
|
|
383
|
+
renderToString(
|
|
384
|
+
createElement(StaticRouter, { location: reqPath },
|
|
385
|
+
createElement(App, props)));`}
|
|
386
|
+
</pre>
|
|
387
|
+
</Card>
|
|
388
|
+
</MemoryRouter>
|
|
389
|
+
)}
|
|
390
|
+
|
|
391
|
+
{/* ══ Fetch Tab ══ */}
|
|
392
|
+
{tab === 'fetch' && (
|
|
393
|
+
<Card>
|
|
394
|
+
<h2 className="font-semibold text-slate-900 mb-3">Client Fetch Demo</h2>
|
|
395
|
+
<p className="text-slate-500 text-xs mb-4">
|
|
396
|
+
水合完成后,点击按钮发起 GET 请求。setState 触发局部重渲染,无页面刷新。
|
|
397
|
+
</p>
|
|
398
|
+
<div className="flex gap-3 flex-wrap">
|
|
399
|
+
<Btn
|
|
400
|
+
onClick={() => doFetch(`${_urlBase}test/json?type=4`)}
|
|
401
|
+
disabled={isFetching}
|
|
402
|
+
>
|
|
403
|
+
{isFetching ? 'Fetching...' : 'GET /test/json?type=4'}
|
|
404
|
+
</Btn>
|
|
405
|
+
<Btn
|
|
406
|
+
outline
|
|
407
|
+
onClick={() => doFetch(`${_urlBase}test/json?type=2`)}
|
|
408
|
+
disabled={isFetching}
|
|
409
|
+
>
|
|
410
|
+
GET type=2 (error resp)
|
|
411
|
+
</Btn>
|
|
412
|
+
</div>
|
|
413
|
+
{fetchResult !== null ? (
|
|
414
|
+
<pre className="mt-4 bg-slate-50 border border-slate-200 rounded-lg p-4 text-xs overflow-auto leading-relaxed text-slate-700">
|
|
415
|
+
{fetchResult}
|
|
416
|
+
</pre>
|
|
417
|
+
) : (
|
|
418
|
+
<p className="mt-3 text-slate-400 text-xs">Click a button above to see the response</p>
|
|
419
|
+
)}
|
|
420
|
+
</Card>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
</div>
|
|
424
|
+
{/*
|
|
425
|
+
__kebab_props__:存储 fullProps JSON 供水合脚本读取。放在 <body> 末尾以确保
|
|
426
|
+
JSX 树结构与 renderToString 输出、浏览器 DOM 三者一致,避免水合错误 #418。
|
|
427
|
+
服务端:_propsJson 有值(fullProps 的完整 JSON)。
|
|
428
|
+
客户端:_propsJson 为 undefined(本身不在 JSON 中),渲染空串。
|
|
429
|
+
suppressHydrationWarning:跳过此元素的文本内容比对。
|
|
430
|
+
*/}
|
|
431
|
+
<script
|
|
432
|
+
id="__kebab_props__"
|
|
433
|
+
type="application/json"
|
|
434
|
+
suppressHydrationWarning
|
|
435
|
+
dangerouslySetInnerHTML={{ '__html': _propsJson ?? '' }}
|
|
436
|
+
/>
|
|
437
|
+
{/*
|
|
438
|
+
水合脚本:读取 __kebab_props__ → hydrateRoot(document, createElement(App, p))。
|
|
439
|
+
服务端:_hydrateScript 有值 → 渲染此 script 元素。
|
|
440
|
+
客户端:同一值读自 __kebab_props__ → 渲染相同 → 水合匹配。
|
|
441
|
+
*/}
|
|
442
|
+
{_hydrateScript && (
|
|
443
|
+
<script type="module" dangerouslySetInnerHTML={{ '__html': _hydrateScript }} />
|
|
444
|
+
)}
|
|
445
|
+
</body>
|
|
446
|
+
</html>
|
|
447
|
+
);
|
|
448
|
+
}
|