weifuwu 0.63.0 → 0.64.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.
@@ -0,0 +1,697 @@
1
+ # 前端 API 核心(weifuwu/client)
2
+
3
+ > 以下为完整 API 参考,按需查阅。新手建议先阅读 README 的「核心概念」和「快速开始」。
4
+
5
+ > 本页为 weifuwu 官方文档拆分页 · [返回 README](../README.md)
6
+
7
+ 零外部 npm 运行时依赖。组件签名:`(initProps, ctx) => (props) => VNode`(两阶段模型,外层 mount 只一次,内层 render 每次变化时执行)。无状态组件可简写为 `() => () => VNode`。
8
+
9
+ 构建配置(esbuild):
10
+
11
+ ```js
12
+ esbuild.build({
13
+ jsx: 'automatic',
14
+ jsxImportSource: 'weifuwu/client',
15
+ bundle: true,
16
+ })
17
+ ```
18
+
19
+ ---
20
+
21
+ ## createApp — 应用引导
22
+
23
+ ```tsx
24
+ import { createApp } from 'weifuwu/client'
25
+
26
+ const app = createApp()
27
+
28
+ // 注册中间件
29
+ app.use(middleware1)
30
+ app.use(middleware2)
31
+
32
+ // 挂载到 DOM
33
+ app.mount('#root', RootComponent)
34
+
35
+ // 获取当前 ctx
36
+ console.log(app.ctx)
37
+
38
+ // 销毁
39
+ app.destroy()
40
+ ```
41
+
42
+ | 方法 | 说明 |
43
+ |------|------|
44
+ | `createApp()` | 创建应用实例 |
45
+ | `app.use(mw)` | 注册 AppMiddleware |
46
+ | `app.mount(selector, RootComponent)` | 挂载到 DOM |
47
+ | `app.destroy()` | 卸载应用 |
48
+ | `app.ctx` | 当前 WfuiContext |
49
+
50
+ ---
51
+
52
+ ## 组件模型
53
+
54
+ ```tsx
55
+ import type { Component, WfuiContext } from 'weifuwu/client'
56
+
57
+ // 两阶段组件:mount(只一次)→ render(每次 dirty/props 变化)
58
+ const Counter: Component = (_init, ctx) => {
59
+ // ── mount ──
60
+ let count = 0
61
+
62
+ // ── render ──
63
+ return (props) =>
64
+ h('button', { onClick: () => { count++; ctx.ui.render() } }, count)
65
+ }
66
+
67
+ // 无状态组件:只有 render
68
+ const Badge: Component = () =>
69
+ (props) => h('span', { class: `badge-${props.variant}` }, props.children)
70
+ ```
71
+
72
+ ### 类型流(props 泛型 + ctx 注入)
73
+
74
+ ```tsx
75
+ import type { Component } from 'weifuwu/client'
76
+ import type { ApiInjected, RouteInjected } from 'weifuwu/client'
77
+
78
+ // ① props 泛型:JSX 使用时自动类型检查(传错类型编译期报错)
79
+ interface DeckCardProps { title: string; pages: number }
80
+ const DeckCard: Component<DeckCardProps> = (_init, ctx) =>
81
+ (props) => <div>{props.title} / {props.pages} 页</div>
82
+ // <DeckCard title="x" pages={8} /> ✓
83
+ // <DeckCard title="x" pages="8" /> ✗ 编译期报错
84
+
85
+ // ② ctx 注入声明:use(api()).use(router()) 后组件声明依赖,ctx 直接访问
86
+ const Home: Component<{}, ApiInjected & RouteInjected> = (_init, ctx) => {
87
+ ctx.api.get('/users') // ✓ 有类型
88
+ ctx.app.navigate('/x') // ✓ 有类型
89
+ return () => <h1>Home</h1>
90
+ }
91
+ // 未声明的注入字段编译期报错——注入从"文档约定"变成"类型保证"
92
+
93
+ createApp()
94
+ .use(api()) // 注入 ctx.api
95
+ .use(router({ routes })) // 注入 ctx.route / ctx.app
96
+ .mount('#root', Home) // mount 时类型累积完整
97
+ ```
98
+
99
+ > 各中间件的注入接口:`api()` → `ApiInjected`、`auth()` → `AuthInjected`、`ws()` → `WsInjected`、`i18n()` → `I18nInjected`、`router()` → `RouteInjected`(均可从 `weifuwu/client` 导入)。
100
+
101
+ | 规则 | 说明 |
102
+ |------|------|
103
+ | 组件签名 | `(initProps: P, ctx: WfuiContext) => (props: P) => VNode \| null` |
104
+ | mount 阶段 | 外层函数只执行一次,初始化状态 |
105
+ | render 阶段 | 内层函数每次 dirty/props 变化时执行,返回 VNode |
106
+ | 无 class | 无 `this`,无实例方法 |
107
+ | 无 hook | 无 `useState` / `useEffect` / `useMemo` |
108
+ | 状态 | 闭包变量 + `ctx.ui.render()` 手动触发,或 `ctx.ui.$()` 响应式容器 |
109
+ | ref 引用 | `ref={el => { if (el) init; else cleanup }}` 获取 DOM |
110
+
111
+ ### JSX 工厂
112
+
113
+ ```tsx
114
+ // 由 esbuild 自动调用(jsxImportSource: 'weifuwu/client')
115
+ import { h, jsx, jsxs, jsxDEV, Fragment } from 'weifuwu/client'
116
+
117
+ // h 支持 variadic children
118
+ h('div', { class: 'x' }, child1, child2)
119
+
120
+ // Fragment
121
+ <><div>A</div><div>B</div></>
122
+ ```
123
+
124
+ | 导出 | 用途 |
125
+ |------|------|
126
+ | `h(type, props, ...children)` | hyperscript |
127
+ | `jsx` / `jsxs` / `jsxDEV` | JSX 编译目标 |
128
+ | `Fragment` | 片段 |
129
+ | `Portal` / `createPortal(children, portalKey?)` | 渲染到 `document.body#__wf_portal` 独立容器(弹层/对话框,脱离父级 overflow 裁剪) |
130
+
131
+ ```tsx
132
+ import { createPortal } from 'weifuwu/client'
133
+
134
+ // 内容渲染到 body 下的独立容器(不在父组件的 DOM 树内)
135
+ const Tooltip = (_init, ctx) =>
136
+ (props) => createPortal(
137
+ <div class="tooltip">{props.text}</div>
138
+ )
139
+
140
+ // 配合 ctx.ui.selfId('name') 可从任何地方精准刷新 portal 内容
141
+ ctx.ui.render(['name'])
142
+ ```
143
+
144
+ ---
145
+
146
+ ## 状态管理
147
+
148
+ ### ctx.ui 方法速查
149
+
150
+ | 方法 | 签名 | 一句话说明 |
151
+ |------|------|-----------|
152
+ | `$()` | `$(): Record<string, any>` | 深度 Proxy 响应式状态容器,赋值自动触发渲染(**推荐首选**) |
153
+ | `render()` | `render(ids?: string[])` | 同步强制渲染;无参 = 当前组件,传参 = 指定组件列表 |
154
+ | `dirty()` | `dirty(ids?: string[])` | 异步渲染(微任务批处理合并);`$` 内部就是调它 |
155
+ | `selfId()` | `selfId(name: string)` | 注册组件自定义 ID,配合 `render(['id'])` 跨组件精准刷新 |
156
+ | `useMedia()` | `useMedia(query, cb)` | 响应式媒体查询,断点变化时自动回调 |
157
+ | `useBreakpoint()` | `useBreakpoint(cb \| bps, cb?)` | 命名断点 mobile/tablet/desktop |
158
+ | `usePopupPosition()` | `usePopupPosition(opts)` | 弹层坐标跟随:scroll/resize 时自动重算 fixed 坐标 |
159
+ | `usePopup()` | `usePopup(opts)` | **弹层组合器**:触发(hover/tap 降级/longpress)+ Escape + 外部点击 + 定位/clamp + portal |
160
+ | `useHoverCapable()` | `useHoverCapable()` | 设备是否支持 hover(`matchMedia '(hover: hover)'`),触屏降级判断 |
161
+ | `useLongPress()` | `useLongPress({ onLongPress, duration })` | 长按手势(pointer 事件 + 位移取消 + 桌面右键兼容) |
162
+ | `useVisualViewport()` | `useVisualViewport()` | 可视视口跟踪(键盘弹起/缩放),`{ height, offsetTop, keyboardOpen }` 响应式 |
163
+ | `useInView()` | `useInView(opts)` | 可见性观察(IntersectionObserver 封装,替代组件自建 scroll 监听);`isIn` 响应式 + `ready` |
164
+ | `useScrollPosition()` | `useScrollPosition({ getScroller? })` | 滚动位置跟踪(全局 scroll 监听 + rAF 节流);`y` 响应式,容器/视口通用 |
165
+
166
+ > 每个方法的完整说明见下文对应章节。
167
+
168
+ ### Render 机制总览
169
+
170
+ | API | 触发时机 | 渲染方式 | 作用域 | 使用场景 |
171
+ |------|---------|---------|--------|---------|
172
+ | `$.x = val` | 赋值后自动 | 微任务批量(异步) | 当前组件 | **日常 UI 状态** — 表单输入、切换开关、异步数据加载等 |
173
+ | `ctx.ui.dirty()` | 主动调用 | 微任务批量(异步) | 当前/指定 | **绕过 Proxy 后手动标记** |
174
+ | `ctx.ui.render()` | 主动调用 | 立即同步 | 当前/指定 | **需要立即拿到最新 DOM** — DOM 测量、动画触发 |
175
+ | `ctx.ui.render(['id'])` | 主动调用 | 立即同步 | 指定组件 | **跨组件精准刷新** — 全局事件、Portal 远程控制 |
176
+ | `ctx.ui.useMedia()` | 注册监听 | 浏览器事件驱动 | 当前组件 | **响应式媒体查询** — 断点变化时自动 dirty |
177
+ | `ctx.ui.useBreakpoint()` | 注册监听 | 浏览器事件驱动 | 当前组件 | **命名断点** — mobile/tablet/desktop 自动 dirty |
178
+ | `ctx.ui.usePopupPosition()` | 注册监听 | 浏览器事件驱动 | 当前组件 | **弹层坐标跟随** — scroll/resize 时自动重算 fixed 坐标 |
179
+ | `ctx.ui.usePopup()` | 注册监听 | 事件驱动 + document 监听 | 当前组件 | **弹层组合器** — 触发 + Escape + 外部点击 + 定位/clamp + portal(移动端友好由构造保证) |
180
+ | `ctx.ui.useHoverCapable()` | mount 期判定 | 一次 matchMedia | 当前组件 | **hover 能力检测** — 触屏降级 tap 判断 |
181
+ | `ctx.ui.useLongPress()` | 事件驱动 | pointer 事件 | 当前组件 | **长按手势** — ContextMenu 触屏触发、自定义长按操作 |
182
+ | `ctx.ui.useVisualViewport()` | 注册监听 | visualViewport resize/scroll | 当前组件 | **键盘/缩放跟踪** — fixed 底部栏防键盘遮挡(AiChat `raiseOnKeyboard`) |
183
+ | `ctx.ui.useInView()` | 注册监听 | IO 合成器线程评估 | 当前组件 | **可见性观察**(IO 封装,无 scroll-linked 警告)— Affix/BackTop/InView 统一使用;rootMargin/threshold 支持函数 |
184
+ | `ctx.ui.useScrollPosition()` | 注册监听 | 全局 scroll + rAF 节流 | 当前组件 | **滚动位置跟踪** — `y` 响应式(视口/内部容器通用),Affix/VirtualList 使用 |
185
+
186
+ `render()` 和 `dirty()` 无参 = 当前组件,传参 = 指定组件列表。三套 API 同一 scope 机制。
187
+
188
+ ### 闭包变量 + `ctx.ui.render()`(简单场景)
189
+
190
+ ```tsx
191
+ const Counter: Component = (_init, ctx) => {
192
+ let count = 0
193
+ return (props) =>
194
+ h('button', { onClick: () => { count++; ctx.ui.render() } }, count)
195
+ }
196
+ ```
197
+
198
+ 适合状态极少的简单组件。每次修改后手动调用 `ctx.ui.render()` 同步刷新 DOM。
199
+
200
+ ### `ctx.ui.$()` — 响应式 Proxy(推荐首选)
201
+
202
+ `ctx.ui.$()` 返回**深度 Proxy** 容器。任意层级赋值操作自动触发渲染(微任务批量合并):
203
+
204
+ ```tsx
205
+ const FormPage: Component = (_init, ctx) => {
206
+ const $ = ctx.ui.$()
207
+ $.email = ''
208
+ $.loading = false
209
+ return (props) =>
210
+ h('input', {
211
+ value: $.email,
212
+ onInput: (e: any) => { $.email = e.target.value }
213
+ })
214
+ }
215
+ ```
216
+
217
+ **深度 Proxy 拦截**:
218
+ - `$.x = val` → 自动排队重渲染
219
+ - `$.obj.a = 1` → 自动 dirty(嵌套对象递归包装)
220
+ - `$.arr.push(val)` / `$.arr[0].x = y` → 自动 dirty(数组变异 + 嵌套属性拦截)
221
+ - `delete $.x` → 自动 dirty
222
+ - 每个组件实例独立 Proxy,同名变量不冲突
223
+
224
+ **注意**:mount/render 中 `$.x = val` **不触发渲染**,仅事件/timer/Promise.then 中生效。这是有意设计——初始化和 mount 阶段设置状态不应触发额外渲染。
225
+
226
+ **何时用 `$`**:所有需要触发 UI 重新渲染的状态。90% 以上的场景用 `$` 就够。
227
+
228
+ **何时不用**:
229
+ - 不需要触发渲染的内部缓存(用闭包变量 `let`)
230
+ - 简单组件只有一两个状态变量(闭包变量 + `render()` 更轻量)
231
+
232
+ ### 响应式自适应组件
233
+
234
+ #### `ctx.ui.useMedia(query, callback)` — 响应式媒体查询
235
+
236
+ 注册媒体查询监听,值变化时自动调用 callback(callback 内赋值 `$` 触发 dirty):
237
+
238
+ ```tsx
239
+ const Card = (_init, ctx) => {
240
+ const $ = ctx.ui.$()
241
+ $.isMobile = false
242
+ // 立即回调一次(取当前值),之后变化时自动重新回调
243
+ ctx.ui.useMedia('(max-width: 640px)', (v) => { $.isMobile = v })
244
+
245
+ return (props) => (
246
+ <div class={$.isMobile ? 'wf-stack' : 'wf-row'}>
247
+ {!$.isMobile && <Sidebar />}
248
+ <Content />
249
+ </div>
250
+ )
251
+ }
252
+ ```
253
+
254
+ `callback` 在 mount 时立即执行一次,之后断点变化时再次执行。赋值给 `$` 的属性自动触发渲染。
255
+
256
+ #### `ctx.ui.useBreakpoint(callback)` — 命名断点
257
+
258
+ 预设三个断点名称:`mobile`(<640px)、`tablet`(640-1023px)、`desktop`(≥1024px):
259
+
260
+ ```tsx
261
+ const Layout = (_init, ctx) => {
262
+ const $ = ctx.ui.$()
263
+ ctx.ui.useBreakpoint((vp) => { $.vp = vp })
264
+
265
+ return (props) =>
266
+ <div class={`sidebar-${$.vp}`}>
267
+ {$.vp === 'mobile' ? <BottomNav /> : <SideNav />}
268
+ {$.vp === 'mobile' ? <MobileContent /> : <Content />}
269
+ </div>
270
+ }
271
+ ```
272
+
273
+ 也支持自定义断点:
274
+
275
+ ```tsx
276
+ ctx.ui.useBreakpoint(
277
+ { narrow: '(max-width: 480px)', wide: '(min-width: 1200px)' },
278
+ (vp) => { $.size = vp },
279
+ )
280
+ ```
281
+
282
+ #### `ctx.ui.usePopupPosition(options)` — 弹层坐标跟随
283
+
284
+ 解决弹出层(Popover / Tooltip / Dropdown / DatePicker 等)在 **页面滚动 / 窗口缩放后不跟随触发元素** 的问题。基于 `position: fixed` + `getBoundingClientRect()`(视口坐标)的弹层,滚动后坐标需要重算——本 API 用全局 scroll/resize 监听(rAF 节流)自动重算并精准刷新当前组件。
285
+
286
+ ```tsx
287
+ const DatePicker = (_init, ctx) => {
288
+ let show = false
289
+ let inputEl: HTMLElement | null = null
290
+ let prevOpen = false
291
+
292
+ // mount 阶段注册:scroll/resize 时自动重算 pos
293
+ const pos = ctx.ui.usePopupPosition({
294
+ el: () => inputEl, // 锚定元素(ref 保存)
295
+ isOpen: () => show, // 弹层是否显示
296
+ compute: (r) => ({ top: r.bottom + 4, left: r.left }), // rect → 坐标
297
+ })
298
+
299
+ return (props) => {
300
+ const isOpen = show
301
+ // 打开瞬间算一次初始坐标(受控/非受控统一覆盖)
302
+ if (isOpen && !prevOpen) pos.refresh()
303
+ prevOpen = isOpen
304
+
305
+ return h('div', {}, [
306
+ h('input', {
307
+ ref: (el) => { inputEl = el as HTMLElement },
308
+ onClick: () => { show = !show; ctx.ui.render() },
309
+ }),
310
+ isOpen ? h('div', { style: { top: pos.top, left: pos.left } }) : null,
311
+ ].filter(Boolean))
312
+ }
313
+ }
314
+ ```
315
+
316
+ 要点:
317
+
318
+ - `pos` 是稳定对象,render 闭包直接读取 `top/left/width`,滚动重算原地更新,无需重新绑定
319
+ - `pos.refresh()` 只重算不渲染——配合打开路径上已有的 `render()`,避免重复渲染
320
+ - 监听是**全局单例**(capture 捕获所有嵌套滚动容器 + rAF 节流),按组件 selfId 注册,组件多时开销 O(1)
321
+ - `compute` 是纯函数(rect → 坐标),可单独单测
322
+
323
+ 已内置接入的组件:**Popover / Tooltip / Dropdown / DatePicker / Chart**(tooltip)——它们的弹出层在页面滚动、嵌套容器滚动、窗口缩放时都会自动跟随触发元素,无需额外配置。
324
+
325
+ #### `ctx.ui.usePopup(options)` — 弹层组合器(推荐:移动端友好由构造保证)
326
+
327
+ `usePopupPosition` 的**上层封装**:把弹层组件的完整生命周期(打开状态 + 触发 + Escape + 外部点击 + 定位/视口 clamp + portal)收敛成一个原语。弹层组件用它替代手写样板,**移动端行为自动正确**:
328
+
329
+ - **hover 触发在触屏自动降级为 tap**(内部 `matchMedia '(hover: hover)'` 判定)
330
+ - **Escape 关闭是 document 级**——焦点在 portal 弹层内按 Escape 也能关
331
+ - **外部点击关闭**(document mousedown,点弹层内部不关)
332
+ - **宽度自动 clamp 视口**(≤ `100vw - 32px`,375px 屏不横向溢出)
333
+ - **定位 + 视口夹紧**(复用 `usePopupPosition`,超高/超宽面板平移回视口)
334
+ - 支持受控(`open`/`onOpenChange`)、动态 props(`placement`/`trigger`/`openDelay` 支持 getter)
335
+
336
+ ```tsx
337
+ const Tooltip = (_init, ctx) => {
338
+ let show = false
339
+ let wrapEl: HTMLElement | null = null
340
+ const wrapRef = (el) => { wrapEl = el }
341
+
342
+ const popup = ctx.ui.usePopup({
343
+ trigger: 'hover', // 触屏自动降级 tap
344
+ placement: () => latestPos, // getter:动态读最新 props
345
+ el: () => wrapEl,
346
+ isOpen: () => show,
347
+ setOpen: (v) => { show = v; ctx.ui.render() },
348
+ width: 320, // 自动 clamp 视口
349
+ disabled: () => disabled,
350
+ openDelay: () => delay, // hover 延迟(HoverCard 用)
351
+ })
352
+
353
+ return (props) => h('div', { ref: wrapRef, ...popup.wrapProps }, [
354
+ props.children,
355
+ popup.portal(h('div', { class: 'wf-tooltip' }, props.content), 'tooltip'),
356
+ ].filter(Boolean))
357
+ }
358
+ ```
359
+
360
+ - `popup.wrapProps` — 触发 + Escape + focus 处理,spread 到包装/触发元素
361
+ - `popup.portal(content, portalKey)` — 定位 + clamp + portal(挂载 `#__wf_portal`),关闭时返回 null;自动附加 `wf-popup` 基类
362
+ - `popup.open` / `popup.setOpen()` — 状态读取与设置
363
+
364
+ **边界(诚实裁剪)**:Modal/Drawer 全屏对话框不进 `usePopup`(focus-trap/scroll-lock/退场状态机生命周期不同,各自实现)。
365
+
366
+ 已迁移组件:**Tooltip / HoverCard / Popover / Dropdown / Menubar / Mentions / Cascader / ContextMenu**(长按双通道)。
367
+
368
+ #### `ctx.ui.useHoverCapable()` / `useLongPress()` / `useVisualViewport()` — 移动端原语
369
+
370
+ - **`useHoverCapable()`** — 设备是否支持 hover(`matchMedia '(hover: hover)'`,mount 期一次判定)。hover 触发组件用它降级 tap。
371
+
372
+ ```ts
373
+ const canHover = ctx.ui.useHoverCapable()
374
+ // canHover=false(触屏)→ 用 tap 打开而非 mouseenter
375
+ ```
376
+
377
+ - **`useLongPress({ onLongPress, duration })`** — 长按手势:`pointerdown` 按住 `duration`(默认 500ms)触发,提前松开/位移 >10px 取消,`contextmenu` 兼容。返回的 props spread 到目标元素。ContextMenu 已内置桌面右键 + 触屏长按双通道。
378
+
379
+ ```ts
380
+ const press = ctx.ui.useLongPress({ onLongPress: (e) => openAt(e), duration: 500 })
381
+ return h('div', { ...press }, children) // onPointerDown/Up/Leave/Move + onContextMenu
382
+ ```
383
+
384
+ - **`useVisualViewport()`** — 可视视口跟踪(`visualViewport` resize/scroll 监听):虚拟键盘弹起/页面缩放时自动更新并 dirty。返回响应式 `{ height, offsetTop, keyboardOpen }`;无 `visualViewport` 环境(桌面)降级 `innerHeight`。fixed 底部栏防键盘遮挡用(AiChat `raiseOnKeyboard` prop)。
385
+
386
+ ```ts
387
+ const vv = ctx.ui.useVisualViewport()
388
+ // vv.keyboardOpen → 输入区 fixed 抬升到键盘上方
389
+ ```
390
+
391
+ #### `ctx.ui.selfId(name)` — 跨组件精准刷新
392
+
393
+ 用于全局事件通知、Portal 远程控制、兄弟组件协调等场景——绕过多层 props 传递,直接按 ID 刷新目标组件:
394
+
395
+ ```tsx
396
+ // 组件 A:mount 阶段注册自定义 ID
397
+ const StatsPanel = (_init, ctx) => {
398
+ ctx.ui.selfId('stats')
399
+ const $ = ctx.ui.$()
400
+ $.data = []
401
+ return (props) => h('div', {}, String($.data.length))
402
+ }
403
+
404
+ // 组件 B(或其他任何地方)用 ID 精准刷新
405
+ ctx.ui.render(['stats']) // 同步刷新
406
+ // 或:ctx.ui.dirty(['stats']) // 异步批处理版本
407
+ ```
408
+
409
+ **语义**:
410
+
411
+ - 必须在 **mount 阶段**调用(组件初始化时),注册后组件即可被 `render(['id'])` / `dirty(['id'])` 精准定位
412
+ - **同名冲突直接抛错**,每个自定义 ID 必须全局唯一
413
+ - 配合 `selfId` 注册的组件在跨组件场景下无需把刷新逻辑层层传 props
414
+
415
+ #### CSS 层响应式(不碰 JS)
416
+
417
+ 配合 `weifuwu/layout` 的断点变体,纯 CSS 实现布局方向切换:
418
+
419
+ ```html
420
+ <!-- 小屏堆叠,桌面并排 -->
421
+ <div class="wf-stack wf-stack@md"></div>
422
+
423
+ <!-- 小屏隐藏侧栏 -->
424
+ <aside class="wf-hidden wf-block@md"></aside>
425
+ ```
426
+
427
+ 可用断点变体:
428
+
429
+ | 原语 | 变体 | 效果 |
430
+ |------|------|------|
431
+ | `wf-stack` | `@sm` `@md` `@lg` | 断点以上改为横向排列 |
432
+ | `wf-row` | `@sm` `@md` `@lg` | 断点以上保持横向 |
433
+ | `wf-hidden` | `@sm` `@md` `@lg` | 断点以上隐藏 |
434
+ | `wf-block` | `@sm` `@md` `@lg` | 断点以上显示 |
435
+
436
+ 断点尺寸:`--wf-bp-sm: 640px` / `--wf-bp-md: 768px` / `--wf-bp-lg: 1024px` / `--wf-bp-xl: 1280px`
437
+
438
+ **移动端专用工具**(`weifuwu/layout`):
439
+
440
+ | 工具 | 效果 |
441
+ |------|------|
442
+ | `wf-popup` | 浮层基类:宽度视口 clamp(`min(var(--wf-popup-max, 480px), calc(100vw - 32px))`)——手动浮层防横向溢出 |
443
+ | `wf-safe-bottom` / `wf-safe-top` | iOS 安全区:`padding: env(safe-area-inset-bottom/top)`(刘海屏/Home 条) |
444
+ | `@media (pointer: coarse)` 44px | 触屏命中区:button/input/select 全局覆盖;非 button 交互元素由 style-audit 规则强制登记 |
445
+
446
+ > **移动端开发指南**:断点体系 / 44px 命中区纪律 / usePopup / 手势原语 / safe-area / 验收清单 → [`docs/mobile.md`](mobile.md)
447
+
448
+ ### `ctx.ui.dirty()` — 异步标记脏
449
+
450
+ 异步版本,无参 = 当前组件,传参 = 指定组件列表。多次调用合并为一次微任务渲染。`$` 内部就是调 `dirty()`。
451
+
452
+ 与 `render()` 的区别:`dirty()` 是**异步**(微任务批量合并,同帧多次调用只渲染一次),`render()` 是**同步**(立即执行 VDOM diff + patch)。日常 UI 状态用 `$` 或 `dirty()`,需要立即拿到最新 DOM(测量/动画/第三方库)时用 `render()`。
453
+
454
+ ### `ctx.ui.render()` — 同步强制渲染
455
+
456
+ 与 `dirty()` 的微任务批量不同,`render()` 是**同步执行**的。调用后立即执行 VDOM diff + patch,DOM 立刻更新。无参时只刷新当前组件,传参时可精准刷新指定组件。
457
+
458
+ **何时必须用 `render()`**:
459
+
460
+ ```tsx
461
+ // 1. DOM 测量(读取 offsetHeight/scrollWidth 等)
462
+ // 用 ref 在 DOM 创建后操作
463
+ ref: (el) => {
464
+ if (!el) return
465
+ el.style.height = 'auto'
466
+ ctx.ui.render()
467
+ const h = el.offsetHeight
468
+ el.style.height = h + 'px'
469
+ }
470
+
471
+ // 2. 动画触发(需要确保上一帧 DOM 已提交)
472
+ function startAnimation() {
473
+ $.animating = true
474
+ ctx.ui.render() // 同步刷新 DOM
475
+ el.startViewTransition(...) // 拿到最新 DOM 启动动画
476
+ }
477
+
478
+ // 3. 第三方库需要在事件回调中读取最新 DOM
479
+ onClick: () => {
480
+ $.selected = !$.selected
481
+ ctx.ui.render() // 确保 DOM 已更新
482
+ thirdPartyLib.measure(el) // 读取最新状态
483
+ }
484
+ ```
485
+
486
+ **规则**:能用 `$` 就用 `$`。只有当你**必须同步拿到最新 DOM 状态**时才用 `render()`。
487
+
488
+ ### 三种方式速查
489
+
490
+ ```tsx
491
+ // 自动:$.x = val — 微任务批量,绑定当前组件
492
+ const $ = ctx.ui.$()
493
+ $.count++
494
+ $.name = 'hello' // 多次赋值合并为一次渲染
495
+
496
+ // 手动:ctx.ui.render() — 同步,无参=当前,传参=指定
497
+ let count = 0
498
+ count++
499
+ ctx.ui.render() // DOM 立刻更新
500
+ ctx.ui.render(['stats']) // 精准刷新指定组件
501
+
502
+ // 异步:ctx.ui.dirty() — 微任务批量,同 render() 作用域
503
+ ctx.ui.dirty()
504
+ ctx.ui.dirty(['stats']) // 批处理合并
505
+ ```
506
+
507
+ **性能说明**:
508
+ - `$.x = val` 和 `dirty()` 都是微任务批量合并
509
+ - `render()` 从 dirty 组件**向下**遍历(scope render),兄弟组件不遍历
510
+ - **三态 skip 自动优化**:组件重新渲染时,框架自动检查三个维度:
511
+ - **props**(含 children 元素级比较)——值没变则不渲染
512
+ - **`$` 状态**——没被 dirty 标记则不渲染
513
+ - **ctx 版本**——ctx 没变化则不渲染
514
+ 三个条件全部满足时跳过整个子树(零 `_render` 调用、零 `patchValue` 遍历)
515
+ - **lastIndex keyed diff**:列表 diff 采用正向 lastIndex 算法(React 同款),顺序不变时零 `insertBefore`。对比传统的逆序循环全量移动,DOM 修改从 O(N) 降到 O(0)。
516
+ - 示例:DemoButton 点击一次,DOM 修改从 34 次降到 **1 次**(仅变更文本节点的 `textContent`)
517
+
518
+ ### 实践建议
519
+
520
+ **组件库**(可分享组件)推荐手动模式:
521
+
522
+ ```tsx
523
+ const DatePicker = (_init, ctx) => {
524
+ let show = false // let 不触发渲染
525
+ return (props) =>
526
+ h('input', {
527
+ onClick: () => { show = true; ctx.ui.render() }
528
+ })
529
+ }
530
+ ```
531
+
532
+ 行为只由 `render()` 显式控制,不依赖 `$`,测试中 `render()` 直接 mock 为空函数。
533
+
534
+ **业务层**推荐自动模式:
535
+
536
+ ```tsx
537
+ const OrderPage = (_init, ctx) => {
538
+ const $ = ctx.ui.$()
539
+ $.orders = [] // $ 赋值自动触发渲染
540
+ $.loading = false
541
+ return (props) => h('div', {}, $.loading ? h(Spinner) : h(OrderList, { orders: $.orders }))
542
+ }
543
+ ```
544
+
545
+ 省事、安全、`$` 绑定所属组件不波及兄弟。
546
+
547
+ 同一个组件内可以按变量混用两种模式:需要渲染的用 `$`,不需要的用 `let`。
548
+
549
+ ### VDOM diff 优化机制
550
+
551
+ weifuwu 的 VDOM 在每次 render 时自动执行**三态 skip 判定**,减少不必要的组件渲染和 DOM 操作:
552
+
553
+ ```
554
+ canSkip = (props 没变) AND ($ 没脏) AND (ctx 版本一致)
555
+ ↑ 值级浅比较 ↑ VNode dirty 标记 ↑ 全局版本号
556
+ ```
557
+
558
+ 三个维度各自独立判断,AND 合并。任何一个维度说
559
+
560
+ ---
561
+
562
+ ## 条件与列表
563
+
564
+ 使用原生 JS 控制流:
565
+
566
+ ```tsx
567
+ // 条件
568
+ {cond ? <A /> : <B />}
569
+ {cond && <A />}
570
+
571
+ // 列表 — 必须指定 key
572
+ {items.map(item => (
573
+ <div key={item.id}>{item.name}</div>
574
+ ))}
575
+ ```
576
+
577
+ ---
578
+
579
+ ## ref 管理 DOM
580
+
581
+ 使用 `ref` prop 获取元素引用,适合管理第三方库或读取 DOM:
582
+
583
+ ```tsx
584
+ const Timer: Component = (_init, ctx) => {
585
+ let timer: ReturnType<typeof setInterval> | undefined
586
+
587
+ return (props) =>
588
+ h('div', {
589
+ ref: (el) => {
590
+ if (el) {
591
+ timer = setInterval(() => console.log('tick'), 1000)
592
+ } else {
593
+ clearInterval(timer)
594
+ }
595
+ },
596
+ }, 'Timer')
597
+ }
598
+ ```
599
+
600
+ `ref` 在元素创建时调用 `ref(el)`,元素移除时调用 `ref(null)`。
601
+ `ref` 不接受返回值,清理逻辑直接在 `else` 分支处理。
602
+
603
+ 对于**内嵌元素**(非根元素),直接在目标元素上放 `ref`:
604
+
605
+ ```tsx
606
+ return h('div', {},
607
+ h('input', {
608
+ type: 'text',
609
+ ref: (el) => el?.focus(),
610
+ })
611
+ )
612
+ ```
613
+
614
+ ### 异步组件
615
+
616
+ 在 mount 阶段发起请求,数据通过 `$.x = val` 自动触发渲染:
617
+
618
+ ```tsx
619
+ const UserProfile: Component = (initProps, ctx) => {
620
+ const $ = ctx.ui.$()
621
+ $.loading = true
622
+
623
+ fetch(`/api/user/${initProps.id}`)
624
+ .then(r => r.json())
625
+ .then(user => { $.user = user; $.loading = false })
626
+
627
+ return (props) =>
628
+ $.loading
629
+ ? h('div', {}, '加载中...')
630
+ : h('div', {}, $.user?.name ?? '')
631
+ }
632
+ ```
633
+
634
+ ### asyncComponent 工厂(async 组件)— 同步式数据声明
635
+
636
+ `async (ctx) => (initProps, ctx) => (props) => VNode` — 工厂层(async,只执行一次并缓存)声明数据/加载代码,mount/render 保持同步。数据经闭包注入组件,渲染无 loading 分支:
637
+
638
+ ```tsx
639
+ import { asyncComponent } from 'weifuwu/client'
640
+
641
+ const UserProfile = asyncComponent(async (ctx) => {
642
+ const user = await ctx.data.get(`/api/user/${ctx.params.id}`)
643
+ return (_init, ctx) => {
644
+ const $ = ctx.ui.$()
645
+ $.liked = false // 客户端状态(交互后变化)
646
+ return (props) =>
647
+ h('div', {},
648
+ h('p', {}, user.name), // 服务端状态(闭包,SSR 进 HTML)
649
+ h('button', { onClick: () => $.liked = !$.liked }, $.liked ? '❤️' : '🤍'),
650
+ )
651
+ }
652
+ })
653
+ ```
654
+
655
+ - **客户端**:首次渲染占位 → 工厂 resolve 后整树重渲染补全(SPA);数据经 `ctx.data` 缓存(hydration 时从 `__DATA__` 同步命中,不重跑请求)
656
+ - **服务端**:`ctx.ui.ssr()` 直接 await 工厂 → 数据进 HTML(无占位)
657
+ - 工厂缓存绑定页面上下文:路由导航/登录登出时自动失效,工厂以新 ctx 重新执行
658
+ - 会变的数据:初始值 seed 自服务端数据(`$.count = data.count`),交互改 `$`;初始状态必须确定性(禁止 `window.innerWidth` 直接初始化 → SSR/hydration mismatch)
659
+
660
+ ---
661
+
662
+ ## 前端类型
663
+
664
+ ```tsx
665
+ import type { VNode, VNodeType, Component, WfuiContext, AppMiddleware, RouteDef } from 'weifuwu/client'
666
+ import type { ApiClient, ApiOptions, ApiRequestOptions, ApiError } from 'weifuwu/client'
667
+ import type { AuthClient, AuthOptions } from 'weifuwu/client'
668
+ import type { ErrorBoundaryProps } from 'weifuwu/client'
669
+ import type { I18nOptions, I18nState, LocalePackage } from 'weifuwu/client'
670
+ import type { PopupPositionOptions, PopupPosition } from 'weifuwu/client'
671
+ import type { ConfirmProps, ConfirmOptions } from 'weifuwu/components'
672
+ import type { ToastOptions, ToastPosition } from 'weifuwu/components'
673
+ import type { RouterOptions } from 'weifuwu/client'
674
+ ```
675
+
676
+ | 类型 | 说明 |
677
+ |------|------|
678
+ | `VNode` | `{ type, props, key? }` |
679
+ | `VNodeType` | `string \| Component \| typeof Fragment` |
680
+ | `Component<P>` | `(initProps: P, ctx: WfuiContext) => (props: P) => VNode \| null` |
681
+ | `WfuiContext` | `{ ui, route?, app?, ws?, api?, auth?, i18n?, confirm?, toast?, [key]: unknown }` |
682
+ | `AppMiddleware` | `(ctx: WfuiContext) => WfuiContext` |
683
+ | `RouteDef` | `{ path, component?, layout?, children?, auth?, title? }` |
684
+ | `ApiClient` | `{ get, post, put, patch, delete }` |
685
+ | `ApiError` | `class { status, body } extends Error` |
686
+ | `AuthClient` | `{ token, user, isLoggedIn, login, logout, setUser, refresh }` |
687
+ | `I18nOptions` | `{ locale?, messages?, components? }` |
688
+ | `I18nState` | `{ locale, t, setLocale, components }` |
689
+ | `ErrorBoundaryProps` | `{ fallback?, children? }` |
690
+ | `ConfirmProps` | `{ open?, title?, message?, confirmText?, cancelText?, variant?, width?, onConfirm?, onCancel? }` |
691
+ | `ConfirmOptions` | `{ title?, confirmText?, cancelText?, variant?, width? }` — 命令式 ctx.confirm 选项 |
692
+ | `ToastOptions` | `{ position?, duration?, max? }` — 命令式 ctx.toast 配置 |
693
+ | `PopupPositionOptions` | `{ el, isOpen, compute }` — 弹层位置跟踪配置(见 usePopupPosition) |
694
+ | `PopupPosition` | `{ top, left, width?, refresh }` — 弹层位置跟踪器 |
695
+
696
+ ---
697
+