首页 / 文章 / 用于实时 WebSocket 分析仪表板的文本合同与 Zod 校验机制

用于实时 WebSocket 分析仪表板的文本合同与 Zod 校验机制

如何用 TypeScript 构建可靠的实时仪表板:定义数据载荷契约,使用 Zod 验证 WebSocket 消息,并防止重复连接。

1298 词

要求“实时数据,立即显示”看似是图表展示方面的问题,但实际上主要是数据可信度的问题。当指标以未类型化的 JSON 形式传来、两个接口对同一字段的命名不同,或是套接字不断循环重连时,控制面板虽然看起来很忙碌,但没人会相信其显示的数据。本指南将以一个用 TypeScript 构建的小型实时分析控制面板为例,展示使其具备可靠性的设计模式:类型化的契约、在套接字层进行的验证、受保护的连接以及简洁明了的布局。

为何未类型化的版本不可信

以一个典型的起点为例:用松散的 JavaScript 编写的半成品管理面板。其症状很常见:

  • 数据在代码中以 any 类型传递,因此编辑器无法提供任何帮助。
  • 图表库会接收服务器随机发送的任何格式的数据。
  • 对于类似的需求,一个 API 返回 user,另一个则返回 users_count。
  • 每当某个字段缺失或格式错误时,UI 中就会显示 NaN。
  • 这些都不是什么罕见的错误。它们的根本原因都是:数据源与 UI 之间没有统一的规范。解决办法就是制定一条全团队都能遵守的规则:如果数据格式未被定义和验证,就不能传递给 UI。

    设计真正会被使用的仪表板

    外观精美的仪表板与实用高效的仪表板往往不是一回事。初版可以仅包含以下内容:

    1. 当前的访问者数量
    2. 过去 24 小时的转化率
    3. 访问量最高的页面
    4. 当前的错误率
    5. 一个“最后更新”指示器,让查看者知道数据是最新的

    技术栈始终保持一致:

    • 使用 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 模式验证的简单数据负载开始,显示三个数字以及更新时间戳,等基础架构稳定后再引入套接字。

    相关阅读