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.
Files changed (38) hide show
  1. package/README.md +38 -41
  2. package/dist/components/DatePicker/DatePicker.d.ts +1 -1
  3. package/dist/components/List/List.d.ts +3 -0
  4. package/dist/components/Tour/Tour.d.ts +1 -1
  5. package/dist/components/index.js +12 -12
  6. package/dist/components/style.css +17 -0
  7. package/dist/index.js +1272 -1228
  8. package/dist/scheduler/index.d.ts +7 -2
  9. package/dist/ui-dom/hooks/index.d.ts +0 -1
  10. package/dist/ui-dom/hooks/popup.d.ts +1 -10
  11. package/dist/ui-dom/hooks/stable.d.ts +1 -1
  12. package/dist/ui-dom/hooks/types.d.ts +0 -1
  13. package/dist/ui-dom/index.d.ts +1 -1
  14. package/dist/ui-dom/index.js +10 -10
  15. package/dist/ui-dom/jsx-runtime.js +1 -1
  16. package/dist/ui-dom/testing.js +1 -1
  17. package/dist/ui-dom/types.d.ts +31 -41
  18. package/dist/ui-dom/vdom/audit.d.ts +20 -0
  19. package/dist/ui-dom/vdom/build.d.ts +10 -3
  20. package/dist/ui-dom/vdom/diff.d.ts +7 -8
  21. package/dist/ui-dom/vdom/index.d.ts +1 -1
  22. package/dist/ui-dom/vdom/mount.d.ts +14 -5
  23. package/dist/ui-dom/vdom/render.d.ts +4 -0
  24. package/dist/ui-dom/vdom/serve.d.ts +1 -1
  25. package/dist/ui-dom/vdom/transform.d.ts +32 -0
  26. package/dist/ui-dom/vnode.d.ts +18 -10
  27. package/docs/components.md +5 -5
  28. package/docs/custom-components.md +86 -54
  29. package/docs/examples.md +35 -41
  30. package/docs/frontend-middleware.md +3 -4
  31. package/docs/frontend-ui-dom.md +22 -18
  32. package/docs/frontend.md +243 -168
  33. package/docs/mobile.md +2 -2
  34. package/docs/realtime.md +8 -3
  35. package/package.json +1 -1
  36. package/dist/ui-dom/focus-trap.d.ts +0 -4
  37. package/dist/ui-dom/scroll-lock.d.ts +0 -5
  38. 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`(两阶段模型,外层 mount 只一次,内层 render 每次变化时执行)。无状态组件可简写为 `() => () => 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()` 手动触发,或 `ctx.ui.$()` 响应式容器 |
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
- | `useDialog()` | `useDialog({ name })` | 全屏对话框:退场状态机 + 滚动锁 + 焦点 trap(Modal/Drawer 同款) |
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 延迟卸载;useDialog 是其特例) |
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)` | **弹层组合器**:触发(hover/tap 降级/longpress)+ Escape + 外部点击 + 定位/clamp + portal |
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
- | `$.x = val` | 赋值后自动 | 微任务批量(异步) | 当前组件 | **日常 UI 状态** 表单输入、切换开关、异步数据加载等 |
212
- | `ctx.ui.dirty()` | 主动调用 | 微任务批量(异步) | 当前/指定 | **绕过 Proxy 后手动标记** |
213
- | `ctx.ui.render()` | 主动调用 | 立即同步 | 当前/指定 | **需要立即拿到最新 DOM** DOM 测量、动画触发 |
214
- | `ctx.ui.render(['id'])` | 主动调用 | 立即同步 | 指定组件 | **跨组件精准刷新**全局事件、Portal 远程控制 |
215
- | `ctx.ui.useMedia()` | 注册监听 | 浏览器事件驱动 | 当前组件 | **响应式媒体查询**断点变化时自动 dirty |
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()` 和 `dirty()` 无参 = 当前组件,传参 = 指定组件列表。三套 API 同一 scope 机制。
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
- 适合状态极少的简单组件。每次修改后手动调用 `ctx.ui.render()` 同步刷新 DOM。
236
+ **render-only 唯一规则**:渲染只发生在 `render()` 调用处(design/render-only-plan.md)——
237
+ 状态是普通对象(`let` / `createStore`),**没有 `$` Proxy、没有赋值自动渲染**。改状态后必须显式 `ctx.ui.render()`。
238
238
 
239
- ### `ctx.ui.$()` — 响应式 Proxy(推荐首选)
239
+ ### `createStore` + `ctx.ui.useExternal()` — 跨组件共享状态
240
240
 
241
- `ctx.ui.$()` 返回**深度 Proxy** 容器。任意层级赋值操作自动触发渲染(微任务批量合并):
241
+ 需要多个组件共享同一状态时(登录态、主题、全局缓存),用 `createStore`(`weifuwu/ui-dom`)订阅:
242
242
 
243
243
  ```tsx
