Day 65 · 数据层整合 — ScreenStore:唯一的数据警察
昨天定下铁律"数据只有 Store 一条路",今天把这位警察请出来。它要管的事比昨天图纸上画的多:快照的存(最新值 + 滚动窗口)、连接状态的广播、告警的累积列表、按屏幕需求的分频分发。另外还有两个整合期特有的问题:轮播切屏时非激活屏要不要继续喂数(要,但可以降频)、切回来的屏怎么立刻拿到"当前状态"而不是等下一推(Store 必须能回答"现在")。今天结束时,三个屏都能从 Store 领到正确节奏的数据。
目录
- 一、Store 的职责边界:管什么、不管什么
- 二、状态设计:三类数据三种存法
- 三、ScreenStore 实现
- 四、按需订阅:分频与"立即回答"
- 五、激活屏的数据预热
- 六、接入三屏占位验证
- 七、常见坑点
- 八、自测挑战
- 九、总结
一、Store 的职责边界:管什么、不管什么
1.1 管什么
| 职责 | 说明 |
|---|---|
| 持有状态 | 最新快照、温度滚动窗口、告警列表、连接状态 |
| 分发数据 | 按订阅者声明的频率推送(继承 Day 62 分频思想) |
| 回答"现在" | 新订阅者连上立刻拿到当前状态(不用等下一推) |
| 生命周期 | start/stop 全权管理 DataSource 的启停 |
1.2 不管什么
| 不属于 Store | 归属 |
|---|---|
| 数据怎么画 | 各屏的图表模块 |
| 补传/重连细节 | WsDataSource 内部(Day 58/61 已完成) |
| 路由与显隐 | ScreenRouter |
| 业务告警规则 | 服务器(阈值判定在源头) |
🎯 判据:Store 是"数据的缓存与分发",不是"业务大脑"。判断某逻辑放不放 Store,就看它是否屏幕无关——屏幕无关且数据相关的,归 Store。
二、状态设计:三类数据三种存法
第 2 个月反复出现的"数据语义决定存取方式",今天做一次总归纳:
| 数据类型 | 存法 | 例子 |
|---|---|---|
| 最新值语义 | 单个引用,新值覆盖 | 当前 KPI、设备状态、连接状态 |
| 流式序列 | 定长环形窗口(Day 56/61 同款) | 温度曲线的 120 点窗口 |
| 事件累积 | 追加 + 上限截断 + 去重(按 id) | 告警列表(最新 50 条) |
/**
* Store 状态形状(core/types.ts 扩展)
*/
export interface ScreenState {
/** 最新快照(最新值语义) */
latest: PanelSnapshot | null;
/** 各设备温度滚动窗口(流式语义):deviceId → 定长序列 */
tempWindows: Map<string, { ts: number; v: number }[]>;
/** 告警列表(事件语义,最新在前,上限 50) */
alarms: AlarmRecord[];
/** 连接状态 */
connState: "connecting" | "open" | "reconnecting" | "closed";
}
export interface AlarmRecord {
id: string; // 服务器生成,去重依据
ts: number;
deviceId: string;
deviceName: string;
message: string;
level: "warn" | "danger";
}
三、ScreenStore 实现
import type { PanelSnapshot, AlarmRecord, ScreenState, DataSource } from "../types";
/** 订阅者声明:要什么数据 + 什么频率 */
export interface ScreenSubscription {
/** 最新快照(频率:minIntervalMs 毫秒最多一次) */
onSnapshot?: (snap: PanelSnapshot) => void;
/** 温度窗口变化(曲线类屏幕用,每帧推送) */
onWindow?: (windows: ScreenState["tempWindows"]) => void;
/** 告警列表变化 */
onAlarms?: (alarms: AlarmRecord[]) => void;
/** 连接状态变化 */
onConnState?: (s: ScreenState["connState"]) => void;
/** 快照最小推送间隔(默认 1000ms) */
minIntervalMs?: number;
}
const WINDOW_SIZE = 120; // 温度窗口:120 点
const ALARM_LIMIT = 50; // 告警上限
/**
* 全局数据中枢:唯一持有状态、唯一连接数据源
* 三屏及所有组件只能通过 subscribe 领数据
*/
export class ScreenStore {
private state: ScreenState = {
latest: null,
tempWindows: new Map(),
alarms: [],
connState: "closed",
};
private subs: { fn: ScreenSubscription; lastPush: number }[] = [];
private source: DataSource | null = null;
// ===== 生命周期 =====
/** 绑定数据源并启动(全应用只调用一次) */
attach(source: DataSource): void {
this.source = source;
source.subscribe((snap) => this.ingest(snap));
// WsDataSource 的扩展能力:连接状态(Day 63 的 onState)
(source as { onState?: (s: ScreenState["connState"]) => void }).onState?.(
(s) => this.setConnState(s)
);
source.start();
}
stop(): void {
this.source?.stop();
this.source = null;
}
// ===== 订阅 API =====
/**
* 订阅数据(返回退订函数)
* 新订阅者立即收到当前状态快照("回答现在"——见第四节)
*/
subscribe(sub: ScreenSubscription): () => void {
const entry = { fn: sub, lastPush: 0 };
this.subs.push(entry);
// 立即回答:新订阅者马上拿到已有状态
if (this.state.latest && sub.onSnapshot) sub.onSnapshot(this.state.latest);
if (sub.onConnState) sub.onConnState(this.state.connState);
if (sub.onAlarms) sub.onAlarms(this.state.alarms);
if (sub.onWindow) sub.onWindow(this.state.tempWindows);
return () => { this.subs = this.subs.filter((s) => s !== entry); };
}
// ===== 数据摄入 =====
/** 快照到达:更新三类状态 + 分发 */
private ingest(snap: PanelSnapshot): void {
this.state = {
...this.state,
latest: snap,
tempWindows: this.pushWindows(snap),
};
this.dispatch();
}
/** 告警到达(服务器独立通道或快照内嵌) */
ingestAlarm(alarm: AlarmRecord): void {
// 事件语义:按 id 去重(QoS1 思想的业务层落地,Day 60/61 同款)
if (this.state.alarms.some((a) => a.id === alarm.id)) return;
this.state.alarms = [alarm, ...this.state.alarms].slice(0, ALARM_LIMIT);
this.subs.forEach(({ fn }) => fn.onAlarms?.(this.state.alarms));
}
private setConnState(s: ScreenState["connState"]): void {
this.state = { ...this.state, connState: s };
this.subs.forEach(({ fn }) => fn.onConnState?.(s));
}
/** 温度窗口维护(定长滚动,Day 56 同款) */
private pushWindows(snap: PanelSnapshot): Map<string, { ts: number; v: number }[]> {
const windows = new Map(this.state.tempWindows);
for (const d of snap.devices) {
const buf = windows.get(d.deviceId) ?? [];
buf.push({ ts: snap.ts, v: d.temperature });
if (buf.length > WINDOW_SIZE) buf.shift();
windows.set(d.deviceId, buf);
}
return windows;
}
// ===== 分发(含分频) =====
private dispatch(): void {
const now = performance.now();
for (const entry of this.subs) {
const { fn, lastPush } = entry;
const interval = fn.minIntervalMs ?? 1000;
// 快照:分频(订阅者声明的最小间隔)
if (fn.onSnapshot && now - lastPush >= interval) {
entry.lastPush = now;
fn.onSnapshot(this.state.latest!);
}
// 窗口:曲线类每帧推(频率天然受 WS 推送节奏限制)
fn.onWindow?.(this.state.tempWindows);
}
}
}
四、按需订阅:分频与"立即回答"
4.1 分频的三个来源(一张表理清)
| 分频层 | 出处 | 控制什么 |
|---|---|---|
| MessageScheduler | Day 62(已内建在 WsDataSource) | 消息进主应用的节奏(rAF 对齐) |
| Store 订阅参数 | 今天(minIntervalMs) |
每个屏幕拿到快照的节奏 |
| 屏幕内部 | Day 56(模块级分帧) | 屏内各图表的更新节奏 |
三层各管一段,互不越权:Store 不关心屏内谁 5 帧谁 30 帧,屏幕也不关心消息原来是 1Hz 还是 100Hz。
4.2 “立即回答”:为什么新订阅者要马上收到状态
轮播从屏 A 切到屏 B 的瞬间,屏 B 的模块刚被激活并订阅 Store——如果只等下一次推送(最长 1 秒后),用户看到 1 秒空白。Store 在 subscribe 时立即回放当前状态,屏幕零等待上屏。
轮播切屏的时间线(有/无"立即回答"对比):
无:切屏 → 屏B激活订阅 → [最长1s空白] → 下次推送 → 数据上屏
有:切屏 → 屏B激活订阅 → 立即回放 latest → 数据上屏(同帧内完成)
五、激活屏的数据预热
5.1 非激活屏的数据策略
昨天选了 opacity 切屏(三屏同时渲染),数据策略要配套:
| 策略 | 做法 | 成本 |
|---|---|---|
| 全喂 | 非激活屏照常收数据更新图表 | 切屏零等待,但三屏同时 setOption(CPU 浪费) |
| 激活喂(采用) | 非激活屏只收 onConnState 等轻量订阅;激活时再订阅重数据 |
切屏零等待(靠"立即回答"),CPU 只花在可见屏 |
5.2 屏幕的标准订阅模式(后面两天的模板)
/**
* 屏幕模块的标准生命周期(Day 68/69 的迁移模板)
*/
export function createMonitorScreen(el: HTMLElement, store: ScreenStore): ScreenModule {
let unsub: (() => void) | null = null;
return {
/** 路由激活时调用:订阅重数据 */
activate() {
unsub = store.subscribe({
onSnapshot: (snap) => updateCharts(snap), // minIntervalMs: 5000 可选
onWindow: (windows) => updateCurve(windows),
onConnState: (s) => statusLight.set(s),
});
resizeCharts(); // 容器从 opacity:0 回来,保险起见 resize(Day 52 坑 1)
},
/** 路由离开时调用:退订重数据(轻量订阅可保留) */
deactivate() {
unsub?.();
unsub = null;
},
};
}
配合昨天 ScreenRouter 的 onActive 事件,把 activate/deactivate 接到路由上——路由管显隐,Store 管数据,屏幕只管画,三层各司其职。
六、接入三屏占位验证
今天下午的验收(占位屏即可):
1. server 启动(Day 63 迁移版)
2. app.ts:store.attach(new WsDataSource()) + router 启动
3. 每个屏的占位模块订阅 Store:
屏A:onSnapshot → 显示 KPI 数字(纯文本)
屏B:onWindow → 显示曲线点数(console 即可)
屏C:onConnState → 显示状态灯(Day 58 T3 的组件迁移到 components/)
4. 验收点:
a. 杀服务器 → 三屏状态灯同时变黄(状态唯一性的证明)
b. 重启 → 状态灯同时变绿
c. 轮播切换时数据零空白("立即回答"生效)
七、常见坑点
坑 1:Store 里混进了业务判断
"温度 >90 时告警变色"写进 Store → 屏 C 想用不同阈值就得改 Store。修复:阈值判定在服务器(快照里带状态字段)或各屏自己判,Store 只搬运。
坑 2:subscribe 里直接改 state
订阅者回调里顺手 set → 状态环依赖、调试地狱。纪律:Store 状态只能由 ingest/setXxx 修改(本实现 state 字段 private + 统一入口已保证)。
坑 3:窗口 Map 每帧重建导致 GC 压力
pushWindows 每次新建 Map(不可变性)——1Hz 没问题,将来提频到 60Hz 时会制造垃圾。BOSS 战量级下可接受;提频场景改为原 Map 原地改 + 订阅者拿引用(记录进第 3 个月待办)。
坑 4:告警去重用 ts 而不是 id
同一事件重发时 ts 可能不同(服务器补传重打包)→ 去重失效。修复:事件去重永远用业务 id(Day 61"单调序列去重"的事件版同理)。
坑 5:attach 被调用两次
两条 WS 连接、双份数据。修复:attach 里查重(if (this.source) throw)。
八、自测挑战
T1 · Store + 三屏占位跑通(50 分钟)
完成第三节 Store + 第六节占位接入,三个验收点全过。今天核心作业。
T2 · 分频验证(20 分钟)
给屏 A 的订阅设 minIntervalMs: 5000,屏 B 默认 1000——console 计数验证两屏各收到多少次快照(1 分钟内 A 约 12 次、B 约 60 次)。
T3 · 告警通道(30 分钟)
服务器加告警推送(随机 30 秒一次,带 id),Store 的 ingestAlarm 接收,屏 A 占位显示最新 5 条。补传场景下同一告警重发两次,验证去重。
T4 · Store 单元测试(进阶,40 分钟)
用 vitest 给 ScreenStore 写测试:fake DataSource(手动调 subscribe 回调)驱动,断言"最新值覆盖 / 窗口定长 / 告警去重 / 新订阅者立即回放"。第 4 周类型测试思想的数据层复现——这个测试文件会跟 Demo 一起进 GitHub,含金量极高。
九、总结
| 设计 | 落点 |
|---|---|
| 三类数据三种存法 | 覆盖 / 环形窗口 / 追加去重 |
| 立即回答 | subscribe 时回放状态,轮播切屏零空白 |
| 三层分频 | scheduler → Store 订阅参数 → 屏内分帧,各管一段 |
| 激活喂策略 | 非激活屏轻订阅,activate 时才吃重数据 |
| 标准屏幕模块 | activate/deactivate + 订阅,Day 68/69 的迁移模板 |
数据中枢就位。明天开始穿衣服:大屏视觉工程(上)——布局系统与工业风主题 token,让这块屏从"能跑"变成"能看"。