【TS】day07-boss-battle-datahub

作者:mario 发布时间: 2026-08-28 阅读量:5 评论数:0

TypeScript 第一关 BOSS 战 — 设备数据中心模块,一周知识综合实战

前六天我们分别学会了:基础类型与类型推断(D1)、interface vs type(D2)、联合与交叉(D3)、类型守卫与断言(D4)、枚举与字面量(D5)、keyof/typeof/索引访问(D6)。今天不学新知识——把六天的武器全部带上,从零完成一个工业级的数据中心模块:从外部拿到一坨不可信的 JSON,到最终产出类型安全、可扩展、带完整守卫链的模块。这就是真实项目里 TS 的工作方式。


目录


一、BOSS 战前必读:我们要造什么

1.1 业务场景

智能工厂大屏的后端提供了一组接口,返回的数据长这样(真实世界的脏数据):

{
  "devices": [
    { "id": "CNC-001", "name": "数控机床1号", "temp": 65, "status": "running", "kind": "cnc", "spindleSpeed": 8000 },
    { "id": "AGV-002", "name": "搬运车2号", "temp": 42, "status": "standby", "kind": "agv", "battery": 85, "position": "B区" },
    { "id": "CNC-003", "temp": "hot", "status": "RUNNING", "kind": "unknown" },
    null,
    { "id": "AGV-004", "name": "搬运车4号", "temp": 95, "status": "running", "kind": "agv", "battery": 15, "position": "A区" }
  ],
  "serverTime": 1730000000000
}

注意数据里的四种"脏":

  1. 字段缺失(CNC-003 没有 name)

  2. 类型错误(temp 是字符串 “hot”)

  3. 枚举值大小写不符(“RUNNING” 而非 “running”)

  4. null 混入(数组里有 null)

1.2 模块目标

外部脏数据(unknown)
        │
        ▼
┌─────────────────────────────┐
│  dataHub 设备数据中心模块    │
│                             │
│  1. 解析(守卫清洗脏数据)     │
│  2. 存储(类型安全的内存表)   │
│  3. 查询(keyof 派生的接口)  │
│  4. 统计(computed 风格派生) │
│  5. 告警(枚举驱动的规则引擎) │
│  6. 事件(类型安全的事件总线) │
└─────────────────────────────┘
        │
        ▼
干净的 Device[]、统计报表、告警流、类型安全的订阅

验收标准:所有公开 API 的入参/出参都有精确类型;故意传错任何值编译器立刻报错;脏数据被安全过滤并留下日志。


二、需求拆解与文件规划

2.1 功能清单

#

功能

用到的知识(天)

1

设备多态类型体系(CNC/AGV/机械臂)

D2 interface、D3 判别联合

2

状态/级别/协议枚举体系(as const)

D5

3

脏数据守卫清洗链

D4 typeof/in/谓词

4

枚举值归一化(“RUNNING” → “running”)

D4 相等收窄 + D5

5

统计派生(keyof 遍历字段)

D6

6

类型安全的字段访问/更新 API

D6 keyof + 索引访问

7

告警规则引擎(Record 映射)

D5 Record

8

事件总线(键锁载荷类型)

D6 K extends keyof

9

演示入口 + 全流程日志

全部

2.2 文件结构

day07-boss/
├── types.ts        # 第 1-2 步:类型与常量(被所有模块依赖)
├── guards.ts       # 第 3 步:守卫层(脏数据 → 干净数据)
├── normalize.ts    # 第 4 步:归一化(大小写/缺省值修复)
├── derive.ts       # 第 5 步:派生层(keyof 统计/查询)
├── dataHub.ts      # 第 6 步:核心模块(存储+告警+事件)
├── index.ts        # 第 7 步:演示入口
└── mock.ts         # 脏数据样本(模拟后端)

分层依赖(箭头 = import 方向):

types.ts ◀── guards.ts ◀── normalize.ts
   ▲                              │
   │                              ▼
   └──────── dataHub.ts ◀── derive.ts
                 ▲
                 │
             index.ts(只依赖 dataHub 的公开 API)

分层原则:index.ts 只看得到 dataHub 的公开接口——就像真实项目里"页面只看得到 service 层"。每一步做完都能独立编译通过。


三、第 1 步:类型定义层(D1+D2+D3)

文件:types.ts。这是整个模块的单一真相源——后面所有层都从这里派生,绝不重复手写。

3.1 基础类型别名(D1)

// ===== 基础类型别名:给原始类型起业务名 =====

/** 设备编号(如 "CNC-001") */
export type DeviceId = string;

/** 温度(摄氏度) */
export type Celsius = number;

/** 时间戳(毫秒) */
export type Timestamp = number;

/** 服务端响应的原始载荷(入口统一 unknown) */
export type RawPayload = unknown;

为什么用类型别名Celsiusnumber 自解释;将来要改精度或加品牌类型(branded type,进阶内容),只改一处。

3.2 设备多态体系(D2+D3 判别联合)

