【阶段1收官】day64-architecture

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

Day 64 · 项目架构设计 — 大屏不是页面,是一个有边界的小系统

三个作品整合成一个系统,第一件事不是写代码,是画边界:目录怎么摆、屏幕怎么路由、数据怎么流、模块之间谁认识谁。今天全程"图纸作业"——上午产出架构文档(依赖图 + 数据流图),下午把项目骨架搭出来(目录、路由框架、空屏占位)。地基的密度决定上层建筑的速度:今天多花 30 分钟想清楚,后面五天每天省 30 分钟返工。


目录


一、从一个问题开始:为什么不能三个页面各写各的

1.1 反面架构

屏A.html ── 自己 new WsDataSource() ──▶ 自己的图表
屏B.html ── 自己 new WsDataSource() ──▶ 自己的图表
屏C.html ── 自己 new WsDataSource() ──▶ 自己的拓扑

问题清单:

  1. 三条 WS 连接(服务器 3 倍负担,心跳/重连/补传逻辑跑三份)
  2. 数据不一致(屏 A 断线了屏 B 不知道,KPI 和曲线对不上)
  3. 状态灯要画三个(每个页面自己感知连接状态)

1.2 正面架构

一条 WS 连接 → 一个全局 Store → 三个屏都只是 Store 的订阅者

连接只有一条、状态只有一份、屏幕只是"视图"。这就是 Day 65 的 Store 和今天的模块边界要服务的目标


二、目录结构:模块边界即团队边界

2.1 最终目录

smart-factory-screen/
├── server/                        # 开发环境模拟服务器(第 9 周迁移 + 扩展)
│   ├── server.ts
│   ├── device-simulator.ts
│   └── ring-buffer.ts
├── src/
│   ├── core/                      # 🔒 核心层:不依赖任何 UI
│   │   ├── types.ts               # PanelSnapshot / DataSource / Store 契约
│   │   ├── ws/                    # 第 9 周数据链路(整体迁移)
│   │   │   ├── reconnecting-ws.ts
│   │   │   ├── session.ts
│   │   │   └── scheduler.ts
│   │   └── store/
│   │       └── screen-store.ts    # ⭐ Day 65 主角:全局唯一数据中枢
│   ├── router/
│   │   └── screen-router.ts       # ⭐ 今天主角:三屏路由 + 轮播
│   ├── screens/                   # 🖼️ 屏幕层:每屏一个目录
│   │   ├── overview/              # 屏 A:KPI 总览
│   │   ├── monitor/               # 屏 B:实时监控(第 9 周面板迁移)
│   │   └── topology/              # 屏 C:产线拓扑(第 7 周编辑器迁移)
│   ├── components/                # 跨屏共享 UI:状态灯、边框、翻牌器
│   ├── themes/
│   │   └── industrial-dark.ts     # ECharts 主题(Day 55 迁移)
│   ├── app.ts                     # 组装入口:Store + Router + Screens
│   └── main.ts                    # bootstrap
├── styles/
│   └── screen.css                 # 大屏壳 + 布局(Day 66 扩展)
└── README.md

2.2 分层的判据

判据(放这层的条件)
core 在 Node 里也能跑(纯逻辑,零 DOM 依赖)
router 只操作 DOM 显隐,不关心屏幕内部
screens 只订阅 Store + 渲染,不直接碰网络
components 两个以上屏幕都用

💡 一个文件的归属犹豫不决时,问"它 import 了谁":import 了 DOM 就不进 core;被多屏共享就上提 components。


三、模块依赖规则:谁可以 import 谁

3.1 依赖图(单向无环)

