导致大型 React 项目逐步衰亡的架构错误
Source: Dev.to
请提供您希望翻译的正文内容,我将为您翻译成简体中文并保留原有的格式、Markdown 语法以及技术术语。谢谢!
问题
- 客户端数据过多
- 过度使用
useMemo - React Query 在链上获取数据时变得过于复杂
- 渲染不可预测
- Hook 逻辑难以维护
- 过早创建的抽象无法随项目演进
介绍
现代 DeFi 前端尝试提供最大透明度:所有数据都在链上获取,所有计算实时进行,所有内容都是响应式的。
在一开始这运行得非常好。你拥有简洁的 UI、少量合约调用、几个 Hook,且一切看起来都易于管理。
但随着项目的增长,数据也随之膨胀:
- 更多金库
- 更多市场
- 更多余额
- 更多派生状态
- 更多年化收益率
- 更多用户持仓
突然间,你的前端在 React 组件内部承担了后端 + 数据库的工作。早期的一些小错误——一个辅助 Hook、一次抽象——开始堆积。条件增多、memo 增多、状态增多。在你意识到之前,你已经在与本不该出现的复杂性搏斗。
本文解释了为什么会出现这种情况,为什么 React Query 在 Web3 中变得极其棘手,以及如何通过一种简单的架构——fetch → mapper → hook——永久性地解决这些问题。
客户端数据过多
随着数据集的增长,我们意识到并非所有数据都需要实时从链上获取。有些数据是:
- 计算成本高,
- 很少变化,或
- 从多个来源聚合而来。
因此,我们将特定类别的数据迁移到后端(或子图 / 任务工作者),随后使用 Fetch → Mapper → UI 模式进行消费。
“如果已经有后端,这种模式不是就不太有用了吧?”
你可能会认为,一旦引入后端,Fetch → Mapper → UI 架构就不那么必要了。
但实际情况是:
即使拥有强大的后端,仍然会有必须直接来自链上的数据。这些值具有以下特性:
- 时间敏感,
- 用户特定,或
- 交易门控 …以安全地卸载到后端。
这就产生了一个不可避免的挑战:前端必须合并两个不同的世界。
| 来源 | 特性 |
|---|---|
| 后端提供的数据 | 已缓存、聚合、变化缓慢 |
| 链上数据 | 实时、响应式、针对用户 |
Fetch → Mapper → UI 模式正是实现这一点的关键:
- Fetchers 将数据的 获取方式 和 来源(后端或链)进行隔离。
- Mappers 将这两种来源合并、标准化、格式化并调和。
- UI 接收一个单一、干净、稳定的数据对象——无需知道(也无需关心)它是来自 RPC、后端,还是两者兼有。
过度使用 useMemo
在传统应用中,后端会为你准备好数据。
在 Web3 中,区块链只提供原始、基础的状态,你必须自行计算所有内容。
- 更多数据 → 更多派生计算 → 更多
useMemo。 - 只有 10 个金库时,即使每个金库有 20 个 memo 也不是大问题,但一旦金库数量增长……
| 金库数量 | memo 数量(≈20 × 金库) |
|---|---|
| 10 | 200 |
| 500 | 10 000 |
每次重新渲染都会触发:
- 依赖比较
- 重新计算
- 差异比较
- 内存使用
- 竞争条件
随着数据增长,“安全失误”的容忍度降至零。单个不稳定的依赖就可能导致性能崩溃。
Web3 与 Web2 中的 React Query
我审查了许多 DeFi 项目,发现了一个常见错误:像在 Web2 中一样使用 useQuery。
“如果我在构建 Web2 或 Web3 应用,React Query 有什么区别吗?我只是进行 RPC 调用而不是 HTTP,对吧?”
并不是那么简单。
| Web2 | Web3 |
|---|---|
| 数据库 / 后端服务承担繁重工作(计算、关联、格式化)。 | 只有链可用 → 必须在前端完成所有类似数据库的工作。 |
| 一个 hook → 一个 query → 你需要的全部。 | 多个原始调用 → 结果是许多相互依赖的 hook。 |
典型(凌乱)大量 hook 的做法是什么样的
// 1️⃣ Fetch vault
const {
data: vault,
isLoading: isVaultLoading,
} = useVault({
address: vaultAddress,
query: { refetchOnMount: false },
});
// 2️⃣ Fetch Net Asset Value (depends on vault)
const {
data: netAssetValue,
isLoading: isNetAssetValueLoading,
} = useNetAssetValue({
accountAddress: account,
vaultAddress,
enabled: Boolean(vault), // enabled #1
});
// 3️⃣ Fetch APY (also depends on vault)
const {
data: apy,
isLoading: isApyLoading,
} = useApy({
account,
vaultAddress,
enabled: Boolean(vault), // enabled #2
});
// Global loading state
const isLoading =
isVaultLoading ||
isNetAssetValueLoading ||
isApyLoading;
// TODO: handle errors, etc.
这种模式的问题
- 每个数据点都携带一套 React Query 状态(加载中、错误、状态标志、时间戳等)。
- 由于大多数 hook 互相依赖,如果任意底层 query 正在加载,复合 hook 就会变成“加载中”。
- 实际上你无论如何都需要全部数据,因此多个
useQuery调用最终只起到一个作用:缓存。
一个自然的问题出现了:
为什么不直接一次性获取所有数据呢?
更简洁的方法:Fetch → Mapper → Hook
下面是一个示例,展示如何用单一、清晰的数据流来取代纠结的、依赖大量 Hook 的代码。
// mapper.ts
export async function fetchVaultData({
vaultAddress,
account,
}: {
vaultAddress: string;
account: string;
}) {
// 1️⃣ Fetch the vault (required) and await it
const vault = await fetchVault(vaultAddress);
if (!vault) throw new Error('Vault not found');
// 2️⃣ Fetch dependent values in parallel
const [netAssetValue, apy] = await Promise.all([
fetchNetAssetValue({ accountAddress: account, vaultAddress }),
fetchApy({ account, vaultAddress }),
]);
// 3️⃣ Combine / map everything into a single object
return {
vault,
netAssetValue,
apy,
};
}
// useVaultData.ts
import { useQuery } from '@tanstack/react-query';
import { fetchVaultData } from './mapper';
export function useVaultData({
vaultAddress,
account,
}: {
vaultAddress: string;
account: string;
}) {
return useQuery(
['vaultData', vaultAddress, account],
() => fetchVaultData({ vaultAddress, account }),
{
// you can still control refetching, caching, etc.
staleTime: 60_000,
enabled: Boolean(vaultAddress && account),
}
);
}
优势
- 单一真相来源 – 一个查询返回完整的数据对象。
- 无需层层加载标记 – Hook 的
isLoading直接反映整个 payload 的加载状态。 - 更易测试与维护 – Mapper 是纯函数,能够进行单元测试。
- 减少
useMemo的使用 – 派生值在 Mapper 中计算一次,而不是在每次渲染时重新计算。
TL;DR
- 不要让前端变成临时的后端。 将静态/昂贵的数据迁移到真实的后端或子图(subgraph)。
- 避免大量使用
useMemo。 在专用的映射器中计算派生数据,而不是在各组件中散布 memo 钩子。 - 用单一的 “fetch → mapper → hook” 流程取代交错的
useQuery钩子网络。 这样可以获得干净、可预测的数据流,随着 DeFi 应用的增长而易于扩展。
Source: …
使用 React Query 重构数据获取
// 1. 定义查询选项
export const getOwnerQueryOptions = (
vaultAddress: Address,
chainId: number,
) => ({
// readContractQueryOptions utils from wagmi
...readContractQueryOptions(getWagmiConfig(), {
abi: eulerEarnAbi,
address: vaultAddress,
chainId,
functionName: "owner",
args: [],
}),
staleTime: 30 * 60 * 1000, // ← 缓存最小的数据块(30 分钟)
});
// 2. 使用查询客户端获取数据
export async function fetchOwner(vaultAddress: Address, chainId: number) {
const result = await getQueryClient().fetchQuery(
// ← 从查询客户端获取查询
getOwnerQueryOptions(vaultAddress, chainId),
);
return result;
}
为什么这样更简洁
- 单一数据来源 – 所有获取逻辑都放在普通函数中。
- 内置缓存 –
staleTime自动处理过期。 - 没有 Hook 嵌套 – 高层操作可以直接调用
fetchOwner,保持 Hook 本身轻量。
从 Hook 密集到普通获取函数
之前我们可能会写一个巨大的 Hook,类似下面(简化版):
// 大量使用 Hook 的实现示例
function useVaultData(vaultAddress: Address, chainId: number) {
const { data: owner } = useQuery(getOwnerQueryOptions(vaultAddress, chainId));
const { data: isVerified } = useQuery(getVerifiedQueryOptions(vaultAddress));
// … 还有许多 useQuery 调用、useMemo 等
// 500–800 行交织的逻辑
}
这种做法的问题
- 缓存逻辑分散 – 每个内部 Hook 都需要各自的
enabled、staleTime等配置。 - 需求变更困难 – 添加前置检查(例如 “金库是否已验证?”)需要在 dozens 个地方同步更新。
- 性能开销 – 大量
useMemo调用、依赖数组和查询键会导致不必要的重新渲染。
更清晰的流程
- 获取函数 – 纯粹的 async 函数,返回数据(如
fetchOwner、fetchIsVerified)。 - 映射层 – 调用所需的多个获取函数(通常使用
Promise.all)。 useQuery包装器 – 单一的useQuery包装映射层,为 UI 提供响应式。
// 1️⃣ 获取函数(已在上面展示)
// 2️⃣ 组合数据的映射函数
async function fetchVaultInfo(vaultAddress: Address, chainId: number) {
const [owner, isVerified] = await Promise.all([
fetchOwner(vaultAddress, chainId),
fetchIsVerified(vaultAddress, chainId),
]);
if (!isVerified) {
throw new Error("Vault is not verified");
}
return { owner, isVerified };
}
// 3️⃣ 为 UI 提供响应式的 Hook
export function useVaultInfo(vaultAddress: Address, chainId: number) {
return useQuery({
queryKey: ["vaultInfo", vaultAddress, chainId],
queryFn: () => fetchVaultInfo(vaultAddress, chainId),
staleTime: 5 * 60 * 1000, // 示例缓存时长
});
}
现在 Hook 非常小,数据层 易于测试,缓存只在获取函数中 统一处理。
过度响应式架构的代价
| 问题 | 会发生什么 |
|---|---|
大量 useQuery 调用 | 7+ 查询键 → 许多独立缓存 |
多个 useMemo 包装 | 每个 hook 额外 3‑5 个依赖数组 |
| 10+ 依赖数组 | React 必须在每次渲染时比较它们 |
| 可扩展性 | 组件增多 → 依赖数组和键增多 → 重新渲染连锁 |
| 内存与 CPU | 存储/比较数组的开销变得显著 |
“我们就把它包在
useMemo里吧。”
这只会增加另一层响应式(更多依赖、更多相等性检查、更多内存),并未解决根本问题。
正确的前进方式
- 将获取逻辑移出 Hook – 保持 Hook 简洁;让它们只提供响应性。
- 简化流水线 – 使用
Promise.all(或类似方式)并行获取。 - 利用 React Query – 用于缓存、过期时间、重试以及 UI 状态(加载/错误)。
- 每个功能只保留一个
useQuery– 为 UI 响应提供唯一的真相来源。
好处
- 易于调试 – 纯函数易于单元测试。
- 可预测 – 组件之间的交互更少,重新渲染更可控。
- 性能佳 – 占用更少内存,比较次数更少。
- 可扩展 – 添加新数据需求只需新增一个获取函数。
- 易维护 – 数据获取与 UI 逻辑明确分离。
TL;DR
- Fetch functions → 纯异步,通过
queryClient.fetchQuery缓存。 - Mapper layer → 组合多个 fetch,处理业务逻辑(例如验证)。
- Single
useQuery→ 将 mapper 包装用于 UI 响应。
通过采用这种三层结构,你可以避免“hook 过多”的代码陷阱,让 React Query 发挥其最擅长的功能——管理缓存和状态——同时保持代码库整洁、高性能且易于理解。