用于实时 WebSocket 分析仪表板的文本合同与 Zod 校验机制
如何用 TypeScript 构建可靠的实时仪表板:定义数据载荷契约,使用 Zod 验证 WebSocket 消息,并防止重复连接。
要求“实时数据,立即显示”看似是图表展示方面的问题,但实际上主要是数据可信度的问题。当指标以未类型化的 JSON 形式传来、两个接口对同一字段的命名不同,或是套接字不断循环重连时,控制面板虽然看起来很忙碌,但没人会相信其显示的数据。本指南将以一个用 TypeScript 构建的小型实时分析控制面板为例,展示使其具备可靠性的设计模式:类型化的契约、在套接字层进行的验证、受保护的连接以及简洁明了的布局。
为何未类型化的版本不可信
以一个典型的起点为例:用松散的 JavaScript 编写的半成品管理面板。其症状很常见:
- 数据在代码中以
any类型传递,因此编辑器无法提供任何帮助。 - 图表库会接收服务器随机发送的任何格式的数据。
user,另一个则返回 users_count。NaN。这些都不是什么罕见的错误。它们的根本原因都是:数据源与 UI 之间没有统一的规范。解决办法就是制定一条全团队都能遵守的规则:如果数据格式未被定义和验证,就不能传递给 UI。
设计真正会被使用的仪表板
外观精美的仪表板与实用高效的仪表板往往不是一回事。初版可以仅包含以下内容:
- 当前的访问者数量
- 过去 24 小时的转化率
- 访问量最高的页面
- 当前的错误率
- 一个“最后更新”指示器,让查看者知道数据是最新的
技术栈始终保持一致:
- 使用 App Router 的 Next.js
- 严格模式下的 TypeScript
- 用于绘制图表的 Recharts
- 用于推送更新的 WebSockets
- 在 React 处理之前用 Zod 验证所有数据
目标并非打造完美的产品,而是让团队不再为某些数值争论。
首先编写数据契约
不要先获取 JSON 再期望它符合预期,而应首先明确 UI 所需要的具体内容。以下的类型定义涵盖了一个通用指标(包括其变化百分比和 ISO 时间戳),以及 WebSocket 传输的完整实时数据。
type DashboardMetric = {
id: string;
label: string;
value: number;
deltaPercent: number;
updatedAt: string; // ISO
};
type LiveDashboardPayload = {
visitorsNow: number;
conversionRate: number;
topPages: Array<{ path: string; views: number }>;
errorRate: number;
metrics: DashboardMetric[];
};
这些类型定义能够体现设计意图并提供自动补全功能,但在运行时却会消失。WebSocket 消息本质上只是字符串,TypeScript 无法检查服务器发送的内容。正因如此,下一步才如此重要。
使用 Zod 验证每个套接字消息
Zod 模式反映了数据契约,并增加了类型本身无法表达的规则:数量不能为负数,页面浏览量必须是整数,而转化率和错误率则是介于 0 和 1 之间的小数。updatedAt 字段必须是有效的日期时间字符串。
import { z } from "zod";
const LiveDashboardSchema = z.object({
visitorsNow: z.number().nonnegative(),
conversionRate: z.number().min(0).max(1),
topPages: z.array(
z.object({
path: z.string(),
views: z.number().int().nonnegative(),
})
),
errorRate: z.number().min(0).max(1),
metrics: z.array(
z.object({
id: z.string(),
label: z.string(),
value: z.number(),
deltaPercent: z.number(),
updatedAt: z.string().datetime(),
})
),
});
有了这些规则,格式错误的负载数据就不会导致页面崩溃或向图表中注入 NaN 值。这样的数据会被拒绝,屏幕上仍会显示最后的有效状态。
同时保留手写类型和模式容易引发偏差。一种常见的改进方式是将模式视为真实数据源,通过 z.infer<typeof LiveDashboardSchema> 生成类型。还需检查所使用的 Zod 版本:最新版本提供了 z.iso.datetime() 作为更推荐的日期时间验证方式,因此请根据最新文档确认相关 API。如需深入了解如何在不同层级间共享同一模式,请参阅 在前端和后端使用同一 Zod 模式。
控制重新连接与重复监听器
实时功能往往会出现特定的故障表现。初版实现通常会无限重新连接,每次尝试都添加新的消息处理程序,将新的图表更新叠加在过时的数据之上,最终导致浏览器性能下降。
解决方法是把这种连接关系视为一台小型状态机:先处于idle状态,接着进入connecting状态,然后变为live状态;当网络恢复时则回到reconnecting状态,再重新变为live状态。最重要的规则是同一时间只能存在一个套接字。connect函数会确保这一规则得到遵守:如果已有套接字处于打开状态或正在建立连接中,该函数会立即返回。传入的消息会通过safeParse函数进行解析,该函数会返回一个结果对象而非抛出异常,这样无效数据会被记录并跳过,而有效数据则会更新状态。
let socket: WebSocket | null = null;
function connect() {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return;
}
socket = new WebSocket(process.env.NEXT_PUBLIC_WS_URL!);
socket.onmessage = (event) => {
const parsed = LiveDashboardSchema.safeParse(JSON.parse(event.data));
if (!parsed.success) {
console.warn("Invalid live payload", parsed.error);
return;
}
setDashboard(parsed.data);
};
}
在正式上线前,有一些漏洞需要修复。JSON.parse在接收到非JSON格式的数据时会抛出异常,因此应将其包裹在try/catch结构中。示例代码展示了异常处理部分,但未体现重新连接机制;应添加onclose事件处理逻辑,并设置延迟时间,以避免服务器故障引发频繁的重新连接循环。在React中,应在效应清理函数中关闭套接字,这样组件重新挂载时(包括在开发模式的严格模式下效应被多次调用时),就不会出现连接泄漏问题。
针对“我应该先看哪里?”这一问题的设计思路
人们很容易想要用渐变效果、发光卡片以及多种颜色来美化实时仪表板。更好的方法是先询问相关方他们应该首先关注哪些内容,然后去掉所有无法满足该需求的设计元素。一个优秀的布局应包含:
- 最多四项核心指标的横排显示
实时 • 2秒前更新在这里,明确的数据格式也很重要。当每个指标都有固定的呈现形式时,界面就不会擅自添加针对未指定数据的需求的组件。这些约束让设计更加严谨。
发布后用户会注意到什么
一旦这样的仪表板上线,用户的反馈很少涉及架构方面。他们表示终于可以信任这些数据了,页面也不再卡顿,还惊讶于它确实是实时的。这正是仪表板的真正作用:不是图表集,而是人们在会议中可以依赖的工具。
关键要点
- 在运行时界定数据边界并验证所有外部数据;仅靠TypeScript无法查看服务器发送的内容。
- 始终保持严格模式开启,每次修改代码时都会带来好处。
- 将实时连接视为状态机,并仅允许一个套接字。
- 移除界面元素,直到核心内容清晰可见。
- 宁可选择数据准确但外观简单的界面,也不要选择数据可疑但设计精美的界面。
如果你正在构建第一个实时仪表板,不要一上来就追求大规模功能。先从一个通过 Zod 模式验证的简单数据负载开始,显示三个数字以及更新时间戳,等基础架构稳定后再引入套接字。
相关阅读
- 在 Next.js App Router 代码库中分离领域层、数据层和 UI 层 —— 通过 Pokédex 的案例研究,展示如何利用 Prisma、Zod、Cookie 认证和缓存功能将 Next.js App Router 应用拆分为领域层、数据层和展示层。
- Next.js App Router中的技术SEO:元数据、站点地图与JSON-LD — 了解共享的元数据辅助工具、根布局默认设置、robots.ts文件、动态站点地图、规范的JSON-LD格式以及页面审计如何为Next.js应用奠定良好的SEO基础。
- Vue 3实战:组合式函数、类型化契约与比例状态 — 探讨Vue 3的组合式API、类型化的属性与事件、Pinia存储以及渐进式采用策略,如何让应用仅根据实际需求变得复杂,并判断何时Vue并非最佳选择。