weifuwu 0.36.0 → 0.36.2
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 +358 -599
- package/dist/client/index.js +21 -12
- package/dist/client/vnode.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,45 +1,33 @@
|
|
|
1
1
|
# weifuwu
|
|
2
2
|
|
|
3
|
-
**全栈框架 — 后端 `(req, ctx) => Response` + 前端 `(props, ctx) =>
|
|
3
|
+
**全栈框架 — 后端 `(req, ctx) => Response` + 前端 `(props, ctx) => VNode` + 纯 CSS 布局系统**
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install weifuwu
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
一个包,零上游依赖。后端提供 HTTP 路由、数据库、中间件;前端提供 VDOM + Proxy 驱动的前端框架;布局提供纯 CSS 原语 + 主题 Token。
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
## 模块总览
|
|
14
14
|
|
|
15
|
-
| 模块 |
|
|
16
|
-
|
|
17
|
-
| **Router** | `
|
|
18
|
-
| **serve** | `
|
|
19
|
-
| **cors** | `
|
|
20
|
-
| **serveStatic** | `
|
|
21
|
-
| **postgres** | `
|
|
22
|
-
| **redis** | `
|
|
23
|
-
| **ui** | `
|
|
24
|
-
| **graphql** | `
|
|
25
|
-
| **client** |
|
|
26
|
-
| **layout** |
|
|
27
|
-
|
|
28
|
-
**前端 `weifuwu/client` 模块总览:**
|
|
29
|
-
|
|
30
|
-
| 类别 | 导出 | 用途 |
|
|
31
|
-
|------|------|------|
|
|
32
|
-
| **渲染引擎** | `VNode`, `Component` | 虚拟 DOM 节点与组件类型 | — |
|
|
33
|
-
| **JSX 运行时** | `jsx`/`jsxs`/`jsxDEV`, `Fragment` | TSX 编译目标 | — |
|
|
34
|
-
| **应用** | `createApp` | 中间件链 + 挂载 | — |
|
|
35
|
-
| **路由** | `router`, `RouteView` | 嵌套布局路由 | — |
|
|
36
|
-
| **中间件** | `ws`, `api`, `auth` | WebSocket / HTTP 客户端 / 认证状态 | — |
|
|
37
|
-
| **工具** | `extendCtx` | ctx 扩展 | — |
|
|
38
|
-
| **类型** | `WfuiContext`, `AppMiddleware`, `RouteDef`, `VNodeType`, `Component`, `ApiClient`, `AuthClient` | — | — |
|
|
15
|
+
| 模块 | 导入路径 | 用途 | 依赖 |
|
|
16
|
+
|------|---------|------|------|
|
|
17
|
+
| **Router** | `weifuwu` | HTTP 路由 + 中间件链 + WebSocket + GraphQL | — |
|
|
18
|
+
| **serve** | `weifuwu` | HTTP 服务器 | `Router` |
|
|
19
|
+
| **cors** | `weifuwu` | CORS 跨域中间件 | `Router` |
|
|
20
|
+
| **serveStatic** | `weifuwu` | 静态文件服务 | `Router` |
|
|
21
|
+
| **postgres** | `weifuwu` | PostgreSQL 客户端 → `ctx.sql` | `Router` |
|
|
22
|
+
| **redis** | `weifuwu` | Redis 客户端 → `ctx.redis` | `Router` |
|
|
23
|
+
| **ui** | `weifuwu` | SSR 渲染 + 动态 JS/CSS 编译 → `ctx.ui` | `Router` |
|
|
24
|
+
| **graphql** | `weifuwu` | GraphQL 端点 | `Router` |
|
|
25
|
+
| **client** | `weifuwu/client` | 前端 VDOM + Proxy 框架 | — |
|
|
26
|
+
| **layout** | `weifuwu/layout` | 纯 CSS 布局原语 + 主题 Token | — |
|
|
39
27
|
|
|
40
28
|
---
|
|
41
29
|
|
|
42
|
-
##
|
|
30
|
+
## 核心理念
|
|
43
31
|
|
|
44
32
|
前后端共享同一模式:**中间件向 `ctx` 注入字段,handler/组件从 `ctx` 读取。**
|
|
45
33
|
|
|
@@ -55,80 +43,44 @@ npm install weifuwu
|
|
|
55
43
|
|
|
56
44
|
---
|
|
57
45
|
|
|
58
|
-
## 快速开始
|
|
46
|
+
## 快速开始 —— 全栈应用
|
|
59
47
|
|
|
60
48
|
```ts
|
|
61
|
-
|
|
62
|
-
import { serve, Router, cors, ui } from 'weifuwu'
|
|
49
|
+
import { serve, Router, ui } from 'weifuwu'
|
|
63
50
|
|
|
64
51
|
const app = new Router()
|
|
65
|
-
app.use(cors())
|
|
66
52
|
app.use(ui())
|
|
67
53
|
|
|
68
|
-
//
|
|
69
|
-
app.get('/
|
|
70
|
-
const posts = [{ id: 1, title: 'Hello' }]
|
|
71
|
-
return Response.json(posts)
|
|
72
|
-
})
|
|
54
|
+
// 前端 TSX → JS bundle(动态编译,零构建步骤)
|
|
55
|
+
app.get('/app.js', async (req, ctx) => ctx.ui.js('./src/main.tsx'))
|
|
73
56
|
|
|
74
|
-
//
|
|
75
|
-
app.
|
|
76
|
-
open(ws) { ws.send(JSON.stringify({ type: 'system', body: 'connected' })) },
|
|
77
|
-
message(ws, ctx, data) {
|
|
78
|
-
const msg = JSON.parse(data.toString())
|
|
79
|
-
ws.send(JSON.stringify({ type: 'echo', body: msg.body }))
|
|
80
|
-
},
|
|
81
|
-
})
|
|
57
|
+
// CSS(PostCSS + Tailwind 编译)
|
|
58
|
+
app.get('/style.css', async (req, ctx) => ctx.ui.css('./src/style.css'))
|
|
82
59
|
|
|
83
|
-
// SPA 入口
|
|
60
|
+
// SPA 入口
|
|
84
61
|
app.get('/', async (req, ctx) => ctx.ui.html`
|
|
85
62
|
<!DOCTYPE html>
|
|
86
|
-
<html
|
|
87
|
-
<
|
|
63
|
+
<html>
|
|
64
|
+
<head><link rel="stylesheet" href="/style.css"></head>
|
|
65
|
+
<body><div id="root"></div><script src="/app.js"></script></body>
|
|
66
|
+
</html>
|
|
88
67
|
`)
|
|
89
|
-
app.get('/static/app.js', async (req, ctx) => ctx.ui.js('./src/main.tsx'))
|
|
90
68
|
|
|
91
69
|
serve(app, { port: 3000 })
|
|
92
70
|
```
|
|
93
71
|
|
|
94
72
|
```tsx
|
|
95
|
-
// src/main.tsx
|
|
96
|
-
import { createApp, router, RouteView
|
|
73
|
+
// src/main.tsx —— 前端
|
|
74
|
+
import { createApp, router, RouteView } from 'weifuwu/client'
|
|
97
75
|
import type { WfuiContext, RouteDef } from 'weifuwu/client'
|
|
98
76
|
|
|
99
|
-
// 组件 = (props, ctx) => VNode
|
|
100
77
|
function Home(_props: {}, ctx: WfuiContext) {
|
|
101
|
-
|
|
102
|
-
if (!ctx.ui.ready) { $.items = [{ id: 1, text: 'hello' }] }
|
|
103
|
-
return <div>{$.items.map((i: any) => <div key={i.id}>{i.text}</div>)}</div>
|
|
78
|
+
return <h1>Hello weifuwu</h1>
|
|
104
79
|
}
|
|
105
80
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
app.use(ws())
|
|
110
|
-
app.use(router({
|
|
111
|
-
routes: [
|
|
112
|
-
{ path: '/', component: Home },
|
|
113
|
-
{
|
|
114
|
-
path: '/dashboard',
|
|
115
|
-
layout: DashboardLayout,
|
|
116
|
-
],
|
|
117
|
-
},
|
|
118
|
-
],
|
|
119
|
-
mode: 'hash',
|
|
120
|
-
scrollRestoration: true,
|
|
121
|
-
}))
|
|
122
|
-
app.mount('#root', AppShell)
|
|
123
|
-
|
|
124
|
-
function AppShell(_props: {}, ctx: WfuiContext) {
|
|
125
|
-
return (
|
|
126
|
-
<div>
|
|
127
|
-
<nav>{/* ... */}</nav>
|
|
128
|
-
<main><RouteView /></main>
|
|
129
|
-
</div>
|
|
130
|
-
)
|
|
131
|
-
}
|
|
81
|
+
createApp()
|
|
82
|
+
.use(router({ routes: [{ path: '/', component: Home }], mode: 'history' }))
|
|
83
|
+
.mount('#root', () => <Home />)
|
|
132
84
|
```
|
|
133
85
|
|
|
134
86
|
---
|
|
@@ -138,110 +90,134 @@ function AppShell(_props: {}, ctx: WfuiContext) {
|
|
|
138
90
|
### Router
|
|
139
91
|
|
|
140
92
|
```ts
|
|
93
|
+
import { Router } from 'weifuwu'
|
|
94
|
+
|
|
141
95
|
const app = new Router()
|
|
142
96
|
|
|
143
|
-
//
|
|
144
|
-
app.
|
|
145
|
-
app.post / put / delete / patch / head / options(path, ...handlers)
|
|
146
|
-
app.all(path, ...handlers)
|
|
97
|
+
// 中间件
|
|
98
|
+
app.use(cors())
|
|
147
99
|
|
|
148
|
-
//
|
|
149
|
-
app.
|
|
150
|
-
|
|
100
|
+
// 路由
|
|
101
|
+
app.get('/api/users', async (req: Request, ctx: Context) => {
|
|
102
|
+
return Response.json(users)
|
|
103
|
+
})
|
|
151
104
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
105
|
+
app.post('/api/users', async (req: Request, ctx: Context) => {
|
|
106
|
+
const body = await req.json()
|
|
107
|
+
return Response.json({ id: 1, ...body }, { status: 201 })
|
|
108
|
+
})
|
|
156
109
|
|
|
157
|
-
//
|
|
158
|
-
app.
|
|
110
|
+
// URL 参数
|
|
111
|
+
app.get('/users/:id', async (req: Request, ctx: Context) => {
|
|
112
|
+
const id = ctx.params.id
|
|
113
|
+
return Response.json({ id, name: 'User ' + id })
|
|
114
|
+
})
|
|
159
115
|
```
|
|
160
116
|
|
|
161
|
-
| 方法 |
|
|
117
|
+
| 方法 | 路由 | 说明 |
|
|
162
118
|
|------|------|------|
|
|
163
|
-
| `get
|
|
164
|
-
| `
|
|
165
|
-
| `
|
|
166
|
-
| `
|
|
167
|
-
| `
|
|
168
|
-
| `
|
|
169
|
-
| `
|
|
170
|
-
| `
|
|
171
|
-
|
|
172
|
-
|
|
119
|
+
| `app.get(path, handler)` | 任意 | GET 请求 |
|
|
120
|
+
| `app.post(path, handler)` | 任意 | POST 请求 |
|
|
121
|
+
| `app.put(path, handler)` | 任意 | PUT 请求 |
|
|
122
|
+
| `app.patch(path, handler)` | 任意 | PATCH 请求 |
|
|
123
|
+
| `app.delete(path, handler)` | 任意 | DELETE 请求 |
|
|
124
|
+
| `app.use(middleware)` | 全路由 | 中间件 |
|
|
125
|
+
| `app.ws(path, handler)` | 任意 | WebSocket |
|
|
126
|
+
| `app.graphql(options)` | 自动 | GraphQL 端点 |
|
|
127
|
+
| `app.onError(handler)` | 全局 | 错误处理 |
|
|
128
|
+
|
|
129
|
+
### serve —— HTTP 服务器
|
|
173
130
|
|
|
174
131
|
```ts
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
132
|
+
import { serve, Router } from 'weifuwu'
|
|
133
|
+
|
|
134
|
+
const router = new Router()
|
|
135
|
+
serve(router, { port: 3000 })
|
|
178
136
|
```
|
|
179
137
|
|
|
180
|
-
|
|
|
181
|
-
|
|
182
|
-
| `port` | `
|
|
183
|
-
| `hostname` | `'0.0.0.0'` | 监听地址 |
|
|
184
|
-
| `
|
|
185
|
-
| `
|
|
186
|
-
| `shutdown` | `true` |
|
|
138
|
+
| 参数 | 类型 | 默认值 | 说明 |
|
|
139
|
+
|------|------|--------|------|
|
|
140
|
+
| `port` | `number` | `0`(随机端口) | 监听端口 |
|
|
141
|
+
| `hostname` | `string` | `'0.0.0.0'` | 监听地址 |
|
|
142
|
+
| `timeout` | `number` | `30000` | 连接超时(ms)|
|
|
143
|
+
| `maxBodySize` | `number` | `10MB` | 请求体上限 |
|
|
144
|
+
| `shutdown` | `boolean` | `true` | 自动注册 SIGTERM/SIGINT |
|
|
187
145
|
|
|
188
|
-
### cors
|
|
146
|
+
### cors —— CORS 中间件
|
|
189
147
|
|
|
190
148
|
```ts
|
|
149
|
+
app.use(cors())
|
|
150
|
+
|
|
191
151
|
app.use(cors({
|
|
192
|
-
origin: ['https://example.com'],
|
|
193
|
-
|
|
152
|
+
origin: ['https://app.example.com'],
|
|
153
|
+
methods: ['GET', 'POST'],
|
|
194
154
|
}))
|
|
195
155
|
```
|
|
196
156
|
|
|
197
|
-
|
|
157
|
+
| 参数 | 默认值 |
|
|
158
|
+
|------|--------|
|
|
159
|
+
| `origin` | `*` |
|
|
160
|
+
| `methods` | `GET, POST, PUT, PATCH, DELETE, OPTIONS` |
|
|
161
|
+
| `allowedHeaders` | `Content-Type, Authorization` |
|
|
162
|
+
|
|
163
|
+
### serveStatic —— 静态文件
|
|
198
164
|
|
|
199
165
|
```ts
|
|
200
|
-
|
|
201
|
-
```
|
|
166
|
+
import { serveStatic } from 'weifuwu'
|
|
202
167
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
168
|
+
// 单一路径
|
|
169
|
+
app.get('/static/*', serveStatic('./public'))
|
|
170
|
+
|
|
171
|
+
// 多目录
|
|
172
|
+
app.get('/uploads/*', serveStatic('./uploads'))
|
|
173
|
+
```
|
|
207
174
|
|
|
208
|
-
### postgres
|
|
175
|
+
### postgres —— PostgreSQL
|
|
209
176
|
|
|
210
177
|
```ts
|
|
178
|
+
import { postgres } from 'weifuwu'
|
|
179
|
+
|
|
211
180
|
app.use(postgres())
|
|
212
|
-
// → ctx.sql`SELECT * FROM users`
|
|
213
181
|
|
|
214
|
-
|
|
215
|
-
|
|
182
|
+
// 然后在 handler 中使用 ctx.sql
|
|
183
|
+
app.get('/users', async (req, ctx) => {
|
|
184
|
+
const users = await ctx.sql`SELECT * FROM users`
|
|
185
|
+
return Response.json(users)
|
|
186
|
+
})
|
|
216
187
|
```
|
|
217
188
|
|
|
218
|
-
| 选项 |
|
|
219
|
-
|
|
220
|
-
| `url` | `DATABASE_URL` | 连接字符串 |
|
|
221
|
-
| `
|
|
222
|
-
|
|
223
|
-
依赖:需要 `postgres` npm 包。实现 `close(): Promise<void>`。
|
|
189
|
+
| 选项 | 类型 | 默认值 | 说明 |
|
|
190
|
+
|------|------|--------|------|
|
|
191
|
+
| `url` | `string` | `DATABASE_URL` 环境变量 | 连接字符串 |
|
|
192
|
+
| `max` | `number` | `10` | 连接池大小 |
|
|
224
193
|
|
|
225
|
-
### redis
|
|
194
|
+
### redis —— Redis
|
|
226
195
|
|
|
227
196
|
```ts
|
|
197
|
+
import { redis } from 'weifuwu'
|
|
198
|
+
|
|
228
199
|
app.use(redis())
|
|
229
|
-
|
|
230
|
-
//
|
|
231
|
-
|
|
200
|
+
|
|
201
|
+
// 使用 ctx.redis
|
|
202
|
+
app.get('/cache', async (req, ctx) => {
|
|
203
|
+
const cached = await ctx.redis.get('key')
|
|
204
|
+
return Response.json({ cached })
|
|
205
|
+
})
|
|
232
206
|
```
|
|
233
207
|
|
|
234
|
-
|
|
208
|
+
| 选项 | 类型 | 默认值 | 说明 |
|
|
209
|
+
|------|------|--------|------|
|
|
210
|
+
| `url` | `string` | `REDIS_URL` 环境变量 | 连接字符串 |
|
|
235
211
|
|
|
236
|
-
### ui
|
|
212
|
+
### ui —— SSR + SPA 渲染
|
|
237
213
|
|
|
238
214
|
```ts
|
|
239
215
|
app.use(ui())
|
|
240
216
|
|
|
241
217
|
// SSR 页面
|
|
242
|
-
app.get('/
|
|
243
|
-
|
|
244
|
-
<
|
|
218
|
+
app.get('/page', async (req, ctx) => ctx.ui.html`
|
|
219
|
+
<h1>${title}</h1>
|
|
220
|
+
<p>${body}</p>
|
|
245
221
|
`)
|
|
246
222
|
|
|
247
223
|
// 动态 JS 编译(esbuild,零构建步骤)
|
|
@@ -255,17 +231,17 @@ app.get('/style.css', async (req, ctx) => ctx.ui.css('./src/style.css'))
|
|
|
255
231
|
|------|------|
|
|
256
232
|
| `ctx.ui.html\`...\`` | 渲染 HTML 模板(转义变量防 XSS) |
|
|
257
233
|
| `ctx.ui.html.unsafe(str)` | 插入原始 HTML |
|
|
258
|
-
| `ctx.ui.js(entryPath)` |
|
|
259
|
-
| `ctx.ui.css(entryPath)` | 编译 CSS
|
|
234
|
+
| `ctx.ui.js(entryPath)` | 编译 TSX → JS bundle |
|
|
235
|
+
| `ctx.ui.css(entryPath)` | 编译 CSS(PostCSS + Tailwind) |
|
|
260
236
|
|
|
261
|
-
### graphql
|
|
237
|
+
### graphql —— GraphQL
|
|
262
238
|
|
|
263
239
|
```ts
|
|
264
|
-
app.graphql(
|
|
240
|
+
app.graphql({
|
|
265
241
|
schema: `type Query { hello: String }`,
|
|
266
242
|
resolvers: { Query: { hello: () => 'world' } },
|
|
267
243
|
graphiql: true,
|
|
268
|
-
})
|
|
244
|
+
})
|
|
269
245
|
```
|
|
270
246
|
|
|
271
247
|
### WebSocket
|
|
@@ -295,7 +271,6 @@ app.onError((err, req, ctx) => {
|
|
|
295
271
|
|---------|------|
|
|
296
272
|
| `HttpError` | HTTP 错误 `new HttpError(msg, status)` |
|
|
297
273
|
| `DEFAULT_MAX_BODY` | 默认请求体上限 10MB |
|
|
298
|
-
| `MIGRATIONS_TABLE` | Postgres 迁移表名 |
|
|
299
274
|
|
|
300
275
|
### 后端类型
|
|
301
276
|
|
|
@@ -305,9 +280,10 @@ app.onError((err, req, ctx) => {
|
|
|
305
280
|
|
|
306
281
|
## 前端 (`weifuwu/client`)
|
|
307
282
|
|
|
308
|
-
|
|
283
|
+
零外部依赖。组件模型:**纯函数 `(props, ctx) => VNode`**。
|
|
309
284
|
|
|
310
285
|
构建配置(esbuild):
|
|
286
|
+
|
|
311
287
|
```js
|
|
312
288
|
esbuild.build({
|
|
313
289
|
jsx: 'automatic',
|
|
@@ -316,428 +292,246 @@ esbuild.build({
|
|
|
316
292
|
})
|
|
317
293
|
```
|
|
318
294
|
|
|
319
|
-
###
|
|
320
|
-
|
|
321
|
-
`ctx.ui.$` 是**深度 Proxy**:任何属性/数组/对象层面的写入自动触发渲染,无需手动调用。
|
|
295
|
+
### 组件
|
|
322
296
|
|
|
323
297
|
```tsx
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
$.user = { name: 'alice' } // → 新值自动深度包装
|
|
329
|
-
|
|
330
|
-
// 数组突变
|
|
331
|
-
$.items.push(newItem) // → 自动渲染
|
|
332
|
-
$.items.pop() // → 自动渲染
|
|
333
|
-
$.items.splice(i, 1) // → 自动渲染
|
|
334
|
-
|
|
335
|
-
// 对象属性突变(数组内部也支持)
|
|
336
|
-
$.items[0].done = true // → 自动渲染
|
|
337
|
-
$.msgs[idx].content += event.text // → 自动渲染
|
|
298
|
+
// 组件 = 纯函数 (props, ctx) => VNode
|
|
299
|
+
function Greeting(props: { name: string }, _ctx: WfuiContext) {
|
|
300
|
+
return <div>Hello, {props.name}!</div>
|
|
301
|
+
}
|
|
338
302
|
|
|
339
|
-
//
|
|
340
|
-
|
|
303
|
+
// 使用
|
|
304
|
+
<Greeting name="world" />
|
|
341
305
|
```
|
|
342
306
|
|
|
343
|
-
|
|
344
|
-
|------|------|
|
|
345
|
-
| `ctx.ui.$` | 深度 Proxy 对象,所有写入自动触发渲染 |
|
|
346
|
-
| `ctx.ui.dirty()` | ⚠ 极少需要 — 仅当绕过 Proxy 直接操作底层对象时 |
|
|
307
|
+
### 状态 —— 深度 Proxy
|
|
347
308
|
|
|
348
|
-
|
|
309
|
+
`ctx.ui.$` 是**深度 Proxy**:任何属性/数组/对象写入自动触发渲染,无需手动调用。
|
|
349
310
|
|
|
350
311
|
```tsx
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
312
|
+
function Counter(_props: {}, ctx: WfuiContext) {
|
|
313
|
+
const $ = ctx.ui.$
|
|
314
|
+
if (!ctx.ui.ready) $.count = 0
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<div>
|
|
318
|
+
<span>{$.count}</span>
|
|
319
|
+
<button onClick={() => $.count++}>+</button>
|
|
320
|
+
</div>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
354
323
|
```
|
|
355
324
|
|
|
356
|
-
|
|
|
325
|
+
| API | 说明 |
|
|
357
326
|
|------|------|
|
|
358
|
-
| `
|
|
359
|
-
| `
|
|
360
|
-
|
|
361
|
-
|
|
327
|
+
| `ctx.ui.$` | 深度 Proxy,所有写入自动触发渲染 |
|
|
328
|
+
| `$.x = val` | 顶层属性赋值 → 自动渲染 |
|
|
329
|
+
| `$.items.push(val)` | 数组突变 → 自动渲染 |
|
|
330
|
+
| `$.items[0].x = val` | 对象属性突变 → 自动渲染 |
|
|
331
|
+
| `ctx.ui.dirty()` | 仅当绕过 Proxy 直接操作底层对象时使用 |
|
|
362
332
|
|
|
363
333
|
### 条件与列表
|
|
364
334
|
|
|
365
|
-
使用原生 JS
|
|
335
|
+
使用原生 JS 控制流:
|
|
366
336
|
|
|
367
337
|
```tsx
|
|
368
338
|
// 条件
|
|
369
|
-
{
|
|
370
|
-
{
|
|
339
|
+
{cond ? <A /> : <B />}
|
|
340
|
+
{cond && <A />}
|
|
371
341
|
|
|
372
342
|
// 列表
|
|
373
|
-
{
|
|
374
|
-
<div key={item.id}>{item.name}</div>
|
|
375
|
-
))}
|
|
343
|
+
{items.map(item => <div key={item.id}>{item.name}</div>)}
|
|
376
344
|
```
|
|
377
345
|
|
|
378
|
-
|
|
379
|
-
|------|------|
|
|
380
|
-
| `{cond && <A/>}` | 条件渲染(cond 为 true 时渲染 A) |
|
|
381
|
-
| `{cond ? <A/> : <B/>}` | 二选一 |
|
|
382
|
-
| `{arr.map(x => <div key={x.id}/>)}` | 列表渲染,必须加 `key` |
|
|
383
|
-
|
|
384
|
-
### 生命周期
|
|
346
|
+
### 生命周期 —— ref 回调
|
|
385
347
|
|
|
386
|
-
|
|
348
|
+
`ref` 回调在 mount 时触发,接收 DOM 元素。返回的清理函数在 unmount 时由框架保证调用。
|
|
387
349
|
|
|
388
350
|
```tsx
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}} />
|
|
351
|
+
function MyComponent(_props: {}, ctx: WfuiContext) {
|
|
352
|
+
return (
|
|
353
|
+
<div ref={el => {
|
|
354
|
+
// mount: el 是 DOM 元素
|
|
355
|
+
el.addEventListener('scroll', handler)
|
|
356
|
+
// 返回清理函数,unmount 时框架保证调用
|
|
357
|
+
return () => el.removeEventListener('scroll', handler)
|
|
358
|
+
}} />
|
|
359
|
+
)
|
|
360
|
+
}
|
|
400
361
|
```
|
|
401
362
|
|
|
402
|
-
|
|
363
|
+
| 场景 | 写法 |
|
|
364
|
+
|------|------|
|
|
365
|
+
| 事件监听 | `ref={el => { el.addEventListener(...); return () => el.removeEventListener(...) }}` |
|
|
366
|
+
| 定时器 | `ref={el => { const t = setInterval(f, 1000); return () => clearInterval(t) }}` |
|
|
367
|
+
| 第三方库 | `ref={el => { const c = new Chart(el); return () => c.destroy() }}` |
|
|
368
|
+
| 仅 mount | `ref={el => { init(el) }}` |
|
|
403
369
|
|
|
404
|
-
### 应用
|
|
370
|
+
### 应用 —— createApp
|
|
405
371
|
|
|
406
372
|
```tsx
|
|
373
|
+
import { createApp } from 'weifuwu/client'
|
|
374
|
+
|
|
407
375
|
const app = createApp()
|
|
408
376
|
app.use(middleware1)
|
|
409
377
|
app.use(middleware2)
|
|
410
|
-
|
|
378
|
+
app.mount('#root', RootComponent)
|
|
379
|
+
app.destroy()
|
|
411
380
|
```
|
|
412
381
|
|
|
413
|
-
|
|
414
|
-
|------|------|
|
|
415
|
-
| `use(mw)` | 注册中间件,返回 `this` 支持链式 |
|
|
416
|
-
| `mount(selector, RootComponent)` | 挂载到 DOM |
|
|
417
|
-
| `hydrate(selector, Component, props?)` | 在 SSR 内容上附加组件 |
|
|
418
|
-
| `ctx` | 当前上下文 |
|
|
419
|
-
|
|
420
|
-
### 路由
|
|
382
|
+
### 路由 —— router + RouteView
|
|
421
383
|
|
|
422
384
|
```tsx
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
385
|
+
import { router, RouteView } from 'weifuwu/client'
|
|
386
|
+
|
|
387
|
+
createApp()
|
|
388
|
+
.use(router({
|
|
389
|
+
routes: [
|
|
390
|
+
{ path: '/', component: Home },
|
|
391
|
+
{ path: '/users', component: UserList },
|
|
392
|
+
{ path: '/users/:id', component: UserDetail },
|
|
393
|
+
],
|
|
394
|
+
notFound: NotFound,
|
|
395
|
+
mode: 'history', // 或 'hash'
|
|
396
|
+
}))
|
|
397
|
+
.mount('#root', AppShell)
|
|
398
|
+
|
|
399
|
+
// 嵌套布局
|
|
400
|
+
const routes = [
|
|
426
401
|
{
|
|
427
402
|
path: '/dashboard',
|
|
428
|
-
layout: DashboardLayout,
|
|
403
|
+
layout: DashboardLayout, // 持久布局
|
|
429
404
|
children: [
|
|
430
|
-
{ path: '/overview', component: Overview },
|
|
405
|
+
{ path: '/overview', component: Overview },
|
|
431
406
|
{ path: '/settings', component: Settings },
|
|
432
407
|
],
|
|
433
408
|
},
|
|
434
|
-
{ path: '/user/:id', component: UserPage, title: '用户' },
|
|
435
409
|
]
|
|
436
410
|
|
|
437
|
-
//
|
|
438
|
-
|
|
439
|
-
routes,
|
|
440
|
-
notFound: NotFound,
|
|
441
|
-
mode: 'hash', // 'hash' | 'history'
|
|
442
|
-
scrollRestoration: true,
|
|
443
|
-
}))
|
|
444
|
-
|
|
445
|
-
// 路由出口 — 根层级和嵌套层级用同一个组件
|
|
446
|
-
function AppShell() {
|
|
447
|
-
return <main><RouteView /></main> // 根出口
|
|
448
|
-
}
|
|
449
|
-
function DashboardLayout() {
|
|
411
|
+
// 在 layout 中放置 RouteView 渲染子路由
|
|
412
|
+
function DashboardLayout(_props: {}, ctx: WfuiContext) {
|
|
450
413
|
return (
|
|
451
|
-
<div class="
|
|
452
|
-
<
|
|
453
|
-
<main><RouteView /></main>
|
|
414
|
+
<div class="wf-split">
|
|
415
|
+
<aside>sidebar</aside>
|
|
416
|
+
<main><RouteView /></main>
|
|
454
417
|
</div>
|
|
455
418
|
)
|
|
456
419
|
}
|
|
457
420
|
```
|
|
458
421
|
|
|
459
|
-
|
|
|
460
|
-
|
|
461
|
-
| `path` |
|
|
462
|
-
| `
|
|
463
|
-
| `
|
|
464
|
-
| `
|
|
465
|
-
| `auth` | `boolean` | 是否需要登录 |
|
|
466
|
-
| `title` | `string` | 页面标题(自动设置 `document.title`)|
|
|
467
|
-
| `loader` | `(ctx) => Promise<data>` | 数据预取 → `ctx.route.data` |
|
|
468
|
-
| `transition` | `string` | 页面切换过渡动画 CSS class 前缀 |
|
|
469
|
-
|
|
470
|
-
| RouterOptions | 默认 | 说明 |
|
|
471
|
-
|---------------|------|------|
|
|
472
|
-
| `mode` | `'hash'` | 路由模式 |
|
|
473
|
-
| `notFound` | — | 404 组件 |
|
|
474
|
-
| `scrollRestoration` | `true` | 历史模式时恢复滚动位置 |
|
|
475
|
-
| `transition` | — | 全局过渡动画 |
|
|
476
|
-
|
|
477
|
-
**`ctx.route` 注入:**
|
|
478
|
-
|
|
479
|
-
```tsx
|
|
480
|
-
ctx.route.path // '/user/42'
|
|
481
|
-
ctx.route.params // { id: '42' }
|
|
482
|
-
ctx.route.query // { tab: 'profile' }
|
|
483
|
-
ctx.route.component // 当前路由组件
|
|
484
|
-
ctx.route.data // loader 返回的数据
|
|
485
|
-
ctx.route.loading // loader 是否加载中
|
|
486
|
-
ctx.app.navigate('/path')
|
|
487
|
-
```
|
|
488
|
-
|
|
489
|
-
### 代码分割
|
|
490
|
-
|
|
491
|
-
```tsx
|
|
492
|
-
const AdminPage = lazy(() => import('./pages/AdminPage'), {
|
|
493
|
-
fallback: () => <div>加载中...</div>,
|
|
494
|
-
})
|
|
495
|
-
|
|
496
|
-
const routes = [
|
|
497
|
-
{ path: '/admin', component: AdminPage },
|
|
498
|
-
]
|
|
499
|
-
```
|
|
500
|
-
|
|
501
|
-
需 esbuild `splitting: true` + `outdir`。
|
|
422
|
+
| API | 说明 |
|
|
423
|
+
|------|------|
|
|
424
|
+
| `ctx.route.path` | 当前路由路径 |
|
|
425
|
+
| `ctx.route.params` | URL 参数(如 `:id`)|
|
|
426
|
+
| `ctx.route.query` | 查询参数对象 |
|
|
427
|
+
| `ctx.app.navigate(path)` | 编程式导航 |
|
|
502
428
|
|
|
503
429
|
### 中间件
|
|
504
430
|
|
|
505
|
-
|
|
431
|
+
**ws —— WebSocket 客户端**
|
|
506
432
|
|
|
507
433
|
```tsx
|
|
508
|
-
app.use(ws(
|
|
434
|
+
app.use(ws())
|
|
509
435
|
|
|
510
|
-
//
|
|
511
|
-
const unsub = ctx.ws?.onMessage((data) => { ... })
|
|
512
|
-
// 清理在 ref 中处理
|
|
436
|
+
// 发送消息
|
|
513
437
|
ctx.ws?.send({ type: 'chat', body: 'hello' })
|
|
514
|
-
{ctx.ws?.isConnected && <span>🟢 已连接</span>}
|
|
515
|
-
```
|
|
516
438
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
| `onMessage` | `(handler) => dispose()` | 注册消息监听 |
|
|
521
|
-
| `isConnected` | `Signal<boolean>` | 连接状态信号 |
|
|
522
|
-
|
|
523
|
-
| 选项 | 默认 | 说明 |
|
|
524
|
-
|------|------|------|
|
|
525
|
-
| `url` | `'/ws'` | WebSocket 地址 |
|
|
526
|
-
| `reconnectInterval` | `3000` | 重连间隔 (ms) |
|
|
527
|
-
| `maxReconnect` | `10` | 最大重连次数 |
|
|
439
|
+
// 接收消息
|
|
440
|
+
ctx.ws?.onMessage((msg) => { console.log(msg) })
|
|
441
|
+
```
|
|
528
442
|
|
|
529
|
-
|
|
443
|
+
**api —— HTTP 客户端**
|
|
530
444
|
|
|
531
445
|
```tsx
|
|
532
446
|
app.use(api({ baseURL: '/api' }))
|
|
533
447
|
|
|
534
|
-
//
|
|
535
|
-
await ctx.api
|
|
536
|
-
await ctx.api
|
|
537
|
-
await ctx.api.put<User>('/users/1', body)
|
|
538
|
-
await ctx.api.patch<User>('/users/1', body)
|
|
539
|
-
await ctx.api.delete('/users/1')
|
|
448
|
+
// 自动携带 Authorization header
|
|
449
|
+
const user = await ctx.api?.get('/users/1')
|
|
450
|
+
const res = await ctx.api?.post('/users', { name: 'Alice' })
|
|
540
451
|
```
|
|
541
452
|
|
|
542
|
-
|
|
|
543
|
-
|-----------|------|------|
|
|
544
|
-
| `get` | `<T>(url, opts?) => Promise<T>` | GET 请求 |
|
|
545
|
-
| `post` | `<T>(url, body?, opts?) => Promise<T>` | POST 请求 |
|
|
546
|
-
| `put` | `<T>(url, body?, opts?) => Promise<T>` | PUT 请求 |
|
|
547
|
-
| `patch` | `<T>(url, body?, opts?) => Promise<T>` | PATCH 请求 |
|
|
548
|
-
| `delete` | `<T>(url, opts?) => Promise<T>` | DELETE 请求 |
|
|
549
|
-
|
|
550
|
-
| 选项 | 说明 |
|
|
453
|
+
| 方法 | 说明 |
|
|
551
454
|
|------|------|
|
|
552
|
-
| `
|
|
553
|
-
| `
|
|
554
|
-
| `
|
|
555
|
-
| `
|
|
556
|
-
|
|
557
|
-
错误类型:`ApiError` — 包含 `status` 和 `body`。
|
|
455
|
+
| `ctx.api.get(url, opts?)` | GET |
|
|
456
|
+
| `ctx.api.post(url, body?, opts?)` | POST |
|
|
457
|
+
| `ctx.api.put(url, body?, opts?)` | PUT |
|
|
458
|
+
| `ctx.api.patch(url, body?, opts?)` | PATCH |
|
|
459
|
+
| `ctx.api.delete(url, opts?)` | DELETE |
|
|
558
460
|
|
|
559
|
-
|
|
461
|
+
**auth —— 认证状态管理**
|
|
560
462
|
|
|
561
463
|
```tsx
|
|
562
464
|
app.use(auth())
|
|
563
465
|
|
|
564
|
-
// 组件中:
|
|
565
|
-
{ctx.auth?.isLoggedIn ? (
|
|
566
|
-
<div>
|
|
567
|
-
<span>{ctx.auth?.user?.name}</span>
|
|
568
|
-
<button onClick={() => ctx.auth?.logout()}>退出</button>
|
|
569
|
-
</div>
|
|
570
|
-
) : (
|
|
571
|
-
<Login />
|
|
572
|
-
)}
|
|
573
|
-
|
|
574
466
|
// 登录
|
|
575
|
-
ctx.auth
|
|
576
|
-
// 退出
|
|
577
|
-
ctx.auth.logout()
|
|
578
|
-
```
|
|
579
|
-
|
|
580
|
-
| `ctx.auth` | 类型 | 说明 |
|
|
581
|
-
|-----------|------|------|
|
|
582
|
-
| `token` | `Signal<string \| null>` | 当前 token |
|
|
583
|
-
| `user` | `Signal<AuthUser \| null>` | 当前用户 |
|
|
584
|
-
| `isLoggedIn` | `Signal<boolean>` | 是否已登录(computed)|
|
|
585
|
-
| `login` | `(token, user) => void` | 存储 token + 用户到 localStorage |
|
|
586
|
-
| `logout` | `() => void` | 清除 token + 用户 |
|
|
587
|
-
| `setUser` | `(user) => void` | 更新用户信息 |
|
|
588
|
-
| `authorizationHeader` | `Signal<string \| null>` | `'Bearer xxx'` 或 `null` |
|
|
589
|
-
|
|
590
|
-
| 选项 | 默认 | 说明 |
|
|
591
|
-
|------|------|------|
|
|
592
|
-
| `storage` | `localStorage` | 存储方式 |
|
|
593
|
-
| `tokenKey` | `'weifuwu_token'` | token 存储 key |
|
|
594
|
-
| `userKey` | `'weifuwu_user'` | 用户信息存储 key |
|
|
467
|
+
ctx.auth?.login(token, user)
|
|
595
468
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
#### useForm — 表单管理
|
|
599
|
-
|
|
600
|
-
```tsx
|
|
601
|
-
const form = useForm({
|
|
602
|
-
initial: { name: '', email: '' },
|
|
603
|
-
validate: {
|
|
604
|
-
name: (v) => !v ? '请输入姓名' : null,
|
|
605
|
-
email: [
|
|
606
|
-
(v) => !v ? '请输入邮箱' : null,
|
|
607
|
-
(v) => !v.includes('@') ? '邮箱格式错误' : null,
|
|
608
|
-
],
|
|
609
|
-
},
|
|
610
|
-
onSubmit: async (values) => {
|
|
611
|
-
await ctx.api.post('/users', values)
|
|
612
|
-
},
|
|
613
|
-
})
|
|
469
|
+
// 登出
|
|
470
|
+
ctx.auth?.logout()
|
|
614
471
|
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
<input {...form.field('name')} />
|
|
618
|
-
<span>{form.errors.value.name}</span>
|
|
619
|
-
<button disabled={form.submitting}>提交</button>
|
|
620
|
-
</form>
|
|
472
|
+
// 状态
|
|
473
|
+
if (ctx.auth?.isLoggedIn) { ... }
|
|
621
474
|
```
|
|
622
475
|
|
|
623
|
-
|
|
|
624
|
-
|
|
625
|
-
| `
|
|
626
|
-
| `
|
|
627
|
-
| `
|
|
628
|
-
| `
|
|
629
|
-
| `
|
|
630
|
-
| `field` | `(name) => { value, onInput, error }` | 字段绑定对象 |
|
|
631
|
-
| `setValue` | `(name, value) => void` | 设字段值 |
|
|
632
|
-
| `reset` | `() => void` | 重置表单 |
|
|
633
|
-
| `validateAll` | `() => boolean` | 触发全部验证 |
|
|
476
|
+
| API | 说明 |
|
|
477
|
+
|------|------|
|
|
478
|
+
| `ctx.auth.token` | JWT token |
|
|
479
|
+
| `ctx.auth.user` | 用户对象 |
|
|
480
|
+
| `ctx.auth.isLoggedIn` | 是否已登录 |
|
|
481
|
+
| `ctx.auth.login(token, user, refreshToken?)` | 登录 |
|
|
482
|
+
| `ctx.auth.logout()` | 登出 |
|
|
634
483
|
|
|
635
|
-
|
|
484
|
+
### ErrorBoundary
|
|
636
485
|
|
|
637
486
|
```tsx
|
|
638
|
-
|
|
639
|
-
() => fetch('/api/posts').then(r => r.json()),
|
|
640
|
-
{ initialValue: [] }
|
|
641
|
-
)
|
|
642
|
-
|
|
643
|
-
// JSX:
|
|
644
|
-
<Show when={loading}><p>加载中...</p></Show>
|
|
645
|
-
<Show when={error}><p>错误: {error.value?.message}</p></Show>
|
|
646
|
-
<Show when={computed(() => !loading.value && !error.value)}>
|
|
647
|
-
<For each={data}>{(item) => <div>{item.title}</div>}</For>
|
|
648
|
-
</Show>
|
|
649
|
-
```
|
|
487
|
+
import { ErrorBoundary } from 'weifuwu/client'
|
|
650
488
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
| `data` (元组第一项) | `Signal<T \| undefined>` | 数据信号 |
|
|
654
|
-
| `loading` | `Signal<boolean>` | 加载状态 |
|
|
655
|
-
| `error` | `Signal<Error \| undefined>` | 错误信号 |
|
|
656
|
-
| `refetch` | `() => void` | 手动重新加载 |
|
|
657
|
-
|
|
658
|
-
#### ErrorBoundary — 错误捕获
|
|
659
|
-
|
|
660
|
-
```tsx
|
|
661
|
-
<ErrorBoundary
|
|
662
|
-
fallback={(e) => <p>出错了: {e.message}</p>}
|
|
663
|
-
onError={(e) => console.error(e)}
|
|
664
|
-
>
|
|
665
|
-
{() => <Dashboard />} {/* 必须用 thunk */}
|
|
489
|
+
<ErrorBoundary fallback={<p>出错了</p>}>
|
|
490
|
+
<UserProfile />
|
|
666
491
|
</ErrorBoundary>
|
|
667
492
|
```
|
|
668
493
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
```tsx
|
|
672
|
-
<Show when={showModal}>
|
|
673
|
-
{createPortal(<Modal />, document.body)}
|
|
674
|
-
</Show>
|
|
675
|
-
```
|
|
676
|
-
|
|
677
|
-
#### wrap — 封装三方库为组件
|
|
678
|
-
|
|
679
|
-
```tsx
|
|
680
|
-
const Chart = wrap('div', (el, props: { data: any }, ctx) => {
|
|
681
|
-
const chart = echarts.init(el)
|
|
682
|
-
chart.setOption(props.data)
|
|
683
|
-
effect(() => chart.setOption(props.data))
|
|
684
|
-
return () => chart.dispose() // 卸载时自动清理
|
|
685
|
-
})
|
|
686
|
-
|
|
687
|
-
// 使用:
|
|
688
|
-
<Chart data={salesData} />
|
|
689
|
-
```
|
|
690
|
-
|
|
691
|
-
#### createContext / extendCtx — 上下文扩展
|
|
692
|
-
|
|
693
|
-
```tsx
|
|
694
|
-
// 类型安全的 provide/inject
|
|
695
|
-
const ThemeCtx = createContext<string>('theme')
|
|
696
|
-
ThemeCtx.provide(ctx, 'dark')
|
|
697
|
-
const theme = ThemeCtx.inject(ctx) // 'dark' | null
|
|
698
|
-
|
|
699
|
-
// 中间件注入
|
|
700
|
-
function myMiddleware(): AppMiddleware {
|
|
701
|
-
return (ctx) => extendCtx(ctx, { myField: 'hello' })
|
|
702
|
-
}
|
|
703
|
-
```
|
|
704
|
-
|
|
705
|
-
### React 对照表
|
|
494
|
+
### 工具
|
|
706
495
|
|
|
707
|
-
|
|
|
708
|
-
|
|
709
|
-
| `
|
|
710
|
-
| `useMemo(() => a*2, [a])` | `const doubled = a * 2`(render 时计算) |
|
|
711
|
-
| `useEffect(() => f, [])` | `if (!ctx.ui.ready) { f() }` |
|
|
712
|
-
| `{cond && <X/>}` | 相同 |
|
|
713
|
-
| `{arr.map(i => <X/>)}` | 相同,加 `key` |
|
|
714
|
-
| `Suspense` | `{$.loading && <Loading/>}` |
|
|
715
|
-
| `useNavigate()` | `ctx.app?.navigate()` |
|
|
716
|
-
| `useParams()` | `ctx.route?.params` |
|
|
717
|
-
| `axios.get()` | `ctx.api?.get()` |
|
|
496
|
+
| 函数 | 用途 |
|
|
497
|
+
|------|------|
|
|
498
|
+
| `extendCtx(ctx, fields)` | 创建新 ctx,继承原 ctx 的 getter |
|
|
718
499
|
|
|
719
500
|
### 前端类型
|
|
720
501
|
|
|
721
|
-
`VNode`, `Component`, `WfuiContext`, `AppMiddleware`, `RouteDef`, `ApiClient`, `AuthClient`
|
|
502
|
+
`VNode`, `VNodeType`, `Component`, `WfuiContext`, `AppMiddleware`, `RouteDef`, `ApiClient`, `ApiOptions`, `ApiRequestOptions`, `ApiError`, `AuthClient`, `AuthOptions`, `ErrorBoundaryProps`
|
|
722
503
|
|
|
723
504
|
---
|
|
724
505
|
|
|
725
506
|
## 布局 & 主题 (`weifuwu/layout`)
|
|
726
507
|
|
|
727
|
-
纯 CSS 布局原语 + 主题 Token
|
|
728
|
-
|
|
729
|
-
```bash
|
|
730
|
-
npm install weifuwu # 已包含
|
|
731
|
-
```
|
|
508
|
+
纯 CSS 布局原语 + 主题 Token。不绑定任何 JS 框架。
|
|
732
509
|
|
|
733
510
|
```ts
|
|
734
|
-
// 服务端编译
|
|
735
|
-
app.get('/
|
|
511
|
+
// 服务端编译
|
|
512
|
+
app.get('/layout.css', async (req, ctx) => ctx.ui.css('./node_modules/weifuwu/dist/layout/weifuwu-layout.css'))
|
|
736
513
|
```
|
|
737
514
|
|
|
738
515
|
```html
|
|
739
516
|
<!-- 或直接引入 -->
|
|
740
|
-
<link rel="stylesheet" href="/
|
|
517
|
+
<link rel="stylesheet" href="/layout.css">
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
### 使用示例
|
|
521
|
+
|
|
522
|
+
```html
|
|
523
|
+
<div class="wf-stack" style="--wf-gap: 24px">
|
|
524
|
+
<div class="wf-split">
|
|
525
|
+
<h2 style="color: var(--wf-color-text)">仪表盘</h2>
|
|
526
|
+
<button style="background: var(--wf-color-primary); color: #fff; border-radius: var(--wf-radius)">+ 新建</button>
|
|
527
|
+
</div>
|
|
528
|
+
<div class="wf-row" style="--wf-gap: 16px">
|
|
529
|
+
<div class="wf-fill wf-surface wf-stack" style="padding: 20px; background: var(--wf-color-bg); --wf-gap: 4px">
|
|
530
|
+
<span style="color: var(--wf-color-text-secondary)">总用户</span>
|
|
531
|
+
<span style="font-size: var(--wf-font-size-4xl); font-weight: var(--wf-font-weight-bold); color: var(--wf-color-text)">1,234</span>
|
|
532
|
+
</div>
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
741
535
|
```
|
|
742
536
|
|
|
743
537
|
### 33 个布局原语
|
|
@@ -745,7 +539,7 @@ app.get('/weifuwu.css', async (req, ctx) => ctx.ui.css('./node_modules/weifuwu/d
|
|
|
745
539
|
| 类别 | 原语 | 含义 | CSS 实现 |
|
|
746
540
|
|------|------|------|---------|
|
|
747
541
|
| **排列** | `wf-stack` | 纵向堆叠 | `flex-direction: column + gap` |
|
|
748
|
-
| | `wf-stack-reverse` | 反向堆叠 | `flex-direction: column-reverse
|
|
542
|
+
| | `wf-stack-reverse` | 反向堆叠 | `flex-direction: column-reverse` |
|
|
749
543
|
| | `wf-row` | 横向排列 | `flex + flex-wrap + gap` |
|
|
750
544
|
| | `wf-row-reverse` | 反向排列 | `flex-direction: row-reverse` |
|
|
751
545
|
| | `wf-nowrap` | 不换行 | `flex-wrap: nowrap` |
|
|
@@ -758,20 +552,20 @@ app.get('/weifuwu.css', async (req, ctx) => ctx.ui.css('./node_modules/weifuwu/d
|
|
|
758
552
|
| **对齐** | `wf-top` | 顶部 | `align-items: flex-start` |
|
|
759
553
|
| | `wf-bottom` | 底部 | `align-items: flex-end` |
|
|
760
554
|
| | `wf-stretch` | 拉伸 | `align-items: stretch` |
|
|
761
|
-
| **弹性** | `wf-fill` |
|
|
762
|
-
| | `wf-fixed` |
|
|
763
|
-
| | `wf-auto` |
|
|
764
|
-
| | `wf-shrink` | 可收缩 | `min-width: 0
|
|
555
|
+
| **弹性** | `wf-fill` | 撑满剩余空间 | `flex: 1 + min-width: 0` |
|
|
556
|
+
| | `wf-fixed` | 固定不伸缩 | `flex: none` |
|
|
557
|
+
| | `wf-auto` | 按内容撑满 | `flex: auto` |
|
|
558
|
+
| | `wf-shrink` | 可收缩 | `min-width: 0 + min-height: 0` |
|
|
765
559
|
| **Z轴** | `wf-cover` | 全屏覆盖 | `position: fixed + inset: 0` |
|
|
766
560
|
| | `wf-pop` | 浮动层 | `position: absolute` |
|
|
767
561
|
| | `wf-anchor` | 锚点容器 | `position: relative` |
|
|
768
562
|
| | `wf-layer` | 层级控制 | `position: relative + z-index` |
|
|
769
|
-
| | `wf-sticky` |
|
|
563
|
+
| | `wf-sticky` | 粘性定位 | `position: sticky` |
|
|
770
564
|
| **容器** | `wf-surface` | 基础面 | `border-radius + box-shadow + bg` |
|
|
771
|
-
| | `wf-grid` |
|
|
565
|
+
| | `wf-grid` | 二维网格 | `display: grid + --wf-cols` |
|
|
772
566
|
| | `wf-container` | 宽度约束 | `max-width + margin: auto` |
|
|
773
|
-
| | `wf-scroll` |
|
|
774
|
-
| | `wf-clip` |
|
|
567
|
+
| | `wf-scroll` | 可滚动 | `overflow: auto` |
|
|
568
|
+
| | `wf-clip` | 溢出裁剪 | `overflow: hidden` |
|
|
775
569
|
| **显隐** | `wf-hidden` | 隐藏 | `display: none` |
|
|
776
570
|
| | `wf-block` | 块级 | `display: block` |
|
|
777
571
|
| | `wf-inline` | 行内 | `display: inline` |
|
|
@@ -780,151 +574,116 @@ app.get('/weifuwu.css', async (req, ctx) => ctx.ui.css('./node_modules/weifuwu/d
|
|
|
780
574
|
|
|
781
575
|
### 72 个主题 Token
|
|
782
576
|
|
|
783
|
-
| 类别 | Token 示例 |
|
|
784
|
-
|
|
785
|
-
| 品牌色 | `--wf-color-primary`, `--wf-color-primary-bg` |
|
|
786
|
-
| 语义色 | `--wf-color-success
|
|
787
|
-
| 中性色 | `--wf-color-text
|
|
577
|
+
| 类别 | Token 示例 | 值/层级 |
|
|
578
|
+
|------|-----------|---------|
|
|
579
|
+
| 品牌色 | `--wf-color-primary`, `--wf-color-primary-bg` | 品牌色 + Hover + 背景 |
|
|
580
|
+
| 语义色 | `--wf-color-success/warning/error/info` | 各带 `-bg` 背景变体 |
|
|
581
|
+
| 中性色 | `--wf-color-text/text-secondary/text-tertiary/text-disabled` | 4 级文字色 |
|
|
582
|
+
| | `--wf-color-bg/bg-secondary/bg-tertiary` | 3 级背景色 |
|
|
583
|
+
| | `--wf-color-border/border-light/border-dark` | 3 级边框色 |
|
|
788
584
|
| 字体 | `--wf-font-sans`, `--wf-font-mono` | 字体族 |
|
|
789
|
-
| 字号 | `--wf-font-size-xs`
|
|
790
|
-
| 字重 | `--wf-font-weight-normal`
|
|
791
|
-
| 行高 | `--wf-line-height-tight
|
|
792
|
-
| 字距 | `--wf-letter-spacing
|
|
793
|
-
| 间距 | `--wf-space-xs`
|
|
794
|
-
| 间隔 | `--wf-gap-xs`
|
|
795
|
-
| 圆角 | `--wf-radius-sm`
|
|
796
|
-
| 阴影 | `--wf-shadow-sm`
|
|
585
|
+
| 字号 | `--wf-font-size-xs/sm/base/lg/xl/2xl/3xl/4xl/5xl` | 9 级字号 |
|
|
586
|
+
| 字重 | `--wf-font-weight-normal/medium/semibold/bold` | 4 级字重 |
|
|
587
|
+
| 行高 | `--wf-line-height-tight/normal/relaxed` | 3 级行高 |
|
|
588
|
+
| 字距 | `--wf-letter-spacing/wide/wider` | 3 级字符间距 |
|
|
589
|
+
| 间距 | `--wf-space-xs/sm/md/lg/xl/2xl` | 8 级 margin/padding |
|
|
590
|
+
| 间隔 | `--wf-gap-xs/sm/md/lg/xl/2xl` | 6 级 flex/grid gap |
|
|
591
|
+
| 圆角 | `--wf-radius-sm/md/lg/xl` | 5 级 border-radius |
|
|
592
|
+
| 阴影 | `--wf-shadow-sm/md/lg` | 4 级 box-shadow |
|
|
797
593
|
| 边框 | `--wf-border-width` | 边框宽度 |
|
|
798
|
-
| 聚焦 | `--wf-focus-ring` |
|
|
799
|
-
| 动效 | `--wf-transition-duration
|
|
800
|
-
| 表单 | `--wf-accent-color`, `--wf-caret-color` |
|
|
801
|
-
| 透明 | `--wf-opacity-disabled`, `--wf-opacity-overlay` |
|
|
802
|
-
| 层级 | `--wf-pop-z`, `--wf-cover-z` | z-index |
|
|
594
|
+
| 聚焦 | `--wf-focus-ring` | 聚焦环(box-shadow)|
|
|
595
|
+
| 动效 | `--wf-transition-duration/timing` | 过渡时长 + 曲线 |
|
|
596
|
+
| 表单 | `--wf-accent-color`, `--wf-caret-color` | 控件主题色 + 光标色 |
|
|
597
|
+
| 透明 | `--wf-opacity-disabled`, `--wf-opacity-overlay` | 禁用态 + 遮罩透明度 |
|
|
598
|
+
| 层级 | `--wf-pop-z`, `--wf-cover-z` | z-index 层 |
|
|
803
599
|
|
|
804
600
|
### 暗色模式
|
|
805
601
|
|
|
806
|
-
切换 `html`
|
|
602
|
+
切换 `html` 的 `data-theme` 属性即可自动切换全部主题色:
|
|
807
603
|
|
|
808
604
|
```ts
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
|
|
812
|
-
document.documentElement.setAttribute('data-theme', dark ? 'light' : 'dark')
|
|
813
|
-
}
|
|
605
|
+
document.documentElement.setAttribute('data-theme', 'dark')
|
|
606
|
+
// → 全部引用 var(--wf-*) 的元素自动变色
|
|
814
607
|
```
|
|
815
608
|
|
|
816
609
|
### 基础元素默认样式
|
|
817
610
|
|
|
818
|
-
引入
|
|
611
|
+
引入 weifuwu/layout 后,以下 HTML 元素自动绑定主题 Token:
|
|
819
612
|
|
|
820
613
|
`body`, `h1`~`h6`, `p`, `a`, `label`, `small`, `input`, `textarea`, `select`, `button`, `table`, `th`, `td`, `hr`, `pre`, `code`
|
|
821
614
|
|
|
822
615
|
---
|
|
823
616
|
|
|
824
|
-
##
|
|
825
|
-
|
|
826
|
-
### 认证流程
|
|
827
|
-
|
|
828
|
-
```ts
|
|
829
|
-
// 后端
|
|
830
|
-
app.post('/api/login', async (req, ctx) => {
|
|
831
|
-
const { email } = await req.json()
|
|
832
|
-
return Response.json({
|
|
833
|
-
token: 'jwt_' + Math.random().toString(36),
|
|
834
|
-
user: { id: 1, name: email.split('@')[0], email },
|
|
835
|
-
})
|
|
836
|
-
})
|
|
837
|
-
|
|
838
|
-
// 前端
|
|
839
|
-
app.use(api({ baseURL: '' }))
|
|
840
|
-
app.use(auth())
|
|
617
|
+
## 环境变量
|
|
841
618
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
619
|
+
| 变量 | 用途 | 默认值 |
|
|
620
|
+
|------|------|--------|
|
|
621
|
+
| `DATABASE_URL` | PostgreSQL 连接字符串 | — |
|
|
622
|
+
| `REDIS_URL` | Redis 连接字符串 | — |
|
|
846
623
|
|
|
847
|
-
|
|
624
|
+
---
|
|
848
625
|
|
|
849
|
-
|
|
850
|
-
// 后端 — 同路径既支持 SSR 也支持 API
|
|
851
|
-
app.get('/api/posts', async (req, ctx) => {
|
|
852
|
-
return Response.json(posts)
|
|
853
|
-
})
|
|
626
|
+
## 项目结构
|
|
854
627
|
|
|
855
|
-
// 前端 — 客户端获取
|
|
856
|
-
const [posts, { loading }] = createResource(
|
|
857
|
-
() => ctx.api.get('/api/posts')
|
|
858
|
-
)
|
|
859
628
|
```
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
629
|
+
src/
|
|
630
|
+
├── index.ts # 统一导出
|
|
631
|
+
├── types.ts # 后端类型
|
|
632
|
+
├── request.ts # 请求解析
|
|
633
|
+
├── response.ts # 响应工具
|
|
634
|
+
├── core/
|
|
635
|
+
│ ├── router.ts # HTTP 路由
|
|
636
|
+
│ ├── serve.ts # HTTP 服务器
|
|
637
|
+
│ └── ws.ts # WebSocket
|
|
638
|
+
├── middleware/
|
|
639
|
+
│ ├── cors.ts
|
|
640
|
+
│ └── static.ts
|
|
641
|
+
├── postgres/
|
|
642
|
+
├── redis/
|
|
643
|
+
├── graphql.ts
|
|
644
|
+
├── ui/ # SSR + JS/CSS 编译
|
|
645
|
+
├── client/ # 前端 VDOM 框架
|
|
646
|
+
│ ├── index.ts
|
|
647
|
+
│ ├── vnode.ts
|
|
648
|
+
│ ├── app.ts
|
|
649
|
+
│ ├── render.ts
|
|
650
|
+
│ ├── router.ts
|
|
651
|
+
│ ├── types.ts
|
|
652
|
+
│ ├── error-boundary.ts
|
|
653
|
+
│ └── middleware/
|
|
654
|
+
│ ├── api.ts
|
|
655
|
+
│ ├── auth.ts
|
|
656
|
+
│ └── ws.ts
|
|
657
|
+
└── layout/ # 纯 CSS 布局 + 主题
|
|
658
|
+
├── weifuwu-layout.css
|
|
659
|
+
├── _tokens.css
|
|
660
|
+
├── _dark.css
|
|
661
|
+
├── _base.css
|
|
662
|
+
└── _*.css # 33 个原语
|
|
874
663
|
```
|
|
875
664
|
|
|
876
665
|
---
|
|
877
666
|
|
|
878
|
-
##
|
|
879
|
-
|
|
880
|
-
| 变量 | 默认 | 说明 |
|
|
881
|
-
|------|------|------|
|
|
882
|
-
| `DATABASE_URL` | `postgres://root:123456@localhost:5432/demo` | Postgres 连接字符串 |
|
|
883
|
-
| `REDIS_URL` | `redis://localhost:6379` | Redis 连接字符串 |
|
|
884
|
-
|
|
885
|
-
---
|
|
886
|
-
|
|
887
|
-
## Demo
|
|
667
|
+
## 开发
|
|
888
668
|
|
|
889
669
|
```bash
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
# http://localhost:3000
|
|
893
|
-
```
|
|
670
|
+
# 构建
|
|
671
|
+
npm run build
|
|
894
672
|
|
|
895
|
-
|
|
673
|
+
# 类型检查
|
|
674
|
+
npm run typecheck
|
|
896
675
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
## 项目结构
|
|
676
|
+
# 测试
|
|
677
|
+
npm test
|
|
900
678
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
├── index.ts 入口,导出所有后端模块
|
|
904
|
-
├── types.ts Context, Handler, Middleware 等类型
|
|
905
|
-
├── core/ Router, serve, WebSocket upgrade
|
|
906
|
-
├── middleware/ cors, serveStatic
|
|
907
|
-
├── postgres/ PostgreSQL 客户端
|
|
908
|
-
├── redis/ Redis 客户端
|
|
909
|
-
├── ui/ SSR 渲染 + 动态编译
|
|
910
|
-
├── graphql.ts GraphQL
|
|
911
|
-
├── client/
|
|
912
|
-
│ ├── index.ts 前端导出入口
|
|
913
|
-
│ ├── vnode.ts VNode 类型 + JSX 工厂
|
|
914
|
-
│ ├── render.ts VDOM 渲染器(render + patchValue)
|
|
915
|
-
│ ├── router.ts 路由中间件 + RouteView
|
|
916
|
-
│ ├── app.ts createApp 应用实例
|
|
917
|
-
│ ├── types.ts 前端类型
|
|
918
|
-
│ └── middleware/
|
|
919
|
-
│ ├── ws.ts WebSocket 客户端
|
|
920
|
-
│ ├── api.ts HTTP 客户端
|
|
921
|
-
│ └── auth.ts 认证状态管理
|
|
922
|
-
├── test/ 测试
|
|
923
|
-
apps/demo/ 全栈 demo
|
|
679
|
+
# 发布
|
|
680
|
+
node scripts/release.mjs <version>
|
|
924
681
|
```
|
|
925
682
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
npm
|
|
930
|
-
|
|
683
|
+
## 设计原则
|
|
684
|
+
|
|
685
|
+
- **后端为工具箱** —— 提供 HTTP 路由、数据库、中间件原语,不捆绑业务模块
|
|
686
|
+
- **全栈单包** —— `npm install weifuwu` = 后端 + 前端 + 布局
|
|
687
|
+
- **Web 标准优先** —— 所有 handler 使用 `(req: Request, ctx: Context) => Response`
|
|
688
|
+
- **零外部依赖** —— 前端和布局没有任何 npm 运行时依赖
|
|
689
|
+
- **LLM 友好** —— 模块总览表 + 一致格式 + 清晰依赖链
|
package/dist/client/index.js
CHANGED
|
@@ -56,7 +56,8 @@ function renderValue(v, ctx) {
|
|
|
56
56
|
el.appendChild(renderValue(child, ctx));
|
|
57
57
|
}
|
|
58
58
|
if (vnode.props?.ref) {
|
|
59
|
-
|
|
59
|
+
const result = vnode.props.ref(el);
|
|
60
|
+
if (typeof result === "function") vnode._cleanup = result;
|
|
60
61
|
}
|
|
61
62
|
return el;
|
|
62
63
|
}
|
|
@@ -140,7 +141,7 @@ function patchValue(parent, oldNode, oldInput, newInput, ctx) {
|
|
|
140
141
|
}
|
|
141
142
|
if (newInput == null) {
|
|
142
143
|
if (oldNode) {
|
|
143
|
-
|
|
144
|
+
callRefCleanup(oldInput);
|
|
144
145
|
oldNode.remove();
|
|
145
146
|
}
|
|
146
147
|
return null;
|
|
@@ -148,7 +149,7 @@ function patchValue(parent, oldNode, oldInput, newInput, ctx) {
|
|
|
148
149
|
const oldType = typeOf(oldInput);
|
|
149
150
|
const newType = typeOf(newInput);
|
|
150
151
|
if (oldType !== newType) {
|
|
151
|
-
|
|
152
|
+
callRefCleanup(oldInput);
|
|
152
153
|
const node = renderValue(newInput, ctx);
|
|
153
154
|
if (oldNode?.parentNode) {
|
|
154
155
|
oldNode.parentNode.replaceChild(node, oldNode);
|
|
@@ -181,7 +182,7 @@ function patchValue(parent, oldNode, oldInput, newInput, ctx) {
|
|
|
181
182
|
patchProps(oldNode, oldV.props, newV.props);
|
|
182
183
|
patchChildren(oldNode, oldV, newV, ctx);
|
|
183
184
|
} else if (oldNode) {
|
|
184
|
-
|
|
185
|
+
callRefCleanup(oldInput);
|
|
185
186
|
const node = renderValue(newInput, ctx);
|
|
186
187
|
oldNode.parentNode?.replaceChild(node, oldNode);
|
|
187
188
|
return node;
|
|
@@ -286,7 +287,7 @@ function patchSimpleChildren(parent, oldChildren, newChildren, ctx) {
|
|
|
286
287
|
parent.appendChild(node);
|
|
287
288
|
} else if (newChild === void 0) {
|
|
288
289
|
if (existingNode) {
|
|
289
|
-
|
|
290
|
+
callRefCleanup(oldChild);
|
|
290
291
|
existingNode.remove();
|
|
291
292
|
}
|
|
292
293
|
} else {
|
|
@@ -306,7 +307,7 @@ function patchKeyedChildren(parent, oldChildren, newChildren, ctx) {
|
|
|
306
307
|
for (const key of oldKeyMap.keys()) {
|
|
307
308
|
if (!newKeys.includes(key)) {
|
|
308
309
|
const entry = oldKeyMap.get(key);
|
|
309
|
-
|
|
310
|
+
callRefCleanup(entry.vnode);
|
|
310
311
|
entry.node?.remove();
|
|
311
312
|
oldKeyMap.delete(key);
|
|
312
313
|
}
|
|
@@ -316,7 +317,7 @@ function patchKeyedChildren(parent, oldChildren, newChildren, ctx) {
|
|
|
316
317
|
if (key === void 0) {
|
|
317
318
|
const node = parent.childNodes[i];
|
|
318
319
|
if (node) {
|
|
319
|
-
|
|
320
|
+
callRefCleanup(oldChildren[i]);
|
|
320
321
|
node.remove();
|
|
321
322
|
}
|
|
322
323
|
}
|
|
@@ -337,13 +338,21 @@ function patchKeyedChildren(parent, oldChildren, newChildren, ctx) {
|
|
|
337
338
|
}
|
|
338
339
|
}
|
|
339
340
|
}
|
|
340
|
-
function
|
|
341
|
+
function runRefCleanup(vnode) {
|
|
342
|
+
if (vnode._cleanup) {
|
|
343
|
+
vnode._cleanup();
|
|
344
|
+
vnode._cleanup = void 0;
|
|
345
|
+
}
|
|
346
|
+
forEach(vnode.props?.children, (child) => {
|
|
347
|
+
if (child && typeof child === "object" && child._cleanup) {
|
|
348
|
+
runRefCleanup(child);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
function callRefCleanup(input) {
|
|
341
353
|
if (input == null || typeof input !== "object") return;
|
|
342
354
|
const vnode = input;
|
|
343
|
-
if (
|
|
344
|
-
vnode.props.ref(el);
|
|
345
|
-
}
|
|
346
|
-
forEach(vnode.props?.children, (child) => callRef(child, el));
|
|
355
|
+
if (vnode._cleanup) runRefCleanup(vnode);
|
|
347
356
|
}
|
|
348
357
|
|
|
349
358
|
// src/client/router.ts
|
package/dist/client/vnode.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface VNode {
|
|
|
16
16
|
_$?: Record<string, any>;
|
|
17
17
|
/** 子 VNode 缓存(用于 patchValue diff,避免重复执行组件) */
|
|
18
18
|
_child?: any;
|
|
19
|
+
/** ref 回调返回的清理函数,卸载时由框架调用 */
|
|
20
|
+
_cleanup?: (() => void) | undefined;
|
|
19
21
|
}
|
|
20
22
|
export type Component<P = {}> = (props: P, ctx: WfuiContext) => VNode | null;
|
|
21
23
|
export declare const Fragment: unique symbol;
|