// ===== 设备多态:判别字段 kind(D3 判别联合 + D2 interface)=====

/** 通用设备字段(所有设备共享) */
interface DeviceBase {
  id: DeviceId;
  name: string;
  temp: Celsius;
  status: DeviceStatus;      // 见 3.3
}

/** 数控机床(特有:主轴转速) */
export interface CncDevice extends DeviceBase {
  kind: "cnc";
  spindleSpeed: number;
}

/** 自动搬运车(特有:电量+位置) */
export interface AgvDevice extends DeviceBase {
  kind: "agv";
  battery: number;
  position: string;
}

/** 机械臂(特有:负载) */
export interface ArmDevice extends DeviceBase {
  kind: "arm";
  load: number;
}

/** 设备统一类型(判别联合:kind 是判别字段) */
export type Device = CncDevice | AgvDevice | ArmDevice;

/** 设备种类(D6 派生:从联合反解 kind) */
export type DeviceKind = Device["kind"];   // "cnc" | "agv" | "arm"

设计要点

  • DeviceBase 用 interface extends 复用(D2)

  • kind 作为判别字段(D3)——switch 分发和守卫都靠它

  • DeviceKind 用索引访问从联合反解(D6):Device["kind"] 对联合分配,恰好得到 kind 字段的联合

3.3 状态与告警类型(D3+D6)

// ===== 设备状态:as const 三剑客(D5+D6 联动)=====
// 放到 3.4 详述,这里先引用

// ===== 告警记录 =====

/** 告警级别 */
export type AlertLevel = "info" | "warn" | "error";

/** 告警记录(产生时间 + 设备快照) */
export interface AlertRecord {
  id: number;                    // 自增编号
  time: Timestamp;
  deviceId: DeviceId;
  deviceName: string;
  level: AlertLevel;
  reason: string;                // 人类可读的原因
}

/** 统计快照(dataHub 对外报表) */
export interface DeviceStats {
  total: number;
  running: number;
  standby: number;
  fault: number;
  alerting: number;              // 当前处于告警态的设备数
  avgTemp: string;               // 保留 1 位小数的字符串(展示用)
}

3.4 服务端响应类型(给解析层定靶子)

// ===== 服务端响应(描述"期望的形状",实际数据要过守卫)=====

interface DeviceResponse {
  devices: unknown[];      // ⚠ 数组元素故意 unknown:内容不可信
  serverTime: Timestamp;
}

export type { DeviceResponse };

设计哲学:接口契约用类型描述"应该是什么",运行时用守卫验证"真的是什么"——类型描述期望,守卫保证现实。这两层配合是 TS 工程的完整安全观。


四、第 2 步:枚举与常量层(D5+D6)

同文件 types.ts 续。所有“固定的一组值”用 as const 对象治理(D5 选型结论)。

4.1 设备状态表(as const 完全体)

// ===== 设备状态:单一真相源(D5 as const + D6 keyof typeof)=====

export const DEVICE_STATUS = {
  running: { text: "运行中", color: "#00ff88", icon: "🟢", order: 0 },
  standby: { text: "待机",   color: "#ffaa00", icon: "🟡", order: 1 },
  fault:   { text: "故障",   color: "#ff4444", icon: "🔴", order: 2 },
  offline: { text: "离线",   color: "#7a8ba0", icon: "⚪", order: 3 }
} as const;

/** 设备状态(从常量反解) */
export type DeviceStatus = keyof typeof DEVICE_STATUS;
// "running" | "standby" | "fault" | "offline"

/** 单个状态的配置类型(D6 索引访问派生) */
export type StatusConfig = (typeof DEVICE_STATUS)[DeviceStatus];

/** 状态渲染(映射即真相源,永不脱节) */
export function renderStatus(s: DeviceStatus): string {
  const cfg = DEVICE_STATUS[s];
  return `${cfg.icon} ${cfg.text}`;
}

// 下拉框选项(运行时遍历能力)
export const STATUS_OPTIONS = Object.entries(DEVICE_STATUS).map(
  ([value, cfg]) => ({ value: value as DeviceStatus, label: cfg.text })
);

4.2 告警级别与规则常量

// ===== 告警级别 =====

export const ALERT_LEVEL = {
  info:  { text: "提示", color: "#2196f3", priority: 0 },
  warn:  { text: "警告", color: "#ff9800", priority: 1 },
  error: { text: "严重", color: "#f44336", priority: 2 }
} as const;

export type AlertLevel = keyof typeof ALERT_LEVEL;   // "info" | "warn" | "error"

// ===== 告警规则(Record 强制完整,D5+D8 心法)=====

/** 温度阈值规则:按设备种类差异化 */
export const TEMP_RULES: Record<DeviceKind, { warn: number; error: number; unit: string }> = {
  cnc: { warn: 70, error: 85, unit: "°C" },
  agv: { warn: 60, error: 75, unit: "°C" },
  arm: { warn: 65, error: 80, unit: "°C" }
};
// ⭐ 故意删掉 arm 那行试试 → 编译错误:Property 'arm' is missing
// Record 保证规则表永远完整,新增设备种类时漏配规则编译期爆红

