Day 75 · Composables 组合式函数 — 数据层的 Vue 化改造
组合式函数不是"工具函数",是有状态逻辑的复用单元。今天的核心工程:
useScreenStore——把 Day 65 的手写 ScreenStore 桥接进响应式世界,并封装useDataSource让"订阅与退订的成对纪律"从自觉变成结构。完成后,屏 B 的任何组件都能用一行const { latest, alarms } = useScreenStore()拿到全局响应式数据——Day 77 BOSS 战的弹药库今天备齐。
目录
- 一、组合式函数的设计原则
- 二、Store 的桥接策略:渐进式改造
- 三、实战:useScreenStore
- 四、实战:useDataSource
- 五、屏幕激活协议:订阅与轮播的联动
- 六、常见坑点
- 七、自测挑战
- 八、总结
一、组合式函数的设计原则
1.1 两个硬性标准
// ✅ 组合式函数:use 开头 + 内部用响应式 API + 返回 ref/computed
export function useFoo(): Ref<string> { ... }
// ❌ 普通工具函数:纯函数,无状态无响应式(放 utils/,别挂 use)
export function formatDate(ts: number): string { ... }
1.2 两条设计纪律
- 同步调用:Composable 内部注册的钩子(onUnmounted 等)挂靠"调用时的组件"。必须在 setup 顶层同步调用——在 setTimeout / 事件回调里调用等于把钩子挂到空气上(Day 73 坑 3 的展开)
- 返回 ref 而非 reactive 对象:调用方解构后仍保响应(ref 解构不丢响应性),并可用
toRefs灵活改名
1.3 与 Mixin 的对照(为什么是函数)
Vue2 的 Mixin 是"隐式合并"——来源不明、命名冲突、追踪困难。组合式函数是显式调用、显式返回:const { latest } = useScreenStore(),数据从哪来一目了然。这也是大屏 Store 桥接选它而非 provide/inject 全局化的原因(Week 12 换 Pinia 后职责更清晰)。
二、Store 的桥接策略:渐进式改造
2.1 决策:本周"桥接",下周"替换"
Day 65 的 ScreenStore 类是精心调试过的(分频、窗口维护、立即回答),本周不重写它。策略:
Week 11(本周) ScreenStore 类原样保留,外面套一层响应式壳(useScreenStore)
→ 数据从类流向组件,经过响应式转换
Week 12 Pinia 正规军替换:类退役,状态进 store/,订阅逻辑进 actions
理由:(1) 一次只换一个变量——先换"视图层"(Vue 组件),数据层保持稳定,出问题好定位;(2) Week 12 换 Pinia 时,本周的响应式壳就是回归测试的对照物。
2.2 桥接的关键:类状态 → shallowRef
ScreenStore 内部是普通可变状态。桥接层做的事:订阅它,把每次分发转写成 shallowRef 的整体替换——Vue 的响应式追踪从这里开始。
三、实战:useScreenStore
// src/composables/useScreenStore.ts
import { onUnmounted, shallowRef } from "vue";
import { ScreenStore } from "../core/screen-store"; // Day 65 原样迁移的类
import { WsDataSource } from "../core/ws-data-source";
import type { PanelSnapshot, Alarm } from "../core/types";
/**
* 全局数据中枢的响应式桥(单例模式)
* 职责:
* 1. 全应用只创建一次 ScreenStore + 数据源(挂到模块级变量)
* 2. 把 Store 的每次分发转写为 shallowRef 替换 → 组件自动更新
* 3. 订阅成对:组件卸载自动退订(宿主无感)
*/
// —— 模块级单例:import 即共享,全应用只有这一份 ——
let store: ScreenStore | null = null;
/** 懒初始化:首个调用的组件触发数据源启动 */
function getStore(): ScreenStore {
if (!store) {
store = new ScreenStore();
store.attach(new WsDataSource()); // 唯一的 WS 连接(Day 65 纪律:不许私连)
}
return store;
}
/** 组合式函数入口:任意组件调用,拿到响应式全局状态 */
export function useScreenStore() {
const s = getStore();
// 响应式镜像:shallowRef——大快照整体替换(Day 72 选型三连)
const latest = shallowRef<PanelSnapshot | null>(null);
const alarms = shallowRef<Alarm[]>([]);
const tempWindows = shallowRef<Map<string, { ts: number; v: number }[]>>(new Map());
const connState = shallowRef<"connected" | "connecting" | "closed">("closed");
// 订阅类:分发改写镜像(注意是整体替换,让 shallowRef 生效)
const unsub = s.subscribe({
onSnapshot: (snap) => { latest.value = snap; },
onWindow: (w) => { tempWindows.value = w; },
onAlarms: (a) => { alarms.value = [...a]; }, // 拷贝一次,确保引用变化
onConnState:(c) => { connState.value = c; },
minIntervalMs: 1000, // 屏级默认分频(Day 65 的分频能力原样可用)
});
// 成对纪律:宿主组件卸载 → 退订(Day 69 泄漏排查的结构性解法)
onUnmounted(() => unsub());
return { latest, alarms, tempWindows, connState };
}
3.1 使用效果(屏 B 里的样子)
<script setup lang="ts">
import { useScreenStore } from "../composables/useScreenStore";
const { latest, alarms, connState } = useScreenStore();
// 此后模板里 {{ connState }} / v-for="a in alarms" 全自动更新
</script>
与 Day 65 原生版的对照:屏幕模块从"activate 时 subscribe、deactivate 时 unsub 的自觉协议"变成"setup 即订阅、卸载即退订的框架协议"——纪律的执行者从人变成了结构。
3.2 一个遗留问题的说明
多组件各自调用 useScreenStore() 会产生多份订阅(各自维护镜像)。这在"每个组件只取所需分频"的设计下是可接受的轻量冗余(Day 65 分频表的延续);Week 12 的 Pinia 会把状态收敛为真正的单一来源,镜像冗余随之消失——记进 NOTES.md 作为对比素材。
四、实战:useDataSource
// src/composables/useDataSource.ts
import { onUnmounted, shallowRef } from "vue";
import { WsDataSource } from "../core/ws-data-source";
import type { PanelSnapshot } from "../core/types";
/**
* 轻量数据源钩子:给"不需要全局 Store"的独立小场景用
* (如调试页、单卡片独立预览。正式屏面一律走 useScreenStore——单连接纪律)
*/
export function useDataSource() {
const source = new WsDataSource();
const latest = shallowRef<PanelSnapshot | null>(null);
const stop = source.subscribe((snap) => { latest.value = snap; });
source.start();
// 卸载自动停源:连接、重连定时器、心跳全部由类内部成对清理(Day 58-63 的功劳)
onUnmounted(() => source.stop());
return { latest };
}
它的价值不在功能而在模式示范:任何"资源创建 → 使用 → 销毁"的三段式逻辑(WS 连接、MQTT client、音视频流),都可以封装成这种"调用即创建、卸载即销毁"的钩子——资源生命周期与组件生命周期同构,这是组合式函数最漂亮的用法。
五、屏幕激活协议:订阅与轮播的联动
5.1 本周的简化决策
Day 64 的原生 ScreenRouter 有"激活/失活"协议(激活喂、隐藏不喂)。Vue 版今天先简化:三屏 v-show 常驻 + 各自订阅(依赖 Store 分频控制频率)。轮播激活协议的组件化(onActivated/onDeactivated,需要 KeepAlive)留给 Week 12——一次只换一个变量。
5.2 简化的代价评估(记进 NOTES.md)
| 原生版(激活喂) | 本周简化版(常驻订阅 + 分频) |
|---|---|
| 隐藏屏零回调开销 | 隐藏屏每秒收 1 次分频快照(1s 分频下可忽略) |
| 切回零等待 | 切回零等待(同样成立——订阅从未断) |
结论:在 1s 分频 + 三屏规模下,简化版开销可忽略;若未来屏数增多或分频变细,Week 12 的 KeepAlive 方案再收回激活性。这就是"渐进式改造"的好处——每个决策都有对照数据。
六、常见坑点
坑 1:组合式函数里返回 reactive 对象
调用方解构丢响应(Day 72 坑 1 的传染版)。规范:返回 ref 对象的普通对象(return { latest, alarms }),解构安全。
坑 2:单例初始化的时机陷阱
模块级 let store 在 SSR 或 HMR 时可能重复创建(开发环境热更新看到两条 WS 连接)。识别方法:server 控制台连接数 > 1。规避:HMR 后刷新页面验证;Week 12 Pinia 自带应用级单例,此坑自然消失。
坑 3:在组合式函数内部把 props 写进状态
Composable 应该"接收 ref 参数"而不是读组件内部变量——保持它对宿主的无知。签名设计:useRollingNumber(source: Ref<number>) 传 ref 而非传值,保证依赖追踪。
坑 4:忘记 alarms 的拷贝
alarms.value = a 直接赋内部数组引用——后续类内部 push 同一数组,引用没变,shallowRef 不通知。所以桥接层用 [...a] 拷贝。镜像层纪律:凡赋值必产生新引用。
七、自测挑战
T1 · useScreenStore 桥接(60 分钟)
完成第三节代码;屏 B 占位页用 {{ connState }} + AlarmList(Day 74)展示真实数据,杀服务器验证状态灯变红、重启恢复——Day 63 容错场景在 Vue 版全量复现。
T2 · 订阅成对验证(30 分钟)
切换 v-show 卸载屏 B 组件 10 次,DevTools Console 打印 subs.length(Store 加临时日志),确认稳定不涨——Day 69 坑 4 的 Vue 版回归测试。
T3 · 镜像拷贝实验(30 分钟)
把第三节 onAlarms 的 [...a] 改成 a,用"新增告警"场景复现"列表不更新"的坑 4,截图进 NOTES.md——这是 shallowRef 最典型的翻车现场。
八、总结
| 环节 | 要点 |
|---|---|
| 设计原则 | use 开头 + 响应式 API + setup 顶层同步调用 |
| 桥接策略 | 类原样保留 + 响应式镜像壳;Week 12 Pinia 替换 |
| useScreenStore | 模块级单例 + shallowRef 镜像 + 卸载自动退订 |
| useDataSource | 资源生命周期与组件生命周期同构的模式示范 |
| 激活协议 | 本周简化为常驻订阅 + 分频,代价可忽略且有数据 |
数据管道已通。明天封图表基座:VChart 组件。