背景与挑战
这个应用使用 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,否则初始化
- 如果 networkInfo 缺失,启动
- 微任务故障保险
- 为了处理恢复时序差异导致无法触发的情况,在客户端的微任务中运行一次初始化检查
具体实现
存储类型
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 实现起来更容易,代码也更简洁,给我留下了深刻的积极印象。
