weifuwu 0.55.3 → 0.56.1

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 CHANGED
@@ -9,7 +9,7 @@ npm install weifuwu
9
9
  一个包 = 后端 (`weifuwu`) + 前端 (`weifuwu/client`) + 组件库 (`weifuwu/components`) + 布局系统 (`weifuwu/layout`)。
10
10
 
11
11
  > ⚠️ **注意:前后端都有 `ctx.ui`,但用途完全不同**
12
- > - **后端** `ctx.ui`(SSR/编译):`ctx.ui.html`(HTML 模板)、`ctx.ui.js`(TSX→JS 动态编译)、`ctx.ui.css`(CSS 编译)
12
+ > - **后端** `ctx.ui`(SSR/编译):`ctx.ui.html`(HTML 模板)、`ctx.ui.js`(TSX→JS 动态编译)、`ctx.ui.css`(CSS 编译)、`ctx.ui.ssr`(组件 SSR)、`ctx.ui.ssrData`(数据序列化)
13
13
  > - **前端** `ctx.ui`(渲染引擎):`ctx.ui.$()`(响应式状态)、`ctx.ui.render()` / `dirty()`(渲染控制)、`useMedia()` / `useBreakpoint()` / `usePopupPosition()`(浏览器事件监听)
14
14
  > 后端的是「把页面和代码交给浏览器」,前端的是「在浏览器里驱动 UI」。
15
15
 
@@ -29,49 +29,111 @@ npm install weifuwu
29
29
 
30
30
  **SSR + 动态编译** — 后端 `ctx.ui.js()` 用 esbuild 实时编译 TSX,开发时改代码即刷即用,零构建步骤。
31
31
 
32
+ **async 工厂组件** — `async (ctx) => (initProps, ctx) => (props) => VNode`:工厂层声明数据(`await ctx.data.get`)、mount 初始化状态(`$`)、render 输出视图。异步只在工厂边界,mount/render 保持同步;数据经闭包注入,写数据像写同步代码。
33
+
34
+ **SPA/SSR/Hydration 统一透明** — 同一份路由定义(`routes`)一个组件形态三场景自动适配:后端 `uiSsr({ routes })` 匹配即自动 SSR(完整 HTML + `__DATA__`),客户端 `router({ routes })` + `RouteView` + `mount(..., { hydrate: true })` 按 URL 同源匹配并收养服务端 HTML(不重建、无闪跳)。`ctx.data.get` 一个 API:SSR 预取 / hydration 命中(不重复请求)/ SPA 触发 fetch。服务端直接用 `.tsx`(`weifuwu/dev` Node loader),前后端同一 JSX 运行时。
35
+
32
36
  ---
33
37
 
34
38
  ## 快速开始
35
39
 
40
+ 两种模式,**组件和路由的写法完全一样**,差异只有后端/客户端入口两行:
41
+
42
+ | 模式 | 适用场景 | 后端 | 客户端入口 |
43
+ |------|---------|------|-----------|
44
+ | **SPA** | 应用页(Dashboard、工具、后台) | HTML 外壳 | `mount('#root', RouteView)` |
45
+ | **SSR + Hydration** | 内容页(博客、营销,需要 SEO/首屏) | `uiSsr` 一行 | `mount('#root', RouteView, { hydrate: true })` |
46
+
47
+ ### 先写共享部分(两种模式都一样)
48
+
49
+ ```tsx
50
+ // routes.tsx —— 页面声明(前后端共用)
51
+ import type { RouteDef } from 'weifuwu/client'
52
+ import { asyncComponent } from 'weifuwu/client'
53
+
54
+ // async 工厂组件:await 数据 → 返回视图(两阶段:外层初始化,内层渲染)
55
+ const Home = asyncComponent(async (ctx) => {
56
+ const msg = await ctx.data.get('/api/hello') // 数据管道:一个 API 三场景
57
+ return (_init, ctx) =>
58
+ (props) => <h1>{msg.msg}</h1>
59
+ })
60
+
61
+ export const routes: RouteDef[] = [{ path: '/', component: Home }]
62
+ ```
63
+
64
+ ### 模式 A:纯 SPA
65
+
36
66
  ```ts
37
67
  // server.ts
38
- import { serve, Router, ui, cors, serveStatic } from 'weifuwu'
68
+ import { serve, Router, ui, cors } from 'weifuwu'
39
69
 
40
70
  const app = new Router()
41
71
  app.use(cors())
42
- app.use(ui())
72
+ app.use(ui()) // 注入 ctx.ui.html / ctx.ui.js / ctx.ui.css
43
73
 
44
- // SPA 入口
74
+ // SPA 外壳(空 root + 前端 bundle)
45
75
  app.get('/', (req, ctx) => ctx.ui.html`
