Zustand

在现代 React 生态中,状态管理方案经历了几轮技术更迭。传统的 Redux 虽然强大但由于样板代码冗长而饱受诟病,原生的 Context 又容易陷入性能陷阱。而 Zustand(德语中意为“状态”)凭借其极其轻量(约 1KB)、自适应优化、零样板代码的特性,成为了如今高性能前端项目(尤其是涉及高频数据交互、大屏可视化、WebGL/3D 孪生系统)的明星方案。

接下来,我们从架构设计、性能调优、项目实战以及技术选型四个维度,彻底拆解 Zustand 的硬核玩法。


一、 Zustand 的核心哲学与项目管理(Slice 模式)

Zustand 的底层是一个独立于 React 渲染树之外的纯 JS 订阅中心(Publish-Subscribe Pattern)。这意味着它既能在 React 组件内部通过 Hook 消费,也能在任何纯 TS/JS 异步函数、Web Worker、甚至是自建的事件循环中直接读写。

📂 大型项目中的目录与架构设计

在真实工程中,如果把所有状态都挤在一个 useUwbStore 文件里,后期代码会彻底失控。Zustand 官方推荐使用 Slice(切片)模式,将状态按业务领域拆分,最后合并成一个大 Store。

1. 目录结构

src/
└── store/
    ├── slices/
    │   ├── uiSlice.ts        # 负责侧边栏、弹窗、图表显隐等 UI 状态
    │   └── assetSlice.ts     # 负责车辆、标签、基站等高频核心资产数据
    └── index.ts              # 统一组装出口

2. 切片编写示例 (src/store/slices/uiSlice.ts)

import { StateCreator } from 'zustand';

export interface UISlice {
  showSidebar: boolean;
  toggleSidebar: () => void;
}

// 使用 StateCreator 约束切片类型
export const createUISlice: StateCreator<UISlice> = (set) => ({
  showSidebar: true,
  toggleSidebar: () => set((state) => ({ showSidebar: !state.showSidebar })),
});

3. 终极组装 (src/store/index.ts)

import { create } from 'zustand';
import { createUISlice, UISlice } from './slices/uiSlice';
import { createAssetSlice, AssetSlice } from './slices/assetSlice';

// 联姻合并成全局唯一的联邦 Store
export const useBoundStore = create<UISlice & AssetSlice>()((...a) => ({
  ...createUISlice(...a),
  ...createAssetSlice(...a),
}));

在组件中引入时,只需 const showSidebar = useBoundStore(state => state.showSidebar) 即可。


二、 核心炸弹:如何利用 Zustand 调高 React 应用性能?

React 核心的重绘机制是自上而下的虚拟 DOM 比对(Reconciliation)。Zustand 能够大幅提升项目帧率的核心秘密在于它提供了“精准拦截”“绕过 React”的两套绝杀手段。

1. 原子选择器(Selector)的严格依赖检查

当你写下 const nodes = useBoundStore(state => state.nodes) 时,Zustand 在幕后为该组件注册了一个事件监听器。
只有当 state.nodes引用地址发生改变时(默认使用 Object.is 进行浅层比对),才会触发当前组件的 forceUpdate

⚡ 进阶避坑:避免解构导致的“全量误伤”

// ❌ 错误示范:只要 Store 里任何风吹草动(比如 showSidebar 变了),这个组件都会疯狂重绘
const { nodes, showSidebar } = useBoundStore(); 

//  正确示范:利用浅层比较器(shallow),合并监听多个状态
import { useShallow } from 'zustand/react/shallow';
const { nodes, selectedId } = useBoundStore(useShallow(state => ({
  nodes: state.nodes,
  selectedId: state.selectedId
})));

2. 瞬时更新(Transient Updates)—— 彻底摆脱 React 渲染流水线

在面临高频事件(例如 WebSocket 每秒上百次推送requestAnimationFrame 帧循环onMouseMove 拖拽)时,任何 setState 或组件级重绘都会导致 CPU 瞬间拉满,导致丢帧卡顿。

Zustand 允许你只利用它的发布订阅中心,完全不让 React 组件感知到数据变动,而是直接用原生指针去操作 3D 节点或 DOM。

