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
}
注意数据里的四种"脏":
字段缺失(CNC-003 没有 name)
类型错误(temp 是字符串 “hot”)
枚举值大小写不符(“RUNNING” 而非 “running”)
null 混入(数组里有 null)
1.2 模块目标
外部脏数据(unknown)
│
▼
┌─────────────────────────────┐
│ dataHub 设备数据中心模块 │
│ │
│ 1. 解析(守卫清洗脏数据) │
│ 2. 存储(类型安全的内存表) │
│ 3. 查询(keyof 派生的接口) │
│ 4. 统计(computed 风格派生) │
│ 5. 告警(枚举驱动的规则引擎) │
│ 6. 事件(类型安全的事件总线) │
└─────────────────────────────┘
│
▼
干净的 Device[]、统计报表、告警流、类型安全的订阅
验收标准:所有公开 API 的入参/出参都有精确类型;故意传错任何值编译器立刻报错;脏数据被安全过滤并留下日志。
二、需求拆解与文件规划
2.1 功能清单
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;
为什么用类型别名:Celsius 比 number 自解释;将来要改精度或加品牌类型(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;
}
清洗策略说明(工程决策,值得咀嚼):
原则:可无损修复的修,核心字段坏的丢——修复要有依据,丢弃要留日志。
七、第 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;
}
}
设计决策说明:
返回副本(
[...this.devices]):外部拿不到内部数组的引用——防止"外部 push 破坏内部状态"(呼应阶段1 模块私有思想)告警差集计算:不是每次全量重报,而是精确识别"新增/解除"——这是实时大屏告警流的标准做法
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_RULES的arm行 → Record 完整性报错[ ] 给
DEVICE_STATUS加maintain状态 →computeStats的穷尽检查爆红
10.3 扩展性验收(单一真相源复利)
试着做这三个实验,体会"一处修改、处处同步":
加状态:
DEVICE_STATUS加maintain: {...}→ 观察哪些地方编译爆红(computeStats、所有 Record 表)——这就是类型系统在帮你找所有需要适配的点加设备种类:
Device联合加conveyor(传送带)→ TEMP_RULES 报缺、guards 需要新守卫加事件:
HubEvents加"hub:error"→ on/emit 立即支持新事件,类型自动锁定
十一、BOSS 战复盘:知识点覆盖对照表
刻意留白(诚实说明,防止误导):
交叉类型 &:本项目没有"同时是 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 —— 全流程验收
最值得带走的三条工程心法:
外部数据入口统一 unknown,出口统一守卫——类型描述期望,守卫保证现实
as const 对象 + keyof typeof 派生——常量即真相,类型自动跟
Record 表驱动替代 if-else 硬编码——规则是数据,扩展靠加行,漏配编译器找
13.2 下一步:第二周(泛型)预告
BOSS 战里你已经三次撞见泛型而没被点名:
K extends keyof HubEvents(事件总线的键锁)Record<DeviceKind, Rule>(其实就是泛型工具)Parameters<F>/ReturnType<T>(泛型反解函数)
第 2 周正式解锁:泛型函数、泛型类、泛型约束、条件类型、infer——把今天"惊鸿一瞥"的威力全部展开,最终手写 Partial/Pick/Omit 等你天天在用的工具类型。
延伸阅读
学编程就像蜗牛往上爬,慢一点没关系,关键是不停下来。
第一关 BOSS 战通关!第 1 周类型基础全部实战验证。接下来是泛型的世界——每天 2 小时,28 天通关 TypeScript 深入。加油!