46
76
  <!doctype html><html><body>
47
77
  <div id="root"></div>
48
- <script src="/app.js"></script>
78
+ <script src="/static/app.js"></script>
49
79
  </body></html>
50
80
  `)
81
+ app.get('/static/app.js', (req, ctx) => ctx.ui.js('./src/client.ts'))
82
+ app.get('/api/hello', () => Response.json({ msg: 'world' }))
83
+
84
+ serve(app, { port: 3000 })
85
+ ```
86
+
87
+ ```ts
88
+ // src/client.ts —— 纯客户端渲染
89
+ import { createApp, router, RouteView } from 'weifuwu/client'
90
+ import { routes } from './routes.tsx'
91
+
92
+ createApp().use(router({ routes })).mount('#root', RouteView)
93
+ ```
94
+
95
+ ### 模式 B:SSR + Hydration(内容页/SEO)
96
+
97
+ 同一份 `routes`、同一个组件,差异只在**后端加 `uiSsr` 一行、客户端加 `hydrate` 参数**:
51
98
 
52
- // 动态编译前端 TSX(零构建步骤)
53
- app.get('/app.js', (req, ctx) => ctx.ui.js('./src/main.tsx'))
54
- app.get('/style.css', (req, ctx) => ctx.ui.css('./src/style.css'))
99
+ ```ts
100
+ // server.ts —— 完整版(与模式 A 的差异:uiSsr 中间件 + 一条样式路由)
101
+ import { serve, Router, ui, uiSsr, cors } from 'weifuwu'
102
+ import { routes } from './routes.tsx'
103
+
104
+ const app = new Router()
105
+ app.use(cors())
106
+ app.use(ui())
107
+
108
+ // 路由级 SSR:GET 匹配 routes → 注入 ctx.route.params → await 组件工厂
109
+ // → 完整 HTML + __DATA__ + bundle/styles 引用(无需手写页面 handler)
110
+ app.use(uiSsr({ routes, bundle: '/static/app.js', styles: ['/static/style.css'] }))
55
111
 
56
- // API
112
+ app.get('/static/app.js', (req, ctx) => ctx.ui.js('./src/client.ts'))
113
+ app.get('/static/style.css', (req, ctx) => ctx.ui.css('./src/style.css'))
57
114
  app.get('/api/hello', () => Response.json({ msg: 'world' }))
58
115
 
59
116
  serve(app, { port: 3000 })
60
117
  ```
61
118
 
62
- ```tsx
63
- // src/main.tsx
119
+ ```ts
120
+ // src/client.ts —— 与模式 A 的唯一差异:hydrate: true(收养服务端 HTML,无闪跳)
64
121
  import { createApp, router, RouteView } from 'weifuwu/client'
65
- import type { Component } from 'weifuwu/client'
122
+ import { routes } from './routes.tsx'
66
123
 
