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
package/docs/frontend.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
> 本页为 weifuwu 官方文档拆分页 · [返回 README](../README.md)
|
|
8
8
|
|
|
9
|
-
零外部 npm 运行时依赖。组件签名:`(initProps, ctx) => (props) => VNode
|
|
9
|
+
零外部 npm 运行时依赖。组件签名:`async (initProps, ctx) => (props) => Promise<VNode>`(两阶段模型,外层 mount 只一次可 await 数据,内层 renderFn 每次变化时执行——强制异步)。同步组件已不支持;无状态组件可简写为 `async (_init) => (props) => VNode`。
|
|
10
10
|
|
|
11
11
|
构建配置(esbuild):
|
|
12
12
|
|
|
@@ -54,12 +54,12 @@ const handle = uiServe(app, { root: '#root' })
|
|
|
54
54
|
import type { Component, WfuiContext } from 'weifuwu/ui-dom'
|
|
55
55
|
|
|
56
56
|
// 两阶段组件:mount(只一次)→ render(每次 dirty/props 变化)
|
|
57
|
-
const Counter: Component = (_init, ctx) => {
|
|
57
|
+
const Counter: Component = async (_init, ctx) => {
|
|
58
58
|
// ── mount ──
|
|
59
59
|
let count = 0
|
|
60
60
|
|
|
61
61
|
// ── render ──
|
|
62
|
-
return (props) =>
|
|
62
|
+
return async (props) =>
|
|
63
63
|
h('button', { onClick: () => { count++; ctx.ui.render() } }, count)
|
|
64
64
|
}
|
|
65
65
|
|
|
@@ -82,7 +82,7 @@ const DeckCard: Component<DeckCardProps> = (_init, ctx) =>
|
|
|
82
82
|
// <DeckCard title="x" pages="8" /> ✗ 编译期报错
|
|
83
83
|
|
|
84
84
|
// ② ctx 注入声明:use(api()).use(router()) 后组件声明依赖,ctx 直接访问
|
|
85
|
-
const Home: Component<{}, ApiInjected & RouteInjected> = (_init, ctx) => {
|
|
85
|
+
const Home: Component<{}, ApiInjected & RouteInjected> = async (_init, ctx) => {
|
|
86
86
|
ctx.api.get('/users') // ✓ 有类型
|
|
87
87
|
ctx.app.navigate('/x') // ✓ 有类型
|
|
88
88
|
return () => <h1>Home</h1>
|
|
@@ -104,7 +104,7 @@ uiServe(app, { root: '#root' }) // 类型累积完整(UIRouter<C & O>)
|
|
|
104
104
|
| render 阶段 | 内层函数每次 dirty/props 变化时执行,返回 VNode |
|
|
105
105
|
| 无 class | 无 `this`,无实例方法 |
|
|
106
106
|
| 无 hook | 无 `useState` / `useEffect` / `useMemo` |
|
|
107
|
-
| 状态 | 闭包变量 + `ctx.ui.render()`
|
|
107
|
+
| 状态 | 闭包变量 `let` + `ctx.ui.render()` 手动触发;跨组件共享用 `createStore()` + `ctx.ui.useExternal()` |
|
|
108
108
|
| ref 引用 | `ref={el => { if (el) init; else cleanup }}` 获取 DOM |
|
|
109
109
|
|
|
110
110
|
### JSX 工厂
|
|
@@ -184,18 +184,18 @@ await browser.copyText(text)
|
|
|
184
184
|
| `useAsync()` | `useAsync(fetcher)` | 异步取数:`data/loading/error` 响应式 + `reload()` |
|
|
185
185
|
| `useControlled()` | `useControlled({ value, onChange, name })` | 受控/非受控统一:受控判定 + 缺回调 warn + 内部状态跨渲染保持 |
|
|
186
186
|
| `useStableRef()` | `useStableRef(init, cleanup?)` | 稳定 ref 引用(根治内联 ref 陷阱) |
|
|
187
|
-
| `
|
|
187
|
+
| `usePopup()` 会话级模态 | `usePopup({ presence, trapFocus, lockScroll, positioning })` | **统一弹窗能力**:锚定浮层 + 会话级模态(Modal/Drawer 同款——退场状态机 + 滚动锁 + 焦点 trap + 居中定位)——一个入口按 options 组合 |
|
|
188
188
|
| `useGlobalKey()` | `useGlobalKey(handler)` | 全局键盘监听(window keydown:mount 注册 + 卸载清理) |
|
|
189
189
|
| `useDrag()` | `useDrag({ onMove, onStart?, onEnd? })` | 指针拖拽(pointerdown 捕获 → window move delta / up 释放) |
|
|
190
190
|
| `useDragDrop()` | `useDragDrop({ onDrop, onDragOver?, onDragLeave? })` | 原生 DnD(drop/dragover/dragleave + preventDefault,dropProps spread) |
|
|
191
191
|
| `useReducedMotion()` | `useReducedMotion()` | 响应式系统偏好(JS 动画侧跳过;CSS 动画已有全局降级) |
|
|
192
192
|
| `useAnimationEnd()` | `useAnimationEnd(cb, { once? })` | 元素动画完成回调(stableRef:挂载绑定/卸载清理/引用恒定) |
|
|
193
193
|
| `useTween()` | `useTween(target, { duration?, ease? })` | 数值补间(rAF + easeOutCubic + reduced-motion 直落;幂等 reset) |
|
|
194
|
-
| `usePresence()` | `usePresence({ name? })` | 通用显隐状态机(open→exit→closed,animationend 延迟卸载;
|
|
194
|
+
| `usePresence()` | `usePresence({ name? })` | 通用显隐状态机(open→exit→closed,animationend 延迟卸载;usePopup presence 模式内部使用) |
|
|
195
195
|
| `useMedia()` | `useMedia(query, cb)` | 响应式媒体查询,断点变化时自动回调 |
|
|
196
196
|
| `useBreakpoint()` | `useBreakpoint(cb \| bps, cb?)` | 命名断点 mobile/tablet/desktop |
|
|
197
197
|
| `usePopupPosition()` | `usePopupPosition(opts)` | 弹层坐标跟随:scroll/resize 时自动重算 fixed 坐标 |
|
|
198
|
-
| `usePopup()` | `usePopup(opts)` |
|
|
198
|
+
| `usePopup()` | `usePopup(opts)` | **统一弹窗能力层**:触发(hover/tap 降级/longpress)+ Escape + 外部点击 + 定位/clamp + portal + 会话级模态(presence/trapFocus/lockScroll/positioning none——Modal/Drawer 同款) |
|
|
199
199
|
| `useHoverCapable()` | `useHoverCapable()` | 设备是否支持 hover(`matchMedia '(hover: hover)'`),触屏降级判断 |
|
|
200
200
|
| `useLongPress()` | `useLongPress({ onLongPress, duration })` | 长按手势(pointer 事件 + 位移取消 + 桌面右键兼容) |
|
|
201
201
|
| `useVisualViewport()` | `useVisualViewport()` | 可视视口跟踪(键盘弹起/缩放),`{ height, offsetTop, keyboardOpen }` 响应式 |
|
|
@@ -208,12 +208,11 @@ await browser.copyText(text)
|
|
|
208
208
|
|
|
209
209
|
| API | 触发时机 | 渲染方式 | 作用域 | 使用场景 |
|
|
210
210
|
|------|---------|---------|--------|---------|
|
|
211
|
-
|
|
|
212
|
-
| `ctx.ui.
|
|
213
|
-
| `ctx.ui.
|
|
214
|
-
| `ctx.ui.
|
|
215
|
-
| `ctx.ui.
|
|
216
|
-
| `ctx.ui.useBreakpoint()` | 注册监听 | 浏览器事件驱动 | 当前组件 | **命名断点** — mobile/tablet/desktop 自动 dirty |
|
|
211
|
+
| `ctx.ui.render()` | 主动调用 | 异步落地(fire-and-forget,`await` 可精确等待) | 当前组件 | **唯一渲染触发** — 改状态后调用;`await` 后拿最新 DOM(测量/动画) |
|
|
212
|
+
| `ctx.ui.render(['id'])` | 主动调用 | 异步落地 | 指定组件 | **跨组件精准刷新** — 全局事件、Portal 远程控制 |
|
|
213
|
+
| `ctx.ui.useExternal(store)` | 订阅共享状态 | store 变更自动重渲染(unmount 退订) | 当前组件 | **跨组件共享状态** — createStore 唯一消费通道 |
|
|
214
|
+
| `ctx.ui.useMedia()` | 注册监听 | 浏览器事件驱动 | 当前组件 | **响应式媒体查询** — 断点变化时自动重渲染 |
|
|
215
|
+
| `ctx.ui.useBreakpoint()` | 注册监听 | 浏览器事件驱动 | 当前组件 | **命名断点** — mobile/tablet/desktop 自动重渲染 |
|
|
217
216
|
| `ctx.ui.usePopupPosition()` | 注册监听 | 浏览器事件驱动 | 当前组件 | **弹层坐标跟随** — scroll/resize 时自动重算 fixed 坐标 |
|
|
218
217
|
| `ctx.ui.usePopup()` | 注册监听 | 事件驱动 + document 监听 | 当前组件 | **弹层组合器** — 触发 + Escape + 外部点击 + 定位/clamp + portal(移动端友好由构造保证) |
|
|
219
218
|
| `ctx.ui.useHoverCapable()` | mount 期判定 | 一次 matchMedia | 当前组件 | **hover 能力检测** — 触屏降级 tap 判断 |
|
|
@@ -222,89 +221,80 @@ await browser.copyText(text)
|
|
|
222
221
|
| `ctx.ui.useInView()` | 注册监听 | IO 合成器线程评估 | 当前组件 | **可见性观察**(IO 封装,无 scroll-linked 警告)— Affix/BackTop/InView 统一使用;rootMargin/threshold 支持函数 |
|
|
223
222
|
| `ctx.ui.useScrollPosition()` | 注册监听 | 全局 scroll + rAF 节流 | 当前组件 | **滚动位置跟踪** — `y` 响应式(视口/内部容器通用),Affix/VirtualList 使用 |
|
|
224
223
|
|
|
225
|
-
`render()`
|
|
224
|
+
`render()` 无参 = 当前组件(闭包绑定),传参 = 指定组件列表。hooks(useMedia/useInView 等)是事件驱动重渲染——与"赋值自动"本质不同。
|
|
226
225
|
|
|
227
|
-
### 闭包变量 + `ctx.ui.render()
|
|
226
|
+
### 闭包变量 + `ctx.ui.render()`(唯一状态模式)
|
|
228
227
|
|
|
229
228
|
```tsx
|
|
230
|
-
const Counter: Component = (_init, ctx) => {
|
|
229
|
+
const Counter: Component = async (_init, ctx) => {
|
|
231
230
|
let count = 0
|
|
232
|
-
return (props) =>
|
|
231
|
+
return async (props) =>
|
|
233
232
|
h('button', { onClick: () => { count++; ctx.ui.render() } }, count)
|
|
234
233
|
}
|
|
235
234
|
```
|
|
236
235
|
|
|
237
|
-
|
|
236
|
+
**render-only 唯一规则**:渲染只发生在 `render()` 调用处(design/render-only-plan.md)——
|
|
237
|
+
状态是普通对象(`let` / `createStore`),**没有 `$` Proxy、没有赋值自动渲染**。改状态后必须显式 `ctx.ui.render()`。
|
|
238
238
|
|
|
239
|
-
### `ctx.ui
|
|
239
|
+
### `createStore` + `ctx.ui.useExternal()` — 跨组件共享状态
|
|
240
240
|
|
|
241
|
-
`
|
|
241
|
+
需要多个组件共享同一状态时(登录态、主题、全局缓存),用 `createStore`(`weifuwu/ui-dom`)订阅:
|
|
242
242
|
|
|
243
243
|
```tsx
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
$.email = ''
|
|
247
|
-
$.loading = false
|
|
248
|
-
return (props) =>
|
|
249
|
-
h('input', {
|
|
250
|
-
value: $.email,
|
|
251
|
-
onInput: (e: any) => { $.email = e.target.value }
|
|
252
|
-
})
|
|
253
|
-
}
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
**深度 Proxy 拦截**:
|
|
257
|
-
- `$.x = val` → 自动排队重渲染
|
|
258
|
-
- `$.obj.a = 1` → 自动 dirty(嵌套对象递归包装)
|
|
259
|
-
- `$.arr.push(val)` / `$.arr[0].x = y` → 自动 dirty(数组变异 + 嵌套属性拦截)
|
|
260
|
-
- `delete $.x` → 自动 dirty
|
|
261
|
-
- 每个组件实例独立 Proxy,同名变量不冲突
|
|
244
|
+
// 模块级单例(或组件内 createStore 传递)
|
|
245
|
+
const store = createStore({ user: null, theme: 'light' })
|
|
262
246
|
|
|
263
|
-
|
|
247
|
+
const NavBar: Component = async (_init, ctx) => {
|
|
248
|
+
const state = ctx.ui.useExternal(store) // 订阅:store 变更 → 自身自动重渲染
|
|
249
|
+
return async (props) => h('div', { class: 'nav' }, state.user?.name ?? '未登录')
|
|
250
|
+
}
|
|
264
251
|
|
|
265
|
-
|
|
252
|
+
// 任意位置更新(写入方不需要知道谁在订阅):
|
|
253
|
+
store.set({ theme: 'dark' }) // 合并写 + 通知
|
|
254
|
+
store.update((s) => { s.user = user }) // 可变写 + 通知
|
|
255
|
+
store.notify() // 手动通知
|
|
256
|
+
```
|
|
266
257
|
|
|
267
|
-
|
|
268
|
-
-
|
|
269
|
-
-
|
|
258
|
+
- `useExternal` 在 mount 阶段订阅、unmount 自动退订(无需手动清理)
|
|
259
|
+
- `store.state` 是普通对象(非 Proxy)——渲染期读最新值,无隐式触发
|
|
260
|
+
- SSR 无害:服务端 shim 返回 `store.state` 只读不订阅
|
|
270
261
|
|
|
271
262
|
### 响应式自适应组件
|
|
272
263
|
|
|
273
264
|
#### `ctx.ui.useMedia(query, callback)` — 响应式媒体查询
|
|
274
265
|
|
|
275
|
-
|
|
266
|
+
注册媒体查询监听,值变化时自动重渲染当前组件(回调内改状态 + `ctx.ui.render()`):
|
|
276
267
|
|
|
277
268
|
```tsx
|
|
278
|
-
const Card = (_init, ctx) => {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
{!$.isMobile && <Sidebar />}
|
|
269
|
+
const Card = async (_init, ctx) => {
|
|
270
|
+
let isMobile = false
|
|
271
|
+
// 立即回调一次(取当前值),之后变化时自动重渲染
|
|
272
|
+
ctx.ui.useMedia('(max-width: 640px)', (v) => { isMobile = v; ctx.ui.render() })
|
|
273
|
+
|
|
274
|
+
return async (props) => (
|
|
275
|
+
<div class={isMobile ? 'wf-stack' : 'wf-row'}>
|
|
276
|
+
{!isMobile && <Sidebar />}
|
|
287
277
|
<Content />
|
|
288
278
|
</div>
|
|
289
279
|
)
|
|
290
280
|
}
|
|
291
281
|
```
|
|
292
282
|
|
|
293
|
-
`callback` 在 mount
|
|
283
|
+
`callback` 在 mount 时立即执行一次,之后断点变化时再次执行。回调里改状态后调 `ctx.ui.render()` 触发渲染(hooks 内部已封装——组件内通常无需手动 render)。
|
|
294
284
|
|
|
295
285
|
#### `ctx.ui.useBreakpoint(callback)` — 命名断点
|
|
296
286
|
|
|
297
287
|
预设三个断点名称:`mobile`(<640px)、`tablet`(640-1023px)、`desktop`(≥1024px):
|
|
298
288
|
|
|
299
289
|
```tsx
|
|
300
|
-
const Layout = (_init, ctx) => {
|
|
301
|
-
|
|
302
|
-
ctx.ui.useBreakpoint((
|
|
303
|
-
|
|
304
|
-
return (props) =>
|
|
305
|
-
<div class={`sidebar-${
|
|
306
|
-
{
|
|
307
|
-
{
|
|
290
|
+
const Layout = async (_init, ctx) => {
|
|
291
|
+
let vp = 'desktop'
|
|
292
|
+
ctx.ui.useBreakpoint((next) => { vp = next; ctx.ui.render() })
|
|
293
|
+
|
|
294
|
+
return async (props) =>
|
|
295
|
+
<div class={`sidebar-${vp}`}>
|
|
296
|
+
{vp === 'mobile' ? <BottomNav /> : <SideNav />}
|
|
297
|
+
{vp === 'mobile' ? <MobileContent /> : <Content />}
|
|
308
298
|
</div>
|
|
309
299
|
}
|
|
310
300
|
```
|
|
@@ -314,7 +304,7 @@ const Layout = (_init, ctx) => {
|
|
|
314
304
|
```tsx
|
|
315
305
|
ctx.ui.useBreakpoint(
|
|
316
306
|
{ narrow: '(max-width: 480px)', wide: '(min-width: 1200px)' },
|
|
317
|
-
(vp) => {
|
|
307
|
+
(vp) => { size = vp; ctx.ui.render() },
|
|
318
308
|
)
|
|
319
309
|
```
|
|
320
310
|
|
|
@@ -322,8 +312,10 @@ ctx.ui.useBreakpoint(
|
|
|
322
312
|
|
|
323
313
|
解决弹出层(Popover / Tooltip / Dropdown / DatePicker 等)在 **页面滚动 / 窗口缩放后不跟随触发元素** 的问题。基于 `position: fixed` + `getBoundingClientRect()`(视口坐标)的弹层,滚动后坐标需要重算——本 API 用全局 scroll/resize 监听(rAF 节流)自动重算并精准刷新当前组件。
|
|
324
314
|
|
|
315
|
+
> **使用场景**:`usePopup` 内部已集成 usePopupPosition(弹窗组件无需直接使用);本 API 供**坐标工具**独立使用(Affix 阈值重算 / Chart tooltip)或**自定义弹层**场景。
|
|
316
|
+
|
|
325
317
|
```tsx
|
|
326
|
-
const
|
|
318
|
+
const CustomPopup = async (_init, ctx) => {
|
|
327
319
|
let show = false
|
|
328
320
|
let inputEl: HTMLElement | null = null
|
|
329
321
|
let prevOpen = false
|
|
@@ -335,7 +327,7 @@ const DatePicker = (_init, ctx) => {
|
|
|
335
327
|
compute: (r) => ({ top: r.bottom + 4, left: r.left }), // rect → 坐标
|
|
336
328
|
})
|
|
337
329
|
|
|
338
|
-
return (props) => {
|
|
330
|
+
return async (props) => {
|
|
339
331
|
const isOpen = show
|
|
340
332
|
// 打开瞬间算一次初始坐标(受控/非受控统一覆盖)
|
|
341
333
|
if (isOpen && !prevOpen) pos.refresh()
|
|
@@ -368,12 +360,14 @@ const DatePicker = (_init, ctx) => {
|
|
|
368
360
|
- **hover 触发在触屏自动降级为 tap**(内部 `matchMedia '(hover: hover)'` 判定)
|
|
369
361
|
- **Escape 关闭是 document 级**——焦点在 portal 弹层内按 Escape 也能关
|
|
370
362
|
- **外部点击关闭**(document mousedown,点弹层内部不关)
|
|
371
|
-
- **宽度自动 clamp 视口**(≤ `100vw - 32px`,375px
|
|
363
|
+
- **宽度自动 clamp 视口**(≤ `100vw - 32px`,375px 屏不横向溢出);`width` 支持 getter(DatePicker 跟随 trigger 宽)
|
|
372
364
|
- **定位 + 视口夹紧**(复用 `usePopupPosition`,超高/超宽面板平移回视口)
|
|
365
|
+
- **mask 遮罩**(`mask: true`——全屏遮罩 + 点击关闭;`maskCentered` 全屏居中——Modal 缩放预览/Command 面板;`mask: VNode` 自定义遮罩内容——Tour 挖洞高亮)
|
|
366
|
+
- **trigger `'focus'`**(DatePicker)——focus 开 + blur 延迟关(`closeDelay` 窗口内面板交互生效)
|
|
373
367
|
- 支持受控(`open`/`onOpenChange`)、动态 props(`placement`/`trigger`/`openDelay` 支持 getter)
|
|
374
368
|
|
|
375
369
|
```tsx
|
|
376
|
-
const Tooltip = (_init, ctx) => {
|
|
370
|
+
const Tooltip = async (_init, ctx) => {
|
|
377
371
|
let show = false
|
|
378
372
|
let wrapEl: HTMLElement | null = null
|
|
379
373
|
const wrapRef = (el) => { wrapEl = el }
|
|
@@ -389,7 +383,7 @@ const Tooltip = (_init, ctx) => {
|
|
|
389
383
|
openDelay: () => delay, // hover 延迟(HoverCard 用)
|
|
390
384
|
})
|
|
391
385
|
|
|
392
|
-
return (props) => h('div', { ref: wrapRef, ...popup.wrapProps }, [
|
|
386
|
+
return async (props) => h('div', { ref: wrapRef, ...popup.wrapProps }, [
|
|
393
387
|
props.children,
|
|
394
388
|
popup.portal(h('div', { class: 'wf-tooltip' }, props.content), 'tooltip'),
|
|
395
389
|
].filter(Boolean))
|
|
@@ -397,12 +391,15 @@ const Tooltip = (_init, ctx) => {
|
|
|
397
391
|
```
|
|
398
392
|
|
|
399
393
|
- `popup.wrapProps` — 触发 + Escape + focus 处理,spread 到包装/触发元素
|
|
400
|
-
- `popup.portal(content, portalKey)` — 定位 + clamp + portal(挂载 `#__wf_portal`),关闭时返回 null;自动附加 `wf-popup`
|
|
394
|
+
- `popup.portal(content, portalKey)` — 定位 + clamp + portal(挂载 `#__wf_portal`),关闭时返回 null;自动附加 `wf-popup` 基类;`positioning: 'none'` 时不加坐标(只 `position: fixed`——组件自定义定位,Modal/Toast 用)
|
|
401
395
|
- `popup.open` / `popup.setOpen()` — 状态读取与设置
|
|
396
|
+
- `popup.sync(open)` / `popup.phase` — 会话级模态模式(`presence: true`)render 期同步打开状态 + 读退场 phase(`'closed' | 'open' | 'exit'`——exit 阶段保留退场动画)
|
|
397
|
+
|
|
398
|
+
**会话级模态模式**(Modal/Drawer/Confirm 同款):`presence: true`(退场状态机)+ `trapFocus: true`(焦点 trap)+ `lockScroll: true`(滚动锁)+ `positioning: 'none'`(自定义定位)——全部能力 usePopup 内部实现(`trapFocus`/`lockScroll` 不对外导出)。
|
|
402
399
|
|
|
403
|
-
|
|
400
|
+
**已迁移组件(全部弹窗统一 usePopup 单一入口)**:Tooltip / HoverCard / Popover / Dropdown / Menubar / Mentions / Cascader / ContextMenu / Select / AutoComplete / NavMenu / Popconfirm / DatePicker(focus 触发)/ Tour(mask 自定义遮罩)/ Toast / Notification(positioning 'none' 常驻容器)/ Modal / Drawer / Confirm / Command / Img(mask 全屏遮罩)/ TreeSelect。
|
|
404
401
|
|
|
405
|
-
|
|
402
|
+
**usePopupPosition 独立用户**:Affix / Chart(tooltip)——坐标工具(非弹窗组合器),滚动跟随自动。
|
|
406
403
|
|
|
407
404
|
#### `ctx.ui.useHoverCapable()` / `useLongPress()` / `useVisualViewport()` — 移动端原语
|
|
408
405
|
|
|
@@ -433,58 +430,59 @@ const vv = ctx.ui.useVisualViewport()
|
|
|
433
430
|
|
|
434
431
|
```tsx
|
|
435
432
|
// 组件 A:mount 阶段注册自定义 ID
|
|
436
|
-
const StatsPanel = (_init, ctx) => {
|
|
433
|
+
const StatsPanel = async (_init, ctx) => {
|
|
437
434
|
ctx.ui.selfId('stats')
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
return (props) => h('div', {}, String($.data.length))
|
|
435
|
+
let data: unknown[] = []
|
|
436
|
+
return async (props) => h('div', {}, String(data.length))
|
|
441
437
|
}
|
|
442
438
|
|
|
443
439
|
// 组件 B(或其他任何地方)用 ID 精准刷新
|
|
444
|
-
ctx.ui.render(['stats'])
|
|
445
|
-
// 或:ctx.ui.dirty(['stats']) // 异步批处理版本
|
|
440
|
+
ctx.ui.render(['stats'])
|
|
446
441
|
```
|
|
447
442
|
|
|
448
443
|
**语义**:
|
|
449
444
|
|
|
450
|
-
- 必须在 **mount 阶段**调用(组件初始化时),注册后组件即可被 `render(['id'])`
|
|
445
|
+
- 必须在 **mount 阶段**调用(组件初始化时),注册后组件即可被 `render(['id'])` 精准定位
|
|
451
446
|
- **同名冲突直接抛错**,每个自定义 ID 必须全局唯一
|
|
452
447
|
- 配合 `selfId` 注册的组件在跨组件场景下无需把刷新逻辑层层传 props
|
|
453
448
|
|
|
454
449
|
#### `ctx.ui.useChat(options)` — AI 对话会话(AiChat 配套)
|
|
455
450
|
|
|
456
|
-
会话语义的流式 AI 状态容器:消息累积 / 工具调用内嵌 / HITL 审批 / stop / retry,协议对页面完全透明(wf: 协议见 `design/ai-contract.md
|
|
451
|
+
会话语义的流式 AI 状态容器:消息累积 / 工具调用内嵌 / HITL 审批 / stop / retry,协议对页面完全透明(wf: 协议见 `design/ai-contract.md`)。返回 handle 带 `subscribe(cb)`——子组件用 `ctx.ui.useExternal(chat)` 订阅会话变化(render-only 共享状态原语):
|
|
457
452
|
|
|
458
453
|
```tsx
|
|
459
454
|
// mount 阶段(服务端 `ai()` 中间件 + `AiChat` 组件配套)
|
|
460
|
-
const
|
|
455
|
+
const chat = ctx.ui.useChat({
|
|
461
456
|
url: '/api/chat', // POST 端点(返回 wf: SSE 流)
|
|
462
457
|
approveUrl: '/api/approve', // HITL 审批上行(缺省时 approve() 只清卡片)
|
|
463
458
|
body: (messages) => ({ messages, mode: 'agent' }), // 定制请求体
|
|
464
459
|
onEvent: (name, data) => { console.log('x:' + name, data) }, // x:* 透传
|
|
465
460
|
})
|
|
466
461
|
|
|
467
|
-
|
|
462
|
+
// 子组件订阅会话变化(AiChat 已内置 useExternal):
|
|
463
|
+
// const state = ctx.ui.useExternal(chat)
|
|
464
|
+
|
|
465
|
+
return async (props) =>
|
|
468
466
|
h('div', {},
|
|
469
|
-
h(AiChat, { chat
|
|
470
|
-
|
|
467
|
+
h(AiChat, { chat }), // 标准对话界面:流式 token/工具卡/审批卡/自动滚动
|
|
468
|
+
chat.streaming ? '生成中…' : '', // 会话状态(直接读 handle)
|
|
471
469
|
)
|
|
472
470
|
```
|
|
473
471
|
|
|
474
|
-
|
|
472
|
+
**状态(handle 上)**:
|
|
475
473
|
|
|
476
474
|
| 字段 | 类型 | 说明 |
|
|
477
475
|
|------|------|------|
|
|
478
|
-
|
|
|
479
|
-
|
|
|
480
|
-
|
|
|
481
|
-
|
|
|
482
|
-
|
|
|
483
|
-
|
|
|
476
|
+
| `chat.messages` | `UiMessage[]` | 消息列表(`{ id, role, content, status, toolCalls?, approval?, usage?, error? }`) |
|
|
477
|
+
| `chat.input` | `string` | 输入框值(双向绑定) |
|
|
478
|
+
| `chat.streaming` | `boolean` | 是否正在流式生成 |
|
|
479
|
+
| `chat.error` | `WfError \| null` | 最近错误(code + message) |
|
|
480
|
+
| `chat.usage` | `WfUsage \| null` | token 用量(prompt/completion/total) |
|
|
481
|
+
| `chat.step` | `WfStep \| null` | 最近 agent 步骤指示(思考/工具),done/error 时清空 |
|
|
484
482
|
|
|
485
|
-
|
|
483
|
+
**操作(handle 上的方法)**:`chat.send()`(发送当前输入)/ `chat.stop()`(中止)/ `chat.retry()`(截断到最后一条 user 重生成)/ `chat.clear()`(清空)/ `chat.approve(decision, note?)`(响应审批)/ `chat.dispose()`(卸载时释放流)。
|
|
486
484
|
|
|
487
|
-
**共享
|
|
485
|
+
**共享 handle 的子组件**(如 `<AiChat chat={chat}>`):会话状态变化 → `notify()` → `useExternal` 订阅者自动重渲染(AiChat 已内置——替代已删除的 `__watch`)。
|
|
488
486
|
|
|
489
487
|
#### `ctx.ui.useAsync(fetcher)` — 异步取数
|
|
490
488
|
|
|
@@ -496,7 +494,7 @@ const list = ctx.ui.useAsync(() => ctx.api.get<User[]>('/users'))
|
|
|
496
494
|
return () => list.loading ? h(Loading) : list.data?.map(u => h('div', {}, u.name))
|
|
497
495
|
```
|
|
498
496
|
|
|
499
|
-
- `list.data` / `list.loading` / `list.error`
|
|
497
|
+
- `list.data` / `list.loading` / `list.error` 变化自动重渲染当前组件
|
|
500
498
|
- `list.reload()` 重跑;组件卸载后旧 Promise resolve 不再触发渲染(idRegistry 查无此组件,安全忽略)
|
|
501
499
|
|
|
502
500
|
#### 动画原语(4 层能力)
|
|
@@ -506,7 +504,7 @@ return () => list.loading ? h(Loading) : list.data?.map(u => h('div', {}, u.name
|
|
|
506
504
|
| 原语 | 层 | 说明 |
|
|
507
505
|
|------|----|------|
|
|
508
506
|
| `useAnimationEnd(cb, { once? })` | 生命周期 | 元素动画完成回调(stableRef:挂载绑定/卸载清理/引用恒定)——**组件内动画事件唯一入口** |
|
|
509
|
-
| `usePresence({ name? })` | 生命周期 | 显隐状态机:open → exit → closed(animationend 延迟卸载);`
|
|
507
|
+
| `usePresence({ name? })` | 生命周期 | 显隐状态机:open → exit → closed(animationend 延迟卸载);`usePopup` presence 模式内部使用 |
|
|
510
508
|
| `useTween(target, { duration?, ease? })` | 数值驱动 | 数值补间(rAF + easeOutCubic + reduced-motion 直落;幂等 reset + 每帧自动渲染) |
|
|
511
509
|
| `useReducedMotion()` | 偏好感知 | 响应式系统偏好——**JS 动画**(rAF/tween)侧跳过(CSS 动画已有全局降级) |
|
|
512
510
|
| `useInView` / `useScrollPosition` | 数值驱动 | 进入视口播 / 滚动位置联动(已有) |
|
|
@@ -565,117 +563,134 @@ if (!ctx.ui.useReducedMotion()) { /* 启动 rAF/动画 */ }
|
|
|
565
563
|
|
|
566
564
|
> **移动端开发指南**:断点体系 / 44px 命中区纪律 / usePopup / 手势原语 / safe-area / 验收清单 → [`docs/mobile.md`](mobile.md)
|
|
567
565
|
|
|
568
|
-
### `ctx.ui.
|
|
569
|
-
|
|
570
|
-
异步版本,无参 = 当前组件,传参 = 指定组件列表。多次调用合并为一次微任务渲染。`$` 内部就是调 `dirty()`。
|
|
571
|
-
|
|
572
|
-
与 `render()` 的区别:`dirty()` 是**异步**(微任务批量合并,同帧多次调用只渲染一次),`render()` 是**同步**(立即执行 VDOM diff + patch)。日常 UI 状态用 `$` 或 `dirty()`,需要立即拿到最新 DOM(测量/动画/第三方库)时用 `render()`。
|
|
566
|
+
### `ctx.ui.render()` — 渲染唯一入口(render-only)
|
|
573
567
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
与 `dirty()` 的微任务批量不同,`render()` 是**同步执行**的。调用后立即执行 VDOM diff + patch,DOM 立刻更新。无参时只刷新当前组件,传参时可精准刷新指定组件。
|
|
568
|
+
**渲染只发生在 `render()` 调用处**——改状态后必须调 `ctx.ui.render()`(无参 = 当前组件,传参 = 指定组件列表)。异步落地(fire-and-forget,`await` 可精确等待),多次调用合并为一次渲染。
|
|
577
569
|
|
|
578
570
|
**何时必须用 `render()`**:
|
|
579
571
|
|
|
580
572
|
```tsx
|
|
581
573
|
// 1. DOM 测量(读取 offsetHeight/scrollWidth 等)
|
|
582
|
-
// 用 ref 在 DOM
|
|
583
|
-
ref: (el) => {
|
|
574
|
+
// 用 ref 在 DOM 创建后操作;需要最新 DOM 时 await render()
|
|
575
|
+
ref: async (el) => {
|
|
584
576
|
if (!el) return
|
|
585
577
|
el.style.height = 'auto'
|
|
586
|
-
ctx.ui.render()
|
|
578
|
+
await ctx.ui.render() // await 等 VDOM patch 完成
|
|
587
579
|
const h = el.offsetHeight
|
|
588
580
|
el.style.height = h + 'px'
|
|
589
581
|
}
|
|
590
582
|
|
|
591
583
|
// 2. 动画触发(需要确保上一帧 DOM 已提交)
|
|
592
584
|
function startAnimation() {
|
|
593
|
-
|
|
594
|
-
ctx.ui.render()
|
|
585
|
+
animating = true
|
|
586
|
+
ctx.ui.render()
|
|
595
587
|
el.startViewTransition(...) // 拿到最新 DOM 启动动画
|
|
596
588
|
}
|
|
597
589
|
|
|
598
590
|
// 3. 第三方库需要在事件回调中读取最新 DOM
|
|
599
|
-
onClick: () => {
|
|
600
|
-
|
|
601
|
-
ctx.ui.render()
|
|
591
|
+
onClick: async () => {
|
|
592
|
+
selected = !selected
|
|
593
|
+
await ctx.ui.render() // 确保 DOM 已更新
|
|
602
594
|
thirdPartyLib.measure(el) // 读取最新状态
|
|
603
595
|
}
|
|
604
596
|
```
|
|
605
597
|
|
|
606
|
-
|
|
598
|
+
**规则**:状态是普通对象(`let` / `createStore`),改状态后必须显式 `render()`;共享状态用 `createStore` + `useExternal`(store 变更自动重渲染订阅组件)。
|
|
607
599
|
|
|
608
600
|
### 三种方式速查
|
|
609
601
|
|
|
610
602
|
```tsx
|
|
611
|
-
//
|
|
612
|
-
const $ = ctx.ui.$()
|
|
613
|
-
$.count++
|
|
614
|
-
$.name = 'hello' // 多次赋值合并为一次渲染
|
|
615
|
-
|
|
616
|
-
// 手动:ctx.ui.render() — 同步,无参=当前,传参=指定
|
|
603
|
+
// 手动:ctx.ui.render() — 异步落地(fire-and-forget),无参=当前,传参=指定
|
|
617
604
|
let count = 0
|
|
618
605
|
count++
|
|
619
|
-
ctx.ui.render() // DOM
|
|
606
|
+
ctx.ui.render() // DOM 更新(await 可精确等待)
|
|
620
607
|
ctx.ui.render(['stats']) // 精准刷新指定组件
|
|
621
608
|
|
|
622
|
-
//
|
|
623
|
-
|
|
624
|
-
|
|
609
|
+
// 共享:createStore + useExternal — store 变更自动重渲染订阅组件
|
|
610
|
+
const store = createStore({ count: 0 })
|
|
611
|
+
store.set({ count: store.state.count + 1 }) // 通知订阅者
|
|
625
612
|
```
|
|
626
613
|
|
|
627
614
|
**性能说明**:
|
|
628
|
-
-
|
|
629
|
-
-
|
|
630
|
-
-
|
|
631
|
-
- **
|
|
632
|
-
|
|
633
|
-
- **ctx 版本**——ctx 没变化则不渲染
|
|
634
|
-
三个条件全部满足时跳过整个子树(零 `_render` 调用、零 `patchValue` 遍历)
|
|
615
|
+
- `render()` 精准渲染目标组件(renderByIds),兄弟组件不遍历
|
|
616
|
+
- **剪枝/三态 skip 自动优化**:组件重新渲染时,框架自动检查:
|
|
617
|
+
- **props**(含 children 元素级比较)——值没变则复用旧 _child(renderFn 不重跑)
|
|
618
|
+
- **ctx 版本**——`bumpCtxVersion` 后版本变化强制重跑(i18n 切换语言)
|
|
619
|
+
全部满足时跳过整个子树(零 `_render` 调用、零 `patchValue` 遍历)
|
|
635
620
|
- **lastIndex keyed diff**:列表 diff 采用正向 lastIndex 算法(React 同款),顺序不变时零 `insertBefore`。对比传统的逆序循环全量移动,DOM 修改从 O(N) 降到 O(0)。
|
|
636
621
|
- 示例:DemoButton 点击一次,DOM 修改从 34 次降到 **1 次**(仅变更文本节点的 `textContent`)
|
|
637
622
|
|
|
638
|
-
###
|
|
623
|
+
### 实践建议(render-only 唯一模式)
|
|
639
624
|
|
|
640
|
-
|
|
625
|
+
**组件库与业务层同一模式**:
|
|
641
626
|
|
|
642
627
|
```tsx
|
|
643
|
-
const DatePicker = (_init, ctx) => {
|
|
628
|
+
const DatePicker = async (_init, ctx) => {
|
|
644
629
|
let show = false // let 不触发渲染
|
|
645
|
-
return (props) =>
|
|
630
|
+
return async (props) =>
|
|
646
631
|
h('input', {
|
|
647
632
|
onClick: () => { show = true; ctx.ui.render() }
|
|
648
633
|
})
|
|
649
634
|
}
|
|
650
635
|
```
|
|
651
636
|
|
|
652
|
-
行为只由 `render()`
|
|
637
|
+
行为只由 `render()` 显式控制,测试中 `render()` mock 为空即可。
|
|
638
|
+
|
|
639
|
+
**跨组件共享**(业务层):
|
|
640
|
+
|
|
641
|
+
```tsx
|
|
642
|
+
const store = createStore({ orders: [], loading: false })
|
|
643
|
+
|
|
644
|
+
const OrderPage = async (_init, ctx) => {
|
|
645
|
+
const state = ctx.ui.useExternal(store) // 订阅:store 变更自动重渲染
|
|
646
|
+
return async (props) => h('div', {}, state.loading ? h(Spinner) : h(OrderList, { orders: state.orders }))
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// 数据到达:store.set({ orders, loading: false }) → 订阅组件自动更新
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
内部状态用 `let` + `render()`,共享状态用 `store` + `useExternal`。
|
|
653
653
|
|
|
654
|
-
|
|
654
|
+
**配置式数据定义在 mount 层 / 模块层**(剪枝命中率——三态 skip 的组件侧纪律):
|
|
655
655
|
|
|
656
656
|
```tsx
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
657
|
+
// ❌ 差:columns/options 内联 renderFn——每次 render 新建数组 + 内联函数 → Table 全量重跑
|
|
658
|
+
const DemoTable = async (_init, ctx) =>
|
|
659
|
+
async () => h(Table, { columns: [{ key: 'name', render: v => <Badge>{v}</Badge> }], ... })
|
|
660
|
+
|
|
661
|
+
// ✅ 好:静态配置定义在 mount 层 / 模块层——引用稳定 → 子组件 props 稳定 → 剪枝命中不重跑
|
|
662
|
+
const COLS = [{ key: 'name', sortable: true }] // 模块层(纯静态)
|
|
663
|
+
const DemoTable2 = async (_init, ctx) => {
|
|
664
|
+
const cols = COLS // 或工厂层(读 ctx/状态时)
|
|
665
|
+
return async () => h(Table, { columns: cols, ... })
|
|
662
666
|
}
|
|
663
667
|
```
|
|
664
668
|
|
|
665
|
-
|
|
669
|
+
**规则**:不依赖 render 期数据的配置(columns/options/items/NAV)→ mount 层或模块层定义(引用稳定,子组件剪枝命中);依赖 render 期派生数据(过滤后的列表)→ 才在 render 内构建。
|
|
666
670
|
|
|
667
|
-
|
|
671
|
+
**薄封装用普通函数**(不建组件——组件有工厂 + childCtx 开销):
|
|
672
|
+
|
|
673
|
+
```tsx
|
|
674
|
+
// ❌ 薄封装组件:TypeBadge 无状态无实例需求,却是 async 组件形态 → 每实例走 mountAsyncComponent
|
|
675
|
+
const TypeBadge: Component = async (_init) => async (props) => h(Badge, {...}, props.label)
|
|
676
|
+
|
|
677
|
+
// ✅ 普通函数:在父 renderFn 内调用(不参与 vdom 组件树——无工厂/childCtx 开销)
|
|
678
|
+
const typeBadge = (label: string, type: string) => h(Badge, { variant: ... }, label)
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
**规则**:纯透传 / 派生渲染 → 普通函数(父 renderFn 内调用);有状态 / 需实例化 / props 驱动重渲染 → 组件形态。
|
|
668
682
|
|
|
669
683
|
### VDOM diff 优化机制
|
|
670
684
|
|
|
671
|
-
weifuwu 的 VDOM
|
|
685
|
+
weifuwu 的 VDOM 在每次渲染时自动执行**剪枝 + 三态 skip 判定**,减少不必要的组件渲染和 DOM 操作:
|
|
672
686
|
|
|
673
687
|
```
|
|
674
|
-
canSkip = (props 没变) AND (
|
|
675
|
-
↑ 值级浅比较 ↑
|
|
688
|
+
canSkip = (props 没变) AND (ctx 版本一致) AND (旧 _child 已构建)
|
|
689
|
+
↑ 值级浅比较 ↑ bumpCtxVersion 后强制重跑 ↑ renderFn 不重跑
|
|
676
690
|
```
|
|
677
691
|
|
|
678
|
-
|
|
692
|
+
条件全部满足时复用旧 `_child`(零 `_render` 调用、零 `patchValue` 遍历)——
|
|
693
|
+
版本变化(i18n 切换)时剪枝失效,所有组件重跑 renderFn。
|
|
679
694
|
|
|
680
695
|
---
|
|
681
696
|
|
|
@@ -694,6 +709,35 @@ canSkip = (props 没变) AND ($ 没脏) AND (ctx 版本一致)
|
|
|
694
709
|
))}
|
|
695
710
|
```
|
|
696
711
|
|
|
712
|
+
### 列表性能(v3——剪枝命中率是唯一性能变量)
|
|
713
|
+
|
|
714
|
+
渲染引擎对**大列表**的性能模型:剪枝命中(props 同 + 版本同)→ 复用旧子树(renderFn 不重跑 + diff 零递归)。
|
|
715
|
+
**剪枝只对组件生效**——native 元素(`<div>`/`<tr>` 等)每次渲染都会全量 patch:
|
|
716
|
+
|
|
717
|
+
| 列表行形态 | 更新单行 | 说明 |
|
|
718
|
+
|---|---|---|---|
|
|
719
|
+
| **组件包裹**(推荐) | 剪枝命中,~O(1) | 行 props 不变 → renderFn 不重跑 + diff 跳过 |
|
|
720
|
+
| 裸 native 元素 | 全量 patch O(n) | 每次 render 重建整树 + 全量 diff(1000 行 ~30-40ms jsdom) |
|
|
721
|
+
|
|
722
|
+
```tsx
|
|
723
|
+
// ✅ 大列表行用组件包裹(剪枝生效——更新单行 O(1))
|
|
724
|
+
const Row = (_init, ctx) =>
|
|
725
|
+
(props) => h('div', { class: 'row' }, h('span', {}, props.label))
|
|
726
|
+
|
|
727
|
+
const List = (_init, ctx) =>
|
|
728
|
+
(props) => h('div', {}, props.items.map(r => h(Row, { key: r.id, label: r.label })))
|
|
729
|
+
|
|
730
|
+
// ❌ 裸 native 行:每次 render 全量 patch(1000 行 = 全量遍历)
|
|
731
|
+
{items.map(item => <div key={item.id}>{item.name}</div>)}
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
- **更新单行/单单元格**:数据模型建议行级状态(行组件各自持有状态 + `ctx.ui.render()` 精准刷新该行),
|
|
735
|
+
而非整表状态(整表 renderFn 重跑必然重建全部行)
|
|
736
|
+
- **稳定数组透传**:renderFn 直接返回 `props.items`(不 map 重建)时,引用短路生效——未变项零 diff
|
|
737
|
+
(V3-3a)
|
|
738
|
+
- 基准(1000 行 keyed 列表,jsdom):首帧 build 0.6ms + render 26ms;更新单行(组件剪枝)DOM 写 0;
|
|
739
|
+
头部插入 DOM 写 1
|
|
740
|
+
|
|
697
741
|
---
|
|
698
742
|
|
|
699
743
|
## ref 管理 DOM
|
|
@@ -701,10 +745,10 @@ canSkip = (props 没变) AND ($ 没脏) AND (ctx 版本一致)
|
|
|
701
745
|
使用 `ref` prop 获取元素引用,适合管理第三方库或读取 DOM:
|
|
702
746
|
|
|
703
747
|
```tsx
|
|
704
|
-
const Timer: Component = (_init, ctx) => {
|
|
748
|
+
const Timer: Component = async (_init, ctx) => {
|
|
705
749
|
let timer: ReturnType<typeof setInterval> | undefined
|
|
706
750
|
|
|
707
|
-
return (props) =>
|
|
751
|
+
return async (props) =>
|
|
708
752
|
h('div', {
|
|
709
753
|
ref: (el) => {
|
|
710
754
|
if (el) {
|
|
@@ -733,45 +777,76 @@ return h('div', {},
|
|
|
733
777
|
|
|
734
778
|
### 异步组件
|
|
735
779
|
|
|
736
|
-
在 mount
|
|
780
|
+
在 mount 阶段发起请求,数据到达后 `ctx.ui.render()` 触发渲染:
|
|
737
781
|
|
|
738
782
|
```tsx
|
|
739
|
-
const UserProfile: Component = (initProps, ctx) => {
|
|
740
|
-
|
|
741
|
-
|
|
783
|
+
const UserProfile: Component = async (initProps, ctx) => {
|
|
784
|
+
let loading = true
|
|
785
|
+
let user: { name?: string } | null = null
|
|
742
786
|
|
|
743
787
|
fetch(`/api/user/${initProps.id}`)
|
|
744
788
|
.then(r => r.json())
|
|
745
|
-
.then(
|
|
789
|
+
.then(u => { user = u; loading = false; ctx.ui.render() })
|
|
746
790
|
|
|
747
|
-
return (props) =>
|
|
748
|
-
|
|
791
|
+
return async (props) =>
|
|
792
|
+
loading
|
|
749
793
|
? h('div', {}, '加载中...')
|
|
750
|
-
: h('div', {},
|
|
794
|
+
: h('div', {}, user?.name ?? '')
|
|
751
795
|
}
|
|
752
796
|
```
|
|
753
797
|
|
|
754
798
|
### async 组件(原生)
|
|
755
799
|
|
|
756
|
-
组件 = 函数,async 组件 = async
|
|
800
|
+
组件 = 函数,async 组件 = async 函数:**两阶段都异步**(统一签名 `async (initProps, ctx) => async (props) => Promise<VNode>`)——工厂层(mount 一次)与 renderFn(每次 dirty/props 变化)都可 await 数据。渲染器在 buildVNode 阶段 await 全部;diff 永不执行 renderFn。数据经闭包注入,渲染无 loading 分支:
|
|
757
801
|
|
|
758
802
|
```tsx
|
|
759
803
|
const UserProfile = async (initProps, ctx) => {
|
|
760
|
-
const user = await ctx.data.get(`/api/user/${initProps.userId}`) //
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
h('div', {},
|
|
765
|
-
h('p', {}, user.name),
|
|
766
|
-
h('
|
|
804
|
+
const user = await ctx.data.get(`/api/user/${initProps.userId}`) // ① 工厂层:数据不随 props 变(三场景:SSR→__DATA__ / hydration 种子 / SPA fetch)
|
|
805
|
+
let liked = false // 客户端状态(交互后变化,render-only)
|
|
806
|
+
return async (props) => {
|
|
807
|
+
const related = await ctx.data.get(`/api/user/${props.userId}/related`) // ② renderFn 层:数据随 props 变(每次重跑取新数据)
|
|
808
|
+
return h('div', {},
|
|
809
|
+
h('p', {}, user.name), // 服务端状态(闭包,SSR 进 HTML)
|
|
810
|
+
h('span', {}, `相关 ${related.length}`),
|
|
811
|
+
h('button', { onClick: () => { liked = !liked; ctx.ui.render() } }, liked ? '❤️' : '🤍'),
|
|
767
812
|
)
|
|
813
|
+
}
|
|
768
814
|
}
|
|
769
815
|
```
|
|
770
816
|
|
|
771
|
-
-
|
|
772
|
-
-
|
|
773
|
-
-
|
|
774
|
-
-
|
|
817
|
+
- **两阶段数据分层**:数据不随 props 变 → 工厂层 await(只一次,`ctx.data` 缓存);随 props/状态变 → renderFn 层 await(每次重跑,props 变化自动刷新)
|
|
818
|
+
- **客户端**:主路径 `buildVNode` async 预构建(await 全部工厂 + renderFn;**兄弟组件并行取数**)→ 落地零占位;运行时首次挂载的 async 组件在 buildVNode 阶段 await(无占位/补全回调)——N 处实例 = N 次工厂调用,数据走 `ctx.data` 则零成本(缓存 + 并发合并)
|
|
819
|
+
- **服务端**:`ctx.ui.ssr()` 直接 await 工厂 + renderFn → 数据进 HTML(无占位;数组分支 Promise.all 并行取数)
|
|
820
|
+
- 初始状态必须确定性(禁止 `window.innerWidth` 直接初始化 → SSR/hydration mismatch)
|
|
821
|
+
|
|
822
|
+
### 取数模式(机制与策略分离——不绑定 ctx.data)
|
|
823
|
+
|
|
824
|
+
renderFn 内可 await **任意 Promise**(fetch / `ctx.api` / 第三方 SDK / `ctx.data`)——渲染管线对三种模式一视同仁(并发取数 + 原子落地 + props 自动刷新都成立)。取数是**策略**(开发者决定),框架只提供机制:
|
|
825
|
+
|
|
826
|
+
| 模式 | 写法 | 语义 | 适用 |
|
|
827
|
+
|---|---|---|---|
|
|
828
|
+
| **ctx.data 管道** | `await ctx.data.get(key, fetcher)` | 缓存 + 并发合并 + SSR 三场景(fetcher 可以是任意函数) | 重复执行 / 跨组件共享 / 需要 SSR 的数据 |
|
|
829
|
+
| **直接 await** | `await fetch(...)` / `ctx.api.get(...)` / SDK | **每次 renderFn 重跑重新执行**(无缓存) | 一次性局部取数 |
|
|
830
|
+
| **事件驱动** | 闭包 `let` + fetch + `ctx.ui.render()` | 只执行一次,renderFn 读闭包 | 需精确控制触发时机 / 有副作用 |
|
|
831
|
+
|
|
832
|
+
**决策规则**:数据会重复执行或跨组件共享?→ ctx.data;一次性局部数据?→ 直接 await;需精确控制时机?→ 事件驱动。
|
|
833
|
+
|
|
834
|
+
**红线**:
|
|
835
|
+
- 直接 await = **每次 renderFn 重跑重新请求**(父组件无关状态变化也会触发)——高频数据用 ctx.data 缓存防重复
|
|
836
|
+
- renderFn 内 await 应为**幂等取数**(副作用走事件驱动)
|
|
837
|
+
- ctx.data 的 fetcher 可以是**任意函数**(不只框架 API):`ctx.data.get('/key', () => sdk.query(...))`
|
|
838
|
+
|
|
839
|
+
**页面形态纪律(B-1)**:路由 handler(UIHandler)直接返回 vnode——**页面根是 native vnode,无组件 `_render`/`_id`,handler 闭包内的 `let` 状态 + `ctx.ui.render()` 无效**(静默空操作——`render()` 无参无目标会 console.warn 提示)。页面内部状态两种正确写法:
|
|
840
|
+
1. **async 组件形态**(推荐):`const Page: Component = async (initProps, ctx) => { let state = ...; return async (props) => h(...) }`——handler 返回 `h(Page, {})`
|
|
841
|
+
2. **createStore + useExternal**:跨页面共享状态
|
|
842
|
+
|
|
843
|
+
```tsx
|
|
844
|
+
// ❌ UIHandler 闭包内部状态(render() 空操作)
|
|
845
|
+
const Home: UIHandler = async (_loc, ctx) => { let clicks = 0; return h(Button, { onClick: () => { clicks++; ctx.ui.render() } }) }
|
|
846
|
+
// ✅ handler 只返回组件 vnode,状态在组件里
|
|
847
|
+
const ClickCounter: Component = async (_init, ctx) => { let clicks = 0; return async () => h(Button, { onClick: () => { clicks++; ctx.ui.render() } }) }
|
|
848
|
+
const Home: UIHandler = async () => h('div', {}, h(ClickCounter, {}))
|
|
849
|
+
```
|
|
775
850
|
|
|
776
851
|
---
|
|
777
852
|
|