From bca01f21ec7c2df9f1ead444cc287748933d3c5e Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Thu, 17 Sep 2026 11:11:16 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20createStore=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=20/=20=E6=95=B0=E7=BB=84=E7=9A=84=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E7=BA=A7=E5=93=8D=E5=BA=94=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/core 新增 store.ts:纯对象与数组经 Proxy 惰性代理,读即按属性 订阅、写即通知,数组追踪下标与 length,键集合单独订阅;class 实例、 Map / Set 按值存放。unwrap 取回原始数据。reactive.ts 导出 track / notify 给 store 复用。 todos 页改为 store 写法:改一行的 done 只重跑该行读了它的绑定,For 不重算;push / splice 触发一次 reconcile。在 qjs 上跑过同一场景, patch 流与 node 测试一致。 契约见 runtime-api.md §2.6;roadmap D 组该项记完成,reconcile / produce 留 D 组。 Co-Authored-By: Claude Opus 5 (1M context) --- docs/js-runtime.html | 2 +- docs/roadmap.md | 2 +- docs/runtime-api.md | 23 +++++- packages/core/src/index.ts | 1 + packages/core/src/reactive.ts | 11 ++- packages/core/src/store.ts | 128 +++++++++++++++++++++++++++++++ packages/core/test/store.test.ts | 123 +++++++++++++++++++++++++++++ sample/js/src/pages/todos.tsx | 40 +++++----- 8 files changed, 303 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/store.ts create mode 100644 packages/core/test/store.test.ts diff --git a/docs/js-runtime.html b/docs/js-runtime.html index 2d29f57..36674b0 100644 --- a/docs/js-runtime.html +++ b/docs/js-runtime.html @@ -384,7 +384,7 @@

5. 规则表

