Next.js DApp 并发请求管理:React Query 与 SWR 在钱包连接下的竞态处理

发布时间:2026/7/14 10:52:16
Next.js DApp 并发请求管理:React Query 与 SWR 在钱包连接下的竞态处理 Next.js DApp 并发请求管理React Query 与 SWR 在钱包连接下的竞态处理一、深度引言在构建 Next.js 驱动的 DApp 前端时有一个容易被忽视但影响深远的架构问题钱包连接切换导致的数据竞态。当用户切换钱包账户、更换网络、或者钱包自动刷新连接状态时前端会在极短时间内发起多批相互矛盾的 RPC 请求。这些请求的响应顺序不可预测导致页面上出现短暂的错误数据——例如显示旧地址的余额、过期的合约状态、或者闪烁的NFT列表。这不是一个UI层面的问题而是一个分布式系统中经典的并发控制问题被带到了前端。钱包连接本质上是前端与区块链节点之间的一个持久会话状态而当这个状态发生突变时基于旧状态发起的inflight请求与基于新状态发起的请求形成了竞态关系。React QueryTanStack Query和 SWR 是当前 React 生态中处理服务端状态的两大主流方案它们各自提供了请求去重、缓存失效、后台刷新等机制。但当它们面对钱包断线重连、网络切换、账户变更等 Web3 特有场景时默认的竞态处理策略往往不够精确。本文将深入分析 DApp 场景下的并发请求问题对比 React Query 和 SWR 的竞态处理机制并给出针对钱包连接场景的生产级解决方案——包括请求取消策略、查询键Query Key的版本化设计、以及乐观更新在链上数据中的安全使用模式。二、原理剖析2.1 竞态问题的根源考虑一个典型的 DApp 交互序列1. 用户连接钱包 → 前端查询地址A的余额 → 请求R1发出 2. 用户切换账户 → 前端查询地址B的余额 → 请求R2发出 3. R2先到达且返回 → 页面显示地址B的余额正确 4. R1后到达且返回 → 页面显示地址A的余额错误问题出在第4步R1的响应覆盖了R2的响应。这是因为以太坊RPC节点之间的负载均衡可能导致请求被路由到不同节点而各节点的同步延迟不同响应顺序与请求顺序不一致。2.2 React Query vs SWR 的竞态处理对比两者的竞态处理策略有本质差异graph LR subgraph RQ[React Query 策略] RQ1[查询键驱动] -- RQ2[单实例缓存] RQ2 -- RQ3[isFetching标记] RQ3 -- RQ4[手动invalidate] end subgraph SWR[SWR 策略] SWR1[Stale-While-Revalidate] -- SWR2[缓存优先返回] SWR2 -- SWR3[后台重新验证] SWR3 -- SWR4[竞态忽略旧响应] end subgraph Wallet[钱包状态变更] W1[账户切换] -- RQ2 W1 -- SWR2 W2[网络切换] -- RQ4 W2 -- SWR4 end style RQ fill:#61dafb,color:#000 style SWR fill:#f7df1e,color:#000 style Wallet fill:#e74c3c,color:#fffReact Query采用显式的查询键queryKey管理。当查询键变化时旧的查询结果被标记为stale但旧请求的响应仍可能更新缓存。需要手动调用queryClient.cancelQueries()来取消过时的请求。SWR内置了基于key的竞态防护。当同一个key有新的请求发起时旧请求的响应会被自动丢弃。但这个同一key的判断基于字符串比较在钱包场景下需要精细设计key的构成。2.3 钱包状态管理的核心挑战stateDiagram-v2 [*] -- Disconnected Disconnected -- Connecting: connect() Connecting -- Connected: 连接成功 Connecting -- Disconnected: 用户拒绝/超时 Connected -- SwitchingAccount: 切换账户 SwitchingAccount -- Connected: 切换完成 Connected -- SwitchingNetwork: 切换网络 SwitchingNetwork -- Connected: 切换完成 Connected -- Disconnected: disconnect()/钱包断开 state 竞态风险区 { SwitchingAccount SwitchingNetwork Connecting } note right of 竞态风险区: 这些状态转换期间br/存在inflight请求br/与新请求的竞态在状态转换的灰色区域中必须取消所有基于旧状态的inflight请求。React Query 提供了queryClient.cancelQueries()API但这需要与钱包状态管理如 wagmi紧密集成。三、代码实践3.1 基于 React Query 的 DApp 查询层// hooks/useWalletQueries.ts // 为钱包连接场景定制的React Query封装层 import { useQuery, useMutation, useQueryClient } from tanstack/react-query; import { useAccount, useChainId, useDisconnect } from wagmi; import { useCallback, useRef, useEffect } from react; // 查询键工厂 // 将所有查询键集中管理确保结构一致性 // 查询键的前缀设计为分层结构[领域, 范围, 标识符, ...动态参数] export const queryKeys { // 余额查询: 使用账户地址作为动态参数 balance: (address: string, chainId: number) [balance, account, address, chainId] as const, // NFT列表: 分页查询 nfts: (address: string, chainId: number, page: number) [nfts, account, address, chainId, page] as const, // 合约状态 contractState: (contractAddress: string, chainId: number) [contract, state, contractAddress, chainId] as const, // 交易历史 txHistory: (address: string, chainId: number) [txs, history, address, chainId] as const, }; // 竞态安全的请求取消管理器 class RequestAbortManager { private controllers new Mapstring, AbortController(); /** * 创建请求并关联AbortController * 如果同一key已有请求在进行中先取消旧的 */ createSignal(key: string): { signal: AbortSignal; controller: AbortController } { // 取消旧请求这是竞态防护的核心 this.abort(key); const controller new AbortController(); this.controllers.set(key, controller); return { signal: controller.signal, controller }; } abort(key: string): void { const controller this.controllers.get(key); if (controller) { controller.abort(); this.controllers.delete(key); } } abortAll(): void { // 断开连接时取消所有进行中的请求 this.controllers.forEach((controller) { controller.abort(); }); this.controllers.clear(); } } // 全局单例 const abortManager new RequestAbortManager(); // 核心Hook /** * 钱包感知的余额查询Hook * 自动处理账户切换、网络切换时的请求取消 */ export function useBalance() { const { address, isConnected, isReconnecting } useAccount(); const chainId useChainId(); const queryClient useQueryClient(); // 监听钱包重连状态主动取消所有待处理请求 useEffect(() { if (isReconnecting) { console.log([DApp] 钱包重连中取消所有inflight请求); // 取消所有基于旧状态的查询 queryClient.cancelQueries({ predicate: (query) { const key query.queryKey as readonly unknown[]; // 只取消账户相关的查询余额、NFT、交易历史 return typeof key[0] string [balance, nfts, txs].includes(key[0] as string); } }); abortManager.abortAll(); } }, [isReconnecting, queryClient]); return useQuery({ // 查询键包含地址和链ID任一变化都会触发新查询 // 同时旧查询会被React Query自动标记为stale queryKey: queryKeys.balance(address!, chainId), queryFn: async ({ queryKey, signal }) { const [, , addr, cId] queryKey; // 创建可取消的信号结合React Query的signal和我们的AbortManager const abortKey balance:${addr}:${cId}; const { signal: managedSignal } abortManager.createSignal(abortKey); // 合并信号任一信号触发都会取消请求 const combinedSignal combineSignals(signal, managedSignal); const provider getProvider(cId as number); const balance await provider.getBalance(addr as string, { signal: combinedSignal }); return balance; }, // 仅在连接状态下启用查询 enabled: isConnected !!address !isReconnecting, // 缓存策略余额数据在15秒内视为新鲜 staleTime: 15 * 1000, // 后台刷新间隔非活跃标签页不刷新 refetchInterval: 60 * 1000, // 窗口重新聚焦时不自动刷新钱包地址不变时余额不会突变 refetchOnWindowFocus: false, // 竞态处理使用keepPreviousData保留上一账户的数据 // 避免切换账户时的UI闪烁 placeholderData: (prev) prev, }); } /** * 交易发送的乐观更新模式 * 在交易提交到mempool后立即更新UI确认后刷新真实数据 */ export function useSendTransaction() { const { address } useAccount(); const chainId useChainId(); const queryClient useQueryClient(); return useMutation({ mutationFn: async (tx: { to: string; value: bigint }) { // 这里集成 wagmi 的 sendTransaction const hash await sendTx(tx); // 等待一笔确认 await waitForConfirmation(hash); return hash; }, // 乐观更新交易提交时立即扣减余额缓存 onMutate: async (tx) { // 取消正在进行的余额查询防止旧数据覆盖乐观更新 await queryClient.cancelQueries({ queryKey: queryKeys.balance(address!, chainId), }); // 保存快照用于失败时回滚 const previousBalance queryClient.getQueryData( queryKeys.balance(address!, chainId) ); // 乐观扣减仅当余额缓存存在时 if (previousBalance) { queryClient.setQueryData( queryKeys.balance(address!, chainId), (old: bigint) (old ? old - tx.value : old) ); } return { previousBalance }; }, // 交易确认后使余额缓存失效触发重新查询 onSettled: () { queryClient.invalidateQueries({ queryKey: queryKeys.balance(address!, chainId), }); }, // 失败时回滚到乐观更新前的状态 onError: (_err, _tx, context) { if (context?.previousBalance) { queryClient.setQueryData( queryKeys.balance(address!, chainId), context.previousBalance ); } }, }); } // 工具函数 /** 合并多个AbortSignal */ function combineSignals(...signals: (AbortSignal | undefined)[]): AbortSignal { const controller new AbortController(); const validSignals signals.filter((s): s is AbortSignal !!s); validSignals.forEach((signal) { if (signal.aborted) { controller.abort(signal.reason); } else { signal.addEventListener(abort, () controller.abort(signal.reason), { once: true }); } }); return controller.signal; } /** 获取指定链的Provider */ function getProvider(chainId: number) { // 实际实现应使用公共RPC或Alchemy/Infura端点 const rpcUrls: Recordnumber, string { 1: https://eth-mainnet.g.alchemy.com/v2/..., 137: https://polygon-mainnet.g.alchemy.com/v2/..., 42161: https://arb-mainnet.g.alchemy.com/v2/..., }; return new ethers.JsonRpcProvider(rpcUrls[chainId] || rpcUrls[1]); }3.2 SWR 方案的等效实现// hooks/useWalletQueriesSWR.ts import useSWR, { useSWRConfig, preload } from swr; import { useAccount, useChainId } from wagmi; /** * SWR版本的余额查询 * SWR的竞态处理与React Query不同 * - 同key的新请求自动丢弃旧响应内置竞态防护 * - 但key设计需要更精细账户地址chainId请求时间戳后缀 */ export function useBalanceSWR() { const { address, isConnected } useAccount(); const chainId useChainId(); // SWR的key设计 // 使用数组形式确保序列化一致性 // 添加version字段用于手动触发全局缓存失效 const key isConnected address ? [balance, address, chainId] : null; return useSWR( key, async ([, addr, cId]) { const provider getProvider(cId as number); const balance await provider.getBalance(addr as string); return balance; }, { // SWR默认的revalidate策略数据在focus和reconnect时自动刷新 // 在DApp中需要定制 revalidateOnFocus: false, // 余额不会因窗口聚焦而改变 revalidateOnReconnect: false, // 钱包断线重连时应触发新的查询不是revalidate // 去重间隔同一key 2秒内不重复请求 dedupingInterval: 2000, // 使用keepPreviousData切换账户时保持旧数据显示 // 等待新数据加载完成后再替换 keepPreviousData: true, // 错误重试策略RPC请求失败时最多重试3次 errorRetryCount: 3, errorRetryInterval: 5000, } ); } /** * SWR的乐观更新模式 * 使用全局mutate API实现 */ export function useSendTransactionSWR() { const { address } useAccount(); const chainId useChainId(); const { mutate, cache } useSWRConfig(); const sendTx async (tx: { to: string; value: bigint }) { const balanceKey [balance, address, chainId]; // 乐观更新SWR缓存 const optimisticUpdate await mutate( balanceKey, async (currentBalance: bigint) { // mutate的第二个参数可以是async函数 // 这里我们直接乐观扣减 return currentBalance - tx.value; }, { // 不重新验证等待交易确认后再做 revalidate: false, // 乐观数据如果当前没有缓存使用0作为初始值 optimisticData: (current) current ? current - tx.value : BigInt(0), // 失败时回滚 rollbackOnError: true, } ); try { const hash await sendTransaction(tx); await waitForConfirmation(hash); // 交易确认后强制重新验证余额 mutate(balanceKey); return hash; } catch (error) { // mutate的rollbackOnError会自动回滚 throw error; } }; return { sendTx }; }3.3 全局竞态防护中间件// middleware/walletRaceConditionGuard.ts /** * 钱包竞态防护中间件 * 在 React Query 的 QueryClient 上注册全局回调 * 确保钱包状态变更时自动清理所有过时请求 */ import { QueryClient, QueryCache } from tanstack/react-query; import { watchAccount, watchNetwork } from wagmi/core; export function configureWalletRaceGuard(queryClient: QueryClient) { let currentAddress: string | undefined; let currentChainId: number | undefined; // 监听账户变更 const unwatchAccount watchAccount((account) { const newAddress account.address; if (currentAddress newAddress currentAddress ! newAddress) { // 账户切换取消所有关联旧账户的查询 cancelQueriesForAddress(queryClient, currentAddress); console.log([RaceGuard] 账户切换 ${currentAddress.slice(0, 6)} - ${newAddress.slice(0, 6)}); } currentAddress newAddress; }); // 监听网络切换 const unwatchNetwork watchNetwork((network) { const newChainId network.chain?.id; if (currentChainId newChainId currentChainId ! newChainId) { // 网络切换取消所有链相关查询 cancelQueriesForChain(queryClient, currentChainId); console.log([RaceGuard] 网络切换 ${currentChainId} - ${newChainId}); } currentChainId newChainId; }); // 查询失败全局处理检测AbortError不报告 queryClient.getQueryCache().config.onError (error, query) { if (error instanceof DOMException error.name AbortError) { // AbortError是主动取消导致的不应计入错误统计 return; } // 其他错误由各查询自行处理 }; // 返回清理函数用于组件卸载时取消监听 return () { unwatchAccount(); unwatchNetwork(); }; } function cancelQueriesForAddress(client: QueryClient, address: string) { client.cancelQueries({ predicate: (query) { const key query.queryKey; // 匹配包含该地址的所有查询键 return key.some( (segment) typeof segment string segment address ); } }); } function cancelQueriesForChain(client: QueryClient, chainId: number) { client.cancelQueries({ predicate: (query) { const key query.queryKey; // 匹配包含该链ID的所有查询键 return key.some( (segment) typeof segment number segment chainId ); } }); }四、边界分析乐观更新的安全边界乐观更新在链上数据场景中存在天然风险交易未必会上链网络拥堵、nonce冲突、gas不足而乐观扣减会让用户误以为交易已完成。安全的做法是乐观更新仅作用于UI展示如显示交易处理中并进行金额模拟扣减但不影响实际的合约交互状态查询。当交易确认失败时系统需要清晰的UI信号如红色toast提示交易未确认告知用户乐观数据已被回滚。RPC请求的去重与合并多个组件可能同时查询同一地址的余额如果不加以控制会产生大量重复RPC请求。React Query的queryKey天然提供了去重同一时间只发一个请求但默认的去重窗口由staleTime控制。在DApp中不同数据的staleTime应该差异化设计余额15秒、NFT列表60秒、合约常量数据可以设置为Infinity永不过期。与wagmi的v2状态同步wagmi v2引入了reconnect状态和自动重连机制。在钱包断线重连期间通常200ms-2sisReconnecting为true时应当暂停所有基于钱包状态的查询以免在这短暂的时间窗口中发出基于undefined地址的无效RPC请求。这需要在查询的enabled条件中进行显式的状态守卫。Server Components中的特殊考量Next.js App Router中的Server Components无法直接访问钱包状态钱包是客户端概念。如果需要在服务端预取链上数据例如首页展示的全局统计数据应通过独立的查询键如[global, stats, chainId]绕过钱包依赖。客户端组件使用use client指令再基于钱包地址进行个性化的数据查询。五、总结DApp前端的竞态问题不是一个可以用加个loading解决的UI问题而是需要从查询架构层面系统性处理的并发控制问题。本文的三个核心实践可以被提炼为一条规则任何涉及钱包状态的查询键必须显式包含地址和链ID并在状态变化时主动取消旧请求。React Query 和 SWR 各有优势React Query 的函数式API更适合复杂的乐观更新和手动缓存控制SWR 的极简API在简单场景下足够直接。无论选择哪个方案钱包连接变化的五个关键节点——连接、断开、切换账户、切换网络、重连——都需要在查询层有对应的取消策略。最后RequestAbortManagerwatchAccount/watchNetworkenabled条件三元组合构成了 DApp 的竞态防护基线。在这个基线上构建的查询层能够保证用户在任何钱包操作下看到的都是正确的、一致的数据。