weifuwu 0.50.0 → 0.51.1

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 CHANGED
@@ -16,7 +16,7 @@ npm install weifuwu
16
16
 
17
17
  **两阶段组件模型** — 组件 = `(initProps, ctx) => (props) => VNode`。外层函数只执行一次(mount),内层函数每次状态/props 变化时执行(render)。无 class、无 `this`、无 Hook。
18
18
 
19
- **Proxy 驱动渲染** — `ctx.ui.$()` 返回深度 Proxy,`$.x = val` 自动触发 VDOM patch。无需手动调用 `useState`/`useEffect`。
19
+ **Proxy 驱动渲染** — `ctx.ui.$()` 返回深度 Proxy,`$.x = val` 自动触发当前组件的 VDOM patch。也支持手动 `ctx.ui.render()` 精确控制渲染时机。无需手动调用 `useState`/`useEffect`。
20
20
 
21
21
  **中间件注入一切** — 后端和前端共用同一理念:中间件向 `ctx` 注入能力(`ctx.sql` / `ctx.redis` / `ctx.api` / `ctx.auth` / `ctx.i18n` 等),Handler/组件从 `ctx` 读取。
22
22
 
@@ -66,6 +66,86 @@ createApp()
66
66
 
67
67
  ---
68
68
 
69
+ ## CDN 快速原型(零构建、纯 HTML)
70
+
71
+ 不需要 Node.js 或构建工具,直接在浏览器中用 CDN 使用 weifuwu。创建一个 `.html` 文件即可开始,适合快速原型、Codepen、简单的演示页面。
72
+
73
+ ```html
74
+ <!-- cdn-counter.html -->
75
+ <!doctype html>
76
+ <html lang="zh-CN">
77
+ <head>
78
+ <meta charset="UTF-8" />
79
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
80
+ <title>Weifuwu CDN 示例</title>
81
+
82
+ <!-- 组件样式(可选,如只用 weifuwu/client 则不需要) -->
83
+ <link
84
+ rel="stylesheet"
85
+ href="https://unpkg.com/weifuwu@0.51.0/dist/components/style.css"
86
+ />
87
+ </head>
88
+ <body>
89
+ <div id="root"></div>
90
+
91
+ <!-- Import Map — 将 weifuwu 包名映射到 CDN 地址 -->
92
+ <script type="importmap">
93
+ {
94
+ "imports": {
95
+ "weifuwu/client": "https://unpkg.com/weifuwu@0.51.0/dist/client/index.js",
96
+ "weifuwu/components": "https://unpkg.com/weifuwu@0.51.0/dist/components/index.js"
97
+ }
98
+ }
99
+ </script>
100
+
101
+ <script type="module">
102
+ import { createApp, h } from 'weifuwu/client'
103
+ import { Card, Button, Badge } from 'weifuwu/components'
104
+
105
+ // 组件 = (initProps, ctx) => (props) => VNode
106
+ const Counter = (_props, ctx) => {
107
+ const $ = ctx.ui.$()
108
+ $.count = 0 // mount 初始化
109
+
110
+ return () =>
111
+ h(Card, { variant: 'default', padding: 'lg' },
112
+ h('h2', { style: { textAlign: 'center', margin: 0 } }, '⚡ Weifuwu'),
113
+ h('div', { style: { fontSize: '4rem', fontWeight: 600, textAlign: 'center' } },
114
+ String($.count)),
115
+ h('div', { style: { textAlign: 'center', marginTop: '1rem' } },
116
+ h(Badge, {
117
+ variant: $.count % 2 === 0 ? 'success' : 'warning'
118
+ }, $.count % 2 === 0 ? '偶数' : '奇数')),
119
+ h('hr', { style: { margin: '1rem 0', border: 'none', borderTop: '1px solid #eee' } }),
120
+ h('div', { style: { display: 'flex', gap: '0.5rem', justifyContent: 'center' } },
121
+ h(Button, { variant: 'secondary', onClick: () => $.count-- }, '➖ 减 1'),
122
+ h(Button, { variant: 'danger', onClick: () => $.count = 0 }, '↺ 重置'),
123
+ h(Button, { variant: 'primary', onClick: () => $.count++ }, '➕ 加 1'),
124
+ ),
125
+ )
126
+ }
127
+
128
+ createApp().mount('#root', Counter)
129
+ </script>
130
+ </body>
131
+ </html>
132
+ ```
133
+
134
+ 将此 HTML 保存到本地用浏览器打开即可运行。完整的 CDN 示例见 [`apps/html/test.html`](./apps/html/test.html)。
135
+
136
+ ### CDN 资源地址说明
137
+
138
+ | 资源 | CDN 地址 | 说明 |
139
+ |------|---------|------|
140
+ | `weifuwu/client` | `https://unpkg.com/weifuwu@0.51.0/dist/client/index.js` | 客户端核心(createApp, h, 路由, 状态管理等) |
141
+ | `weifuwu/components` | `https://unpkg.com/weifuwu@0.51.0/dist/components/index.js` | 41 个 UI 组件(Button, Card, Table, Modal 等) |
142
+ | 组件样式 | `https://unpkg.com/weifuwu@0.51.0/dist/components/style.css` | 组件 CSS + 72 个主题 Token + 35 个布局原语 |
143
+ | 独立布局系统 | `https://unpkg.com/weifuwu@0.51.0/dist/layout/weifuwu-layout.css` | 仅 CSS 布局,不依赖 JS |
144
+
145
+ > 提示:将 `@0.51.0` 替换为 `@latest` 始终使用最新版,或固定版本避免意外变更。
146
+
147
+ ---
148
+
69
149
  ## 模块总览
