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:
2026-08-31 20:03:22 +08:00
co-authored by Claude Opus 5
parent da594adb1e
commit 4142f590ee
2 changed files with 146 additions and 0 deletions
@@ -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);
},
};
}