diff --git a/cocoscreator_projects/YouleNexus/assets/framework/ui/store-binder.ts b/cocoscreator_projects/YouleNexus/assets/framework/ui/store-binder.ts new file mode 100644 index 0000000..cd6d6a3 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/ui/store-binder.ts @@ -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(store: ReadonlyReactive, 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(store: ReadonlyReactive, _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); + }, + }; +} \ No newline at end of file diff --git a/cocoscreator_projects/framework-tests/ui/store-binder.test.ts b/cocoscreator_projects/framework-tests/ui/store-binder.test.ts new file mode 100644 index 0000000..070f4f7 --- /dev/null +++ b/cocoscreator_projects/framework-tests/ui/store-binder.test.ts @@ -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 = []; + 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); +}); \ No newline at end of file