67
- const Home: Component = () => () => <h1>Hello weifuwu</h1>
124
+ createApp().use(router({ routes })).mount('#root', RouteView, { hydrate: true })
125
+ ```
68
126
 
69
- createApp()
70
- .use(router({ routes: [{ path: '/', component: Home }] }))
71
- .mount('#root', () => () => <RouteView />) // 根组件也要两阶段:外层返回 render 函数
127
+ ### 启动(两种模式都一样)
128
+
129
+ ```json
130
+ // package.json —— 服务端直接跑 .tsx(零构建)
131
+ { "scripts": { "dev": "node --import weifuwu/dev server.ts" } }
72
132
  ```
73
133
 
74
- 运行 `node server.ts`,访问 `http://localhost:3000` 即可看到页面。后端 `ctx.ui.js()` 会实时编译 `src/main.tsx`,改代码刷新即生效,无需任何构建步骤。
134
+ - 访问页面:SPA 客户端渲染;SSR 页面内容直接进 HTML(`curl /` 可见,SEO)
135
+ - 改组件刷新即生效,无需构建步骤
136
+ - 完整可运行示例见 `apps/demo`(博客页 = SSR + Hydration,SPA 页 = 纯客户端)
75
137
 
76
138
  > 想**零后端、零构建**最快跑起来?直接跳到下面的「CDN 快速原型」。
77
139
 
@@ -167,6 +229,8 @@ createApp()
167
229
  | `weifuwu` | **postgres** | PostgreSQL 客户端(自研 PG v3 协议)→ `ctx.sql` | Router, DATABASE_URL |
168
230
  | `weifuwu` | **redis** | Redis 客户端(自研 RESP2 协议)→ `ctx.redis` | Router, REDIS_URL |
169
231
  | `weifuwu` | **ui** | SSR 渲染 + esbuild JS/CSS 动态编译 → `ctx.ui` | Router |
232
+ | `weifuwu` | **uiSsr** | 路由级 SSR:匹配 routes → 自动完整 HTML + `__DATA__` + bundle | Router, ui |
233
+ | `weifuwu/dev` | **dev loader** | Node loader:服务端直接跑 `.ts/.tsx`(`--import weifuwu/dev`) | esbuild |
170
234
  | `weifuwu` | **graphql** | GraphQL 端点(支持 GraphiQL) | Router |
171
235
  | `weifuwu` | **createMiddleware** | 类型安全中间件工厂 | — |
172
236
  | `weifuwu` | **ok / badRequest / …** | HTTP 响应辅助函数(ok/badRequest/... 等 12 个) | — |
@@ -174,6 +238,8 @@ createApp()
174
238
  | Router 方法 | **app.graphql()** | GraphQL 端点(支持 GraphiQL),Router 实例方法(无需单独 import) | Router |
175
239
  | `weifuwu/client` | **createApp** | 应用引导 + VDOM 渲染引擎 | — |
176
240
  | `weifuwu/client` | **router / RouteView** | 前端路由(history/hash 模式) | createApp |
241
+ | `weifuwu/client` | **asyncComponent** | async 工厂组件(形态 C):工厂层声明数据,mount/render 同步 | — |
242
+ | `weifuwu/client` | **ctx.data** | 数据管道:SSR 预取 / hydration 命中 / SPA fetch(`ctx.data.get`) | createApp |
177
243
  | `weifuwu/client` | **api / auth / ws** | HTTP 客户端 / 认证 / WebSocket 中间件 | createApp |
178
244
  | `weifuwu/client` | **i18n** | 国际化中间件(运行时切换语言) | createApp |
179
245
  | `weifuwu/client` | **ErrorBoundary** | 错误边界组件 | createApp |
@@ -186,6 +252,24 @@ createApp()
186
252
 
187
253
  ## 核心概念
188
254
 
