Next.js DApp 并发请求管理:React Query 与 SWR 在钱包连接下的竞态处理
一、深度引言
在构建 Next.js 驱动的 DApp 前端时,有一个容易被忽视但影响深远的架构问题:钱包连接切换导致的数据竞态。当用户切换钱包账户、更换网络、或者钱包自动刷新连接状态时,前端会在极短时间内发起多批相互矛盾的 RPC 请求。这些请求的响应顺序不可预测,导致页面上出现短暂的错误数据——例如显示旧地址的余额、过期的合约状态、或者闪烁的NFT列表。
这不是一个UI层面的问题,而是一个分布式系统中经典的并发控制问题被带到了前端。钱包连接本质上是前端与区块链节点之间的一个持久会话状态,而当这个状态发生突变时,基于旧状态发起的inflight请求与基于新状态发起的请求形成了竞态关系。
React Query(TanStack 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 Map<string, 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: Record<number, 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-2s),isReconnecting为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在简单场景下足够直接。无论选择哪个方案,钱包连接变化的五个关键节点——连接、断开、切换账户、切换网络、重连——都需要在查询层有对应的取消策略。
最后,RequestAbortManager+watchAccount/watchNetwork+enabled条件三元组合构成了 DApp 的竞态防护基线。在这个基线上构建的查询层,能够保证用户在任何钱包操作下看到的都是正确的、一致的数据。