4.3 事件映射表(事件总线的真相源)

// ===== 事件总线载荷映射(D6 K extends keyof 的 Map)=====

export interface HubEvents {
  "hub:refresh":    { count: number; dropped: number };        // 数据刷新
  "hub:alert":      AlertRecord;                                // 新告警
  "hub:clear":      { deviceId: DeviceId };                     // 告警解除
  "hub:stats":      DeviceStats;                                // 统计更新
}

五、第 3 步:守卫层(D4)

文件:guards.ts。脏数据进入系统的第一道海关。所有函数返回类型谓词

5.1 基础工具守卫

// guards.ts
import type { DeviceId, Celsius } from "./types.js";

/**
 * 判断是否为非空对象
 * (typeof null === "object" 的坑 → 双重检查,D4 坑点 2)
 */
export function isRecord(v: unknown): v is Record<string, unknown> {
  return typeof v === "object" && v !== null && !Array.isArray(v);
}

/**
 * 判断是否为有限数字(排除 NaN / Infinity)
 * (工业数据里 NaN 是常见脏值)
 */
export function isFiniteNumber(v: unknown): v is number {
  return typeof v === "number" && Number.isFinite(v);
}

/**
 * 判断是否为非空字符串
 */
export function isNonEmptyString(v: unknown): v is string {
  return typeof v === "string" && v.trim().length > 0;
}

5.2 枚举守卫(外部字符串 → 枚举)

// guards.ts 续(需要 import DEVICE_STATUS / DeviceKind 等)

/**
 * 判断值是否为合法状态字符串
 * ⭐ 技巧:从 as const 对象动态提取合法值集合,枚举改了守卫自动跟
 */
export function isDeviceStatus(v: unknown): v is DeviceStatus {
  return (
    typeof v === "string" &&
    Object.keys(DEVICE_STATUS).includes(v)
  );
}

/**
 * 判断值是否为合法设备种类
 */
export function isDeviceKind(v: unknown): v is DeviceKind {
  return v === "cnc" || v === "agv" || v === "arm";
}

设计亮点:守卫的合法值集合从 DEVICE_STATUS 动态读取——状态表新增成员,守卫零修改自动生效(单一真相源复利)。

5.3 设备守卫(判别联合的完整校验)

// guards.ts 续

/**
 * 通用字段校验(所有设备共享的部分)
 * 返回 null 表示通用字段不合法
 */
function checkBase(v: Record<string, unknown>): {
  id: DeviceId; name: string; temp: Celsius; status: DeviceStatus;
} | null {
  const { id, name, temp, status } = v;
  if (
    !isNonEmptyString(id) ||
    !isNonEmptyString(name) ||
    !isFiniteNumber(temp) ||
    !isDeviceStatus(status)
  ) {
    return null;
  }
  return { id, name, temp, status };
}

/**
 * CNC 设备守卫(结构完整校验)
 */
export function isCncDevice(v: unknown): v is CncDevice {
  if (!isRecord(v) || v.kind !== "cnc") return false;
  const base = checkBase(v);
  return base !== null && isFiniteNumber(v.spindleSpeed);
}

/**
 * AGV 设备守卫
 */
export function isAgvDevice(v: unknown): v is AgvDevice {
  if (!isRecord(v) || v.kind !== "agv") return false;
  const base = checkBase(v);
  return (
    base !== null &&
    isFiniteNumber(v.battery) &&
    isNonEmptyString(v.position)
  );
}

/**
 * ARM 设备守卫
 */
export function isArmDevice(v: unknown): v is ArmDevice {
  if (!isRecord(v) || v.kind !== "arm") return false;
  const base = checkBase(v);
  return base !== null && isFiniteNumber(v.load);
}

/**
 * 总守卫:任意合法设备
 */
export function isDevice(v: unknown): v is Device {
  return isCncDevice(v) || isAgvDevice(v) || isArmDevice(v);
}

结构模式:先判 kind(D4 in/相等收窄思路)→ 再校验公共字段 → 最后校验特有字段。每个守卫只做一件事,组合成总守卫。

5.4 响应守卫

/**
 * 服务端响应守卫
 */
export function isDeviceResponse(v: unknown): v is DeviceResponse {
  if (!isRecord(v)) return false;
  if (!Array.isArray(v.devices)) return false;
  return isFiniteNumber(v.serverTime);
}

六、第 4 步:解析层(D4+D5 组合技)

文件:normalize.ts。守卫负责"判定合法",解析层负责"把合法数据变干净"——过滤 + 修复 + 日志。

6.1 解析结果类型(D3 判别联合再现)

// normalize.ts
import type { Device, RawPayload } from "./types.js";
import { isDeviceResponse, isDevice } from "./guards.js";