255
+ ### 两阶段组件(新手必读:为什么是两层)
256
+
257
+ 组件 = `(initProps, ctx) => (props) => VNode`——**外层 = 初始化(只执行一次),内层 = 渲染(每次状态/props 变化时执行)**。类比:外层是对象的构造函数,内层是它的 render 方法。
258
+
259
+ ```tsx
260
+ const Counter = (_init, ctx) => {
261
+ // 外层(mount):只跑一次——初始化状态、订阅、定时器
262
+ const $ = ctx.ui.$()
263
+ $.count = 0
264
+ return (props) =>
265
+ // 内层(render):每次变化执行——读状态输出视图
266
+ <button onClick={() => $.count++}>{$.count}</button>
267
+ }
268
+ ```
269
+
270
+ > 为什么不是单层函数(React 风格)?单层函数每次渲染都执行整个函数体,需要 hooks 记忆机制来区分"初始化"和"渲染";两阶段用**位置即语义**——外层天生只跑一次,没有 hooks 规则、没有依赖数组、没有闭包陷阱。
271
+ > 异步数据用 `asyncComponent` 工厂(见下文):`async (ctx) => await ctx.data.get(...)` → 返回两阶段组件,数据经闭包注入。
272
+
189
273
  ### 中间件模式(前后端一致)
190
274
 
191
275
  ```
@@ -595,7 +679,7 @@ app.use(db)
595
679
 
596
680
  ## redis — Redis 客户端(自研)
597
681
 
598
- > **自研 RESP2 协议**(零第三方依赖)——连接/重连(断线 pending 拒绝、指数退避)/离线队列/管道/Pub-Sub(订阅断线自动重放)+ 消除 ioredis 高频痛点(TTL 参数顺序、JSON 手动序列化、缓存样板)。
682
+ > **自研 RESP2 协议**(零第三方依赖)——连接/重连(断线 pending 拒绝、指数退避)/离线队列/管道/Pub-Sub(订阅断线自动重放)+ 消除 ioredis 高频痛点(TTL 参数顺序、JSON 手动序列化、缓存样板)。**二进制安全**:`getBuffer(key)` 原样返回字节(缓存序列化 payload 不损坏)。
599
683
 
600
684
  ```ts
601
685
  import { redis } from 'weifuwu'
@@ -679,6 +763,8 @@ app.use(ui())
679
763
  | `ctx.ui.html.unsafe(str)` | `(string) => string` | 插入原始 HTML |
680
764
  | `ctx.ui.js(entryPath)` | `(string) => Promise<Response>` | esbuild 编译 TSX → JS bundle |
681
765
  | `ctx.ui.css(entryPath)` | `(string) => Promise<Response>` | 读取 CSS 文件 → CSS Response(如安装 postcss + @tailwindcss/postcss 则自动编译) |
766
+ | `ctx.ui.ssr(Comp, props?, { data })` | `(Component, props, opts?) => Promise<string>` | 服务端渲染组件 → HTML 片段(async 工厂自动 await;HtmlSafe 内联不二次转义) |
767
+ | `ctx.ui.ssrData(data)` | `(Map) => string` | 序列化 SSR 数据 → `<script>window.__DATA__=...</script>`(`<` 转义防 XSS) |
682
768
 
683
769
  ### ctx.ui.html — HTML 模板
684
770
 
@@ -715,6 +801,98 @@ app.get('/style.css', (req, ctx) => ctx.ui.css('weifuwu/components/style.css'))
715
801
  - 支持包名(`weifuwu/layout/style.css`, `weifuwu/components/style.css`)或文件路径
716
802
  - 带 mtime 缓存验证(开发时编辑文件后自动失效)
717
803
 