244
- const FormPage: Component = (_init, ctx) => {
245
- const $ = ctx.ui.$()
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
- **注意**:mount/render `$.x = val` **不触发渲染**,仅事件/timer/Promise.then 中生效。这是有意设计——初始化和 mount 阶段设置状态不应触发额外渲染。
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
- **何时用 `$`**:所有需要触发 UI 重新渲染的状态。90% 以上的场景用 `$` 就够。
252
+ // 任意位置更新(写入方不需要知道谁在订阅):
253
+ store.set({ theme: 'dark' }) // 合并写 + 通知
254
+ store.update((s) => { s.user = user }) // 可变写 + 通知
255
+ store.notify() // 手动通知
256
+ ```
266
257
 
267
- **何时不用**:
268
- - 不需要触发渲染的内部缓存(用闭包变量 `let`)
269
- - 简单组件只有一两个状态变量(闭包变量 + `render()` 更轻量)
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
- 注册媒体查询监听,值变化时自动调用 callback(callback 内赋值 `$` 触发 dirty):
266
+ 注册媒体查询监听,值变化时自动重渲染当前组件(回调内改状态 + `ctx.ui.render()`):
276
267
 
277
268
  ```tsx
278
- const Card = (_init, ctx) => {
279
- const $ = ctx.ui.$()
280
- $.isMobile = false
281
- // 立即回调一次(取当前值),之后变化时自动重新回调
282
- ctx.ui.useMedia('(max-width: 640px)', (v) => { $.isMobile = v })
283
-
284
- return (props) => (
285
- <div class={$.isMobile ? 'wf-stack' : 'wf-row'}>
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
- const $ = ctx.ui.$()
302
- ctx.ui.useBreakpoint((vp) => { $.vp = vp })
303
-
304
- return (props) =>
305
- <div class={`sidebar-${$.vp}`}>
306
- {$.vp === 'mobile' ? <BottomNav /> : <SideNav />}
307
- {$.vp === 'mobile' ? <MobileContent /> : <Content />}
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) => { $.size = 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 DatePicker = (_init, ctx) => {
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
- **边界(诚实裁剪)**:Modal/Drawer 全屏对话框不进 `usePopup`(focus-trap/scroll-lock/退场状态机生命周期不同,各自实现)。
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
- 已迁移组件:**Tooltip / HoverCard / Popover / Dropdown / Menubar / Mentions / Cascader / ContextMenu**(长按双通道)。
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
- const $ = ctx.ui.$()
439
- $.data = []
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'])` / `dirty(['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`)。返回的 handle `ctx.ui.$()` **同一个 $**(页面状态与会话状态共处一容器):
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 $ = ctx.ui.useChat({
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
- return (props) =>
462
+ // 子组件订阅会话变化(AiChat 已内置 useExternal):
463
+ // const state = ctx.ui.useExternal(chat)
464
+
465
+ return async (props) =>
468
466
  h('div', {},
469
- h(AiChat, { chat: $ }), // 标准对话界面:流式 token/工具卡/审批卡/自动滚动
470
- $.streaming ? '生成中…' : '', // 会话状态与页面状态同容器
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
- | `$.messages` | `UiMessage[]` | 消息列表(`{ id, role, content, status, toolCalls?, approval?, usage?, error? }`) |
479
- | `$.input` | `string` | 输入框值(双向绑定) |
480
- | `$.streaming` | `boolean` | 是否正在流式生成 |
481
- | `$.error` | `WfError \| null` | 最近错误(code + message) |
482
- | `$.usage` | `WfUsage \| null` | token 用量(prompt/completion/total) |
483
- | `$.step` | `WfStep \| null` | 最近 agent 步骤指示(思考/工具),done/error 时清空 |
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
- **操作(`$` 上的方法)**:`$.send()`(发送当前输入)/ `$.stop()`(中止)/ `$.retry()`(截断到最后一条 user 重生成)/ `$.clear()`(清空)/ `$.approve(decision, note?)`(响应审批)/ `$.dispose()`(卸载时释放流)。
483
+ **操作(handle 上的方法)**:`chat.send()`(发送当前输入)/ `chat.stop()`(中止)/ `chat.retry()`(截断到最后一条 user 重生成)/ `chat.clear()`(清空)/ `chat.approve(decision, note?)`(响应审批)/ `chat.dispose()`(卸载时释放流)。
486
484
 
487
- **共享 $ 的子组件**(如 `<AiChat chat={$}>`):父组件 dirty 不驱动子组件(三态 skip),子组件 mount 阶段 `chat.__watch?.(() => ctx.ui.dirty())` 自订阅(AiChat 已内置)。
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` 赋值自动 dirty 当前组件
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 延迟卸载);`useDialog` 是其对话框特例 |
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.dirty()` — 异步标记脏
569
-
570
- 异步版本,无参 = 当前组件,传参 = 指定组件列表。多次调用合并为一次微任务渲染。`$` 内部就是调 `dirty()`。
571
-
572
- 与 `render()` 的区别:`dirty()` 是**异步**(微任务批量合并,同帧多次调用只渲染一次),`render()` 是**同步**(立即执行 VDOM diff + patch)。日常 UI 状态用 `$` 或 `dirty()`,需要立即拿到最新 DOM(测量/动画/第三方库)时用 `render()`。
566
+ ### `ctx.ui.render()` — 渲染唯一入口(render-only)
573
567
 
574
- ### `ctx.ui.render()` — 同步强制渲染
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
- $.animating = true
594
- ctx.ui.render() // 同步刷新 DOM
585
+ animating = true
586
+ ctx.ui.render()
595
587
  el.startViewTransition(...) // 拿到最新 DOM 启动动画
596
588
  }
597
589
 
598
590
  // 3. 第三方库需要在事件回调中读取最新 DOM
599
- onClick: () => {
600
- $.selected = !$.selected
601
- ctx.ui.render() // 确保 DOM 已更新
591
+ onClick: async () => {
592
+ selected = !selected
593
+ await ctx.ui.render() // 确保 DOM 已更新
602
594
  thirdPartyLib.measure(el) // 读取最新状态
603
595
  }
604
596
  ```
605
597
 
606
- **规则**:能用 `$` 就用 `$`。只有当你**必须同步拿到最新 DOM 状态**时才用 `render()`。
598
+ **规则**:状态是普通对象(`let` / `createStore`),改状态后必须显式 `render()`;共享状态用 `createStore` + `useExternal`(store 变更自动重渲染订阅组件)。
607
599
 
608
600
  ### 三种方式速查
609
601
 
610
602
  ```tsx
611
- // 自动:$.x = val 微任务批量,绑定当前组件
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
- // 异步:ctx.ui.dirty()微任务批量,同 render() 作用域
623
- ctx.ui.dirty()
624
- ctx.ui.dirty(['stats']) // 批处理合并
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
- - `$.x = val` 和 `dirty()` 都是微任务批量合并
629
- - `render()` dirty 组件**向下**遍历(scope render),兄弟组件不遍历
630
- - **三态 skip 自动优化**:组件重新渲染时,框架自动检查三个维度:
631
- - **props**(含 children 元素级比较)——值没变则不渲染
632
- - **`$` 状态**——没被 dirty 标记则不渲染
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()` 显式控制,不依赖 `$`,测试中 `render()` 直接 mock 为空函数。
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
- const OrderPage = (_init, ctx) => {
658
- const $ = ctx.ui.$()
659
- $.orders = [] // $ 赋值自动触发渲染
660
- $.loading = false
661
- return (props) => h('div', {}, $.loading ? h(Spinner) : h(OrderList, { orders: $.orders }))
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
- 同一个组件内可以按变量混用两种模式:需要渲染的用 `$`,不需要的用 `let`。
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 在每次 render 时自动执行**三态 skip 判定**,减少不必要的组件渲染和 DOM 操作:
685
+ weifuwu 的 VDOM 在每次渲染时自动执行**剪枝 + 三态 skip 判定**,减少不必要的组件渲染和 DOM 操作:
672
686
 
673
687
  ```
674
- canSkip = (props 没变) AND ($ 没脏) AND (ctx 版本一致)
675
- ↑ 值级浅比较 ↑ VNode dirty 标记全局版本号
688
+ canSkip = (props 没变) AND (ctx 版本一致) AND ( _child 已构建)
689
+ ↑ 值级浅比较 ↑ bumpCtxVersion 后强制重跑renderFn 不重跑
676
690
  ```
677
691
 
678
- 三个维度各自独立判断,AND 合并。任何一个维度说
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 阶段发起请求,数据通过 `$.x = val` 自动触发渲染:
780
+ 在 mount 阶段发起请求,数据到达后 `ctx.ui.render()` 触发渲染:
737
781
 
738
782
  ```tsx
739
- const UserProfile: Component = (initProps, ctx) => {
740
- const $ = ctx.ui.$()
741
- $.loading = true
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(user => { $.user = user; $.loading = false })
789
+ .then(u => { user = u; loading = false; ctx.ui.render() })
746
790
 
747
- return (props) =>
748
- $.loading
791
+ return async (props) =>
792
+ loading
749
793
  ? h('div', {}, '加载中...')
750
- : h('div', {}, $.user?.name ?? '')
794
+ : h('div', {}, user?.name ?? '')
751
795
  }
752
796
  ```
753
797
 
754
798
  ### async 组件(原生)
755
799
 
756
- 组件 = 函数,async 组件 = async 函数:签名与同步组件一致 `(initProps, ctx) => renderFn`,渲染器按「返回值是 Promise」原生判别。数据经闭包注入,渲染无 loading 分支:
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}`) // 三场景:SSR→__DATA__ / hydration 种子 / SPA fetch
761
- const $ = ctx.ui.$()
762
- $.liked = false // 客户端状态(交互后变化)
763
- return (props) =>
764
- h('div', {},
765
- h('p', {}, user.name), // 服务端状态(闭包,SSR 进 HTML)
766
- h('button', { onClick: () => $.liked = !$.liked }, $.liked ? '❤️' : '🤍'),
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
- - **客户端**:主路径 `buildVNode` async 预构建(await 全部工厂;兄弟并行)→ 落地零占位;动态挂载兑底占位 局部补全——N 处实例 = N 次工厂调用,数据走 `ctx.data` 则零成本(缓存 + 并发合并)
772
- - **服务端**:`ctx.ui.ssr()` 直接 await 工厂 数据进 HTML(无占位)
773
- - **占位显示**:动态挂载的 async 组件占位 = 注释节点,resolve 后局部补全(Suspense 边界已裁剪)
774
- - 会变的数据:初始值 seed 自服务端数据(`$.count = data.count`),交互改 `$`;初始状态必须确定性(禁止 `window.innerWidth` 直接初始化 → SSR/hydration mismatch)
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