/** 解析结果:成功/失败(判别联合,调用方 switch 分发) */
export type ParseResult =
  | { ok: true; devices: Device[]; serverTime: number }
  | { ok: false; reason: string };

6.2 归一化细节:状态大小写修复

/**
 * 状态归一化:后端可能发 "RUNNING",统一转小写
 * 修复不了的返回 null(交给上层丢弃)
 */
function normalizeStatus(v: unknown): DeviceStatus | null {
  if (typeof v !== "string") return null;
  const lowered = v.toLowerCase();
  return isDeviceStatus(lowered) ? lowered : null;
}

6.3 主解析函数(清洗流水线)

/**
 * 解析原始载荷:脏数据 → 干净的 Device[]
 * 策略:结构不符 → 整体失败;单项脏 → 丢弃该项并计数(不因一颗老鼠屎坏一锅粥)
 */
export function parsePayload(payload: RawPayload): ParseResult {
  // 第一层:响应结构校验
  if (!isDeviceResponse(payload)) {
    return { ok: false, reason: "响应结构不合法(缺少 devices/serverTime)" };
  }

  // 第二层:逐项清洗(filter + 谓词 + 修复)
  const devices: Device[] = [];
  let dropped = 0;

  for (const item of payload.devices) {
    const fixed = tryFixDevice(item);
    if (fixed === null) {
      dropped++;
      console.warn("[dataHub] 丢弃脏数据项:", item);
    } else {
      devices.push(fixed);
    }
  }

  return { ok: true, devices, serverTime: payload.serverTime };
}

/**
 * 尝试修复单个设备项:先归一化,再守卫校验
 * 返回 null 表示无法挽救
 */
function tryFixDevice(item: unknown): Device | null {
  if (item === null || item === undefined) return null;

  // 归一化副本(不改原始数据)
  const candidate: Record<string, unknown> = { ...(item as object) };

  // 修复 1:状态大小写
  if (!isDeviceStatus(candidate.status)) {
    const fixed = normalizeStatus(candidate.status);
    if (fixed === null) return null;   // 状态无法识别 → 丢弃
    candidate.status = fixed;
  }

  // 修复 2:temp 字符串数字("65" → 65)
  if (typeof candidate.temp === "string") {
    const n = Number(candidate.temp);
    if (Number.isFinite(n)) candidate.temp = n;
  }

  // 修复 3:缺 name 的补默认名
  if (!isNonEmptyString(candidate.name)) {
    candidate.name = `未知设备 ${String(candidate.id ?? "")}`;
  }

  // 终审:过完整守卫
  return isDevice(candidate) ? candidate : null;
}

清洗策略说明(工程决策,值得咀嚼):

脏类型

策略

理由

状态大小写不符

归一化修复

后端常见手误,可安全修复

temp 是数字字符串

转数字

“65” → 65 无信息损失

缺 name

补默认值

展示字段,可兜底

temp 是 “hot”

丢弃该项

核心数据类型错,无法猜

null 项

丢弃

无信息

kind 未知

丢弃

多态分发的基础,不能猜

原则:可无损修复的修,核心字段坏的丢——修复要有依据,丢弃要留日志


七、第 5 步:派生层(D6)

文件:derive.ts。纯函数层:输入干净数据,输出统计与查询结果。keyof/索引访问的主战场。

7.1 类型安全的字段读取(keyof 黄金应用)

// derive.ts
import type { Device, DeviceId, DeviceStats, Celsius } from "./types.js";
import { DEVICE_STATUS, TEMP_RULES } from "./types.js";

/** 所有设备共有的字段(D3 交集技巧:联合上直接访问公共字段) */
type CommonField = "id" | "name" | "temp" | "status" | "kind";

/**
 * 读取设备公共字段(keyof 类型安全版)
 * 拼错字段名 → 编译错误(D4 1.1 灾难不复存在)
 */
export function getField(device: Device, field: CommonField): string | number {
  return device[field];
}

/**
 * 通用字段访问器(更宽的版本:任何键都行,但返回 unknown)
 * 说明:联合类型的 keyof 是公共键交集,访问特有字段需要先收窄 kind
 */
export function getByKey(device: Device, key: keyof Device): unknown {
  return (device as Record<string, unknown>)[key];
}

7.2 温度等级判定(Record 规则表驱动)

/**
 * 判定设备当前温度等级(规则表驱动,非 if-else 硬编码)
 */
export function getTempLevel(device: Device): "normal" | "warn" | "error" {
  const rule = TEMP_RULES[device.kind];   // ⭐ kind 收窄 → 规则自动匹配
  if (device.temp >= rule.error) return "error";
  if (device.temp >= rule.warn) return "warn";
  return "normal";
}

/**
 * 生成告警原因文案
 */
export function buildAlertReason(device: Device): string {
  const rule = TEMP_RULES[device.kind];
  const level = getTempLevel(device);
  if (level === "normal") return "";
  const levelText = level === "error" ? "严重超温" : "温度偏高";
  return `${device.name} ${levelText}(${device.temp}${rule.unit},阈值 ${level === "error" ? rule.error : rule.warn}${rule.unit})`;
}