804
+ ### ctx.ui.ssr — SSR 渲染组件 → HTML
805
+
806
+ 将组件(含 async 工厂组件)在服务端渲染为完整 HTML 片段,数据经 `ctx.data` 预取并序列化进 `window.__DATA__`(客户端 hydration 时同步命中,不重跑请求):
807
+
808
+ ```ts
809
+ const BlogPage = asyncComponent(async (ctx) => {
810
+ const post = await ctx.data.get(`/api/posts/${ctx.params.slug}`, fetchPost)
811
+ return (_init, ctx) => () =>
812
+ h('article', {},
813
+ h('h1', {}, post.title),
814
+ h('div', { innerHTML: post.body }),
815
+ )
816
+ })
817
+
818
+ app.get('/blog/:slug', async (req, ctx) => {
819
+ const data = new Map()
820
+ const html = await ctx.ui.ssr(BlogPage, {}, { data }) // HtmlSafe:模板内联不二次转义
821
+ return ctx.ui.html`
822
+ <!DOCTYPE html>
823
+ <html><body>
824
+ <div id="root">${html}</div>
825
+ ${ctx.ui.ssrData(data)}
826
+ <script src="/static/app.js"></script>
827
+ </body></html>
828
+ `
829
+ })
830
+ ```
831
+
832
+ - 事件处理器/ref 剥离,文本自动转义(XSS),`class`/`style` 对象序列化,`innerHTML` 原样输出
833
+ - Fragment/Portal 子节点就地内联
834
+ - `ctx.ui.ssrData(data)` 输出 `<script>window.__DATA__=...</script>`(JSON `<` 转义防 XSS)
835
+ - 服务端 ctx shim:`$`(dirty no-op)、`ctx.data` 预取去重、`selfId` 请求级隔离
836
+
837
+ ### Hydration — 客户端收养服务端 HTML
838
+
839
+ 服务端 HTML + `window.__DATA__`(ctx.data 种子)到达客户端后,`mount(..., { hydrate: true })` **收养现有 DOM**(不重建、不闪跳),只接线事件/ref/$:
840
+
841
+ ```ts
842
+ import { createApp } from 'weifuwu/client'
843
+
844
+ createApp()
845
+ .mount('#root', BlogPage, { hydrate: true }) // 容器已有服务端 HTML
846
+ ```
847
+
848
+ - **游标收养**:元素/文本按位置匹配现有 DOM;tag 不匹配 → 局部替换;文本不一致 → 就地修正;服务端多余节点 → 收尾清理
849
+ - **async 工厂 hydration**:工厂 `ctx.data.get` 从 `__DATA__` 同步命中(不重跑请求)→ 渲染与服务端一致 → 收养
850
+ - hydration 后 `$`/dirty/事件全量可用(与纯 SPA 无差别)
851
+ - 诚实裁剪:Portal 内容就地收养(不移动到 `#__wf_portal`);渲染期非确定性(Date/random)会导致 mismatch(dev 警告)
852
+
853
+ ### uiSsr — 路由级 SSR(声明即渲染)
854
+
855
+ 共享路由定义,前后端同一份声明——后端匹配即自动 SSR,无需手写 handler/模板/序列化:
856
+
857
+ ```tsx
858
+ // routes.tsx —— 前后端共用
859
+ import type { RouteDef } from 'weifuwu/client'
860
+ import { BlogPage } from './pages/BlogPage.tsx'
861
+
862
+ export const routes: RouteDef[] = [
863
+ { path: '/blog/:slug', component: BlogPage, title: '博客' },
864
+ ]
865
+
866
+ // server.ts —— 一行中间件:GET 匹配 → 注入 ctx.route.params → await 组件工厂 → 完整 HTML + __DATA__ + bundle
867
+ import { uiSsr } from 'weifuwu'
868
+ app.use(uiSsr({ routes, bundle: '/static/blog.js' }))
869
+
870
+ // blog-hydrate.ts —— 客户端:同一份 routes,router() 注入 ctx.route.params(两端同源)
871
+ createApp()
872
+ .use(router({ routes }))
873
+ .mount('#root', routes[0].component, { hydrate: true })
874
+ ```
875
+
876
+ - 组件工厂读 `ctx.route.params`(`/blog/:slug` → `ctx.route.params.slug`)——后端 uiSsr / 前端 router **同源注入**
877
+ - 未匹配 → next()(交给 API/静态/404);非 GET → next()
878
+ - 可自定义 `title` / `template`
879
+
880
+ ### weifuwu/dev — 服务端直接跑 .tsx
881
+
882
+ Node 原生 TS 只剥离类型(不支持 JSX)。`weifuwu/dev` 注册 esbuild loader,服务端直接跑 `.tsx`(零构建):
883
+
884
+ ```json
885
+ {
886
+ "scripts": {
887
+ "dev": "node --import weifuwu/dev server.ts",
888
+ "start": "node --import weifuwu/dev server.ts"
889
+ }
890
+ }
891
+ ```
892
+
893
+ - 前后端同一 JSX 运行时(`jsxImportSource: weifuwu/client`)→ 两端 VNode 一致 → hydration 可靠
894
+ - 与 `ctx.ui.js` 前端动态编译同一理念:无构建、无产物、改代码即生效
895
+
718
896
  ---