main.ts → app.ts → ┬─ core/(types, ws, store)
                   ├─ router/ → core(仅类型)
                   ├─ screens/* → core + components + themes
                   └─ components → core(仅类型)

三条铁律

  1. core 不 import 任何上层(core/types → core/ws → 上层,箭头只朝上)
  2. screens 之间互不 import(屏 A 不认识屏 B——换掉屏 B 不影响屏 A)
  3. router 不认识具体屏幕内容(只按 id 显隐,不 import screens 的内部模块)

3.2 为什么 screens 互不认识

轮播切屏时如果要"通知屏 B 数据变了",走的必须是 Store 的订阅机制,而不是屏 A 直接调屏 B 的方法——兄弟模块通信永远通过共同父级(Store)。这条规则在 Vue3/React 里同样成立(组件通信靠 store/props,不靠组件互调),今天先在原生 TS 里体会一遍。


四、多屏路由:轻量级路由的设计

4.1 需求

  1. 三屏互斥显示,切换带淡入淡出
  2. 自动轮播(每屏可配停留时长),可暂停/恢复
  3. 手动切换(底部指示器点击)后重置轮播计时

4.2 不引路由库的理由

三个屏、无 URL 语义(大屏不需要刷新还原到某屏),引 vue-router/react-router 是杀鸡用牛刀——写一个 80 行的 ScreenRouter 正好复习第 1 周的发布订阅 + 状态管理

4.3 实现

/**
 * 屏幕路由:三屏互斥显示 + 自动轮播 + 手动切换
 * 设计要点:
 * 1. 屏幕注册制:router 只认识 id 和 DOM,不认识屏幕内容
 * 2. 轮播可暂停:切到非聚焦 Tab 时暂停(省 CPU)
 * 3. 切屏事件广播:屏幕可订阅以处理激活时的 resize(ECharts 需要)
 */
export class ScreenRouter {
  private screens = new Map<string, HTMLElement>();
  private order: string[] = [];
  private current = "";
  private timer = 0;
  private dwellMs = new Map<string, number>();
  private activeListeners = new Set<(id: string) => void>();

  /** 注册屏幕(按调用顺序即轮播顺序) */
  register(id: string, el: HTMLElement, dwellSec = 15): void {
    this.screens.set(id, el);
    this.order.push(id);
    this.dwellMs.set(id, dwellSec * 1000);
    el.classList.add("screen", "screen-hidden");
  }

  /** 启动:显示第一屏并开始轮播 */
  start(initialId: string): void {
    this.switchTo(initialId);
  }

  /** 切换到指定屏 */
  switchTo(id: string): void {
    if (!this.screens.has(id) || id === this.current) return;
    // 互斥:旧的隐藏(带淡出 class,CSS 过渡)
    const prev = this.screens.get(this.current);
    prev?.classList.add("screen-hidden");
    // 新的显示
    const next = this.screens.get(id)!;
    next.classList.remove("screen-hidden");
    this.current = id;
    // 广播激活事件(屏幕订阅后自行 resize 图表)
    this.activeListeners.forEach((l) => l(id));
    this.scheduleNext();
  }

  /** 暂停/恢复轮播(visibilitychange / 用户交互时用) */
  setAutoPlay(on: boolean): void {
    clearTimeout(this.timer);
    if (on) this.scheduleNext();
  }

  /** 订阅屏激活(返回退订) */
  onActive(listener: (id: string) => void): () => void {
    this.activeListeners.add(listener);
    return () => this.activeListeners.delete(listener);
  }

  /** 轮播调度:当前屏停留配置时长后切下一屏 */
  private scheduleNext(): void {
    clearTimeout(this.timer);
    const dwell = this.dwellMs.get(this.current) ?? 15_000;
    this.timer = window.setTimeout(() => {
      const idx = this.order.indexOf(this.current);
      const next = this.order[(idx + 1) % this.order.length];
      this.switchTo(next);
    }, dwell);
  }
}

配套 CSS(styles/screen.css 的路由部分):

/* 屏幕显隐:用 opacity 过渡而非 display(保持布局计算,图表容器尺寸不塌陷) */
.screen {
  position: absolute; inset: 0;
  transition: opacity 0.5s ease;
}
.screen-hidden { opacity: 0; pointer-events: none; }

🎯 为什么用 opacity 不用 display:none:display:none 会让 ECharts 容器宽高归零,切回来必须 resize 且可能闪白(Day 52 坑 1);opacity 保持布局,代价是三屏同时渲染——但配合 Day 67 的"非激活屏暂停更新"纪律,成本可控。


五、数据流总图