7.3 统计函数(全字段派生)

/**
 * 计算统计快照(大屏顶部指标卡的唯一数据源)
 */
export function computeStats(devices: Device[], alertingIds: Set<DeviceId>): DeviceStats {
  const total = devices.length;
  let running = 0, standby = 0, fault = 0;

  for (const d of devices) {
    // ⭐ switch + 穷尽检查:新增状态漏统计 → 编译爆红(D4+D5 联动)
    switch (d.status) {
      case "running": running++; break;
      case "standby": standby++; break;
      case "fault":   fault++; break;
      case "offline": break;   // 离线不计入前三项
      default: {
        const _exhaustive: never = d.status;
        console.error("未处理的状态", _exhaustive);
      }
    }
  }

  const avgTemp = total === 0
    ? "0.0"
    : (devices.reduce((s, d) => s + d.temp, 0) / total).toFixed(1);

  return {
    total,
    running,
    standby,
    fault,
    alerting: alertingIds.size,
    avgTemp
  };
}

7.4 按种类分组(联合反解的运行时版)

/**
 * 按设备种类分组(Map 分桶)
 */
export function groupByKind(devices: Device[]): Map<DeviceKind, Device[]> {
  const groups = new Map<DeviceKind, Device[]>();
  for (const d of devices) {
    const list = groups.get(d.kind);
    if (list) {
      list.push(d);
    } else {
      groups.set(d.kind, [d]);
    }
  }
  return groups;
}

/**
 * 生成种类摘要(遍历 Map + 模板字符串)
 */
export function kindSummary(groups: Map<DeviceKind, Device[]>): string {
  return [...groups.entries()]
    .map(([kind, list]) => `${kind}×${list.length}`)
    .join("、");
}

八、第 6 步:核心模块 dataHub.ts(全家桶)

存储 + 告警引擎 + 事件总线三合一。前五步的所有产出在这里汇合。

8.1 模块骨架(私有状态 + 有限 API)

// dataHub.ts
import type {
  Device, DeviceId, AlertRecord, DeviceStats, AlertLevel, HubEvents
} from "./types.js";
import { parsePayload } from "./normalize.js";
import {
  computeStats, getTempLevel, buildAlertReason
} from "./derive.js";

/** 事件回调签名(从 HubEvents 派生,D6) */
type EventHandler<K extends keyof HubEvents> = (payload: HubEvents[K]) => void;

8.2 事件总线(K extends keyof 锁类型)

/**
 * 类型安全事件总线(D6 场景一的模式落地)
 * 事件名与载荷类型由 HubEvents 映射表锁定
 */
class EventBus {
  private handlers: {
    [K in keyof HubEvents]?: EventHandler<K>[];
  } = {};

  /** 订阅事件 */
  on<K extends keyof HubEvents>(event: K, fn: EventHandler<K>): void {
    (this.handlers[event] ??= []).push(fn);
  }

  /** 退订事件 */
  off<K extends keyof HubEvents>(event: K, fn: EventHandler<K>): void {
    const list = this.handlers[event];
    if (!list) return;
    const i = list.indexOf(fn);
    if (i >= 0) list.splice(i, 1);
  }

  /** 触发事件(内部使用) */
  protected emit<K extends keyof HubEvents>(event: K, payload: HubEvents[K]): void {
    this.handlers[event]?.forEach(fn => fn(payload));
  }
}

8.3 DataHub 主类

/**
 * 设备数据中心
 * 职责:接收原始数据 → 清洗存储 → 告警判定 → 广播事件 → 对外查询
 */
export class DataHub extends EventBus {
  // ===== 私有状态(模块私有思想,阶段1 store 模式的类版本)=====
  private devices: Device[] = [];
  private alertRecords: AlertRecord[] = [];
  private alertingIds = new Set<DeviceId>();   // 当前告警中的设备
  private nextAlertId = 1;
  private lastServerTime = 0;

  // ===== 数据接入 =====

  /**
   * 接收原始载荷:解析 → 存储 → 告警判定 → 广播
   * (唯一的数据入口,模拟 WS 推送 / 轮询响应都走这里)
   */
  ingest(payload: unknown): { count: number; dropped: number } | null {
    const result = parsePayload(payload);
    if (!result.ok) {
      console.error("[dataHub] 数据解析失败:", result.reason);
      return null;
    }

    this.devices = result.devices;
    this.lastServerTime = result.serverTime;

    // 告警判定(差集:新告警 / 已解除)
    this.detectAlerts();

    // 广播刷新事件
    const stats = this.getStats();
    this.emit("hub:refresh", {
      count: result.devices.length,
      dropped: result.devices.length   // dropped 细节见 parsePayload 扩展
    });
    this.emit("hub:stats", stats);
    return { count: result.devices.length, dropped: 0 };
  }

