【阶段1收官】day65-store

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

Day 65 · 数据层整合 — ScreenStore:唯一的数据警察

昨天定下铁律"数据只有 Store 一条路",今天把这位警察请出来。它要管的事比昨天图纸上画的多:快照的存(最新值 + 滚动窗口)、连接状态的广播、告警的累积列表、按屏幕需求的分频分发。另外还有两个整合期特有的问题:轮播切屏时非激活屏要不要继续喂数(要,但可以降频)、切回来的屏怎么立刻拿到"当前状态"而不是等下一推(Store 必须能回答"现在")。今天结束时,三个屏都能从 Store 领到正确节奏的数据。


目录


一、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,让这块屏从"能跑"变成"能看"。

评论