Day 64 · 项目架构设计 — 大屏不是页面,是一个有边界的小系统
三个作品整合成一个系统,第一件事不是写代码,是画边界:目录怎么摆、屏幕怎么路由、数据怎么流、模块之间谁认识谁。今天全程"图纸作业"——上午产出架构文档(依赖图 + 数据流图),下午把项目骨架搭出来(目录、路由框架、空屏占位)。地基的密度决定上层建筑的速度:今天多花 30 分钟想清楚,后面五天每天省 30 分钟返工。
目录
- 一、从一个问题开始:为什么不能三个页面各写各的
- 二、目录结构:模块边界即团队边界
- 三、模块依赖规则:谁可以 import 谁
- 四、多屏路由:轻量级路由的设计
- 五、数据流总图
- 六、搭建项目骨架
- 七、常见坑点
- 八、自测挑战
- 九、总结
一、从一个问题开始:为什么不能三个页面各写各的
1.1 反面架构
屏A.html ── 自己 new WsDataSource() ──▶ 自己的图表
屏B.html ── 自己 new WsDataSource() ──▶ 自己的图表
屏C.html ── 自己 new WsDataSource() ──▶ 自己的拓扑
问题清单:
- 三条 WS 连接(服务器 3 倍负担,心跳/重连/补传逻辑跑三份)
- 数据不一致(屏 A 断线了屏 B 不知道,KPI 和曲线对不上)
- 状态灯要画三个(每个页面自己感知连接状态)
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(仅类型)
三条铁律:
- core 不 import 任何上层(core/types → core/ws → 上层,箭头只朝上)
- screens 之间互不 import(屏 A 不认识屏 B——换掉屏 B 不影响屏 A)
- router 不认识具体屏幕内容(只按 id 显隐,不 import screens 的内部模块)
3.2 为什么 screens 互不认识
轮播切屏时如果要"通知屏 B 数据变了",走的必须是 Store 的订阅机制,而不是屏 A 直接调屏 B 的方法——兄弟模块通信永远通过共同父级(Store)。这条规则在 Vue3/React 里同样成立(组件通信靠 store/props,不靠组件互调),今天先在原生 TS 里体会一遍。
四、多屏路由:轻量级路由的设计
4.1 需求
- 三屏互斥显示,切换带淡入淡出
- 自动轮播(每屏可配停留时长),可暂停/恢复
- 手动切换(底部指示器点击)后重置轮播计时
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——把"唯一数据警察"从图纸变成代码,三个屏从它手里领到各自频率的数据。