70
150
 
71
151
  | 导入路径 | 模块 | 用途 | 依赖 |
@@ -116,7 +196,7 @@ createApp()
116
196
  |------|------|------|
117
197
  | 注入 | 中间件注入 ctx.field | 中间件注入 ctx.field |
118
198
  | 读取 | handler 读取 ctx | 组件读取 ctx |
119
- | 渲染 | 返回 Response | `ctx.ui.render()` / `ctx.ui.dirty()` 触发 VDOM patch |
199
+ | 渲染 | 返回 Response | `ctx.ui.render()` / `ctx.ui.dirty()` / `$.x = val` 触发局部 VDOM patch |
120
200
 
121
201
  ### Closeable 接口
122
202
 
@@ -812,11 +892,14 @@ h('div', { class: 'x' }, child1, child2)
812
892
 
813
893
  ### Render 机制总览
814
894
 
815
- | API | 触发时机 | 渲染方式 | 使用场景 |
816
- |------|---------|---------|---------|
817
- | `$.x = val` | 赋值后自动 | 微任务批量(异步) | **日常 UI 状态** — 表单输入、切换开关、异步数据加载等绝大多数场景 |
818
- | `ctx.ui.dirty()` | 主动调用 | 微任务批量(异步) | **绕过 Proxy 后手动标记** — 批量修改深层次对象、第三方库直接修改了 `$` 内部数据 |
819
- | `ctx.ui.render()` | 主动调用 | 立即同步 | **需要立即拿到最新 DOM** — DOM 测量、动画触发、第三方库在事件中同步读取 DOM |
895
+ | API | 触发时机 | 渲染方式 | 作用域 | 使用场景 |
896
+ |------|---------|---------|--------|---------|
897
+ | `$.x = val` | 赋值后自动 | 微任务批量(异步) | 当前组件 | **日常 UI 状态** — 表单输入、切换开关、异步数据加载等 |
898
+ | `ctx.ui.dirty()` | 主动调用 | 微任务批量(异步) | 当前/指定 | **绕过 Proxy 后手动标记** |
899
+ | `ctx.ui.render()` | 主动调用 | 立即同步 | 当前/指定 | **需要立即拿到最新 DOM** — DOM 测量、动画触发 |
900
+ | `ctx.ui.render(['id'])` | 主动调用 | 立即同步 | 指定组件 | **跨组件精准刷新** — 全局事件、Portal 远程控制 |
901
+
902
+ `render()` 和 `dirty()` 无参 = 当前组件,传参 = 指定组件列表。三套 API 同一 scope 机制。
820
903
 
821
904
  ### 闭包变量 + `ctx.ui.render()`(简单场景)
822
905
 
