【Vue3】day81-pinia-datalink

作者:mario 发布时间: 2026-09-03 阅读量:4 评论数:0

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 状态树 + 时间旅行

六、常见坑点

热更新后 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(告警弹窗)。

评论