children 创建后不可变;结构变化只经 For / Show编译期报错§1:只有 For / Show 开 owner,别处建的节点没人负责删 For / Show 只能直接写在内置节点的 children 里h() 抛 E2§1:槽位表挂在内置节点上,它们需要一个父节点来算 index 内置 / 宿主节点的 prop 不能是函数(on* 除外);业务组件的 prop 不限h() / bindProp 抛 E2,抓的是 text={fmt} 忘了调用ADR-002:函数不过桥,边界就是 patch;业务组件之间是纯 JS 调用 -数组 / 对象整体替换才触发list().push(x) 静默无效§3:=== 判等;v1 没有 Proxy 追踪 +signal 里的数组 / 对象整体替换才触发;要按字段触发用 createStorelist().push(x) 静默无效§3:=== 判等;Proxy 追踪只在 store 里(runtime-api.md §2.6) For 的 item 是 accessor写 todo.title 类型错§4.4:同 key 元素换引用时更新行而不重建行 Show 只在真值性变化时切分支§4.2:切分支 = 重建整棵分支,值变化不该付这个代价 同一 flush 内一个 effect 重跑超过 100 次抛 E2§3:effect 写自己读的 signal 会成环 diff --git a/docs/roadmap.md b/docs/roadmap.md index 5cbf7a8..452a860 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -43,7 +43,7 @@ | 项 | 来源 | 触发条件 | |---|---|---| -| `createStore`(Solid 式 Proxy 深层响应式) | ADR-005 | **v1.1 计划内**:M2 列表页跑通后做 | +| ~~`createStore`(Solid 式 Proxy 深层响应式)~~ | ADR-005 | 已完成(2026-09-17,[runtime-api.md](./runtime-api.md) §2.6):直接赋值、属性级订阅、只深代理纯对象与数组;`reconcile` / `produce` 不做,触发条件"服务端全量刷新导致整表重绑成为性能问题" | | 热下发(页面包分发与版本兼容;引擎侧 Kotlin 回调式 module loader 已在 quickjs-kmp 完成,`JsEngineConfig.moduleLoader`) | ADR-005 | 内核稳定后另立 ADR | | `qjsc-kmp` 宿主二进制随 quickjs-kmp tag 发 GitHub Release,CLI 按版本下载 | build-chain.md §5 | CI 现编耗时或业务方接入成为问题;属 quickjs-kmp 仓改动 | | App 级共享模块(业务共享代码进 external 列表与模块表) | build-chain.md §3 | 页面间重复代码让安装包体积成问题 | diff --git a/docs/runtime-api.md b/docs/runtime-api.md index 0ed015b..dcd4627 100644 --- a/docs/runtime-api.md +++ b/docs/runtime-api.md @@ -1,6 +1,6 @@ # JS 运行时 API:`@tiny-ui/core` v1 -- 状态:已定(2026-09-16);M1 的实现依据。系统说明见 [js-runtime.html](./js-runtime.html),本文只放定义 +- 状态:已定(2026-09-16;2026-09-17 加 §2.6 `createStore`);M1 的实现依据。系统说明见 [js-runtime.html](./js-runtime.html),本文只放定义 - 来源:ADR-001(Signal、所有权)、ADR-002(桥入口、事务、错误)、ADR-004(ref + cmd、事件 payload)、ADR-005(组件函数必须同步、`createResource`、原生 Promise) - 范围:业务可见的 API、它们的精确语义、页面模块契约、以及运行时与 Kotlin 之间的桥入口(内部契约)。JSX 写法如何变成对这些 API 的调用见 [jsx-transform.md](./jsx-transform.md);产出的 patch 形态见 [patch-protocol.md](./patch-protocol.md) @@ -13,6 +13,8 @@ | | `effect(fn)` | 副作用,依赖变化后在 flush 时重跑 | | | `onCleanup(fn)` | 登记清理,随最近的 effect 或结构作用域执行 | | | `untrack(fn)` | 读取但不订阅 | +| | `createStore(init)` → 代理对象 | 对象 / 数组的属性级响应式,直接赋值 | +| | `unwrap(store)` | 取回代理背后的原始数据 | | 节点 | `h(type, props, ...children)` → `Node` | 建节点 / 调组件,JSX 的目标 | | | `Fragment` | 多个兄弟节点 | | | `thunk(fn)` | 编译器包动态 prop 用;业务一般不手写 | @@ -24,7 +26,7 @@ | 页面 | `pageVisible()` | 页面是否可见(K1 `visible`) | | 版本 | `VERSION`、`PROTOCOL` | 包版本;patch 协议版本号 | -不在 v1:`createStore`(v1.1)、`createContext`、`ErrorBoundary`、`Suspense`、`Switch/Match`、`Index`、`Portal`、`lazy`、`setInterval`。页内跨组件共享状态直接用模块顶层的 `signal`(每页一个引擎,模块作用域就是页面作用域)。 +不在 v1:`createContext`、`ErrorBoundary`、`Suspense`、`Switch/Match`、`Index`、`Portal`、`lazy`、`setInterval`。页内跨组件共享状态直接用模块顶层的 `signal`(每页一个引擎,模块作用域就是页面作用域)。 ## 2. 响应式原语 @@ -79,6 +81,23 @@ function untrack(fn: () => T): T 执行 `fn` 期间不建立任何订阅。用于 effect 里"读一下但不想因它重跑"。 +### 2.6 `createStore` + +```ts +function createStore(init: T): T +function unwrap(value: T): T +``` + +- `init` 必须是纯对象或数组,返回它的 Proxy;传入已是 store 的对象原样返回。可在任何地方创建,不要求渲染期 +- **读即订阅,按属性**:effect 里读 `store.user.name` 只订阅 `user` 对象上的 `name`;`Object.keys` / `for…in` / `in` / `JSON.stringify` 订阅键集合 +- **写即通知,直接赋值**:`store.user.name = "x"`、`store.list.push(x)`、`splice` / `sort` / `length = 0` / `delete` 都触发;`===` 同值写入不触发。写入时机与 `signal` 相同,在 flush 统一重跑,受同一个更新环检测 +- 嵌套的纯对象 / 数组在第一次读到时才被代理,代理按原对象缓存:`store.list[0] === store.list[0]`。class 实例、`Map` / `Set` / `Date` 按值存放,不深追踪,只有替换引用才触发;冻结对象同样按值 +- 存进 store 的对象被直接持有(不拷贝):绕过 Proxy 改原对象不会触发 +- 与 `For` 的配合:`each={store.list}` 时行 accessor `item()` 返回缓存的代理,行内绑定落到属性级——改一行的一个字段只重跑读了它的绑定,`For` 不重算;`push` / `splice` 触发一次 reconcile +- `unwrap(value)`:返回代理背后的原对象,并把嵌套的代理原地换回原对象;发请求体、打日志时用。非代理原样返回 +- 与 `@tiny-ui/native` 的 `store`(跨页 KV,native-api.md §3)无关:那是宿主存储,这是页内响应式状态 +- 分工:原始值用 `signal`;有独立变化字段的对象 / 数组用 `createStore`;`signal` 整体替换仍合法。不做 `reconcile` / `produce` / 只读视图(roadmap D 组) + ## 3. 节点与组件 ### 3.1 `h` diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ab52b54..cdc7dd8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ export const VERSION = "0.0.0"; export { PROTOCOL, pageVisible, host, onEmit } from "./page.ts"; export type { HostManifest } from "./page.ts"; export { signal, memo, effect, onCleanup, untrack } from "./reactive.ts"; +export { createStore, unwrap } from "./store.ts"; export { h, Fragment, thunk, ref } from "./node.ts"; export type { Node, Ref, Component } from "./node.ts"; export { For, Show } from "./control.ts"; diff --git a/packages/core/src/reactive.ts b/packages/core/src/reactive.ts index e6cdffe..e51e62e 100644 --- a/packages/core/src/reactive.ts +++ b/packages/core/src/reactive.ts @@ -6,7 +6,7 @@ export interface Owner { disposed: boolean; } -interface Effect { +export interface Effect { fn: () => void; deps: Set[]; owner: Owner | null; @@ -61,6 +61,11 @@ export function insideRender(): boolean { return currentOwner !== null; } +/** True while an effect is collecting dependencies; stores use it to skip allocating subscriber sets. */ +export function tracking(): boolean { + return currentEffect !== null; +} + export function signal(init: T): [get: () => T, set: (next: T | ((prev: T) => T)) => void] { let value = init; const subs = new Set(); @@ -167,14 +172,14 @@ function runCleanups(e: Effect): void { for (const c of cleanups) c(); } -function track(subs: Set): void { +export function track(subs: Set): void { const e = currentEffect; if (!e || subs.has(e)) return; subs.add(e); e.deps.push(subs); } -function notify(subs: Set): void { +export function notify(subs: Set): void { for (const e of subs) { if (e.memo) { if (!e.memo.dirty) { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts new file mode 100644 index 0000000..9e274a8 --- /dev/null +++ b/packages/core/src/store.ts @@ -0,0 +1,128 @@ +// createStore: docs/runtime-api.md §2.6. Property-level subscriptions over lazily created proxies. +import { notify, track, tracking, type Effect } from "./reactive.ts"; + +const RAW: unique symbol = Symbol("tinyui.raw"); +/** Subscriptions to the key set itself (`Object.keys`, `for…in`, `JSON.stringify`); array length changes notify it too. */ +const KEYS: unique symbol = Symbol("tinyui.keys"); + +type Subs = Set; +type Key = string | symbol; + +const proxies = new WeakMap(); +const subscribers = new WeakMap>(); + +/** + * Wraps a plain object or array so that reads inside effects subscribe per property and writes + * through the proxy notify. Nested plain objects and arrays are wrapped on first read; anything + * else (class instances, Map / Set, Date) is stored by value and only a reference swap triggers. + */ +export function createStore(init: T): T { + const raw = unwrapShallow(init); + if (!isWrappable(raw)) throw new Error("createStore() takes a plain object or an array"); + return wrap(raw) as T; +} + +/** The plain data behind a store, with nested proxies replaced in place; for request bodies and logs. */ +export function unwrap(value: T): T { + return unwrapDeep(value, new Set()) as T; +} + +function unwrapDeep(value: unknown, seen: Set): unknown { + const raw = unwrapShallow(value); + if (!isWrappable(raw) || seen.has(raw)) return raw; + seen.add(raw); + const r = raw as Record; + for (const key of Object.keys(r)) { + const v = r[key]; + const u = unwrapDeep(v, seen); + if (u !== v) r[key] = u; + } + return raw; +} + +function unwrapShallow(value: unknown): unknown { + return (value !== null && typeof value === "object" && (value as { [RAW]?: object })[RAW]) || value; +} + +function isWrappable(value: unknown): value is object { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) return false; + const proto = Object.getPrototypeOf(value) as unknown; + return proto === Object.prototype || proto === null || Array.isArray(value); +} + +function wrap(value: unknown): unknown { + if (!isWrappable(value) || RAW in value) return value; + let proxy = proxies.get(value); + if (!proxy) { + proxy = new Proxy(value, handlers); + proxies.set(value, proxy); + } + return proxy; +} + +function subs(target: object, key: Key, create: boolean): Subs | undefined { + let keys = subscribers.get(target); + if (!keys) { + if (!create) return undefined; + subscribers.set(target, keys = new Map()); + } + let s = keys.get(key); + if (!s && create) keys.set(key, s = new Set()); + return s; +} + +function observe(target: object, key: Key): void { + if (tracking()) track(subs(target, key, true)!); +} + +function changed(target: object, key: Key): void { + const s = subs(target, key, false); + if (s) notify(s); +} + +const isIndex = (key: Key): boolean => typeof key === "string" && String(Number(key) >>> 0) === key; + +const handlers: ProxyHandler = { + get(target, key, receiver) { + if (key === RAW) return target; + const value = Reflect.get(target, key, receiver) as unknown; + if (typeof key === "symbol") return value; + // array methods are read through here too; only elements and length are state + if (!Array.isArray(target) || key === "length" || isIndex(key)) observe(target, key); + return wrap(value); + }, + has(target, key) { + if (typeof key !== "symbol") observe(target, key); + return Reflect.has(target, key); + }, + ownKeys(target) { + observe(target, KEYS); + return Reflect.ownKeys(target); + }, + set(target, key, value, receiver) { + const raw = unwrapShallow(value); + const had = Object.prototype.hasOwnProperty.call(target, key); + const old = (target as Record)[key]; + const length = Array.isArray(target) ? target.length : -1; + const ok = Reflect.set(target, key, raw, receiver); + if (!had) changed(target, KEYS); + if (!had || old !== raw) changed(target, key); + if (length >= 0) { + const now = (target as unknown[]).length; + // assigning past the end grows an array without a `length` set; truncating drops elements without deletes + if (key !== "length" && now !== length) changed(target, "length"); + if (key === "length") for (let i = now; i < length; i++) changed(target, String(i)); + if (now !== length) changed(target, KEYS); + } + return ok; + }, + deleteProperty(target, key) { + const had = Object.prototype.hasOwnProperty.call(target, key); + const ok = Reflect.deleteProperty(target, key); + if (had) { + changed(target, key); + changed(target, KEYS); + } + return ok; + }, +}; diff --git a/packages/core/test/store.test.ts b/packages/core/test/store.test.ts new file mode 100644 index 0000000..ac2e472 --- /dev/null +++ b/packages/core/test/store.test.ts @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { bridge } from "./host-stub.ts"; +import { mount, transaction, unmount } from "./helpers.ts"; +import { createOwner, effect, runPending, runWithOwner } from "../src/reactive.ts"; +import { createStore, unwrap } from "../src/store.ts"; +import { Column, For, h, Text, thunk } from "../src/index.ts"; + +const render = (fn: () => T) => runWithOwner(createOwner(), fn); + +describe("createStore", () => { + it("subscribes per property: writing one field re-runs only its readers", () => { + const store = createStore({ a: 1, b: 1 }); + let aRuns = 0, bRuns = 0; + render(() => { + effect(() => { store.a; aRuns++; }); + effect(() => { store.b; bRuns++; }); + }); + store.a = 2; + runPending(); + assert.deepEqual([aRuns, bRuns], [2, 1]); + store.a = 2; + runPending(); + assert.equal(aRuns, 2, "same value is a no-op"); + }); + + it("wraps nested objects lazily with a stable identity", () => { + const store = createStore({ user: { name: "a", tags: ["x"] } }); + assert.equal(store.user, store.user); + assert.equal(store.user.tags, store.user.tags); + let runs = 0; + render(() => effect(() => { store.user.name; runs++; })); + store.user.name = "b"; + runPending(); + assert.equal(runs, 2); + store.user = { name: "c", tags: [] }; + runPending(); + assert.equal(runs, 3, "replacing the parent re-reads through the new object"); + store.user.name = "d"; + runPending(); + assert.equal(runs, 4, "the new object is tracked too"); + }); + + it("tracks array elements and length; push, splice and truncation notify", () => { + const store = createStore({ list: [1, 2, 3] }); + const seen: number[][] = []; + render(() => effect(() => seen.push(store.list.map((n) => n * 10)))); + store.list.push(4); + runPending(); + store.list.splice(0, 1); + runPending(); + store.list.length = 1; + runPending(); + store.list[0] = 9; + runPending(); + assert.deepEqual(seen, [[10, 20, 30], [10, 20, 30, 40], [20, 30, 40], [20], [90]]); + }); + + it("tracks the key set for iteration and `in`", () => { + const store = createStore>({}); + const keys: string[][] = []; + let has = 0; + render(() => { + effect(() => keys.push(Object.keys(store))); + effect(() => { if ("x" in store) has++; }); + }); + store["x"] = 1; + runPending(); + delete store["x"]; + runPending(); + assert.deepEqual(keys, [[], ["x"], []]); + assert.equal(has, 1); + }); + + it("stores class instances and Map by value: only a reference swap triggers", () => { + class Point { x: number; constructor(x = 0) { this.x = x; } } + const store = createStore({ p: new Point(), m: new Map() }); + let runs = 0; + render(() => effect(() => { store.p.x; store.m.size; runs++; })); + store.p.x = 5; + store.m.set("k", 1); + runPending(); + assert.equal(runs, 1); + store.p = new Point(6); + runPending(); + assert.equal(runs, 2); + }); + + it("unwrap returns plain data and rejects non-objects at creation", () => { + const store = createStore({ user: { name: "a" }, list: [{ id: 1 }] }); + const copy = { ...store, extra: store.user }; + assert.equal(unwrap(store), unwrap(store)); + const raw = unwrap(copy); + assert.equal(JSON.stringify(raw), JSON.stringify({ user: { name: "a" }, list: [{ id: 1 }], extra: { name: "a" } })); + assert.notEqual(raw.extra, store.user, "nested proxies are replaced"); + assert.equal(createStore(store), store, "a store is returned as is"); + assert.throws(() => createStore(1 as unknown as object), /plain object or an array/); + assert.throws(() => createStore(new Date() as unknown as object), /plain object or an array/); + }); +}); + +describe("createStore with For", () => { + afterEach(() => unmount()); + + it("a field write updates one binding without reconciling the list", () => { + interface Todo { id: number; title: string; done: boolean } + const store = createStore({ todos: [{ id: 1, title: "a", done: false }, { id: 2, title: "b", done: false }] as Todo[] }); + let reconciles = 0; + mount(() => h(Column, null, + h(For, { each: thunk(() => { reconciles++; return store.todos; }), key: (t: Todo) => t.id }, (item: () => Todo) => + h(Text, { text: thunk(() => item().title), color: thunk(() => (item().done ? "gray" : "black")) })), + )); + assert.equal(reconciles, 1); + // rows are created before their parent: rows 1 / 2, Column 3 + assert.deepEqual(transaction(() => { store.todos[1]!.done = true; }), [["p", 2, "color", "gray"]]); + assert.equal(reconciles, 1, "the For effect did not re-run"); + assert.deepEqual(transaction(() => { store.todos.push({ id: 3, title: "c", done: false }); }), + [["c", 4, "Text"], ["p", 4, "text", "c"], ["p", 4, "color", "black"], ["i", 3, 4, 2]]); + assert.equal(reconciles, 2); + assert.deepEqual(transaction(() => { store.todos.splice(0, 1); }), [["r", 1]]); + assert.equal(bridge.applied.length, 4); + }); +}); diff --git a/sample/js/src/pages/todos.tsx b/sample/js/src/pages/todos.tsx index 28493cd..28c5923 100644 --- a/sample/js/src/pages/todos.tsx +++ b/sample/js/src/pages/todos.tsx @@ -1,47 +1,47 @@ -import { Button, Column, createResource, effect, For, LazyColumn, ref, Row, Show, signal, Text, TextField, untrack, type LazyColumnCommands, type TextFieldCommands } from "@tiny-ui/core"; +import { Button, Column, createResource, createStore, effect, For, LazyColumn, ref, Row, Show, Text, TextField, untrack, type LazyColumnCommands, type TextFieldCommands } from "@tiny-ui/core"; import { http } from "@tiny-ui/native"; interface Todo { id: number; title: string; done: boolean } interface Page { items: Todo[]; next: number | null } export default function Todos() { - const [todos, setTodos] = signal([]); - const [next, setNext] = signal(1); - const [loading, setLoading] = signal(false); + const store = createStore({ todos: [] as Todo[], next: 1 as number | null, loading: false }); const [first, firstMeta] = createResource(() => http.get("/todos?page=1")); effect(() => { const r = first(); - if (r) untrack(() => { setTodos(r.body.items); setNext(r.body.next); }); + if (r) untrack(() => { store.todos = r.body.items; store.next = r.body.next; }); }); const list = ref(); const input = ref(); async function loadMore() { - const page = next(); - if (page === null || loading()) return; - setLoading(true); + const page = store.next; + if (page === null || store.loading) return; + store.loading = true; try { const { body } = await http.get(`/todos?page=${page}`); - setTodos([...todos(), ...body.items]); - setNext(body.next); + store.todos.push(...body.items); + store.next = body.next; } finally { - setLoading(false); + store.loading = false; } } function add(title: string) { if (!title.trim()) return; - setTodos([...todos(), { id: Date.now(), title: title.trim(), done: false }]); + store.todos.push({ id: Date.now(), title: title.trim(), done: false }); input.cmd("setText", { text: "" }); - list.cmd("scrollTo", { index: todos().length - 1 }); + list.cmd("scrollTo", { index: store.todos.length - 1 }); } - const toggle = (id: number) => setTodos(todos().map((t) => (t.id === id ? { ...t, done: !t.done } : t))); - const remove = (id: number) => setTodos(todos().filter((t) => t.id !== id)); + const remove = (id: number) => { + const at = store.todos.findIndex((t) => t.id === id); + if (at >= 0) store.todos.splice(at, 1); + }; return ( - t.done).length} done`} fontSize={20} fontWeight="bold" /> + t.done).length} done`} fontSize={20} fontWeight="bold" /> }> {() => ( @@ -50,8 +50,8 @@ export default function Todos() {