weifuwu 0.76.0 → 0.78.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/README.md +38 -41
- package/dist/components/DatePicker/DatePicker.d.ts +1 -1
- package/dist/components/List/List.d.ts +3 -0
- package/dist/components/Tour/Tour.d.ts +1 -1
- package/dist/components/index.js +12 -12
- package/dist/components/style.css +17 -0
- package/dist/index.js +1272 -1228
- package/dist/scheduler/index.d.ts +7 -2
- package/dist/ui-dom/hooks/index.d.ts +0 -1
- package/dist/ui-dom/hooks/popup.d.ts +1 -10
- package/dist/ui-dom/hooks/stable.d.ts +1 -1
- package/dist/ui-dom/hooks/types.d.ts +0 -1
- package/dist/ui-dom/index.d.ts +1 -1
- package/dist/ui-dom/index.js +10 -10
- package/dist/ui-dom/jsx-runtime.js +1 -1
- package/dist/ui-dom/testing.js +1 -1
- package/dist/ui-dom/types.d.ts +31 -41
- package/dist/ui-dom/vdom/audit.d.ts +20 -0
- package/dist/ui-dom/vdom/build.d.ts +10 -3
- package/dist/ui-dom/vdom/diff.d.ts +7 -8
- package/dist/ui-dom/vdom/index.d.ts +1 -1
- package/dist/ui-dom/vdom/mount.d.ts +14 -5
- package/dist/ui-dom/vdom/render.d.ts +4 -0
- package/dist/ui-dom/vdom/serve.d.ts +1 -1
- package/dist/ui-dom/vdom/transform.d.ts +32 -0
- package/dist/ui-dom/vnode.d.ts +18 -10
- package/docs/components.md +5 -5
- package/docs/custom-components.md +86 -54
- package/docs/examples.md +35 -41
- package/docs/frontend-middleware.md +3 -4
- package/docs/frontend-ui-dom.md +22 -18
- package/docs/frontend.md +243 -168
- package/docs/mobile.md +2 -2
- package/docs/realtime.md +8 -3
- package/package.json +1 -1
- package/dist/ui-dom/focus-trap.d.ts +0 -4
- package/dist/ui-dom/scroll-lock.d.ts +0 -5
- package/dist/ui-dom/vdom/scheduler.d.ts +0 -13
|
@@ -13,31 +13,51 @@
|
|
|
13
13
|
import { h, type Component } from 'weifuwu/ui-dom'
|
|
14
14
|
|
|
15
15
|
// Component<P, C>:P = props(JSX 自动推断),C = ctx 注入依赖(默认 {})
|
|
16
|
-
const Badge: Component<{ text: string; color?: string }> = () =>
|
|
17
|
-
(props) => h('span', { class: 'my-badge', style: { color: props.color } }, props.text)
|
|
16
|
+
const Badge: Component<{ text: string; color?: string }> = async () =>
|
|
17
|
+
async (props) => h('span', { class: 'my-badge', style: { color: props.color } }, props.text)
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
- 两阶段:外层 `(initProps, ctx) => …` 只执行一次(mount),内层 `(props) => VNode
|
|
21
|
-
-
|
|
20
|
+
- 两阶段:外层 `(initProps, ctx) => …` 只执行一次(mount),内层 `(props) => Promise<VNode>` 每次渲染执行(renderFn 强制异步——可 await 数据)
|
|
21
|
+
- 有状态组件用闭包 `let` + 事件里 `ctx.ui.render()`(render-only);不需要渲染的状态不调 `render()`
|
|
22
|
+
|
|
23
|
+
### mount 与 render 的职责(事件函数写在哪层)
|
|
24
|
+
|
|
25
|
+
| | mount(外层工厂,一次) | render(内层 renderFn,每次) |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| 职责 | 初始化状态 / 订阅 / 定时器 / **定义依赖稳定引用的回调** | 读最新 props / 派生数据 / **定义依赖它们的回调** / 输出视图 |
|
|
28
|
+
| 可访问 | `initProps`(首次)、`ctx`、mount `let`、稳定 handle | 最新 `props`、mount 闭包、`ctx` |
|
|
29
|
+
| 事件函数 | **只依赖稳定引用**(ctx / mount let / 稳定 handle 如 useChat 的 `chat`)→ mount 定义,天然引用恒等 | 依赖最新 props / 派生状态(如 Table 的 `rowSelection`、Menu 的 `openSet`)→ render 内定义(闭包捕获最新值) |
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
const AiChat = async (initProps, ctx) => {
|
|
33
|
+
const chat = initProps.chat // 稳定 handle(useChat 返回,引用不变)
|
|
34
|
+
const onSend = () => chat.send() // ✅ mount 定义:只依赖稳定引用——引用恒等,零重绑
|
|
35
|
+
return async (props) => {
|
|
36
|
+
const onSelect = (k: string) => props.onSelect?.(k) // ✅ render 定义:依赖最新 props——闭包捕获当前值
|
|
37
|
+
return h('button', { onClick: onSelect }, '选')
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**规则**:回调只依赖 ctx / mount `let` / 稳定 handle → **mount 定义**(天然稳定,不重绑);依赖最新 props / 派生数据 → **render 内定义**(闭包捕获最新值;引用变化导致事件重绑是**正确性要求**——必须读最新状态,框架不做稳定引用魔法)。
|
|
22
43
|
|
|
23
44
|
## 1. 有状态组件
|
|
24
45
|
|
|
25
46
|
```tsx
|
|
26
|
-
const Toggle: Component = (_init, ctx) => {
|
|
27
|
-
|
|
28
|
-
$.on = false
|
|
47
|
+
const Toggle: Component = async (_init, ctx) => {
|
|
48
|
+
let on = false // 普通对象状态(render-only:无 $ Proxy)
|
|
29
49
|
|
|
30
|
-
return (props) => h('button', {
|
|
50
|
+
return async (props) => h('button', {
|
|
31
51
|
class: 'my-toggle',
|
|
32
|
-
onClick: () =>
|
|
33
|
-
},
|
|
52
|
+
onClick: () => { on = !on; ctx.ui.render() }, // 改状态后显式 render()
|
|
53
|
+
}, on ? '开' : '关')
|
|
34
54
|
}
|
|
35
55
|
```
|
|
36
56
|
|
|
37
57
|
| 状态类型 | 存放位置 | 触发渲染 |
|
|
38
58
|
|---------|---------|---------|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
59
|
+
| 组件内部状态 | 闭包 `let` | 改后调 `ctx.ui.render()` |
|
|
60
|
+
| 共享状态 | `createStore` + `ctx.ui.useExternal()` | store 变更自动 |
|
|
41
61
|
| 内部缓存 | 闭包 `let` | 不触发 |
|
|
42
62
|
|
|
43
63
|
## 2. 带弹层的组件(最高频的自定义场景)
|
|
@@ -45,23 +65,22 @@ const Toggle: Component = (_init, ctx) => {
|
|
|
45
65
|
用 `ctx.ui.usePopup`——一个组合器收敛 open 状态 + 触发(hover→tap 降级/longpress)+ Escape + 外部点击 + 定位/视口 clamp + portal:
|
|
46
66
|
|
|
47
67
|
```tsx
|
|
48
|
-
const MyPopover: Component<{ content: string }> = (_init, ctx) => {
|
|
49
|
-
|
|
50
|
-
$.open = false
|
|
68
|
+
const MyPopover: Component<{ content: string }> = async (_init, ctx) => {
|
|
69
|
+
let open = false
|
|
51
70
|
let wrapEl: HTMLElement | null = null
|
|
52
71
|
const wrapRef = (el: HTMLElement | null) => { wrapEl = el }
|
|
53
72
|
|
|
54
73
|
const popup = ctx.ui.usePopup({
|
|
55
74
|
trigger: 'hover', // 触屏自动降级 tap(useHoverCapable 内部判定)
|
|
56
75
|
el: () => wrapEl, // 锚点
|
|
57
|
-
isOpen: () =>
|
|
58
|
-
setOpen: (v) => {
|
|
76
|
+
isOpen: () => open,
|
|
77
|
+
setOpen: (v) => { open = v; ctx.ui.render() }, // 改状态 + render
|
|
59
78
|
width: 320, // 自动 clamp 视口
|
|
60
79
|
closeOnOutside: true, // 外部点击关闭(默认)
|
|
61
80
|
closeOnEscape: true, // Escape 关闭(默认,document 级——portal 焦点也生效)
|
|
62
81
|
})
|
|
63
82
|
|
|
64
|
-
return (props) =>
|
|
83
|
+
return async (props) =>
|
|
65
84
|
h('span', { class: 'anchor', ref: wrapRef, ...popup.wrapProps },
|
|
66
85
|
props.children,
|
|
67
86
|
popup.portal(h('div', { class: 'wf-panel' }, props.content)),
|
|
@@ -73,54 +92,62 @@ const MyPopover: Component<{ content: string }> = (_init, ctx) => {
|
|
|
73
92
|
|
|
74
93
|
## 3. 对话框类组件(Modal 系)
|
|
75
94
|
|
|
76
|
-
全屏对话框(焦点 trap + 滚动锁 +
|
|
95
|
+
全屏对话框(焦点 trap + 滚动锁 + 退场动画)是 usePopup 的**会话级模态模式**(`presence/trapFocus/lockScroll/positioning: 'none'`——Modal/Drawer 同款):
|
|
77
96
|
|
|
78
97
|
```tsx
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
98
|
+
const MyDialog: Component<{ open: boolean; onClose: () => void }> = async (_init, ctx) => {
|
|
99
|
+
let latestOpen = false
|
|
100
|
+
const popup = ctx.ui.usePopup({
|
|
101
|
+
presence: true, // 退场状态机(open → exit → closed + animationend)
|
|
102
|
+
trapFocus: true, // 焦点 trap(面板挂载锁定/卸载归还)
|
|
103
|
+
lockScroll: true, // 滚动锁(打开锁 / 面板卸载释放)
|
|
104
|
+
positioning: 'none', // 组件自定义定位(.wf-modal inset:0 居中)
|
|
105
|
+
closeOnOutside: false, closeOnEscape: false, // 关闭语义组件自控
|
|
106
|
+
isOpen: () => latestOpen,
|
|
107
|
+
setOpen: () => {},
|
|
108
|
+
}) // mount 创建
|
|
109
|
+
|
|
110
|
+
return async (props) => {
|
|
111
|
+
latestOpen = !!props.open
|
|
112
|
+
const phase = popup.sync!(latestOpen) // render 同步 open
|
|
86
113
|
if (phase === 'closed') return null
|
|
87
114
|
|
|
88
|
-
return
|
|
115
|
+
return popup.portal(h('div', {
|
|
89
116
|
class: 'wf-overlay',
|
|
90
117
|
onClick: (e: any) => { if (e.target === e.currentTarget) props.onClose() },
|
|
91
118
|
}, h('div', {
|
|
92
119
|
class: `wf-modal ${phase === 'exit' ? 'wf-modal--exit' : 'wf-modal--enter'}`,
|
|
93
|
-
ref: dialog.panelRef, // 焦点 trap 目标
|
|
94
120
|
onKeyDown: (e: any) => { if (e.key === 'Escape') props.onClose() }, // Escape 语义组件层
|
|
95
|
-
}, props.children)),
|
|
121
|
+
}, props.children)), 'modal') // portalKey 语义化(#__wf_portal 容器标记)
|
|
96
122
|
}
|
|
97
123
|
}
|
|
98
124
|
```
|
|
99
125
|
|
|
100
|
-
>
|
|
101
|
-
>
|
|
126
|
+
> **ref 接线**:`trapFocus`/`lockScroll`/presence 退场监听全部由 usePopup 内部接线到 portal 面板
|
|
127
|
+
> (`portalPanelRef`——content 的 `ref` prop 会被转发调用)——组件层无需手挂 `rootRef`/`panelRef`。
|
|
128
|
+
> 低层原语 `trapFocus`/`lockScroll` 已收编为 usePopup 内部实现(**不对外导出**——AGENTS.md §5.4);
|
|
129
|
+
> `animateOut` 仍从 `weifuwu/ui-dom` 导出(非弹窗动画场景用)。
|
|
102
130
|
|
|
103
131
|
## 4. AI 组件
|
|
104
132
|
|
|
105
133
|
会话语义由 `ctx.ui.useChat` 提供(消息/流式/工具/审批/stop/retry 全封装),返回的 handle 与 `$` 同一容器:
|
|
106
134
|
|
|
107
135
|
```tsx
|
|
108
|
-
const ChatPanel: Component = (_init, ctx) => {
|
|
109
|
-
const
|
|
136
|
+
const ChatPanel: Component = async (_init, ctx) => {
|
|
137
|
+
const chat = ctx.ui.useChat({
|
|
110
138
|
url: '/api/chat',
|
|
111
139
|
approveUrl: '/api/approve', // HITL 审批上行(缺省 approve() 只清卡片)
|
|
112
140
|
body: (messages) => ({ messages, mode: 'agent' }),
|
|
113
141
|
})
|
|
114
142
|
|
|
115
143
|
return () => h('div', { class: 'chat' },
|
|
116
|
-
|
|
117
|
-
h('
|
|
118
|
-
h('button', { onClick: () => $.send() }, $.streaming ? '…' : '发送'),
|
|
144
|
+
h(AiChat, { chat }), // 标准界面:输入/消息/流式/工具卡全内置
|
|
145
|
+
h('button', { onClick: () => chat.send() }, chat.streaming ? '…' : '发送'),
|
|
119
146
|
)
|
|
120
147
|
}
|
|
121
148
|
```
|
|
122
149
|
|
|
123
|
-
**共享
|
|
150
|
+
**共享 handle 给子组件**(如 `<AiChat chat={chat} />`):会话 handle 带 `subscribe(cb)`——子组件 mount 期 `ctx.ui.useExternal(initProps.chat)` 自订阅(AiChat 已内置),会话状态变化自动重渲染订阅组件。
|
|
124
151
|
|
|
125
152
|
## 5. 异步组件(数据声明在工厂层)
|
|
126
153
|
|
|
@@ -129,29 +156,27 @@ const ChatPanel: Component = (_init, ctx) => {
|
|
|
129
156
|
```tsx
|
|
130
157
|
const UserCard = async (initProps, ctx) => {
|
|
131
158
|
const user = await ctx.data.get(`/api/user/${initProps.userId}`) // 三场景:SSR→__DATA__ / hydration 种子 / SPA fetch
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return (props) => h('div', {}, user.name, h('button', { onClick: () => $.liked = !$.liked }))
|
|
159
|
+
let liked = false
|
|
160
|
+
return async (props) => h('div', {}, user.name, h('button', { onClick: () => { liked = !liked; ctx.ui.render() } }))
|
|
135
161
|
}
|
|
136
162
|
```
|
|
137
163
|
|
|
138
|
-
- 渲染器按「返回值是 Promise」判别:主路径 `buildVNode` await
|
|
164
|
+
- 渲染器按「返回值是 Promise」判别:主路径 `buildVNode` await 全部(无占位);运行时首次挂载的 async 组件在 buildVNode 阶段 await(无占位/补全回调);SSR 直接 await
|
|
139
165
|
- 工厂按实例执行;**数据必须走 ctx.data**(缓存+并发合并,重复执行零成本);禁止副作用裸写工厂
|
|
140
|
-
-
|
|
141
|
-
- **个性化数据不进 ctx.data**(SSR 会序列化给所有客户端)——留在客户端 `$` + fetch
|
|
166
|
+
- **个性化数据不进 ctx.data**(SSR 会序列化给所有客户端)——留在客户端 `let` + fetch + `render()`
|
|
142
167
|
|
|
143
168
|
## 6. 类型纪律(编译期防线)
|
|
144
169
|
|
|
145
170
|
```tsx
|
|
146
|
-
const Badge: Component<{ variant: 'primary' | 'muted' }> = () =>
|
|
147
|
-
(props) => h('span', { class: `badge-${props.variant}` }, props.children)
|
|
171
|
+
const Badge: Component<{ variant: 'primary' | 'muted' }> = async () =>
|
|
172
|
+
async (props) => h('span', { class: `badge-${props.variant}` }, props.children)
|
|
148
173
|
|
|
149
174
|
// 负例:variant 传错 → tsc 报错(@ts-expect-error 是类型流测试的写法)
|
|
150
175
|
// @ts-expect-error variant 不允许 'bogus'
|
|
151
176
|
const bad: { variant: 'primary' | 'muted' } = { variant: 'bogus' }
|
|
152
177
|
|
|
153
178
|
// ctx 注入声明(C 泛型):声明了才能用,未声明编译期报错
|
|
154
|
-
const Page: Component<{}, { api: ApiInjected['api'] }> = (_init, ctx) => {
|
|
179
|
+
const Page: Component<{}, { api: ApiInjected['api'] }> = async (_init, ctx) => {
|
|
155
180
|
ctx.api.get('/x')
|
|
156
181
|
return () => null
|
|
157
182
|
}
|
|
@@ -192,8 +217,8 @@ render() // 重渲染,状态保留
|
|
|
192
217
|
新受控组件**必须**用 `ctx.ui.useControlled`(受控判定 + 缺回调 warn 一次 + 非受控内部状态跨渲染保持):
|
|
193
218
|
|
|
194
219
|
```tsx
|
|
195
|
-
const CollapseItem: Component<{ active?: boolean; onChange?: (v: boolean) => void }> = (_init, ctx) => {
|
|
196
|
-
return (props) => {
|
|
220
|
+
const CollapseItem: Component<{ active?: boolean; onChange?: (v: boolean) => void }> = async (_init, ctx) => {
|
|
221
|
+
return async (props) => {
|
|
197
222
|
const ctrl = ctx.ui.useControlled<boolean>({ value: props.active, onChange: props.onChange, name: 'CollapseItem' })
|
|
198
223
|
return h('button', {
|
|
199
224
|
onClick: () => ctrl.setValue(!(ctrl.value ?? false)), // 受控走 onChange;非受控内部状态
|
|
@@ -221,10 +246,10 @@ const CollapseItem: Component<{ active?: boolean; onChange?: (v: boolean) => voi
|
|
|
221
246
|
> SSR 安全(shim 安全默认)+ 测试 mock 单点 + 环境差异隔离。
|
|
222
247
|
|
|
223
248
|
```tsx
|
|
224
|
-
const MyComp: Component = (_init, ctx) => {
|
|
249
|
+
const MyComp: Component = async (_init, ctx) => {
|
|
225
250
|
// mount 层取 browser(ctx.browser 优先,测试/无注入环境 fallback jsdom)
|
|
226
251
|
const browser = ctx.browser ?? createClientBrowser()
|
|
227
|
-
return (props) =>
|
|
252
|
+
return async (props) =>
|
|
228
253
|
h('button', {
|
|
229
254
|
onClick: () => {
|
|
230
255
|
// 复制/查询/存储/滚动——全部经 browser
|
|
@@ -266,9 +291,16 @@ const MyComp: Component = (_init, ctx) => {
|
|
|
266
291
|
|
|
267
292
|
## 已知边界(诚实裁剪)
|
|
268
293
|
|
|
269
|
-
-
|
|
294
|
+
- **引擎自动写入的 DOM 属性(开发者不需要处理,但写自定义组件时会在 DOM 里看到)**:
|
|
295
|
+
- `data-wf-id`——组件实例 id,写到组件输出**每个顶层节点**(多根全部写)——渲染定位/audit/debug 用;存在性可预期,值不可预期(`_wf_N` 引擎分配)
|
|
296
|
+
- `data-wf-key`——数组项 key(显式或默认下标),写到元素项自身 / **组件项穿透到输出每个顶层节点**——列表项身份可见,动态增删重排建议显式 key(默认下标 = 位置复用 + 状态继承)
|
|
297
|
+
- `<!--wf-hole: xxx-->` 占位注释——条件渲染 false/null/true/非法输入的占位节点(`{cond && <X/>}`=false 时 DOM 里有注释而非消失)——不是 bug,是引擎的透明占位
|
|
298
|
+
- SSR 不输出 `data-wf-id`(id 客户端运行时分配);`data-wf-key` SSR 同步输出
|
|
299
|
+
- 断言/快照测试注意:`outerHTML` 包含这些属性与占位注释;按类选择器/子项数量断言不受影响
|
|
300
|
+
- **列表 key 纪律**:渲染的列表是**有内部状态的组件实例 + 动态增删/重排**(如可输入的卡片)→ 必须显式 key(项 id),否则默认下标位置复用会让后续项继承被删项的内部状态;纯元素列表(格子/行/节点 div)默认下标即可——通用列表组件对外提供 `keyBy`(如 `List`)
|
|
301
|
+
|
|
302
|
+
- `usePopup` 是**统一弹窗能力层**:锚定浮层(Tooltip/Popover/Dropdown/Select/AutoComplete/Mentions/Cascader/ContextMenu/NavMenu/Popconfirm/TreeSelect)+ 会话级模态(Modal/Drawer/Confirm——presence/trapFocus/lockScroll/positioning 'none',Escape 语义留组件层)+ mask 模式(Command/Img preview/Tour——mask/maskCentered/自定义 mask VNode)+ focus 触发(DatePicker)+ positioning 'none' 常驻容器(Toast/Notification)——**全部弹窗单一入口**
|
|
270
303
|
- **事件监听纪律**:组件库内部浏览器事件监听**统一走 `ctx.ui.useXXX`**——滚动/观察/弹层/对话框/快捷键/拖拽/DnD 全覆盖:
|
|
271
|
-
`useInView`(InfiniteScroll)、`useScrollPosition`(AiChat/Affix/BackTop/VirtualList)、`usePopupPosition`(Affix 阈值重算)、`usePopup
|
|
272
|
-
-
|
|
273
|
-
- **Select/DatePicker** 是 inline/absolute 菜单(自适宽),不迁移 usePopup——菜单直接挂在锚点下
|
|
304
|
+
`useInView`(InfiniteScroll)、`useScrollPosition`(AiChat/Affix/BackTop/VirtualList)、`usePopupPosition`(Affix 阈值重算)、`usePopup`(弹窗统一——ContextMenu 自由定位 + Modal/Drawer 模态模式 + mask 遮罩)、`useGlobalKey`(Command 快捷键/Img preview Escape)、`useDrag`(Resizable)、`useDragDrop`(FileUpload)、`useControlled`/`useStableRef`(状态/ref)
|
|
305
|
+
- **唯一保留 usePopupPosition 独立使用**:Affix / Chart(坐标工具——非弹窗组合器,滚动跟随自动)
|
|
274
306
|
- `createReactiveState` 已导出:组件外建全局 store(`createReactiveState(() => {})` + `$.__watch(cb)` 订阅)
|
package/docs/examples.md
CHANGED
|
@@ -5,12 +5,11 @@
|
|
|
5
5
|
## 登录表单
|
|
6
6
|
|
|
7
7
|
```tsx
|
|
8
|
-
const LoginPage = (_init, ctx) => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
$.submitting = false
|
|
8
|
+
const LoginPage = async (_init, ctx) => {
|
|
9
|
+
let errors: Record<string, string> = {}
|
|
10
|
+
let submitting = false
|
|
12
11
|
|
|
13
|
-
return (props) =>
|
|
12
|
+
return async (props) =>
|
|
14
13
|
h('div', { class: 'wf-stack', style: { maxWidth: 400, margin: '40px auto' } },
|
|
15
14
|
h(Card, { padding: 'lg' },
|
|
16
15
|
h('div', { class: 'wf-stack', style: { gap: 'var(--wf-space-md)' } },
|
|
@@ -21,17 +20,19 @@ const LoginPage = (_init, ctx) => {
|
|
|
21
20
|
password: [{ required: true, minLength: 6, message: '密码至少6位' }],
|
|
22
21
|
},
|
|
23
22
|
onSubmit: async (values) => {
|
|
24
|
-
|
|
23
|
+
submitting = true
|
|
24
|
+
ctx.ui.render()
|
|
25
25
|
await ctx.api?.post('/login', values) // api 客户端由中间件注入 ctx.api
|
|
26
|
-
|
|
26
|
+
submitting = false
|
|
27
|
+
ctx.ui.render()
|
|
27
28
|
},
|
|
28
|
-
onError: (
|
|
29
|
+
onError: (errs) => { errors = errs; ctx.ui.render() },
|
|
29
30
|
}, [
|
|
30
|
-
h(Field, { label: '邮箱', error:
|
|
31
|
+
h(Field, { label: '邮箱', error: errors.email },
|
|
31
32
|
h(Input, { name: 'email', type: 'email', placeholder: 'name@example.com' })),
|
|
32
|
-
h(Field, { label: '密码', error:
|
|
33
|
+
h(Field, { label: '密码', error: errors.password },
|
|
33
34
|
h(Input, { name: 'password', type: 'password' })),
|
|
34
|
-
h(Button, { type: 'submit', loading:
|
|
35
|
+
h(Button, { type: 'submit', loading: submitting, block: true }, '登录'),
|
|
35
36
|
])
|
|
36
37
|
)
|
|
37
38
|
)
|
|
@@ -42,20 +43,19 @@ const LoginPage = (_init, ctx) => {
|
|
|
42
43
|
## 数据列表 + 搜索
|
|
43
44
|
|
|
44
45
|
```tsx
|
|
45
|
-
const UserList = (_init, ctx) => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
$.sortOrder = 'asc'
|
|
46
|
+
const UserList = async (_init, ctx) => {
|
|
47
|
+
let keyword = ''
|
|
48
|
+
let sortKey = 'name'
|
|
49
|
+
let sortOrder = 'asc'
|
|
50
50
|
const users = [
|
|
51
51
|
{ id: 1, name: '张三', email: 'zhang@example.com', role: '管理员' },
|
|
52
52
|
{ id: 2, name: '李四', email: 'li@example.com', role: '编辑' },
|
|
53
53
|
]
|
|
54
54
|
|
|
55
|
-
return (props) => {
|
|
56
|
-
// 派生数据必须在 render 内计算(每次 render 读最新
|
|
55
|
+
return async (props) => {
|
|
56
|
+
// 派生数据必须在 render 内计算(每次 render 读最新 keyword)
|
|
57
57
|
const filtered = users.filter(u =>
|
|
58
|
-
|
|
58
|
+
!keyword || u.name.includes(keyword) || u.email.includes(keyword)
|
|
59
59
|
)
|
|
60
60
|
|
|
61
61
|
return h('div', { class: 'wf-stack', style: { gap: 'var(--wf-space-md)' } },
|
|
@@ -85,39 +85,33 @@ const UserList = (_init, ctx) => {
|
|
|
85
85
|
## 消息提示
|
|
86
86
|
|
|
87
87
|
```tsx
|
|
88
|
-
//
|
|
89
|
-
|
|
88
|
+
// 官方推荐:命令式中间件(app.use(toast()) → ctx.toast('消息', 'success'))
|
|
89
|
+
app.use(toast())
|
|
90
|
+
// 任意组件:ctx.toast?.('操作成功', 'success')
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
$.toasts = $.toasts ?? []
|
|
95
|
-
const id = String(++toastId)
|
|
96
|
-
$.toasts = [...$.toasts, { id, type, message }]
|
|
92
|
+
// 自管理列表(render-only:let + render())
|
|
93
|
+
let toasts: { id: string; type: string; message: string }[] = []
|
|
94
|
+
let toastId = 0
|
|
97
95
|
|
|
98
|
-
|
|
96
|
+
function showToast(ctx: WfuiContext, type: string, message: string) {
|
|
97
|
+
toasts = [...toasts, { id: String(++toastId), type, message }]
|
|
98
|
+
ctx.ui.render()
|
|
99
99
|
if (type !== 'error') {
|
|
100
|
-
setTimeout(() => {
|
|
101
|
-
$.toasts = $.toasts.filter((t: any) => t.id !== id)
|
|
102
|
-
}, 3000)
|
|
100
|
+
setTimeout(() => { toasts = toasts.filter((t: any) => t.id !== String(toastId)); ctx.ui.render() }, 3000)
|
|
103
101
|
}
|
|
104
102
|
}
|
|
105
103
|
|
|
106
104
|
// 页面中使用
|
|
107
|
-
const App = (_init, ctx) => {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return (props) =>
|
|
105
|
+
const App = async (_init, ctx) => {
|
|
106
|
+
toasts = []
|
|
107
|
+
return async (props) =>
|
|
112
108
|
h('div', {}, [
|
|
113
|
-
h(Button, {
|
|
114
|
-
onClick: () => showToast(ctx, 'success', '操作成功'),
|
|
115
|
-
}, '显示提示'),
|
|
109
|
+
h(Button, { onClick: () => showToast(ctx, 'success', '操作成功') }, '显示提示'),
|
|
116
110
|
h(Toast, {
|
|
117
|
-
toasts
|
|
111
|
+
toasts,
|
|
118
112
|
position: 'top-right',
|
|
119
113
|
max: 3,
|
|
120
|
-
onRemove: (id) => {
|
|
114
|
+
onRemove: (id) => { toasts = toasts.filter((t: any) => t.id !== id); ctx.ui.render() },
|
|
121
115
|
}),
|
|
122
116
|
])
|
|
123
117
|
}
|
|
@@ -24,8 +24,7 @@ uiServe(app, { root: '#root' }) // 客户端落地(hydrate: true 收养 SS
|
|
|
24
24
|
|
|
25
25
|
```tsx
|
|
26
26
|
const DashboardLayout: UIMiddleware = async (_loc, ctx, children) => {
|
|
27
|
-
|
|
28
|
-
$.open = true
|
|
27
|
+
let open = true
|
|
29
28
|
return async (loc, c) => {
|
|
30
29
|
const child = await children(loc, c) // 子路由/嵌套路由内容
|
|
31
30
|
return h('div', { class: 'wf-row' },
|
|
@@ -132,8 +131,8 @@ app.use(auth())
|
|
|
132
131
|
uiServe(app, { root: '#root' })
|
|
133
132
|
|
|
134
133
|
// 在组件中
|
|
135
|
-
function Profile(_props: {}, ctx: WfuiContext) {
|
|
136
|
-
return (props) => {
|
|
134
|
+
async function Profile(_props: {}, ctx: WfuiContext) {
|
|
135
|
+
return async (props) => {
|
|
137
136
|
if (!ctx.auth?.isLoggedIn) return <p>请登录</p>
|
|
138
137
|
return <p>欢迎, {ctx.auth?.user?.name}</p>
|
|
139
138
|
}
|
package/docs/frontend-ui-dom.md
CHANGED
|
@@ -23,16 +23,15 @@
|
|
|
23
23
|
### 两阶段组件模型:从 hooks 心智负担中解脱
|
|
24
24
|
|
|
25
25
|
```tsx
|
|
26
|
-
const Counter = (initProps, ctx) => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
h('button', { onClick: () => $.count += props.step }, $.count) // render
|
|
26
|
+
const Counter = async (initProps, ctx) => {
|
|
27
|
+
let count = initProps.initial ?? 0 // mount:只执行一次
|
|
28
|
+
return async (props) =>
|
|
29
|
+
h('button', { onClick: () => { count += props.step; ctx.ui.render() } }, count) // render
|
|
31
30
|
}
|
|
32
31
|
```
|
|
33
32
|
|
|
34
|
-
- **没有 hooks 规则、没有依赖数组、没有闭包陷阱**。外层 = 初始化(一次),内层 =
|
|
35
|
-
-
|
|
33
|
+
- **没有 hooks 规则、没有依赖数组、没有闭包陷阱**。外层 = 初始化(一次),内层 = 渲染(每次变化)。
|
|
34
|
+
- **render-only 确定性渲染**(design/render-only-plan.md):渲染只发生在 `ctx.ui.render()` 调用处——改状态后显式 `render()`,行为可静态推导。无 `$` Proxy、无隐式触发;跨组件共享用 `createStore` + `ctx.ui.useExternal()`。
|
|
36
35
|
|
|
37
36
|
### 框架即纪律:浏览器环境抽象把常见坑变成编译期/审计期错误
|
|
38
37
|
|
|
@@ -43,6 +42,7 @@ const Counter = (initProps, ctx) => {
|
|
|
43
42
|
|
|
44
43
|
- **零 npm 运行时依赖**(对比 React + react-dom + react-router + 状态库 + SSR 工具 5+ 依赖)。
|
|
45
44
|
- **自研 VDOM/diff**(keyed children、style diff、CSS 变量、Portal、hydration 游标收养)——每个算法都有对应测试与纪律条目(真实事故沉淀)。**读源码即可完全理解框架行为**。
|
|
45
|
+
- **VDOM 对开发者透明(占位法 + 单一规则源)**:写 JSX → 看 DOM 即真相——`data-wf-key`(数组项身份,元素/组件一致)、`data-wf-id`(组件实例身份)、`<!--wf-hole: xxx-->`(条件渲染 false 的占位注释)直接在 DOM 可见;非法输入(对象/数字 type)→ 诊断占位 + warn,不崩溃不静默;`?vdom_debug=1` 开启 patch trace + 结构 audit。**转化路径唯一清晰、无 magic、可推导**(规则表:design/vdom-transform-rules.md)
|
|
46
46
|
- **零构建步骤**:`weifuwu/dev` loader + `ctx.ui.js/css` 动态编译,服务端直接跑 `.tsx`,改组件刷新即生效。
|
|
47
47
|
|
|
48
48
|
### 弹层/浮层体系:最难的 UI 类别变成复用的原语
|
|
@@ -55,7 +55,7 @@ AGENTS.md 每条纪律对应真实事故(JSONViewer selfId 错位、AutoComple
|
|
|
55
55
|
|
|
56
56
|
| 维度 | 价值 |
|
|
57
57
|
|------|------|
|
|
58
|
-
| **上手** | 两阶段组件 +
|
|
58
|
+
| **上手** | 两阶段组件 + 改状态后 render()——无 hooks/依赖数组心智负担 |
|
|
59
59
|
| **后端互迁** | req/res/中间件契约同构,SSR 透明,一份 router 两端共享 |
|
|
60
60
|
| **可靠性** | 确定性失败、诚实裁剪、环境边界、测试侧同构 |
|
|
61
61
|
| **效率** | 弹层/数据管道/事件原语全覆盖——不重复造轮子 |
|
|
@@ -74,14 +74,13 @@ const app = new UIRouter()
|
|
|
74
74
|
|
|
75
75
|
app.use(toast()) // ctx 注入链(对齐后端 app.use——注入 ctx.toast)
|
|
76
76
|
|
|
77
|
-
// handler = 异步组件:async (location, ctx) => VNode
|
|
77
|
+
// handler = 异步组件:async (location, ctx) => VNode(render-only——改状态后 ctx.ui.render())
|
|
78
78
|
app.get('/', async (location, ctx) => {
|
|
79
79
|
const info = await ctx.data.get('/api/info', async () => ({ title: '首页' }))
|
|
80
|
-
|
|
81
|
-
$.clicks = $.clicks ?? 0
|
|
80
|
+
let clicks = 0
|
|
82
81
|
return h('div', {},
|
|
83
82
|
h('h2', {}, info.title),
|
|
84
|
-
h(Button, { onClick: () =>
|
|
83
|
+
h(Button, { onClick: () => { clicks++; ctx.ui.render() } }, `点击 ${clicks} 次`),
|
|
85
84
|
h(Button, { variant: 'secondary', onClick: () => ctx.toast?.('提示', 'success') }, '弹 toast'),
|
|
86
85
|
)
|
|
87
86
|
})
|
|
@@ -119,17 +118,22 @@ handler 只产 VNode,落地由 serve 决定——SSR 与 SPA 是两种落地
|
|
|
119
118
|
|
|
120
119
|
VNode 契约以 ui-dom 为唯一来源(Fragment/Portal symbol 由 ui-dom 自持)——components 产的 VNode
|
|
121
120
|
直接被 ui-dom 渲染器识别。渲染算法(render/diff/createUi 原语)在 ui-dom 内自主实现,
|
|
122
|
-
**registry/popup-tracker
|
|
121
|
+
**registry/popup-tracker 局部实例**(serve 每实例隔离)。
|
|
123
122
|
|
|
124
123
|
命令式工厂(toast/confirm/notification)位于 ui-dom(`src/ui-dom/Toast.ts` 等):
|
|
125
124
|
components 消费端 import `weifuwu/ui-dom`(构建外部化,共享同一模块实例)。
|
|
126
125
|
|
|
127
|
-
###
|
|
126
|
+
### render-only 渲染(唯一触发:ctx.ui.render())
|
|
128
127
|
|
|
129
|
-
|
|
|
128
|
+
| 原语 | 触发 | 重渲染范围 |
|
|
130
129
|
|------|------|-----------|
|
|
131
|
-
|
|
|
132
|
-
|
|
|
130
|
+
| `ctx.ui.render()`(无参) | 主动调用 | 当前组件(闭包绑定,无 this 陷阱) |
|
|
131
|
+
| `ctx.ui.render(['id'])` | 主动调用 | 指定组件(selfId 注册的语义 ID) |
|
|
132
|
+
| `ctx.ui.useExternal(store)` | store 变更自动 | **仅订阅组件**(unmount 自动退订) |
|
|
133
|
+
|
|
134
|
+
- 状态是普通对象(`let` / `createStore`)——改状态后显式 `render()`,无赋值自动渲染
|
|
135
|
+
- 跨组件共享:`createStore` + `useExternal`(替代已删除的 `$` Proxy / `dirty`)
|
|
136
|
+
- hooks(useMedia/useInView/usePopup 等)事件驱动重渲染——与"赋值自动"本质不同
|
|
133
137
|
|
|
134
138
|
## SSR + hydration(端到端)
|
|
135
139
|
|
|
@@ -154,7 +158,7 @@ uiServe(app, { root: '#root', hydrate: true })
|
|
|
154
158
|
|------|------|
|
|
155
159
|
| `ctx.params` / `ctx.query` | 路由参数 / URL query(顶层,对齐后端) |
|
|
156
160
|
| `ctx.data.get/set/has` | 数据管道(缓存 + in-flight 合并 + `__DATA__` 种子) |
|
|
157
|
-
| `ctx.ui.*` |
|
|
161
|
+
| `ctx.ui.*` | 原语(`render`/`useExternal`/`usePopup`/`useChat`/`useInView`…) |
|
|
158
162
|
| `ctx.browser.*` | 环境抽象(window/document 唯一入口,SSR shim 同构) |
|
|
159
163
|
| `ctx.toast` / `ctx.confirm` / `ctx.notification` | 命令式注入(`app.use(toast())` 等) |
|
|
160
164
|
|