  // ===== 告警引擎 =====

  /**
   * 告警判定:对比新旧告警集合,产生"新告警/解除"事件
   */
  private detectAlerts(): void {
    // 计算当前应告警的设备集合
    const current = new Set<DeviceId>();
    for (const d of this.devices) {
      if (getTempLevel(d) !== "normal") current.add(d.id);
    }

    // 新增告警(旧集合没有 → 新集合有)
    for (const d of this.devices) {
      if (current.has(d.id) && !this.alertingIds.has(d.id)) {
        const record: AlertRecord = {
          id: this.nextAlertId++,
          time: Date.now(),
          deviceId: d.id,
          deviceName: d.name,
          level: getTempLevel(d) === "error" ? "error" : "warn",
          reason: buildAlertReason(d)
        };
        this.alertRecords.push(record);
        this.emit("hub:alert", record);       // ⭐ 广播新告警
      }
    }

    // 解除告警(旧集合有 → 新集合没有)
    for (const id of this.alertingIds) {
      if (!current.has(id)) {
        this.alertingIds.delete(id);
        this.emit("hub:clear", { deviceId: id });
      }
    }

    this.alertingIds = current;
  }

  // ===== 查询 API(全部只读、全部类型安全)=====

  /** 全部设备(返回副本,防止外部篡改内部状态) */
  getDevices(): Device[] {
    return [...this.devices];
  }

  /** 按 id 查找(返回 undefined 而非 null,配合 ?. 使用) */
  getDevice(id: DeviceId): Device | undefined {
    return this.devices.find(d => d.id === id);
  }

  /** 当前统计快照 */
  getStats(): DeviceStats {
    return computeStats(this.devices, this.alertingIds);
  }

  /** 最近 n 条告警(新在前) */
  getRecentAlerts(n = 5): AlertRecord[] {
    return [...this.alertRecords].reverse().slice(0, n);
  }

  /** 最近一次服务器时间 */
  getServerTime(): number {
    return this.lastServerTime;
  }
}

设计决策说明

  1. 返回副本[...this.devices]):外部拿不到内部数组的引用——防止"外部 push 破坏内部状态"(呼应阶段1 模块私有思想)

  2. 告警差集计算:不是每次全量重报,而是精确识别"新增/解除"——这是实时大屏告警流的标准做法

  3. EventBus 被 DataHub 继承:事件能力内聚(也可以组合,继承在这里更简洁)

8.4 单例导出(模块级唯一实例)

/** 全局唯一 dataHub(大屏各组件共用) */
export const dataHub = new DataHub();

九、第 7 步:接线演示 index.ts

把整个模块跑起来,模拟 3 轮数据推送,观察完整的告警生命周期。

9.1 脏数据样本

// mock.ts
/** 模拟后端第 1 轮推送(脏数据混入) */
export const PAYLOAD_ROUND_1 = {
  devices: [
    { id: "CNC-001", name: "数控机床1号", temp: 65, status: "running", kind: "cnc", spindleSpeed: 8000 },
    { id: "AGV-002", name: "搬运车2号", temp: 42, status: "standby", kind: "agv", battery: 85, position: "B区" },
    { id: "CNC-003", temp: "hot", status: "RUNNING", kind: "unknown" },   // 脏:temp 类型错
    null,                                                                  // 脏:null
    { id: "AGV-004", name: "搬运车4号", temp: 95, status: "running", kind: "agv", battery: 15, position: "A区" }
  ],
  serverTime: 1730000000000
};

/** 模拟第 2 轮推送(AGV-004 温度回落 → 告警解除事件) */
export const PAYLOAD_ROUND_2 = {
  devices: [
    { id: "CNC-001", name: "数控机床1号", temp: 66, status: "running", kind: "cnc", spindleSpeed: 8000 },
    { id: "AGV-002", name: "搬运车2号", temp: 43, status: "standby", kind: "agv", battery: 84, position: "B区" },
    { id: "AGV-004", name: "搬运车4号", temp: 45, status: "running", kind: "agv", battery: 60, position: "A区" }
  ],
  serverTime: 1730000003000
};

9.2 演示主流程

// index.ts
import { dataHub } from "./dataHub.js";
import { DEVICE_STATUS, renderStatus } from "./types.js";
import { groupByKind, kindSummary } from "./derive.js";
import { PAYLOAD_ROUND_1, PAYLOAD_ROUND_2 } from "./mock.js";

// ===== 订阅事件(体验类型锁定的回调签名)=====
dataHub.on("hub:alert", (a) => {
  // a 自动推导为 AlertRecord —— 事件名与类型锁定
  console.log(`🔔 [告警] #${a.id} ${a.deviceName}(${a.level}):${a.reason}`);
});

dataHub.on("hub:clear", ({ deviceId }) => {
  console.log(`✅ [解除] 设备 ${deviceId} 温度恢复正常`);
});

