Day 81 · Pinia 进阶 — 数据源接入与 store 设计:ScreenStore 退役日
昨天状态入了 store 但没有活水。今天把 WS 数据源接进 dashboard store——分层设计是今天的主题:实例(连接、定时器)住 store 外,数据与状态住 store 内,分频策略从"订阅端"改为"消费端"。今天结束,Day 65 的 ScreenStore 类与 Day 75 的 useScreenStore 双双退役,数据链路全量 Pinia 化。
目录
一、分层设计:实例与状态分居
1.1 为什么 WebSocket 实例不能进 state
Day 80 坑 3 的原则展开:
WS 实例放进 state 会发生什么:
1. Devtools 序列化它 → 循环引用 / 崩溃或巨慢
2. 时间旅行回放时"状态"里混着活连接 → 回放语义错乱
3. reactive 深层代理包装实例 → 内部 this 指向错乱,心跳定时器行为异常
1.2 正确的分层
┌──────────────────────────────────────────────┐
│ 数据源服务(普通模块,非响应式) │
│ WsDataSource + ReconnectingWebSocket + │
│ SessionManager —— 原样服役(Day 58-63 功劳) │
│ 职责:连接、重连、补传、原始分发 │
├──────────────────────────────────────────────┤
│ Pinia store(响应式状态树) │
│ state:latest/alarms/tempWindows/connState │
│ actions:setSnapshot 等纯状态变更(Day 80) │
│ 新增:startDataLink() —— 接线动作 │
└──────────────────────────────────────────────┘
桥接点只有一个:数据源回调里调 store actions。原 Week 11 的 useScreenStore 镜像层(订阅转写 shallowRef)整个消失——Pinia state 本身就是响应式的,不需要镜像。
二、数据源服务:store 外的活水管道
// src/services/data-link.ts
import { WsDataSource } from "../core/ws-data-source";
import type { PanelSnapshot, Alarm } from "../core/types";
/**
* 数据链路服务(模块单例,非响应式)
* Day 63 的 WsDataSource 原样服役——重连/心跳/补传能力零改动
* 唯一变化:订阅回调从"喂 ScreenStore"改为"喂 Pinia actions"(由调用方注入)
*/
/** 桥接协议:数据链路 → store 的注入接口(store 提供,服务不认识 store) */
export interface DataLinkSink {
onSnapshot(snap: PanelSnapshot): void;
onAlarms(alarms: Alarm[]): void;
onConnState(s: "connected" | "connecting" | "closed"): void;
}
let source: WsDataSource | null = null;
/** 启动链路:注入回调(依赖倒置——服务定义协议,store 实现) */
export function startDataLink(sink: DataLinkSink): void {
if (source) return; // 防重复启动(HMR 保护)
source = new WsDataSource();
source.subscribe((snap) => sink.onSnapshot(snap));
(source as { onState?: (s: DataLinkSink["onConnState"]) => void }).onState?.(
(s) => sink.onConnState(s)
);
source.start();
}
/** 停止链路(测试/卸载用;大屏常驻场景一般不调) */
export function stopDataLink(): void {
source?.stop();
source = null;
}
设计要点:服务不 import store(依赖倒置)——回调协议由服务定义、store 实现。这让 data-link 可以脱离 Pinia 单测(注入假 sink),也避免了循环依赖。
三、接线:App 启动时灌入
// src/stores/dashboard.ts(在 Day 80 基础上追加接线 action)
export const useDashboardStore = defineStore("dashboard", {
state: () => ({ /* Day 80 原样 */ }),
getters: { /* Day 80 原样 */ },
actions: {
/* Day 80 的 setSnapshot / pushAlarms / ackAlarm / setConnState 原样 */
/**
* 启动数据链路:全应用只调一次(App.vue onMounted 里)
* 实现数据源协议:把链路数据翻译成 actions 调用
*/
startDataLink(): void {
startDataLinkService({
onSnapshot: (snap) => {
this.setSnapshot(snap);
// 告警内嵌在快照里的场景:抽取并累积(Day 63 协议)
if (snap.alarms?.length) this.pushAlarms(snap.alarms);
},
onAlarms: (alarms) => this.pushAlarms(alarms),
onConnState: (s) => this.setConnState(s),
});
},
},
});
<!-- App.vue -->
<script setup lang="ts">
import { onMounted } from "vue";
import { useDashboardStore } from "./stores/dashboard";
const store = useDashboardStore();
onMounted(() => {
store.startDataLink(); // 挂载后开闸——首帧渲染不被数据等待阻塞
});
</script>
四、分频迁移:从订阅端到消费端
4.1 原生版的分频回顾
Day 65 的分频在订阅端:订阅者声明 minIntervalMs,Store 分发时跳过过快的推送。
4.2 Pinia 版:watch 的 immediate 与节流
// 屏 B 的消费端分频(ScreenB.vue)
import { watch } from "vue";
import { storeToRefs } from "pinia";
const store = useDashboardStore();
const { latest } = storeToRefs(store);
// KPI 类:1 秒一更(节流 watch——迁移 Day 65 的 minIntervalMs 语义)
let lastKpi = 0;
watch(latest, (snap) => {
const now = performance.now();
if (now - lastKpi < 1000) return;
lastKpi = now;
kpiTiles.forEach((t) => t.update(snap));
});
// 曲线类:每帧推(不节流——窗口天然受 WS 节奏限制,Day 65 同结论)
const { tempWindows } = storeToRefs(store);
watch(tempWindows, () => lineOption.value = buildOption(tempWindows.value));
分频职责的迁移:从"数据中枢统一分发"变为"消费端各自节流"。代价是每个消费点要自己声明频率;收益是中枢极简(纯状态,无分发逻辑)且各屏频率真正解耦。三屏规模下两种方案开销一致——记进对照表的"取舍"行。
五、退役与回归验证
1. 全局搜索 useScreenStore / screen-store —— 替换为 dashboard store 消费
2. 删除 src/composables/useScreenStore.ts 与 src/core/screen-store.ts
3. npm run build 绿
4. 回归:Day 69 联调清单全量跑
□ 数据五断点追踪(温度值 → state.latest → 三屏呈现)
□ 容错三场景(杀服务器/断网 30s/告警跨屏)
□ subs 泄漏检查退役(无订阅数组了)→ 换成:切屏 50 次后 Devtools
Pinia 面板状态正常、组件树无膨胀
对照表补四行:
| 场景 | 手写 ScreenStore | Pinia 方案 |
|---|---|---|
| 全局状态 | 类内部可变状态 + 镜像桥接 | state 树直接响应式 |
| 分发分频 | 订阅端声明、中枢分发 | 消费端节流 watch |
| 单例保证 | 模块变量(HMR 坑) | 应用级 createPinia |
| 可观测性 | 临时 console.log | Devtools 状态树 + 时间旅行 |
六、常见坑点
坑 1:startDataLink 被调了两次(HMR)
热更新后 App.vue 重新挂载 → startDataLink 再跑 → 两条 WS 连接。data-link 服务的 if (source) return 门闩防住;验证方法:server 控制台连接数恒为 1。
坑 2:watch storeToRefs 的 Map 不触发
Day 80 坑 5 的消费版:watch(tempWindows, ...) 监听的是 Map 引用,actions 里 set/push 不换引用就不触发。两种修法:(1) actions 里换新 Map(this.tempWindows = new Map(this.tempWindows),代价是每帧新建);(2) watch 加 { deep: true }(仅对 Map 且体积可控时)。本周选 2 并记录 Map 尺寸(120 点 × 2 设备,deep 遍历成本可忽略);设备数增长后切换方案 1——优化路径预先规划好。
坑 3:actions 里做节流
把消费端的节流 watch 挪进 actions(“setSnapshot 里每秒只执行一次”)——❌ actions 是变更入口不是分发器,节流放这里会导致 state 与链路数据脱节(窗口断档)。节流永远属于消费端。
坑 4:退役不彻底
composables/useScreenStore.ts 删了,但 core/screen-store.ts 还被 day 遗留的测试或 demo 页 import——build 不报错(tree-shaking 静默),但死代码留在库里。退役判据:全局搜索文件名与导出类名双查。
七、自测挑战
T1 · 数据链路接线(70 分钟)
完成第二至三节:真实数据入 store,三屏(B 已迁,A/C 占位)状态灯与曲线全活。杀服务器 → 状态灯红 → 重启 → 恢复(Day 63 容错在 Pinia 版全量复现)。
T2 · 分频迁移(30 分钟)
完成第四节:KPI 1 秒节流 + 曲线逐帧。对照原生版录屏确认节奏一致。
T3 · 双退役 + 回归(30 分钟)
删除两件手写件,build 绿,第五节回归清单全过。本周替换任务的里程碑。
T4 · Map 深侦听实验(进阶,20 分钟)
坑 2 的两种方案各跑一遍,用 Performance 对比 deep watch 的每帧成本,结论写进对照表——"什么规模下必须换方案 1"给出量化判断。
八、总结
| 环节 | 要点 |
|---|---|
| 分层 | 实例住服务层(非响应式),状态住 store,桥接点唯一 |
| 依赖倒置 | 服务定义 sink 协议,store 实现——可单测、无循环依赖 |
| 分频 | 从订阅端中枢分发 → 消费端节流 watch(取舍入对照表) |
| 退役 | 双删 + build 绿 + 联调回归——判据是搜索零引用 |
| HMR | 门闩防重连,server 连接数恒为 1 |
数据层替换完毕,两件手写类退役。明天补齐生态最后两件:KeepAlive(屏幕缓存)与 Teleport(告警弹窗)。