今天的第二张图纸(明天 Store 实现的依据):

                    ┌─────────────────────────────┐
                    │  WsDataSource(唯一连接)      │
                    │  ReconnectingWebSocket        │
                    │  SessionManager(补传)        │
                    │  MessageScheduler(分频)      │
                    └──────────────┬──────────────┘
                                   │ PanelSnapshot(1Hz)
                                   ▼
                    ┌─────────────────────────────┐
                    │  ScreenStore(Day 65)        │
                    │  · 最新快照 + 滚动窗口         │
                    │  · 连接状态 + 告警列表         │
                    │  · 订阅分发(按屏分频)        │
                    └──┬──────────┬──────────┬────┘
                       ▼          ▼          ▼
                   屏A 订阅    屏B 订阅    屏C 订阅
                  (KPI 5s)  (曲线每帧)  (状态 2s)

关键决策:Store 是唯一的"数据警察"——屏幕想拿数据只有订阅这一条路,没有旁门(谁绕过 Store 直接连 WS,code review 一票否决自己)。


六、搭建项目骨架

今天下午的产出清单:

1. 按第二节目录建空文件(每个文件先写模块注释:职责一句话)
2. 迁移第 9 周的 server/ 与 src/core/ws/(复制,不改逻辑)
3. 迁移 types.ts 到 core/
4. 实现 ScreenRouter(第四节代码)+ screen.css 基础样式
5. 三个屏的占位页:大字标题 + "Day 6X 填充"提示
6. app.ts 组装:注册三屏 → router.start("overview")
7. 验收:三屏按 15/20/15 秒轮播切换,指示器点击可切换

app.ts 骨架

import { ScreenRouter } from "./router/screen-router";

/**
 * 应用组装入口:今天只搭骨架,屏幕内容后续天填充
 */
export function bootstrapApp(): ScreenRouter {
  const router = new ScreenRouter();

  router.register("overview",  require_el("#screen-overview"),  15);
  router.register("monitor",   require_el("#screen-monitor"),   20);
  router.register("topology",  require_el("#screen-topology"),  15);

  router.start("overview");

  // Tab 不可见时暂停轮播(省 CPU + 防后台计时器漂移)
  document.addEventListener("visibilitychange", () => {
    router.setAutoPlay(!document.hidden);
  });

  return router;
}

function require_el(sel: string): HTMLElement {
  const el = document.querySelector<HTMLElement>(sel);
  if (!el) throw new Error(`找不到屏幕容器 ${sel}`);
  return el;
}

七、常见坑点

坑 1:三屏用 display:none 切换

切回后 ECharts 图表空白/尺寸错乱。修复:opacity 方案(第四节)+ 激活时 resize。

坑 2:屏幕直接 import ws 模块

绕过 Store 直连数据 → 连接状态不一致。修复:依赖规则检查(3.1 铁律),屏幕只允许 import core/types 和 store。

坑 3:轮播计时器在后台漂移

浏览器节流后台 setTimeout → 切回来瞬间连跳几屏。修复:visibilitychange 暂停轮播(6 的代码已处理)。

坑 4:迁移旧代码时"顺手重构"

改着改着逻辑变了,回归测试全崩。修复:迁移 = 复制 + 最小改动;发现旧代码问题记进"第 3 个月待办",不在整合周动刀。

坑 5:循环依赖

core/store import 了 screens 的类型 → 启动即崩。修复:共享类型全部上提到 core/types。


八、自测挑战

T1 · 骨架跑通(40 分钟)

完成第六节清单 1~7,三屏轮播 + 手动切换演示录屏 20 秒。今天核心作业

T2 · 依赖规则自查(15 分钟)

写一个临时脚本(或用 madge 之类的工具):遍历 src 的 import,断言"screens 之间无互相 import"“core 不 import 上层”。跑通后删掉或留作 CI 检查。

T3 · 键盘切换(15 分钟)

数字键 1/2/3 手动切屏,Esc 暂停/恢复轮播——演示视频时超好用的细节。

T4 · 路由过渡效果(20 分钟)

给切屏加滑动+淡入组合过渡(CSS keyframes),对比纯淡入的观感差异,选定一种写进视觉规范笔记。


九、总结

决策 理由
单连接 + 全局 Store 数据一致性、连接成本、状态唯一
目录四层(core/router/screens/components) 依赖单向、边界清晰
screens 互不认识 换屏不影响兄弟,通信走 Store
opacity 切屏 保住 ECharts 容器尺寸
自写 80 行路由 三屏规模不值得引库,顺便复习订阅模式

骨架已立。明天浇数据:实现 ScreenStore——把"唯一数据警察"从图纸变成代码,三个屏从它手里领到各自频率的数据。

评论