Next.jsReactTypeScriptRefactoringZustand

使用 Next.js × Zustand 重构状态管理

Sloth255
Sloth255
·2 min read·383 words

背景与挑战

这个应用使用 Next.js 15(App Router)+ React 19,采用静态导出配置。使用场景涉及获取、显示和分享网络信息(IPv4/IPv6 等)和设备信息(UA 等)。由于做了本地化,每种语言都有对应页面。

实施前的挑战:

  • 获取逻辑分散在各组件中且存在重复(副作用难以追踪)
  • 同一会话中每次切换语言都重复获取(感觉很慢)
  • 需要从分享按钮等横切功能一致访问状态

为了解决这些问题,我们引入了 Zustand,并重构为由存储驱动的状态管理(初始化、持久化、TTL、重试)。

Zustand: https://zustand-demo.pmnd.rs/

目标

  • 将副作用集中在"数据存储侧"(获取、保存、重新获取决策)
  • 通过使组件"只读"来简化组件渲染
  • 通过会话持久化提升感知性能(避免同一会话内重复获取)
  • 通过 TTL 和重试提高可靠性,支持从 UI 手动更新

采纳的设计

1) 切片模式 + 有界存储

  • 切片:
    • NetworkSlice:networkInfo、networkLoading、networkError、networkFetchedAt、initializeNetworkInfo
    • DeviceSlice:deviceInfo、deviceReady、initializeDeviceInfo
  • 类型:在 types.ts 中定义 NetworkInfo/DeviceInfo 类型以及 Slice/Store 类型
  • 存储:
    • 使用 createStore 创建一个有界存储,组合切片
    • 使用 persist(createJSONStorage(() => sessionStorage)) 进行会话持久化

读取方式:

  • 整个状态:const { networkInfo, deviceInfo } = useAppStore();
  • 选择器:const info = useAppStore(s => s.networkInfo);

2) 在存储侧初始化

  • 网络
    • initializeNetworkInfo(opts?: { force?: boolean })
    • 管理 loading/error 标志
    • 成功时保存 networkFetchedAt = Date.now()(用于 TTL 判断)
  • 设备
    • 通过 initializeDeviceInfo() 从 navigator/window 收集信息
    • 使用 deviceReady 管理显示就绪状态(保持平滑显示)

3) 恢复后的安全自动启动

  • onRehydrateStorage 中,根据恢复的状态条件触发初始化
    • 如果 networkInfo 缺失,启动 initializeNetworkInfo()
    • 如果 deviceInfo 存在,立即设置 deviceReady = true,否则初始化
  • 微任务故障保险
    • 为了处理恢复时序差异导致无法触发的情况,在客户端的微任务中运行一次初始化检查

具体实现

存储类型

src/store/types.ts
export interface NetworkSlice {
  networkInfo: NetworkInfo | null;
  networkLoading: boolean;
  networkError: string | null;
  networkFetchedAt?: number | null; // 用于 TTL
  setNetworkInfo: (info: NetworkInfo | null) => void;
  initializeNetworkInfo: (opts?: { force?: boolean }) => Promise<void>;
}

export interface DeviceSlice {
  deviceInfo: DeviceInfo | null;
  deviceReady: boolean;
  setDeviceInfo: (info: DeviceInfo | null) => void;
  initializeDeviceInfo: () => void;
}

有界存储与 persist

src/store/useAppStore.ts
export const appStore = createStore<AppStore>()(
  persist(
    (set, get, api) => ({
      ...createNetworkSlice(set, get, api),
      ...createDeviceSlice(set, get, api),
    }),
    {
      name: 'own-info-app-storage',
      storage: createJSONStorage(() => sessionStorage),
      partialize: (state) => ({
        networkInfo: state.networkInfo,
        networkFetchedAt: state.networkFetchedAt ?? null,
        deviceInfo: state.deviceInfo,
      }),
      onRehydrateStorage: () => (rehydrated) => {
        if (typeof window === 'undefined') return;
        const s = appStore.getState();
        if (!rehydrated?.networkInfo) s.initializeNetworkInfo().catch(() => {});
        if (rehydrated?.deviceInfo) appStore.setState({ deviceReady: true });
        else s.initializeDeviceInfo();
      },
    }
  )
);

