feat(framework): StoreBinder 响应式视图绑定 mixin
替代 1773 处 set_self 调用的视图端契约: - bind(store, slot, cb): 首次同步 + 订阅变化 - unbindAll(): 退订全部(幂等,可重复调用) - callback 抛错隔离:首次同步与后续订阅均不污染 binder 状态 设计为 mixin 而非 cc.Component 子类: - 单测不依赖 cc(纯逻辑) - 业务侧自由组合 cc.Component + binder - 单一职责:StoreBinder 只管订阅,Component 管生命周期 135/135 tests pass, typecheck exit 0。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import type { Unsubscribe, ReadonlyReactive } from '../core/reactive.ts';
|
||||
|
||||
/**
|
||||
* Slot 标识:用于区分同一个 StoreBinder 中多次 bind。
|
||||
* 不参与运行时逻辑,仅作为开发者文档与日志线索。
|
||||
*/
|
||||
export type StoreSlot = string;
|
||||
|
||||
/**
|
||||
* 响应式绑定 mixin(不依赖 cc,可独立单测)。
|
||||
*
|
||||
* 业务侧使用方式(Cocos 端):
|
||||
* ```ts
|
||||
* class PlayerInfoView extends cc.Component {
|
||||
* binder = createStoreBinder();
|
||||
*
|
||||
* onLoad() {
|
||||
* this.binder.bind(Session.player, 'state', (p) => {
|
||||
* this.nicknameLabel.string = p.nickname;
|
||||
* });
|
||||
* }
|
||||
*
|
||||
* onDestroy() {
|
||||
* this.binder.unbindAll();
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* 也可以包成 Cocos 组件基类(本期不实现——mixin 更灵活,避免单测对 cc 的强依赖)。
|
||||
*/
|
||||
export interface StoreBinder {
|
||||
/** 全部订阅退订函数;onDestroy 时调用。 */
|
||||
unbindAll(): void;
|
||||
/** 订阅一个 store 的变化,回调在每次 state 变化时触发(包括首次同步)。 */
|
||||
bind<T>(store: ReadonlyReactive<T>, slot: StoreSlot, callback: (value: T) => void): void;
|
||||
}
|
||||
|
||||
export function createStoreBinder(): StoreBinder {
|
||||
const unsubs: Unsubscribe[] = [];
|
||||
return {
|
||||
unbindAll() {
|
||||
while (unsubs.length > 0) {
|
||||
const u = unsubs.pop();
|
||||
try { u?.(); } catch { /* 订阅者抛错不影响其它退订 */ }
|
||||
}
|
||||
},
|
||||
bind<T>(store: ReadonlyReactive<T>, _slot: StoreSlot, callback: (value: T) => void) {
|
||||
// 首次同步:bind 时立即用当前 value 跑一次 callback(视图组件需要初始值)
|
||||
try { callback(store.value); }
|
||||
catch (e) { console.error('[StoreBinder] initial callback error:', e); }
|
||||
const unsub = store.subscribe((value) => callback(value));
|
||||
unsubs.push(unsub);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStoreBinder } from '../../YouleNexus/assets/framework/ui/store-binder.ts';
|
||||
import { signal } from '../../YouleNexus/assets/framework/core/reactive.ts';
|
||||
|
||||
test('createStoreBinder.bind: 订阅 store 后,值变化时 callback 触发', () => {
|
||||
const store = signal({ name: 'init' });
|
||||
const b = createStoreBinder();
|
||||
let got: string | null = null;
|
||||
b.bind(store, 'state', (s) => { got = s.name; });
|
||||
assert.equal(got, 'init', '首次同步调用一次');
|
||||
store.value = { name: 'changed' };
|
||||
assert.equal(got, 'changed');
|
||||
});
|
||||
|
||||
test('createStoreBinder.bind: 不同 slot 独立', () => {
|
||||
const store1 = signal(1);
|
||||
const store2 = signal('x');
|
||||
const b = createStoreBinder();
|
||||
const got: Array<number | string> = [];
|
||||
b.bind(store1, 'a', (v) => got.push(v));
|
||||
b.bind(store2, 'b', (v) => got.push(v));
|
||||
assert.deepEqual(got, [1, 'x']);
|
||||
got.length = 0;
|
||||
store1.value = 2;
|
||||
assert.deepEqual(got, [2]);
|
||||
});
|
||||
|
||||
test('createStoreBinder.unbindAll: 退订后 store 变化不再触发 callback', () => {
|
||||
const store = signal({ x: 0 });
|
||||
const b = createStoreBinder();
|
||||
let count = 0;
|
||||
b.bind(store, 'state', () => { count++; });
|
||||
assert.equal(count, 1);
|
||||
store.value = { x: 1 };
|
||||
assert.equal(count, 2);
|
||||
b.unbindAll();
|
||||
store.value = { x: 2 };
|
||||
assert.equal(count, 2, '退订后不再触发');
|
||||
});
|
||||
|
||||
test('createStoreBinder: 多次 bind 的 unbindAll 全部退订', () => {
|
||||
const a = signal(1);
|
||||
const b = signal(2);
|
||||
const binder = createStoreBinder();
|
||||
let na = 0, nb = 0;
|
||||
binder.bind(a, 'a', () => na++);
|
||||
binder.bind(b, 'b', () => nb++);
|
||||
assert.equal(na, 1);
|
||||
assert.equal(nb, 1);
|
||||
binder.unbindAll();
|
||||
a.value = 10;
|
||||
b.value = 20;
|
||||
assert.equal(na, 1);
|
||||
assert.equal(nb, 1);
|
||||
});
|
||||
|
||||
test('createStoreBinder: bind 同一 store 多次,各 callback 都触发', () => {
|
||||
const store = signal(0);
|
||||
const b = createStoreBinder();
|
||||
let a = 0, b2 = 0;
|
||||
b.bind(store, 'x', () => a++);
|
||||
b.bind(store, 'y', () => b2++);
|
||||
assert.equal(a, 1);
|
||||
assert.equal(b2, 1);
|
||||
store.value = 1;
|
||||
assert.equal(a, 2);
|
||||
assert.equal(b2, 2);
|
||||
});
|
||||
|
||||
test('createStoreBinder.unbindAll: 重复调用安全(幂等)', () => {
|
||||
const store = signal(0);
|
||||
const b = createStoreBinder();
|
||||
let n = 0;
|
||||
b.bind(store, 's', () => n++);
|
||||
b.unbindAll();
|
||||
b.unbindAll(); // 不抛错
|
||||
store.value = 1;
|
||||
assert.equal(n, 1);
|
||||
});
|
||||
|
||||
test('createStoreBinder.bind: callback 抛错不影响其它订阅者与下次 bind', () => {
|
||||
const store = signal(0);
|
||||
const b = createStoreBinder();
|
||||
let reached = false;
|
||||
b.bind(store, 'a', () => { throw new Error('boom'); });
|
||||
b.bind(store, 'b', () => { reached = true; });
|
||||
// 首次同步调用 + 第二次同步触发都不会让 binder 崩溃
|
||||
assert.equal(reached, true);
|
||||
store.value = 1;
|
||||
assert.equal(reached, true);
|
||||
});
|
||||
Reference in New Issue
Block a user