// 🚀 工业级高频优化范式
export function HighFrequencyComponent() {
  const meshRef = useRef<THREE.Mesh>(null);

  useEffect(() => {
    // 🎯 核心:只订阅,不触发组件 return 重绘!
    const unsubscribe = useBoundStore.subscribe(
      (state) => state.currentPosition, // 监听位置
      (currentPosition) => {
        // 拿到最新数据,直接在微任务中通过原生引用修改 3D 矩阵或 DOM 属性
        if (meshRef.current) {
          meshRef.current.position.set(...currentPosition);
        }
      }
    );
    return unsubscribe; // 组件卸载时安全销毁监听
  }, []);

  return <mesh ref={meshRef} />;
}


三、 React Context vs Zustand 深度大对决

比较维度 React Context Zustand
状态存储位置 React 组件树的 Fiber 节点上(深度绑定) React 外部的纯 JS 闭包内存空间中(解耦)
重绘触发机制 只要 Provider 的 value 引用变了,下层所有消费者强制重绘(除非拆分 Context 或套用一层 React.memo 基于 Selector(选择器),只有被勾选的数据发生变化时,消费组件才重绘
高频并发承载力 极差。高频更新(>10Hz)会导致整棵组件树闪烁、卡顿 极强。支持瞬时更新(Transient Updates),轻松应对 60FPS 渲染
数据读写解耦 必须在 Provider 内部的组件才能用,无法在普通的 .ts 工具函数中读取 极度自由。在任意组件内、组件外、甚至网络请求拦截器里都能通过 store.getState() 读写
异步行为 (Async) 需要在组件层自行包装 async/await,再更新状态 原生支持。Action 可以是普通的异步函数,直接在里面调 set()
生态与调试 依赖 React DevTools,大项目难以追踪具体是哪个 Action 修改了状态 原生接入 Redux DevTools,支持时间旅行(Time Travel)与状态快照

四、 后续项目中该如何做出理性的技术选型?

在工程落地时,没有最好的技术,只有最适合场景的架构。你可以参考以下黄金决策树:

🎯 坚决选择 React Context 的场景

  1. 低频配置型依赖分发:例如全站的 国际化语言(i18n)UI 主题色切换(Theme: dark/light)、用户的登录 Session 会签凭证。这些数据一旦定下来基本不变,且需要借助 React 的层级树结构(例如局部换肤)。
  2. 构建低依赖的独立 UI 组件库:如果你在开发一套类似 AntD 或 Shadcn 的复合独立组件(例如 TabsSelect),为了不强迫组件库的用户去安装 Zustand,使用原生 Context 进行父子通信是最佳的纯净选择。

🚀 坚决选择 Zustand 的场景

  1. 高频数据流与大屏中台:项目中有 WebSocket、MQTT 等长连接源源不断灌入数据,且界面有实时渲染指标(如工业数字孪生、股票大盘、实时轨迹监控)。
  2. 跨越渲染边界的复杂交互:你的应用中同时存在 DOM 层(各种操作面板、侧边栏、表格)和 WebGL / Canvas 层(Three.js / Pixi.js 场景)。两边需要共享同一套状态并高频联动。
  3. 中大型全栈 Web 应用:模块错综复杂,存在大量的跨组件长途数据通信(A 组件的操作会间接影响树结构极其遥远的 B、C、D 组件),需要利用 Zustand 的 Slice 模式构建高内聚、低耦合的数据中台。

掌握了 Zustand 的 Slice 切片划分、Selector 浅层比对、以及利用 .subscribe() 绕过 React 的瞬时更新技巧,你基本上就已经拿到了通往 React 高性能进阶架构的钥匙。

后续如果有更复杂的联动需求(比如需要把 Zustand 的状态同步到 URL 路由,或者实现跨标签页同步),我们可以探讨如何编写或接入 Zustand 的 Middleware 中间件。你在目前的数字孪生业务中,有遇到哪些数据是需要跨组件大范围共享,或者更新频率特别夸张的吗?

太赞了!既然你决定全面拥抱 Zustand,我们直接把之前的 Context.ProvideruseContext 彻底拿掉。

改用 Zustand 之后,整个数据流会变得极其干净:状态直接归 Store 管理,而你之前在 useEffect 里跑的定时器(或者未来的 WebSocket)可以直接封装成一个干净的“网络初始化 Hook”或直接塞进 Store 的 Action 里

下面是为您重写后的全套工业级方案。


🛠️ 第一步:用 Zustand 重写全局 Store

我们将业务状态(控制显隐)和网络资产数据(Nodes/Areas/Bases)合并到了同一个规范的全局 Store 中。

新建或修改 src/store/useUwbStore.ts

import { create } from "zustand"
import type { AreaState, UwbBaseState, UwbNodeState } from "@/types/uwb"

const FAKE_WIDTH = 500
export const positionRegistry = new Map<string, [number, number, number]>()

interface UwbStore {
  // 1. 业务显隐控制状态
  showNodes: boolean
  showAreas: boolean
  showBases: boolean
  selectedNodeId: string | null

  // 2. 核心实时资产数据
  nodes: Record<string, UwbNodeState>
  areas: Record<string, AreaState>
  bases: Record<string, UwbBaseState>

  // 3. UI 交互控制 Actions
  toggleNodes: () => void
  toggleAreas: () => void
  toggleBases: () => void
  selectNode: (id: string | null) => void

  // 4. 数据变动与清洗 Actions
  removeNode: (id: string) => void
  removeArea: (id: string) => void
  setNodes: (updater: (prev: Record<string, UwbNodeState>) => Record<string, UwbNodeState>) => void
  initStaticData: (areas: Record<string, AreaState>, bases: Record<string, UwbBaseState>) => void
}

export const useUwbStore = create<UwbStore>((set) => ({
  // --- 初始状态 ---
  showNodes: true,
  showAreas: true,
  showBases: true,
  selectedNodeId: null,
  nodes: {},
  areas: {},
  bases: {},

  // --- 显隐交互 Actions ---
  toggleNodes: () => set((state) => ({ showNodes: !state.showNodes })),
  toggleAreas: () => set((state) => ({ showAreas: !state.showAreas })),
  toggleBases: () => set((state) => ({ showBases: !state.showBases })),
  selectNode: (id) => set({ selectedNodeId: id }),

  // --- 数据操作 Actions ---
  removeNode: (id) => set((state) => {
    const next = { ...state.nodes }
    delete next[id]
    return { nodes: next }
  }),

  removeArea: (id) => set((state) => {
    const next = { ...state.areas }
    delete next[id]
    return { areas: next }
  }),

  // 允许高频部分更新 nodes
  setNodes: (updater) => set((state) => ({ nodes: updater(state.nodes) })),

  // 批量初始化静态区域/基站数据
  initStaticData: (areas, bases) => set({ areas, bases })
}))


🔌 第二步:重写网络监听服务 Hook

之前的 UwbSocketProvider 退化成了一个不需要返回任何 JSX 节点的纯逻辑监听器。它的唯一职责就是:默默跟底层数据源连上线,并高频刷入数据给 Zustand

创建或修改 src/hooks/useUwbSocket.ts(**注意:因为已经没有 JSX 了,后缀可以写回 .ts**):

import { useEffect, useRef } from "react"
import { useUwbStore, positionRegistry } from "@/store/useUwbStore"
import { createPositionWebSocket } from "@/api/websocket"

const FAKE_WIDTH = 500

export const useUwbSocket = () => {
  const setNodes = useUwbStore((state) => state.setNodes)
  const initStaticData = useUwbStore((state) => state.initStaticData)
  const nodeIndexRef = useRef(1)

  // 1. 初始化静态区域与基站数据 (等同于以前的另外两个 useEffect)
  useEffect(() => {
    const mockAreas = {
      aaa: {
        id: "aaa",
        label: "测试",
        coordinates: [[97.53, -35.83], [127.39, -35.83], [127.39, -61.75], [97.53, -61.75]],
      },
      bbb: {
        id: "bbb",
        label: "模糊定位区域1",
        coordinates: [[-700, -500], [700, -500], [700, -700], [-700, -700]],
      },
    }

    const mockBases = {
      aaa: { id: "aaa", x: 0, y: 0, z: 0 },
      bbb: { id: "bbb", x: 100, y: 0, z: 0 },
    }

    initStaticData(mockAreas, mockBases)
  }, [initStaticData])

  // 2. 心跳/高频更新 Mock 定时器
  useEffect(() => {
    const heartbeatTimer = setInterval(() => {
      const nextIndex = nodeIndexRef.current
      nodeIndexRef.current += 1

      setNodes((prevNodes) => {
        const next = { ...prevNodes }
        
        // 高频无缝更新位置注册表 (供 useFrame 消费)
        Object.keys(prevNodes).forEach((id) => {
          positionRegistry.set(id, [
            Math.random() * FAKE_WIDTH - FAKE_WIDTH / 2,
            Math.random() * FAKE_WIDTH - FAKE_WIDTH / 2,
            0.0,
          ])
        })

        // 每秒追加一个虚假的新标签
        const id = `UWB_TAG_0${nextIndex}`
        next[id] = { id, color: "#ff0000" } as any
        return next
      })
    }, 1000)

    return () => clearInterval(heartbeatTimer)
  }, [setNodes])

  // 3. 预留:未来真正的 WebSocket 接入点
  /*
  useEffect(() => {
    const ws = createPositionWebSocket({
      onMessage: (event) => {
        // 收到真实数据后通过 Zustand 的 setNodes 快速洗牌
      },
      onClose: () => console.log("📍 位置 WebSocket 已断开"),
      onError: (e) => console.error("📍 位置 WebSocket 错误", e),
    })
    return () => ws.close()
  }, [setNodes])
  */
}


🎯 第三步:可能使用到的地方如何进行适配修改?

由于去掉了 Provider 的限制,现在你可以非常优雅地在任何地方消费它们。

1. 全局生命周期挂载点 (App.tsx)

你只需要把这个网络 Hook 挂载在顶级,它就会像守护进程一样运行:

import React from "react"
import { useUwbSocket } from "./hooks/useUwbSocket"
import { UwbMap } from "./components/UwbMap"
import Sidebar from "./components/Sidebar"

export default function App() {
  // ⚡ 启动 UWB 数据中台进程,它将静默在后台更新状态中心,无需关心它的返回值
  useUwbSocket()

  return (
    <div className="relative w-screen h-screen">
      <Sidebar />
      <UwbMap />
    </div>
  )
}

2. 3D 画布渲染控制器 (UwbMap.tsx)

这里已经不需要套 Provider 标签了,直接干净地通过 Selector 进行条件挂载:

import React from 'react'
import { useUwbStore } from "@/store/useUwbStore"
import { UwbNodes } from "./UwbNodes"
import { Areas } from "./Areas"
import { UwbBases } from "./UwbBases"

export function UwbMap() {
  // 🎯 原子化精准监听:仅当对应的布尔值变化时,UwbMap 才会响应
  const showNodes = useUwbStore((state) => state.showNodes)
  const showAreas = useUwbStore((state) => state.showAreas)
  const showBases = useUwbStore((state) => state.showBases)

  return (
    <group>
      {showNodes && <UwbNodes />}
      {showAreas && <Areas />}
      {showBases && <UwbBases />}
    </group>
  )
}

3. UI 列表组件中的“踢出 / 删除”消费 (Sidebar.tsx / List.tsx)

你甚至可以直接拿到 Action 方法,执行数据清洗:

import React from 'react'
import { useUwbStore } from "@/store/useUwbStore"

export function UwbNodeList() {
  // 拿数据
  const nodes = useUwbStore((state) => state.nodes)
  // 拿动作
  const removeNode = useUwbStore((state) => state.removeNode)

  return (
    <div className="space-y-2">
      {Object.values(nodes).map((node) => (
        <div key={node.id} className="flex justify-between text-white text-xs">
          <span>{node.id}</span>
          <button 
            onClick={() => removeNode(node.id)} // 🎯 直接触发 Zustand 的 Action 清除
            className="text-red-400 hover:text-red-500"
          >
            剔出
          </button>
        </div>
      ))}
    </div>
  )
}

4. 2D 控制台显隐切换面板 (VisibleController.tsx)

import React from "react"
import { useUwbStore } from "@/store/useUwbStore"

export const VisibleController: React.FC = () => {
  const showBases = useUwbStore((state) => state.showBases)
  const toggleBases = useUwbStore((state) => state.toggleBases)

  return (
    <div 
      className={`flex cursor-pointer ${showBases ? 'text-cyan-400' : 'text-slate-500'}`}
      onClick={toggleBases} // 🎯 优雅地通过规范 Action 改变状态
    >
      <span>基站显示</span>
    </div>
  )
}

完美!通过这样改写,不仅消除了原本 Invalid hook call 的 React 报错隐患,还将系统的高频通信网络(数据源)、渲染决定树(R3F 画布)和 UI 控制层(2D 大屏侧边栏)彻底进行了三维解耦

绝对是一个好主意,而且在工业级的数字孪生大屏项目中,这属于典型的标准范式(Single Source of Truth / 单一数据源)。

这类统计指标(如车辆总数、在线/离线数、报警数)在业务上被称为 看板核心元数据 (KPI Metrics)。把它们放入 Zustand Store,不仅架构清晰,还能带来巨大的工程和性能优势:


💡 为什么这是个好主意?(核心优势)

  1. 避免二次计算(Performance Optimization)
    如果不存统计好的数字,侧边栏组件为了拿到“报警数”,就必须每次都去遍历几百个甚至几千个 nodes 对象的集合去过滤计算(比如 Object.values(nodes).filter(...))。在大屏高频刷新的情况下,这会白白浪费大量的 CPU 算力。
  2. 多终端消费(Data Sharing)
    这些数据往往不止侧边栏(Sidebar)要用。可能你的顶部标题栏(Header)要显示报警滚动条,底部的全景图表(Charts)要画在线率折线图,3D 场景(Canvas)在收到严重报警时要触发滤镜。塞入 Zustand 后,所有组件都可以原子化地“按需秒提”这些数字。
  3. 网络层与 UI 层彻底解耦
    WebSocket 收到报文后,只需要调用一行 setMetrics(...),任务就完成了。它不需要知道界面长什么样,界面也不需要知道 WebSocket 什么时候来数据。

🛠️ 最佳实践:代码如何落地?

为了不破坏已有的结构,我们直接在 useUwbStore 中扩充一个 metrics 状态和对应的 Actions。

1. 升级 Store (src/store/useUwbStore.ts)

// 1. 定义清晰的指标数据结构
interface UwbMetrics {
  totalVehicles: number;
  onlineTags: number;
  offlineTags: number;
  activeAlarms: number;
}

interface UwbStore {
  // ... 保留之前所有的状态(nodes, showNodes 等)
  
  // 新增:全局统计指标
  metrics: UwbMetrics;

  // 新增:更新指标的 Actions
  setMetrics: (metrics: Partial<UwbMetrics>) => void;
}

export const useUwbStore = create<UwbStore>((set) => ({
  // ... 保留已有状态
  
  // 默认初始数据
  metrics: {
    totalVehicles: 0,
    onlineTags: 0,
    offlineTags: 0,
    activeAlarms: 0,
  },

  // 支持增量或者全量改写指标数据
  setMetrics: (newMetrics) => set((state) => ({
    metrics: { ...state.metrics, ...newMetrics }
  })),
}));


2. 在网络层接收并注入 (src/hooks/useUwbSocket.ts)

当真实的 WebSocket 收到服务端的统计推送时(或者你在 Mock 阶段的定时器里),直接清洗并塞入:

import { useEffect } from "react"
import { useUwbStore } from "@/store/useUwbStore"

export const useUwbSocket = () => {
  const setMetrics = useUwbStore((state) => state.setMetrics)

  useEffect(() => {
    // 模拟真实的 WebSocket 收到总览统计报文
    const handleWsMessage = (data: any) => {
      // 真实场景:data = JSON.parse(event.data)
      // 假设后端每秒推送一次最新的大屏统计面板数据
      setMetrics({
        totalVehicles: data.totalVehicles ?? 120,
        onlineTags: data.onlineTags ?? 18,
        offlineTags: data.offlineTags ?? 2,
        activeAlarms: data.activeAlarms ?? 3,
      });
    };

    // --- 以下为 Mock 演示,定时模拟数据动态小幅飘动 ---
    const mockTimer = setInterval(() => {
      handleWsMessage({
        totalVehicles: 150,
        onlineTags: 18 + Math.floor(Math.random() * 3), // 模拟在线数偶尔跳动
        offlineTags: Math.max(0, 2 - Math.floor(Math.random() * 2)),
        activeAlarms: Math.random() > 0.7 ? 4 : 2, // 模拟偶发报警
      });
    }, 2000);

    return () => clearInterval(mockTimer);
  }, [setMetrics]);
}


3. 在 Sidebar 中精准、高性能消费

利用 Zustand 的 Selector 机制,侧边栏通过一条精准的指针指向 metrics即使 3D 场景里的标签在以 60FPS 的超高频移动,只要这个 metrics 的总数值没变,Sidebar 就绝对不会触发毫无意义的重绘。

import React from 'react';
import { useUwbStore } from "@/store/useUwbStore";
import { Car, Radio, AlertTriangle } from "lucide-react";

export function SidebarMetricsSummary() {
  // 🎯 核心护城河:精准订阅总览指标
  const metrics = useUwbStore((state) => state.metrics);

  return (
    <div className="grid grid-cols-2 gap-2 p-2 bg-slate-950/40 border border-slate-800/40 rounded-lg">
      
      {/* 车辆总数 */}
      <div className="flex items-center justify-between p-2 bg-slate-900/60 rounded border border-slate-800/20">
        <div className="flex items-center space-x-1.5 text-slate-400 text-xs">
          <Car size={13} className="text-blue-400" />
          <span>车辆总数</span>
        </div>
        <span className="font-mono font-bold text-sm text-blue-400">{metrics.totalVehicles}</span>
      </div>

      {/* 报警总数 */}
      <div className={`flex items-center justify-between p-2 rounded border transition-colors ${
        metrics.activeAlarms > 0 
          ? 'bg-rose-950/20 border-rose-500/30' 
          : 'bg-slate-900/60 border-slate-800/20'
      }`}>
        <div className="flex items-center space-x-1.5 text-slate-400 text-xs">
          <AlertTriangle size={13} className={metrics.activeAlarms > 0 ? "text-rose-500 animate-pulse" : "text-slate-500"} />
          <span className={metrics.activeAlarms > 0 ? "text-rose-300 font-medium" : ""}>实时报警</span>
        </div>
        <span className={`font-mono font-bold text-sm ${metrics.activeAlarms > 0 ? 'text-rose-500' : 'text-slate-400'}`}>
          {metrics.activeAlarms}
        </span>
      </div>

      {/* 在线标签 */}
      <div className="flex items-center justify-between p-2 bg-slate-900/60 rounded border border-slate-800/20">
        <div className="flex items-center space-x-1.5 text-slate-400 text-xs">
          <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 inline-block animate-ping" />
          <span>标签在线</span>
        </div>
        <span className="font-mono font-bold text-sm text-emerald-400">{metrics.onlineTags}</span>
      </div>

      {/* 离线标签 */}
      <div className="flex items-center justify-between p-2 bg-slate-900/60 rounded border border-slate-800/20">
        <div className="flex items-center space-x-1.5 text-slate-400 text-xs">
          <span className="w-1.5 h-1.5 rounded-full bg-slate-600 inline-block" />
          <span>标签离线</span>
        </div>
        <span className="font-mono font-bold text-sm text-slate-400">{metrics.offlineTags}</span>
      </div>

    </div>
  );
}

把这类大屏全局 KPI 塞进全局统一状态库中是非常标准的工程解法。这样改之后,你的业务逻辑界限会极其分明。

posted @ 2026-06-06 17:31  tommao9925  阅读(12)  评论(0)    收藏  举报