上一篇文章我们把需求聊透了:做一个自己掌控的 Status Deck,把散落在各个服务、接口、本地脚本里的状态数据统一收进来,再用一种直观的卡片化方式呈现出来。这一篇不谈愿景,只谈落地。技术栈怎么选,项目结构怎么拆,代码怎么组织,哪些环节最容易翻车,以及为什么我会做出这些看起来很“反潮流”的取舍。
标题里的“全栈”不是噱头。你要把一个 Status Deck 真正做成能长期跑下去的东西,至少要同时碰四块内容:数据采集、状态归一化、实时推送、前端渲染。这四块每一块都有好几套方案可选,但组合在一起时,很多单独看起来不错的方案会互相打架。这篇文章我按自己的真实实施顺序来写,先理清架构,再逐层选型,最后给出可以直接抄作业的项目骨架和代码片段。中间穿插的全是这次实际踩过的坑,希望帮你少走弯路。
1. 需求重新梳理:Status Deck 要解决的到底是哪几件事
动手写代码之前,我先把第一部分的需求重新翻译成了工程语言。很多人做这类面板项目容易失败,不是因为技术不行,而是压根没分清楚“状态”这个词在不同层级上的含义。
1.1 一句话讲清楚 Status Deck 是什么
Status Deck 本质上是一个状态聚合面板:它把来自不同数据源的信息统一收集起来,经过标准化处理后,通过实时通道推送到前端,以卡片的形式展示在屏幕上。你可以把它理解成一个“等保值班室的小型化个人版本”——服务器负载异常时能看到告警卡片,GitHub Actions 构建失败时卡片变红,天气预报要下雨时卡片提示你带伞,智能家居某个传感器掉线时也能第一时间注意到。
和普通监控面板的区别在于,Status Deck 关注的是“综合状态”,不是单一指标的曲线图。它更像是信息流的仪表盘,而不是 Grafana 那种数值分析工具。
1.2 把“状态”拆成可实现的模型
数据显示层需要什么?我抽象成了三个层次:
- 原始数据源:每个数据源都有自己的格式。比如系统指标是数值型,GitHub API 返回的是 JSON 对象,天气接口返回的是嵌套结构,自定义脚本可能输出纯文本。
- 统一状态对象:不管原始数据长什么样,进入 Status Deck 后都被规范成同一套模型。我给它起了个名字叫
DeckItem,包含标题、数值、状态等级、更新时间、附加详情这几项核心字段。 - 呈现规则:前端只认统一状态对象,然后根据状态等级(ok / warning / critical / unknown)去映射颜色、闪烁频率、排序权重。
状态等级是整个项目的灵魂。我一开始也想做成“把原始数据直接塞给前端展示”,后来发现完全不靠谱:十几个数据源就有十几种字段命名,前端每个卡片都要单独写渲染逻辑,加一个新数据源要改一堆代码。统一状态模型之后,新接一个数据源的成本从半天降到了十几分钟。
1.3 总体架构:一条单向数据流
整体架构我设计成了一条清晰的数据管道:
数据源采集 → 归一化处理 → 数据存储 → 事件分发 → 前端渲染每一步只和上下相邻的两层打交道。采集层负责从外部拿数据,不管数据长什么样,进了归一化层之后必须输出标准DeckItem对象。存储层负责把最近的状态和历史记录写进 SQLite,方便回溯和排障。事件分发层负责把状态变化实时推送给所有连接中的前端页面。前端渲染层只消费标准对象,不关心数据来自哪里。
这样的单向流设计有几个直接的好处:第一,新增数据源时不需要动前端;第二,数据源故障可以被隔离在采集层;第三,前端可以做成无状态的,刷新页面不丢任何配置和历史信息。
2. 技术栈选型:每一项都是被真实场景逼出来的
这一章是标题里的重点。业内有个说法是“选技术栈就像选对象,没有最好的,只有最合适的”。我不打算给你列一个“最潮全家桶”,而是把我做选择时的思考过程、对比维度、以及最终决定都摊开来讲。
2.1 前端:为什么选 Vue 3 + TypeScript 而不是 React 全家桶
前端选型我纠结了两天。React 生态确实大,Next.js 也确实火,但最终我选了 Vue 3 + TypeScript + Vite。
真正让我下决心的几个点:第一,Vue 的响应式模型对我这种“状态实时更新”的场景几乎是天然匹配。数据源一变,状态对象跟着变,界面自动更新,中间不需要写任何订阅分发逻辑。React 当然也能做,但需要额外思考 memo、useEffect 依赖、状态提升这些话题,复杂度明显更高。第二,Vue 的单文件组件把模板、脚本、样式收拢到一个文件里,对卡片型 UI 来说阅读效率极高。第三,Vite 的冷启动速度在本地开发时体感很好,改完代码基本秒级刷新。
TypeScript 是强制项。共享类型在前端和后端之间传递,没有类型系统全靠手写文档和记忆,一定会出错。前后端用同一套DeckItem类型定义,接口文档都能省掉一大半。
这个选择不意味着 React 不好。如果你的 Status Deck 后续要加很复杂的交互式图表、拖拽编排工作流,React 生态会更丰富。但对我这个“个人面板 + 快速迭代 + 长期维护”的定位,Vue 的性价比高出不少。
2.2 后端:Fastify 作为“数据网关”而不是万能平台
后端我选了 Node.js + Fastify + TypeScript。很多人会问:为什么不用 Python FastAPI?为什么不用 Go?
首先说 FastAPI。Python 的异步生态确实成熟,写数据采集脚本也比 Node 顺手。但这里有一个协同成本的问题:前后端已经约定用 TypeScript 共享类型,如果后端用 Python,就得靠手写 OpenAPI 或者引入额外的生成工具来维护两边的一致性。Node + TS 让共享类型变成天然行为,reduce 了跨语言沟通成本。
再说 Go。Go 的性能确实惊艳,部署也方便,但对我来说,这个项目的瓶颈从来不在 CPU,而在外部数据源的网络延迟和稳定度。Go 的类型系统在编写快速原型时反而显得啰嗦,标准库里的 WebSocket 支持也不如 Node 生态顺手。
Fastify 相对 Express 的优势很明显:内置 schema 校验、更快的路由解析、原生支持 TypeScript、插件体系清晰。对 Status Deck 这种大量 API 调用和 WebSocket 连接的场景,性能表现足够,开发体验也好。
后端的具体职责被我限定得很窄:提供 REST 接口读配置和状态快照,提供 WebSocket 端口做实时推送,再兼职跑定时采集任务。它不承担复杂的业务逻辑,也不需要处理海量并发。这种“专一”的定位让整个后端代码量控制在两千行左右,维护成本非常低。
2.3 存储选型:SQLite 够用,别急着上 PostgreSQL 和 Redis
存储层是我这次最想“劝退”自嗨式选型的地方。很多人一听说“全栈项目”就直接上 PostgreSQL + Redis,但 Status Deck 的实际存储需求是什么?它需要保存的数据类型其实只有三类:
- 用户配置:展示哪些卡片、卡片位置、刷新间隔、告警阈值。
- 最近状态快照:每个数据源最后一次成功采集的标准化结果。
- 历史状态记录:用于回溯“这个服务昨天什么时候开始不正常的”。
这三类数据用 SQLite 一张数据库文件就能全部覆盖。单机运行、低并发、数据量小,SQLite 完全够用,而且零运维成本,备份就是复制一个文件。
Redis 在这个项目里没有存在感,因为没有跨进程共享缓存的需求。PostgreSQL 的 JSONB 字段看起来很美,但为此要维护一个常驻服务进程,对个人项目来说太沉重了。我见过太多人把项目基础设施搞得比项目本身还复杂,最后维护不下去。技术选型的本质是做减法,不是做加法。
2.4 实时方案:WebSocket 还是 SSE
实时推送这一块,我在 WebSocket 和 SSE(Server-Sent Events)之间犹豫了更久。
SSE 的优势很实在:基于 HTTP,天然支持自动重连,协议简单,部署时不用额外处理端口和代理规则。浏览器端用EventSource几行代码就能接入。但缺点也明显:只能单向推送,客户端没法通过同一个连接发消息回服务端。
WebSocket 是双向的,适合需要交互的场景。比如用户在前端调整刷新频率、手动触发一次数据采集,这些操作如果用 SSE 就得另开一套 HTTP 接口,逻辑被拆得七零八落。最终我选了 WebSocket,并且在设计连接协议时就加了心跳和重连机制。
两者的对比我整理成了表格:
| 对比项 | WebSocket | SSE |
|---|---|---|
| 双向通信 | 支持 | 不支持 |
| 自动重连 | 需自己实现 | 原生支持 |
| 传输格式 | 文本/二进制 | 仅文本 |
| 服务端实现复杂度 | 略高 | 低 |
| 适合场景 | 需要交互、多类型消息 | 纯服务器推送 |
对 Status Deck 这个项目,选 WebSocket 还有一个隐藏理由:后续我想在移动端加控制能力,比如点一下卡片远程执行某个脚本,那时候 WebSocket 的双向能力就是刚需。
3. 项目实施:从空目录到第一张能刷新的卡片
选型结束后就进入实干阶段。我从空目录开始,按“先搭骨架、再填血肉、最后调手感”的顺序推进,整个项目从零到第一张可用的状态卡片,大约花了三个晚上的时间。下面把关键步骤拆开讲。
3.1 工程初始化:用 monorepo 管理三个子包
项目根目录我选择了 npm workspaces 管理的前后端分离结构,但不是粗暴地分成两个独立仓库。原因是共享类型需要在两个地方同时引用,拆两个仓库会让类型同步变成灾难。
status-deck/ ├── packages/ │ ├── shared/ # 共享类型定义与工具函数 │ ├── server/ # Fastify 后端 + 采集器 + WebSocket 网关 │ └── web/ # Vue 3 前端面板 ├── package.json └── tsconfig.base.json初始化命令很简单:
mkdir status-deck && cd status-deck npm init -y npm install -D typescript mkdir -p packages/{shared,server,web}然后给每个子包单独的package.json,声明好各自依赖和构建脚本。共享包放类型定义和纯函数,server 和 web 各自引用它。这样做的好处是:改一个类型,全项目通过 TypeScript 编译器立刻能看到哪里不匹配。
3.2 定义共享类型与状态模型:先画好标准的圆
整个项目最重要的一份代码,是我在packages/shared/src/types.ts里写的状态模型定义。
// 状态等级:unknown 用于初始状态和数据源异常 export type StatusLevel = 'ok' | 'warning' | 'critical' | 'unknown'; // 统一状态对象:所有数据源最终都必须转换成这个结构 export interface DeckItem { id: string; // 稳定唯一标识,例如 'github-actions' title: string; // 卡片标题 status: StatusLevel; // 当前状态 value?: string | number; // 核心数值,比如 CPU 使用率、构建编号 summary?: string; // 备注信息 updatedAt: number; // 时间戳,前端用于显示“多久前更新” meta?: Record<string, unknown>; // 附加数据,避免为了扩展改接口 } // WebSocket 消息包:让前端能区分“全量快照”和“单卡更新” export type DeckMessage = | { type: 'snapshot'; items: DeckItem[] } | { type: 'update'; item: DeckItem } | { type: 'remove'; id: string } | { type: 'pong'; timestamp: number };为什么要分成snapshot和update两种消息?因为前端首次加载时需要一次性拿到所有卡片的当前状态,后续只要接收增量更新就可以了。如果不区分,每次刷新都全量推送,数据源多了以后网络开销会被放大。
这份类型定义写好后,前后端都直接引用它。哪怕后端新增一个字段,只要不破坏老结构,前端就能照常处理。
3.3 实现数据采集与归一化:把一切变成 DeckItem
数据采集层的设计核心是“插件化”。每种数据源对应一个 receiver,接口统一:
export interface Receiver { id: string; title: string; // 每次被调度器触发时调用,必须返回标准 DeckItem fetch(): Promise<DeckItem>; }以最简单的“本地服务器 CPU 使用率”和“GitHub Actions 状态”为例。CPU 采集使用 Node 内置的os模块:
import os from 'node:os'; export const cpuReceiver: Receiver = { id: 'local-cpu', title: '本机 CPU 负载', async fetch() { const load = os.loadavg()[0]; const status = load > 1.5 ? 'critical' : load > 0.8 ? 'warning' : 'ok'; return { id: this.id, title: this.title, status, value: load.toFixed(2), summary: `当前负载 ${load.toFixed(2)}`, updatedAt: Date.now(), }; }, };GitHub Actions 的采集复杂一些,但核心思路一样:调用 GitHub API,解析返回结果,根据conclusion字段映射成状态等级,最后转换成DeckItem返回。
正常来说,loadavg是三个点?让我看看。CPU 采集我用了 os.loadavg()[0]——这里有一个问题:max 值取决于 CPU 核心数。所以更准确的做法是:将 loadavg 除以 os.cpus().length,或者比较核心数。我需要优化这个代码示例:status = load / cpus.length > 1.5 等。让我调整一下。
让我重新写那个 CPU 示例:
import os from 'node:os'; export const cpuReceiver: Receiver = { id: 'local-cpu', title: '本机 CPU 负载', async fetch() { const coreCount = os.cpus().length; const load = os.loadavg()[0] / coreCount; // 归一化到“每核心负载” const status = load > 1.5 ? 'critical' : load > 0.8 ? 'warning' : 'ok'; return { id: this.id, title: this.title, status, value: load.toFixed(2), summary: `当前负载 ${load.toFixed(2)} / 核心 ${coreCount}`, updatedAt: Date.now(), }; }, };好多了。调度器部分:
class Scheduler { private receivers: Receiver[] = []; private timers = new Map<string, NodeJS.Timeout>(); private defaultInterval = 30_000; register(receiver: Receiver, interval?: number) { this.receivers.push(receiver); this.timers.set(receiver.id, setInterval(() => this.runOne(receiver), interval ?? this.defaultInterval)); } private async runOne(receiver: Receiver) { try { const item = await receiver.fetch(); gateway.broadcast({ type: 'update', item }); } catch (err) { const item: DeckItem = { id: receiver.id, title: receiver.title, status: 'unknown', value: '', summary: `采集失败: ${(err as Error).message}`, updatedAt: Date.now(), }; gateway.broadcast({ type: 'update', item }); } } }这段代码想说明的关键点是:数据源故障也必须是一个带有unknown状态的卡片,而不是在服务端静默吞掉异常。前端看到unknown时会有意用灰色显示,视觉上引导用户去关注故障卡片。如果你在调度层 catch 之后就 log 完事,面板上会残留最后一次成功的数据,误报率极高。
3.4 网关与推送:WebSocket 连接管理与事件分发
后端用 Fastify +@fastify/websocket插件实现 WebSocket 网关。设计上我刻意把 WebSocket 连接管理和业务逻辑解耦:网关只负责维护连接集合、提供broadcast方法,任何模块都能调它往外推数据。
import Fastify from 'fastify'; import websocket from '@fastify/websocket'; const app = Fastify(); await app.register(websocket); const clients = new Set<WebSocket>(); app.register(async (fastify) => { fastify.get('/ws', { websocket: true }, (socket) => { clients.add(socket); // 连接建立后立刻推送当前全量快照 socket.send(JSON.stringify({ type: 'snapshot', items: cache.getAll() })); socket.on('close', () => clients.delete(socket)); }); }); export const gateway = { broadcast(msg: DeckMessage) { const data = JSON.stringify(msg); for (const client of clients) { if (client.readyState === WebSocket.OPEN) { client.send(data); } } }, };心跳机制是必须的。没有心跳的 WebSocket 连接在真实网络环境中很容易变成“僵尸连接”——表面开着,实际上收不到任何消息。我在 server 里用setInterval定期向所有客户端发送{ type: 'ping' },前端收到后回{ type: 'pong' },连续两次没收到 pong 就直接主动断开重连。
这里有一个我自己实践出来的细节:broadcast循环发送 JSON 字符串是固定写法,不要每发一个客户端就JSON.stringify一次。序列化开销在高频推送下还是很可观的,提前序列化好再循环发送,能省不少 CPU。
3.5 前端卡片实现:栅格布局、状态色与自动刷新
前端采用了 Vue 3 组合式 API 写卡片网格。整体布局用 CSS Grid,每个卡片根据状态等级渲染不同边框和底色,核心逻辑集中在useDeckStore这个 composable 里。
// packages/web/src/stores/deck.ts import { reactive } from 'vue'; const state = reactive({ items: new Map<string, DeckItem>(), connected: false, }); export function useDeckStore() { const ws = new WebSocket(`ws://${location.host}/ws`); ws.onopen = () => (state.connected = true); ws.onmessage = (event) => { const msg = JSON.parse(event.data) as DeckMessage; if (msg.type === 'snapshot') { state.items.clear(); msg.items.forEach((item) => state.items.set(item.id, item)); } else if (msg.type === 'update') { state.items.set(msg.item.id, msg.item); } }; ws.onclose = () => { state.connected = false; setTimeout(() => setupWebSocket(), 3000); }; return { state }; }Vue 的reactive在这里非常顺手:WebSocket 收到消息后更新itemsMap,页面上的卡片模板自动重新渲染,不需要任何额外的状态管理库。这也是我之前说的,Vue 响应式模型和实时面板场景的契合度。
每张卡片的模板也刻意保持简单:
<template> <div class="deck-card" :class="item.status"> <div class="deck-card__header"> <span class="deck-card__title">{{ item.title }}</span> <span class="deck-card__time">{{ relativeTime(item.updatedAt) }}</span> </div> <div class="deck-card__value">{{ item.value }}</div> <div class="deck-card__summary" v-if="item.summary">{{ item.summary }}</div> </div> </template>状态色的映射用 CSS 类名完成,避免在模板里堆大量v-if判断。卡片出现 warning 状态时我还会加一个轻微的脉冲动画,critical 状态则持续闪烁,视觉层级非常清楚。
3.6 配置与用户偏好持久化:服务端为主,前端只做交互
卡片顺序、是否显示、刷新频率这类用户偏好,我最后没有放在 localStorage,而是通过 REST 接口存到了 SQLite。原因很简单:这个面板可能会在手机、平板、电脑多个设备上打开,偏好必须跨设备同步。
后端提供了两个简单的接口:
app.get('/api/config', async () => loadConfig()); app.put('/api/config', async (req) => { const config = req.body as UserConfig; await saveConfig(config); gateway.broadcast({ type: 'configUpdated', config }); return { ok: true }; });前端在拖动卡片结束或切换开关时调PUT /api/config保存。配置变更通过 WebSocket 推送给所有在线设备,实现多端实时同步。加上 SQLite 的单文件特性,备份整个配置和状态历史只需要一条cp命令,极其省心。
这里有一个容易犯错的点:不要把configUpdated消息也建模成DeckItem。它和状态数据是两回事,强行统一反而会让前端摸不着头脑。我在共享类型里单独加了ConfigUpdated消息,前端通过msg.type区分处理。
4. 实战中翻过的车与排查经验
项目跑通第一版后,真正考验来了。以下问题我全部在实际使用中遇到过,每一个都让我花了不少时间排查。如果你准备做类似项目,这些经验可以直接帮你避坑。
4.1 CORS 与本地网络部署的暗坑
面板跑在浏览器里,数据源接口部署在局域网另一台机器上,浏览器跨域请求直接被拦。开发阶段我用 Vite 代理解决,部署时则通过 Nginx 反代把/api/*请求转发到后端服务。但最隐蔽的坑是:某些第三方 API(比如天气服务)根本不支持 CORS,浏览器里直接 fetch 必失败。这类请求的正确做法是让后端代理转发,而不是指望前端能绕过去。
我把所有跨域请求都收敛到后端统一代理,前端永远只请求自家域名。这样不仅绕开了 CORS,还统一了鉴权逻辑——第三方 API 的 token 只存在服务端,不暴露给浏览器。
4.2 “数据来了但显示不出来”:类型与序列化的坑
有一回 GitHub Actions 的卡片总是空白,后端日志显示数据正常,前端也没有报错。排查到最后发现是 JSON 序列化时丢了字段:后端的DeckItem里新增了一个可选字段,但没有同步到 shared 类型定义。前端拿到数据后按老类型解析,新字段被忽略,而恰好卡片模板渲染依赖这个新字段。
这个坑根治方法只有一个:所有跨端类型定义从 shared 包统一导出,禁止在后端或前端单独声明同名类型。哪怕多用一层 import 显得麻烦,也比“看起来一模一样但就是串不上”强一百倍。
4.3 WebSocket 断连与自动重连:比你想的更频繁
Wi-Fi 切换、电脑休眠唤醒、Nginx 空闲超时,任何一次网络抖动都可能导致 WebSocket 断开。如果不做自动重连,面板会在不知不觉中变成“静态图片”,用户看到的是几小时前的数据,可能误判系统状态。
我的重连策略是:断连后立即尝试重连,失败则按 1s、2s、4s、8s 指数退避,最大间隔 30s。同时每次重连成功后,强制要求服务端重新推送全量快照,而不是只发送增量。这样才能保证前端状态与真实世界完全同步。
4.4 前端状态维护:定时器、HMR 和内存泄漏
开发模式下,Vite 的热更新会让 Vue 组件的状态重新执行,但我早期把 WebSocket 连接和心跳定时器直接写在组件里,导致每次热更新都会新建一个连接,旧的连接因为闭包引用无法释放,最终把内存吃光。这个问题在开发阶段不会立刻暴露,运行越久越明显。
解决方案是把 WebSocket 连接和定时器通通提升到useDeckStore的模块层,组件只负责订阅状态。模块层只在import时执行一次,天然避开了组件销毁重建的坑。如果你的面板页面还要做选项卡切换,务必注意这一点。
4.5 常见问题速查表
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 卡片始终显示 unknown | 采集抛异常后被调度器捕获 | 查看后端日志,确认 API key / 网络连通性 |
| WebSocket 图标显示离线 | Nginx 空闲超时断开连接 | 配置大于 60s 的 proxy_read_timeout,启用心跳 |
| 手机端打开面板布局错乱 | 卡片固定宽度未做响应式 | Grid 列数改用minmax(300px, 1fr)自动换行 |
| 刷新后配置丢失 | 配置只写 localStorage | 改用后端 REST 接口存储 SQLite |
| 卡片显示旧数据 | 只接收增量消息,缺失快照同步 | 在snapshot消息中强制清空重建 Map |
5. 个人体会与下一步想做的事
这个项目最让我意外的,是最终消耗时间最多的环节不是写代码,而是调“数据采集的稳定性”。技术栈选型、架构分层这些决策花一天就能定完,但让十几个数据源都稳定可靠地按时上报,再把异常优雅地暴露出来,才是真正让 Status Deck 从“能跑”变成“能用”的分水岭。
下一步我打算做三件事:第一,给面板加一个简单的历史趋势迷你图,让每个卡片不仅能看当前状态,还能看到过去一小时的变化;第二,把整个前端封装进 Tauri,做成一个桌面原生应用,开机自启、常驻托盘,彻底摆脱“打开浏览器才能看状态”的限制;第三,给数据采集层写一个更友好的插件模板,让家里人也能往面板里加数据源——比如“冰箱温度超过 8 度就亮红灯”这类日常提醒。
如果你也打算自造一个 Status Deck,我的核心建议只有一句话:前期花足够多的时间把状态模型定义清楚,后面所有层级的开发都会顺滑如丝。最怕的就是急着写业务代码,结果每个数据源都在造自己的数据形状,最后组装时痛苦万分。这个教训,希望你能在我踩过坑的基础上再往前走一步。