// 微任务保险
if (typeof window !== 'undefined') {
  queueMicrotask(() => {
    const s = appStore.getState();
    if (!s.networkInfo && !s.networkLoading) s.initializeNetworkInfo().catch(() => {});
    if (s.deviceInfo && !s.deviceReady) appStore.setState({ deviceReady: true });
    else if (!s.deviceInfo && !s.deviceReady) s.initializeDeviceInfo();
  });
}

TTL 与重试

src/app/config.ts
export const networkConfig = {
  ttlMinutes: 60,
  maxRetries: 1,
  retryDelayMs: 500,
} as const;
src/store/networkSlice.ts
import { networkConfig } from '@/app/config';
const DEFAULT_TTL_MS = Math.max(0, networkConfig.ttlMinutes) * 60 * 1000;
const MAX_RETRIES = Math.max(0, networkConfig.maxRetries);
const RETRY_DELAY_MS = Math.max(0, networkConfig.retryDelayMs);

initializeNetworkInfo: async ({ force } = {}) => {
  const { networkInfo, networkLoading, networkFetchedAt } = get();
  if (networkLoading) return;
  const fresh = networkInfo && networkFetchedAt && Date.now() - networkFetchedAt < DEFAULT_TTL_MS;
  if (!force && fresh) return;

  set({ networkLoading: true, networkError: null });
  try {
    const attemptFetch = async () => {
      const token = process.env.TOKEN;
      const [v4Res, v6Res] = await Promise.all([
        fetch(`https://<api>/v4?token=${token}`),
        fetch(`https://<api>/v6?token=${token}`),
      ]);
      const v4Json = v4Res.ok ? await v4Res.json() : '';
      const v6Json = v6Res.ok ? await v6Res.json() : '';
      return {
        ipv4: v4Json?.ip,
        ipv6: v6Json?.ip
      };
    };

    for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
      try {
        const data = await attemptFetch();
        set({ networkInfo: data, networkFetchedAt: Date.now(), networkLoading: false, networkError: null });
        return;
      } catch {
        if (attempt < MAX_RETRIES) {
          await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
          continue;
        }
      }
    }
    set({ networkLoading: false, networkError: 'network_fetch_failed' });
  } catch {
    set({ networkLoading: false, networkError: 'network_fetch_failed' });
  }
}

组件简化与 UI 改进

  • 移除副作用(useEffect/useLayoutEffect),通过存储标志(loading/error/ready)控制显示
  • 添加"刷新"按钮以支持手动更新(忽略 TTL 的重新获取)
src/components/NetworkInfoClient.tsx
const { networkInfo: info, networkLoading, networkError, initializeNetworkInfo } = useAppStore();

<button
  type="button"
  onClick={() => initializeNetworkInfo({ force: true })}
  disabled={networkLoading}
  className={...}
  title={networkLoading ? L('network.refreshing','Refreshing...') : L('network.refresh','Refresh')}
>
  {networkLoading ? <Spinner/> : <span>{L('network.refresh','Refresh')}</span>}
</button>

遇到的陷阱及解决方案

  • "卡在加载状态"
    • 原因:由于恢复时序差异,标志未更新
    • 解决方案:在 onRehydrateStorage 中明确设置 ready + 微任务故障保险
  • 持久化目标
    • 引入 partialize 以避免保存瞬态标志(loading/error/ready)

结果

  • 感知性能提升
    • 会话期间从 persist 即时恢复 → 跳过获取(在 TTL 内)
    • 消除了重复副作用,减少了不必要的重渲染
  • 可靠性提升
    • 恢复后立即安全初始化(onRehydrateStorage + 微任务)
    • 失败时重试,支持从 UI 手动更新
  • 可读性和可维护性
    • 整合了获取逻辑(统一在存储中)
    • 切片的职责分离(network/device)
    • 有界存储的清晰 API

总结

通过引入具有切片模式 + 有界存储的 Zustand,并采用"存储中初始化"和"UI 只读"的职责分离,我们提升了显示速度、可靠性和可维护性。
我之前用 React × Redux 构建过状态管理的应用,但 Zustand 实现起来更容易,代码也更简洁,给我留下了深刻的积极印象。