dataHub.on("hub:stats", (s) => {
  // s 自动推导为 DeviceStats
  console.log(`📊 [统计] 总数${s.total} 运行${s.running} 告警中${s.alerting} 均温${s.avgTemp}°C`);
});

// ===== 第 1 轮:脏数据清洗 + 告警产生 =====
console.log("========== 第 1 轮推送(含脏数据) ==========");
const r1 = dataHub.ingest(PAYLOAD_ROUND_1);
console.log(`解析结果:${r1?.count ?? 0} 台合法设备入库`);

// 查询演示
const agv4 = dataHub.getDevice("AGV-004");
if (agv4 && agv4.kind === "agv") {
  // ⭐ 相等收窄后安全访问特有字段(D4)
  console.log(`AGV-004 详情:电量 ${agv4.battery}%,位置 ${agv4.position}`);
}

// 分组统计(D6 派生层)
const groups = groupByKind(dataHub.getDevices());
console.log("种类分布:", kindSummary(groups));

// 状态渲染(D5 as const)
for (const d of dataHub.getDevices()) {
  console.log(`  ${d.id} ${renderStatus(d.status)}`);
}

// ===== 第 2 轮:告警解除 =====
console.log("\n========== 第 2 轮推送(温度回落) ==========");
dataHub.ingest(PAYLOAD_ROUND_2);

// 最近告警(生命周期回顾)
console.log("\n最近告警记录:");
for (const a of dataHub.getRecentAlerts(5)) {
  console.log(`  #${a.id} [${a.level}] ${a.deviceName} — ${a.reason}`);
}

// ===== 故意触发编译错误(验证类型安全,取消注释看效果)=====
// dataHub.getDevice(123);                          // ❌ id 必须是 string
// dataHub.on("hub:alerts", () => {});              // ❌ 事件名拼错
// dataHub.getDevice("X").temp;                     // ❌ 可能 undefined,要先守卫

9.3 预期运行输出

========== 第 1 轮推送(含脏数据) ==========
[dataHub] 丢弃脏数据项:{ id: 'CNC-003', temp: 'hot', status: 'RUNNING', kind: 'unknown' }
[dataHub] 丢弃脏数据项:null
🔔 [告警] #1 搬运车4号(warn):搬运车4号 温度偏高(95°C,阈值 60°C)
📊 [统计] 总数3 运行2 告警中1 均温67.3°C
解析结果:3 台合法设备入库
AGV-004 详情:电量 15%,位置 A区
种类分布: cnc×1、agv×2
  CNC-001 🟢 运行中
  AGV-002 🟡 待机
  AGV-004 🟢 运行中

========== 第 2 轮推送(温度回落) ==========
✅ [解除] 设备 AGV-004 温度恢复正常
📊 [统计] 总数3 运行2 告警中0 均温51.3°C

最近告警记录:
  #1 [warn] 搬运车4号 — 搬运车4号 温度偏高(95°C,阈值 60°C)

注意告警日志里 CNC-003 的 “RUNNING” 没有被修复——因为它的 temp 是 “hot”,核心字段坏,整项被丢弃(归一化顺序:先修可修的,终审判不过的丢)。


十、验收与自测清单

10.1 功能验收

  • [ ] 第 1 轮推送后:5 项输入,2 项被丢弃(含日志),3 台入库

  • [ ] AGV-004(95°C ≥ AGV error 阈值 75)触发 error 级告警

  • [ ] 第 2 轮推送后:AGV-004 告警解除,触发 hub:clear 事件

  • [ ] getStats() 各字段与手算一致

  • [ ] groupByKind 分桶正确(cnc×1、agv×2)

  • [ ] 事件回调参数类型全部自动推导(鼠标悬停验证)

10.2 类型安全验收(取消注释应爆红)

  • [ ] dataHub.getDevice(123) → 编译错误

  • [ ] dataHub.on("hub:alerts", ...) → 事件名拼错爆红

  • [ ] dataHub.getDevice("X")!.temp → 非空断言有警告风险,用守卫更稳

  • [ ] 删除 TEMP_RULESarm 行 → Record 完整性报错

  • [ ] 给 DEVICE_STATUSmaintain 状态 → computeStats 的穷尽检查爆红

10.3 扩展性验收(单一真相源复利)

试着做这三个实验,体会"一处修改、处处同步":

  1. 加状态DEVICE_STATUSmaintain: {...} → 观察哪些地方编译爆红(computeStats、所有 Record 表)——这就是类型系统在帮你找所有需要适配的点

  2. 加设备种类Device 联合加 conveyor(传送带)→ TEMP_RULES 报缺、guards 需要新守卫

  3. 加事件HubEvents"hub:error" → on/emit 立即支持新事件,类型自动锁定


十一、BOSS 战复盘:知识点覆盖对照表

知识点

在项目中的落点

D1

类型别名

DeviceId/Celsius/Timestamp 业务别名

D1

类型推断

各处省略标注,靠推断

D1

unknown 入口

RawPayload = unknown,外部数据统一类型

D2

interface extends