@@ -862,23 +945,13 @@ const FormPage: Component = (_init, ctx) => {
862
945
  - 不需要触发渲染的内部缓存(用闭包变量 `let`)
863
946
  - 简单组件只有一两个状态变量(闭包变量 + `render()` 更轻量)
864
947
 
865
- ### `ctx.ui.dirty()` — 手动标记脏状态
866
-
867
- 当你绕过 Proxy 直接操作底层数据后,调用 `dirty()` 通知框架在下个微任务批量重渲染:
868
-
869
- ```tsx
870
- // 实际场景:在 mount 阶段需要手动触发渲染
871
- // mount 期间 $.x = val 自动静默(不触发渲染)
872
- $.initialized = true
873
- // 如果非要在这里触发渲染,需要手动调用 dirty():
874
- ctx.ui.dirty()
875
- ```
948
+ ### `ctx.ui.dirty()` — 异步标记脏
876
949
 
877
- **但实际上,绝大多数情况下你不需要 `dirty()`。** 深度 Proxy 已经拦截了所有常见的变更新为方式(深层属性赋值、数组 push/splice、delete 等)。先赋值给 `$` 永远是更清晰的做法。
950
+ 异步版本,无参 = 当前组件,传参 = 指定组件列表。多次调用合并为一次微任务渲染。`$` 内部就是调 `dirty()`。
878
951
 
879
952
  ### `ctx.ui.render()` — 同步强制渲染
880
953
 
881
- 与 `dirty()` 的微任务批量不同,`render()` 是**同步执行**的。调用后立即执行 VDOM diff + patch,DOM 立刻更新。
954
+ 与 `dirty()` 的微任务批量不同,`render()` 是**同步执行**的。调用后立即执行 VDOM diff + patch,DOM 立刻更新。无参时只刷新当前组件,传参时可精准刷新指定组件。
882
955
 
883
956
  **何时必须用 `render()`**:
884
957
 
@@ -913,55 +986,57 @@ onClick: () => {
913
986
  ### 三种方式速查
914
987
 
915
988
  ```tsx
916
- // ✅ 推荐:ctx.ui.$() + $.x = val — 自动、批量、无脑
989
+ // 自动:$.x = val — 微任务批量,绑定当前组件
917
990
  const $ = ctx.ui.$()
918
991
  $.count++
919
- $.name = 'hello' // 微任务合并,只渲染一次
992
+ $.name = 'hello' // 多次赋值合并为一次渲染
920
993
 
921
- // ✅ 简单场景:闭包变量 + ctx.ui.render() — 轻量同步
994
+ // 手动:ctx.ui.render() — 同步,无参=当前,传参=指定
922
995
  let count = 0
923
996
  count++
924
997
  ctx.ui.render() // DOM 立刻更新
998
+ ctx.ui.render(['stats']) // 精准刷新指定组件
925
999
 
926
- // ⚠️ 罕见:ctx.ui.dirty() — 绕过 Proxy 后手动标记
1000
+ // 异步:ctx.ui.dirty() — 微任务批量,同 render() 作用域
1001
+ ctx.ui.dirty()
1002
+ ctx.ui.dirty(['stats']) // 批处理合并
927
1003
  ```
928
1004
 
929
1005
  **性能说明**:
930
- - `$.x = val` 和 `dirty()` 都是微任务批量合并:同一 tick 内 N 次赋值 → 1 次渲染
931
- - `render()` 每次调用都触发一次完整 diff/patch,频繁调用可能影响性能
1006
+ - `$.x = val` 和 `dirty()` 都是微任务批量合并
1007
+ - `render()` 每次调用都触发一次完整 diff/patch
1008
+ - 三个入口同一套 scope 机制,不想要的渲染不触发
1009
+
1010
+ ### 实践建议
932
1011
 
933
- ### 实践建议:日常开发 vs 组件分享
1012
+ **组件库**(可分享组件)推荐手动模式:
1013
+
1014
+ ```tsx
1015
+ const DatePicker = (_init, ctx) => {
1016
+ let show = false // let 不触发渲染
1017
+ return (props) =>
1018
+ h('input', {
1019
+ onClick: () => { show = true; ctx.ui.render() }
1020
+ })
1021
+ }
1022
+ ```
934
1023
 
935
- **日常组件内**:优先用 `$.x = val`,无脑、自动、批量。
1024
+ 行为只由 `render()` 显式控制,不依赖 `$`,测试中 `render()` 直接 mock 为空函数。
936
1025
 
937
- **制作可分享组件**(组件库、npm 包、跨项目复用)时,推荐用 `ctx.ui.dirty()` 或 `ctx.ui.render()` 精确控制刷新时机:
1026
+ **业务层**推荐自动模式:
938
1027
 
939
1028
  ```tsx
940
- // 可分享的 Toast 组件:主动控制渲染,避免消费方上下文干扰
941
- const Toast = (_init, ctx) => {
942
- let items: ToastItem[] = []
943
-
944
- return {
945
- add(item: ToastItem) {
946
- items = [...items, item]
947
- ctx.ui.render() // 显式同步渲染,确保 DOM 立即可见
948
- },
949
- remove(id: string) {
950
- items = items.filter(i => i.id !== id)
951
- ctx.ui.dirty() // 显式标记脏,下个微任务批量渲染
952
- },
953
- render: (props) =>
954
- h('div', { class: 'toast-container' },
955
- items.map(item => h('div', { key: item.id }, item.msg))
956
- ),
957
- }
1029
+ const OrderPage = (_init, ctx) => {
1030
+ const $ = ctx.ui.$
1031
+ $.orders = [] // $ 赋值自动触发渲染
1032
+ $.loading = false
1033
+ return (props) => h('div', {}, $.loading ? h(Spinner) : h(OrderList, { orders: $.orders }))
958
1034
  }
959
1035
  ```
960
1036
 
961
- 理由:
962
- - 分享出去的组件可能被用在各种上下文,`$` 的隐式自动刷新可能不可控
963
- - 暴露 `add/remove` 等命令式 API 时,`render()` / `dirty()` 让刷新时机**显式、可预测**
964
- - 消费方不需要知道组件内部用 `$` 还是闭包,只需调用 API
1037
+ 省事、安全、`$` 绑定所属组件不波及兄弟。
1038
+
1039
+ 同一个组件内可以按变量混用两种模式:需要渲染的用 `$`,不需要的用 `let`。
965
1040
 
966
1041
  ---
967
1042
 
@@ -1282,7 +1357,7 @@ createApp()
1282
1357
 
1283
1358
  // 运行时切换语言
1284
1359
  ctx.i18n?.setLocale('en-US')
1285
- // → 自动触发全应用重渲染
1360
+ // → 自动触发根组件重渲染(所有组件使用新语言文案)
1286
1361
  ```
1287
1362
 
1288
1363
  | I18nOptions | 类型 | 默认值 | 说明 |
@@ -1574,6 +1649,9 @@ props 变化 ──────────────────────
1574
1649
  | `onmounted` | `ref` 的 `if (el)` 分支 |
1575
1650
  | `onunmount` | `ref` 的 `else` 分支 |
1576
1651
  | `onupdate` | render 内层函数收新 props 自行比较 |
1652
+ | `全局刷新` | `ctx.ui.render(['_wf_root'])` |
1653
+ | `局部刷新` | `ctx.ui.render()` 或 `$.x = val` |
1654
+ | `跨组件刷新` | `ctx.ui.selfId('name')` + `render(['name'])` |
1577
1655
 
1578
1656
  ## 组件列表
1579
1657
 
@@ -4,9 +4,15 @@
4
4
  * createApp() → app.use(mw) → app.mount('#root', RootComponent)
5
5
  *
6
6
  * ctx.ui 在 mount 时注入:
7
- * ctx.ui.render() 触发组件重渲染
8
- * ctx.ui.dirty() 标记脏状态,下个微任务批量渲染
9
- * ctx.ui.$() 创建响应式状态容器($.x = val 自动 dirty)
7
+ * ctx.ui.render() 同步刷新当前组件
8
+ * ctx.ui.render(['#id']) 同步刷新指定组件
9
+ * ctx.ui.dirty() 异步刷新当前组件(微任务批处理)
10
+ * ctx.ui.$() 响应式状态容器($.x = val 自动 dirty)
11
+ *
12
+ * render / dirty / $ 通过 prototype chain 实现组件级 scope:
13
+ * 每个组件 mount 时创建 childCtx.ui = Object.create(ctx.ui)
14
+ * 并设置 childCtx.ui._selfId = 组件 ID
15
+ * render() 无参时从 this._selfId 取当前组件 ID
10
16
  */
11
17
  import type { WfuiContext, AppMiddleware } from './types.ts';
12
18
  import type { Component } from './vnode.ts';
@@ -39,6 +39,8 @@ function createPortal(children, portalKey) {
39
39
  // src/client/render.ts
40
40
  var SVG_NS = "http://www.w3.org/2000/svg";
41
41
  var SVG_TAGS = /* @__PURE__ */ new Set(["svg", "path", "circle", "line", "rect", "text", "g", "polyline", "polygon", "ellipse", "defs", "use", "clipPath", "mask", "linearGradient", "radialGradient", "stop", "tspan"]);
42
+ var _idCounter = 0;
43
+ var idRegistry = /* @__PURE__ */ new Map();
42
44
  function render(input, ctx) {
43
45
  return renderValue(input, ctx);
44
46
  }
@@ -76,7 +78,15 @@ function renderValue(v, ctx) {
76
78
  } else {
77
79
  const flatChildren = flattenChildren(vnode.props?.children);
78
80
  for (const child of flatChildren) {
79
- el.appendChild(renderValue(child, ctx));
81
+ const childNode = renderValue(child, ctx);
82
+ el.appendChild(childNode);
83
+ if (child && typeof child === "object" && typeof child.type === "function") {
84
+ const childVNode = child;
85
+ if (!childVNode._parentNode) {
86
+ childVNode._parentNode = el;
87
+ childVNode._refNode = childNode;
88
+ }
89
+ }
80
90
  }
81
91
  }
82
92
  if (selectValue !== void 0) {
@@ -89,9 +99,17 @@ function renderValue(v, ctx) {
89
99
  function renderComponent(Comp, props, vnode, ctx) {
90
100
  ;
91
101
  ctx.ui = ctx.ui ?? {};
102
+ if (!vnode._id) {
103
+ vnode._id = `_wf_${_idCounter++}`;
104
+ idRegistry.set(vnode._id, vnode);
105
+ }
106
+ const childCtx = Object.create(ctx);
107
+ childCtx.ui = Object.create(ctx.ui);
108
+ childCtx.ui._selfId = vnode._id;
109
+ childCtx.ui._selfVNode = vnode;
92
110
  let childVNode;
93
111
  try {
94
- childVNode = Comp(props, ctx);
112
+ childVNode = Comp(props, childCtx);
95
113
  if (typeof childVNode !== "function") {
96
114
  throw new Error(
97
115
  `Component ${Comp.name || "anonymous"} must return a render function. Use (init_props, ctx) => (props) => VNode pattern.`
@@ -114,7 +132,12 @@ function renderComponent(Comp, props, vnode, ctx) {
114
132
  return document.createTextNode("");
115
133
  }
116
134
  vnode._child = childVNode;
117
- return renderValue(childVNode, ctx);
135
+ const domNode = renderValue(childVNode, childCtx);
136
+ if (!vnode._refNode) {
137
+ ;
138
+ vnode._refNode = domNode;
139
+ }
140
+ return domNode;
118
141
  }
119
142
  function renderArray(arr, ctx) {
120
143
  const frag = document.createDocumentFragment();
@@ -236,14 +259,23 @@ function patchValue(parent, oldNode, oldInput, newInput, ctx) {
236
259
  const oldV = oldInput;
237
260
  if (typeof newV.type === "function") {
238
261
  const comp = newV.type;
239
- ctx.ui = ctx.ui ?? {};
240
- if (oldV._render) newV._render = oldV._render;
262
+ if (oldV._render) {
263
+ newV._render = oldV._render;
264
+ newV._id = oldV._id;
265
+ if (newV._id) idRegistry.set(newV._id, newV);
266
+ }
267
+ newV._parentNode = parent;
268
+ newV._refNode = oldNode;
269
+ const childCtx = Object.create(ctx);
270
+ childCtx.ui = Object.create(ctx.ui);
271
+ childCtx.ui._selfId = newV._id;
272
+ childCtx.ui._selfVNode = newV;
241
273
  let childNew;
242
274
  try {
243
275
  if (typeof newV._render === "function") {
244
276
  childNew = newV._render(newV.props);
245
277
  } else {
246
- childNew = comp(newV.props, ctx);
278
+ childNew = comp(newV.props, childCtx);
247
279
  if (typeof childNew === "function") {
248
280
  newV._render = childNew;
249
281
  childNew = childNew(newV.props);
@@ -261,7 +293,7 @@ function patchValue(parent, oldNode, oldInput, newInput, ctx) {
261
293
  }
262
294
  const _prevChild = oldV._child;
263
295
  newV._child = childNew;
264
- return patchValue(parent, oldNode, _prevChild, childNew, ctx);
296
+ return patchValue(parent, oldNode, _prevChild, childNew, childCtx);
265
297
  }
266
298
  if (newV.type === Fragment) {
267
299
  patchChildren(parent, oldV, newV, ctx);
@@ -494,6 +526,186 @@ function callRefCleanup(input) {
494
526
  }
495
527
  }
496
528
 
529
+ // src/client/app.ts
530
+ function createApp() {
531
+ const middlewares = [];
532
+ let ctx = {};
533
+ let container = null;
534
+ let rootComponent = null;
535
+ let oldVNode = null;
536
+ let _rendering = false;
537
+ let _dirtyBatch = /* @__PURE__ */ new Set();
538
+ let _dirtyScheduled = false;
539
+ function renderByIds(ids) {
540
+ if (_rendering) return;
541
+ _rendering = true;
542
+ for (const id of ids) {
543
+ const vnode = idRegistry.get(id);
544
+ if (!vnode || !vnode._render) continue;
545
+ const oldChild = vnode._child;
546
+ const newChild = vnode._render(vnode.props);
547
+ vnode._child = newChild;
548
+ if (!vnode._parentNode && vnode._refNode) {
549
+ ;
550
+ vnode._parentNode = vnode._refNode.parentNode;
551
+ }
552
+ if (vnode._parentNode) {
553
+ const newNode = patchValue(
554
+ vnode._parentNode,
555
+ vnode._refNode ?? null,
556
+ oldChild,
557
+ newChild,
558
+ ctx
559
+ );
560
+ if (newNode && newNode !== vnode._refNode) {
561
+ vnode._refNode = newNode;
562
+ }
563
+ }
564
+ }
565
+ _rendering = false;
566
+ flushDirtyBatch();
567
+ }
568
+ function flushDirtyBatch() {
569
+ if (_dirtyBatch.size > 0 && !_dirtyScheduled) {
570
+ _dirtyScheduled = true;
571
+ queueMicrotask(() => {
572
+ _dirtyScheduled = false;
573
+ const batch = [..._dirtyBatch];
574
+ _dirtyBatch.clear();
575
+ if (batch.length > 0) renderByIds(batch);
576
+ });
577
+ }
578
+ }
579
+ function getSelfId(uiObj) {
580
+ return uiObj?._selfId ?? ctx.ui?._selfId;
581
+ }
582
+ const app = {
583
+ get ctx() {
584
+ return ctx;
585
+ },
586
+ use(mw) {
587
+ middlewares.push(mw);
588
+ return app;
589
+ },
590
+ async mount(rootSelector, RootComponent) {
591
+ rootComponent = RootComponent;
592
+ for (const mw of middlewares) {
593
+ ctx = await mw(ctx);
594
+ }
595
+ const el = typeof rootSelector === "string" ? document.querySelector(rootSelector) : rootSelector;
596
+ if (!el) throw new Error(`mount target not found: ${rootSelector}`);
597
+ container = el;
598
+ container.innerHTML = "";
599
+ ctx.ui = {
600
+ _selfId: "_wf_root",
601
+ /** 同步刷新(无参 = 当前组件,传参 = 指定组件列表) */
602
+ render: function(ids) {
603
+ if (!ids || ids.length === 0) {
604
+ const selfId = getSelfId(this);
605
+ if (selfId) ids = [selfId];
606
+ else return;
607
+ }
608
+ renderByIds(ids);
609
+ },
610
+ /** 异步刷新(微任务批处理,无参 = 当前组件) */
611
+ dirty: function(ids) {
612
+ if (_rendering) return;
613
+ if (!ids || ids.length === 0) {
614
+ const selfId = getSelfId(this);
615
+ if (selfId) ids = [selfId];
616
+ else return;
617
+ }
618
+ for (const id of ids) {
619
+ if (id) _dirtyBatch.add(id);
620
+ }
621
+ if (!_dirtyScheduled) {
622
+ _dirtyScheduled = true;
623
+ queueMicrotask(() => {
624
+ _dirtyScheduled = false;
625
+ const batch = [..._dirtyBatch];
626
+ _dirtyBatch.clear();
627
+ if (batch.length > 0) renderByIds(batch);
628
+ });
629
+ }
630
+ },
631
+ /** 创建响应式状态容器:$.x = val 自动触发 dirty() */
632
+ $: function() {
633
+ const selfId = getSelfId(this);
634
+ return createReactiveState(() => {
635
+ if (selfId) ctx.ui.dirty([selfId]);
636
+ });
637
+ },
638
+ /** 注册组件实例的自定义 ID(用于跨组件精准刷新) */
639
+ selfId: function(name) {
640
+ if (typeof name !== "string" || !name) {
641
+ throw new Error(`[weifuwu] selfId requires a non-empty string, got ${typeof name}`);
642
+ }
643
+ if (idRegistry.has(name)) {
644
+ throw new Error(
645
+ `[weifuwu] Duplicate component ID: "${name}". Each component must have a unique custom ID.`
646
+ );
647
+ }
648
+ const vnode = this._selfVNode;
649
+ if (!vnode) return;
650
+ vnode._customId = name;
651
+ idRegistry.set(name, vnode);
652
+ }
653
+ };
654
+ _rendering = true;
655
+ oldVNode = wrapComponent(RootComponent, ctx);
656
+ oldVNode._id = "_wf_root";
657
+ oldVNode._parentNode = container;
658
+ oldVNode._refNode = null;
659
+ idRegistry.set("_wf_root", oldVNode);
660
+ const node = render(oldVNode, ctx);
661
+ if (node instanceof Node) container.appendChild(node);
662
+ oldVNode._refNode = container.firstChild;
663
+ _rendering = false;
664
+ flushDirtyBatch();
665
+ },
666
+ destroy() {
667
+ if (container) container.innerHTML = "";
668
+ container = null;
669
+ ctx = {};
670
+ }
671
+ };
672
+ return app;
673
+ }
674
+ function wrapComponent(Comp, _ctx) {
675
+ return { type: Comp, props: {}, key: void 0 };
676
+ }
677
+ function createReactiveState(dirty) {
678
+ const proxyCache = /* @__PURE__ */ new WeakMap();
679
+ const reactive = (target) => {
680
+ if (target === null || typeof target !== "object") return target;
681
+ if (proxyCache.has(target)) return proxyCache.get(target);
682
+ const proxy = new Proxy(target, {
683
+ set(target2, key, value) {
684
+ const old = Reflect.get(target2, key);
685
+ if (old === value) return true;
686
+ Reflect.set(target2, key, value);
687
+ dirty();
688
+ return true;
689
+ },
690
+ get(target2, key) {
691
+ const value = Reflect.get(target2, key);
692
+ if (typeof value === "object" && value !== null) return reactive(value);
693
+ return value;
694
+ },
695
+ deleteProperty(target2, key) {
696
+ if (Reflect.has(target2, key)) {
697
+ Reflect.deleteProperty(target2, key);
698
+ dirty();
699
+ }
700
+ return true;
701
+ }
702
+ });
703
+ proxyCache.set(target, proxy);
704
+ return proxy;
705
+ };
706
+ return reactive({});
707
+ }
708
+
497
709
  // src/client/router.ts
498
710
  function flattenRoutes(routes, basePath = "", chain = []) {
499
711
  const result = [];
@@ -508,7 +720,6 @@ function flattenRoutes(routes, basePath = "", chain = []) {
508
720
  }
509
721
  return result;
510
722
  }
511
- var layoutDepth = /* @__PURE__ */ new WeakMap();
512
723
  function joinPaths(a, b) {
513
724
  if (!b || b === "/") return a || "/";
514
725
  const left = a.endsWith("/") ? a.slice(0, -1) : a;
@@ -595,128 +806,20 @@ function router(opts) {
595
806
  };
596
807
  }
597
808
  function RouteView(_props, ctx) {
809
+ const _depth = ctx.route?._rvDepth ?? 0;
598
810
  return () => {
599
811
  const route = ctx.route;
600
- if (!route?.chain?.length) return null;
601
- const ctxAny = ctx;
602
- const depth = layoutDepth.get(ctxAny) ?? 0;
603
- if (depth >= route.chain.length) return null;
604
- const def = route.chain[depth];
812
+ if (!route?.chain?.length || _depth >= route.chain.length) return null;
813
+ const def = route.chain[_depth];
605
814
  const Comp = def.layout ?? def.component;
606
815
  if (!Comp) return null;
607
816
  if (def.layout) {
608
- layoutDepth.set(ctxAny, depth + 1);
817
+ route._rvDepth = _depth + 1;
609
818
  }
610
819
  return { type: Comp, props: {}, key: void 0 };
611
820
  };
612
821
  }
613
822
 
614
- // src/client/app.ts
615
- function createApp() {
616
- const middlewares = [];
617
- let ctx = {};
618
- let container = null;
619
- let rootComponent = null;
620
- let oldVNode = null;
621
- let rendered = false;
622
- const app = {
623
- get ctx() {
624
- return ctx;
625
- },
626
- use(mw) {
627
- middlewares.push(mw);
628
- return app;
629
- },
630
- async mount(rootSelector, RootComponent) {
631
- rootComponent = RootComponent;
632
- for (const mw of middlewares) {
633
- ctx = await mw(ctx);
634
- }
635
- const el = typeof rootSelector === "string" ? document.querySelector(rootSelector) : rootSelector;
636
- if (!el) throw new Error(`mount target not found: ${rootSelector}`);
637
- container = el;
638
- container.innerHTML = "";
639
- let _dirty = false;
640
- let _rendering = false;
641
- const doRender = () => {
642
- if (_rendering || !container || !rootComponent || !oldVNode) return;
643
- _rendering = true;
644
- layoutDepth.delete(ctx);
645
- const newVNode = wrapComponent(rootComponent, ctx);
646
- const oldNode = container.firstChild;
647
- if (oldNode) {
648
- patchValue(container, oldNode, oldVNode, newVNode, ctx);
649
- }
650
- oldVNode = newVNode;
651
- _rendering = false;
652
- };
653
- const scheduleRender = () => {
654
- if (_dirty || _rendering) return;
655
- _dirty = true;
656
- queueMicrotask(() => {
657
- if (!_dirty) return;
658
- _dirty = false;
659
- doRender();
660
- });
661
- };
662
- ctx.ui = {
663
- /** 立即同步渲染 */
664
- render: doRender,
665
- /** 标记脏状态,下个微任务批量渲染 */
666
- dirty: scheduleRender,
667
- /** 创建响应式状态容器:$.x = val 自动触发 dirty()(仅事件/timer 中生效) */
668
- $: () => createReactiveState(scheduleRender)
669
- };
670
- _rendering = true;
671
- oldVNode = wrapComponent(RootComponent, ctx);
672
- const node = render(oldVNode, ctx);
673
- if (node instanceof Node) container.appendChild(node);
674
- _rendering = false;
675
- rendered = true;
676
- },
677
- destroy() {
678
- if (container) container.innerHTML = "";
679
- container = null;
680
- ctx = {};
681
- }
682
- };
683
- return app;
684
- }
685
- function wrapComponent(Comp, _ctx) {
686
- return { type: Comp, props: {}, key: void 0 };
687
- }
688
- function createReactiveState(dirty) {
689
- const proxyCache = /* @__PURE__ */ new WeakMap();
690
- const reactive = (target) => {
691
- if (target === null || typeof target !== "object") return target;
692
- if (proxyCache.has(target)) return proxyCache.get(target);
693
- const proxy = new Proxy(target, {
694
- set(target2, key, value) {
695
- const old = Reflect.get(target2, key);
696
- if (old === value) return true;
697
- Reflect.set(target2, key, value);
698
- dirty();
699
- return true;
700
- },
701
- get(target2, key) {
702
- const value = Reflect.get(target2, key);
703
- if (typeof value === "object" && value !== null) return reactive(value);
704
- return value;
705
- },
706
- deleteProperty(target2, key) {
707
- if (Reflect.has(target2, key)) {
708
- Reflect.deleteProperty(target2, key);
709
- dirty();
710
- }
711
- return true;
712
- }
713
- });
714
- proxyCache.set(target, proxy);
715
- return proxy;
716
- };
717
- return reactive({});
718
- }
719
-
720
823
  // src/client/types.ts
721
824
  function extendCtx(ctx, fields) {
722
825
  return Object.assign(Object.create(ctx), fields);
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import type { VNode } from './vnode.ts';
14
14
  import type { WfuiContext } from './types.ts';
15
+ export declare const idRegistry: Map<string, VNode>;
15
16
  export declare function render(input: any, ctx: WfuiContext): Node;
16
17
  export declare function patchValue(parent: Node, oldNode: Node | null, oldInput: any, newInput: any, ctx: WfuiContext): Node | null;
17
18
  export declare function mountVNode(container: Element, vnode: VNode, ctx: WfuiContext): void;
@@ -9,6 +9,5 @@ export interface RouterOptions {
9
9
  routes: RouteDef[];
10
10
  notFound?: (props: any, ctx: WfuiContext) => any;
11
11
  }
12
- export declare const layoutDepth: WeakMap<any, number>;
13
12
  export declare function router(opts: RouterOptions): AppMiddleware;
14
13
  export declare function RouteView(_props: {}, ctx: WfuiContext): () => any;
@@ -6,12 +6,18 @@ export interface WfuiContext {
6
6
  [key: string]: unknown;
7
7
  /** UI 框架能力(由 createApp.mount 注入) */
8
8
  ui: {
9
- /** 触发组件重渲染 */
10
- render: () => void;
11
- /** 标记脏状态,下一个微任务批量渲染 */
12
- dirty: () => void;
13
- /** 创建响应式状态容器:$.x = val 自动触发 dirty()(微任务批量渲染) */
9
+ /** 触发组件重渲染(同步,无参 = 当前组件) */
10
+ render: (ids?: string[]) => void;
11
+ /** 异步触发组件重渲染(微任务批处理,无参 = 当前组件) */
12
+ dirty: (ids?: string[]) => void;
13
+ /** 创建响应式状态容器:$.x = val 自动触发 dirty() */
14
14
  $: () => Record<string, any>;
15
+ /** 注册组件实例的自定义语义 ID,同名冲突抛错 */
16
+ selfId: (name: string) => void;
17
+ /** 当前组件实例 ID(仅供内部使用,通过 ctx 扩展注入) */
18
+ _selfId?: string;
19
+ /** 当前组件 VNode 引用(仅供内部使用,通过 ctx 扩展注入) */
20
+ _selfVNode?: any;
15
21
  };
16
22
  /** 路由(由 router 中间件注入) */
17
23
  route?: {
@@ -19,6 +19,12 @@ export interface VNode {
19
19
  _portalEl?: HTMLDivElement | undefined;
20
20
  /** 两阶段组件的 render 函数(mount 返回的函数) */
21
21
  _render?: (props: any) => VNode | null;
22
+ /** 组件实例 ID(如 '_wf_0') */
23
+ _id?: string;
24
+ /** 组件输出的 DOM 父节点 */
25
+ _parentNode?: Node;
26
+ /** 组件输出的第一个 DOM 节点 */
27
+ _refNode?: Node | null;
22
28
  }
23
29
  export type Component<P = {}> = (initProps: P, ctx: WfuiContext) => ((props: P) => VNode | null) | null;
24
30
  export declare const Fragment: unique symbol;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "weifuwu",
3
3
  "type": "module",
4
- "version": "0.50.0",
4
+ "version": "0.51.1",
5
5
  "description": "AI SaaS framework — (req, ctx) => Response",
6
6
  "exports": {
7
7
  ".": {