719
897
 
720
898
  ## graphql — GraphQL 端点
@@ -1466,6 +1644,32 @@ const UserProfile: Component = (initProps, ctx) => {
1466
1644
  }
1467
1645
  ```
1468
1646
 
1647
+ ### asyncComponent 工厂(形态 C)— 同步式数据声明
1648
+
1649
+ `async (ctx) => (initProps, ctx) => (props) => VNode` — 工厂层(async,只执行一次并缓存)声明数据/加载代码,mount/render 保持同步。数据经闭包注入组件,渲染无 loading 分支:
1650
+
1651
+ ```tsx
1652
+ import { asyncComponent } from 'weifuwu/client'
1653
+
1654
+ const UserProfile = asyncComponent(async (ctx) => {
1655
+ const user = await ctx.data.get(`/api/user/${ctx.params.id}`)
1656
+ return (_init, ctx) => {
1657
+ const $ = ctx.ui.$()
1658
+ $.liked = false // 客户端状态(交互后变化)
1659
+ return (props) =>
1660
+ h('div', {},
1661
+ h('p', {}, user.name), // 服务端状态(闭包,SSR 进 HTML)
1662
+ h('button', { onClick: () => $.liked = !$.liked }, $.liked ? '❤️' : '🤍'),
1663
+ )
1664
+ }
1665
+ })
1666
+ ```
1667
+
1668
+ - **客户端**:首次渲染占位 → 工厂 resolve 后整树重渲染补全(SPA);数据经 `ctx.data` 缓存(hydration 时从 `__DATA__` 同步命中,不重跑请求)
1669
+ - **服务端**:`ctx.ui.ssr()` 直接 await 工厂 → 数据进 HTML(无占位)
1670
+ - 工厂缓存绑定页面上下文:路由导航/登录登出时自动失效,工厂以新 ctx 重新执行
1671
+ - 会变的数据:初始值 seed 自服务端数据(`$.count = data.count`),交互改 `$`;初始状态必须确定性(禁止 `window.innerWidth` 直接初始化 → SSR/hydration mismatch)
1672
+
1469
1673
  ---
1470
1674
 
1471
1675
  ## router + RouteView — 前端路由
@@ -19,6 +19,8 @@ import type { Component } from './vnode.ts';
19
19
  /** 应用句柄:use() 链式累积中间件注入的 ctx 类型,mount() 时组件拿到完整注入 */
20
20
  export interface App<C extends object = {}> {
21
21
  use<I extends object, O extends object>(mw: AppMiddleware<I, O>): App<C & O>;
22
- mount(rootSelector: string, root: Component<any, C>): Promise<void>;
22
+ mount(rootSelector: string, root: Component<any, C>, options?: {
23
+ hydrate?: boolean;
24
+ }): Promise<void>;
23
25
  }
24
26
  export declare function createApp<C extends object = {}>(): App<C>;
@@ -16,7 +16,8 @@
16
16
  * WfuiContext / AppMiddleware / RouteDef → 类型
17
17
  */
18
18
  export { h, jsx, jsxs, jsxDEV, Fragment, Portal, createPortal } from './vnode.ts';
19
- export type { VNode, VNodeType, Component } from './vnode.ts';
19
+ export type { VNode, VNodeType, Component, AsyncComponent } from './vnode.ts';
20
+ export { asyncComponent, isAsyncComponent } from './vnode.ts';
20
21
  export { createApp } from './app.ts';
21
22
  export type { App } from './app.ts';
22
23
  export { router, RouteView } from './router.ts';