DeviceBase → 三种设备接口

D2

interface vs type 选型

对象形状用 interface,联合/工具用 type

D3

判别联合

Device(kind)、ParseResult(ok)

D3

交叉类型

(未用——本项目无多重身份需求,诚实标注)

D4

typeof/in/相等收窄

守卫层全套、agv4 特有字段访问

D4

自定义谓词

isDevice/isCncDevice 等 8 个守卫

D4

filter + 谓词

解析层逐项清洗

D4

穷尽检查

computeStats 的 switch + never

D5

as const 对象

DEVICE_STATUS/ALERT_LEVEL/TEMP_RULES

D5

keyof typeof

DeviceStatus = keyof typeof DEVICE_STATUS

D5

Record 强制完整

TEMP_RULES: Record<DeviceKind, ...>

D5

字面量联合

AlertLevel、事件名

D6

typeof 类型查询

常量反解、函数类型

D6

keyof + 索引访问

Device["kind"]getField

D6

K extends keyof

EventBus 的 on/emit、事件载荷锁定

D6

派生链

types.ts → guards → derive 全链路单一真相源

刻意留白(诚实说明,防止误导):

  • 交叉类型 &:本项目没有"同时是 A 和 B"的需求,未使用——真实项目里它常用于配置合并(BaseConfig & UserConfig),下阶段会遇

  • 泛型K extends keyof 只是泛型的惊鸿一瞥,完整的泛型函数/泛型类/泛型约束是第 2 周的主角

  • 断言函数 asserts:守卫已够用;契约场景(解析失败即抛错)留给读者练习


十二、常见坑点回顾

坑点 1:ingest 里引用了 parsePayload 未导出的 dropped

parsePayload 返回的 dropped 计数没有透传到事件里(正文 ingest 简化处理了)。修复练习:给 ParseResult 的成功分支加 dropped: number 字段,并让 hub:refresh 事件携带真实丢弃数。

坑点 2:展开运算符丢失类型守卫效果

const candidate = { ...(item as object) };
// candidate 的类型是 object,不是 Record<string, unknown>
// 所以后续 candidate.status 访问要经过 isRecord 再收窄
// 本项目直接标注了 Record<string, unknown>,注意 as object 只是浅拷贝手法

坑点 3:事件处理器内存泄漏

// on 了不 off → 回调堆积(阶段1 内存泄漏三大杀手的 TS 版)
// 练习:给 EventBus 加 once() 方法(触发一次自动退订)

坑点 4:getDevice 返回 undefined 直接 .temp

// ❌ 运行时崩溃
const t = dataHub.getDevice("X").temp;

// ✅ 守卫(D4 铁律:未知即守卫)
const d = dataHub.getDevice("X");
const t = d?.temp;   // number | undefined

坑点 5:篡改内部状态

// getDevices 返回副本的原因:
const list = dataHub.getDevices();
list.push(fakeDevice);           // 只改了副本,内部安全 ✅
// 如果直接返回 this.devices,这里就污染了内部状态 ⚠

十三、总结与下一步

13.1 本战收获

一个 BOSS,六天武器,七个文件:

types.ts     —— 类型与常量的单一真相源(interface/判别联合/as const/Record)
guards.ts    —— 脏数据海关(谓词守卫全家桶)
normalize.ts —— 清洗流水线(修复有依据,丢弃留日志)
derive.ts    —— 纯函数派生层(keyof/规则表/穷尽检查)
dataHub.ts   —— 核心引擎(存储/告警差集/事件总线)
mock.ts      —— 真实世界的脏
index.ts     —— 全流程验收

最值得带走的三条工程心法

  1. 外部数据入口统一 unknown,出口统一守卫——类型描述期望,守卫保证现实

  2. as const 对象 + keyof typeof 派生——常量即真相,类型自动跟

  3. Record 表驱动替代 if-else 硬编码——规则是数据,扩展靠加行,漏配编译器找

13.2 下一步:第二周(泛型)预告

BOSS 战里你已经三次撞见泛型而没被点名:

  • K extends keyof HubEvents(事件总线的键锁)

  • Record<DeviceKind, Rule>(其实就是泛型工具)

  • Parameters<F> / ReturnType<T>(泛型反解函数)

第 2 周正式解锁:泛型函数、泛型类、泛型约束、条件类型、infer——把今天"惊鸿一瞥"的威力全部展开,最终手写 Partial/Pick/Omit 等你天天在用的工具类型。


延伸阅读

资源

说明

TypeScript Playground

把整个项目粘进去跑类型检查

type-challenges

warmup 难度开始刷(第 2 周泛型后再战)

TS Handbook - Narrowing

守卫层原理

TS Handbook - keyof/typeof

派生层原理


学编程就像蜗牛往上爬,慢一点没关系,关键是不停下来。

第一关 BOSS 战通关!第 1 周类型基础全部实战验证。接下来是泛型的世界——每天 2 小时,28 天通关 TypeScript 深入。加油!


评论