导致大型 React 项目逐步衰亡的架构错误

发布: (2025年12月27日 GMT+8 18:38)
14 min read
原文: Dev.to

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 模式正是实现这一点的关键:

  1. Fetchers 将数据的 获取方式来源(后端或链)进行隔离。
  2. Mappers 将这两种来源合并、标准化、格式化并调和。
  3. UI 接收一个单一、干净、稳定的数据对象——无需知道(也无需关心)它是来自 RPC、后端,还是两者兼有。

过度使用 useMemo

在传统应用中,后端会为你准备好数据。
在 Web3 中,区块链只提供原始、基础的状态,你必须自行计算所有内容。

  • 更多数据 → 更多派生计算 → 更多 useMemo
  • 只有 10 个金库时,即使每个金库有 20 个 memo 也不是大问题,但一旦金库数量增长……
金库数量memo 数量(≈20 × 金库)
10200
50010 000

每次重新渲染都会触发:

  • 依赖比较
  • 重新计算
  • 差异比较
  • 内存使用
  • 竞争条件

随着数据增长,“安全失误”的容忍度降至零。单个不稳定的依赖就可能导致性能崩溃。

Web3 与 Web2 中的 React Query

我审查了许多 DeFi 项目,发现了一个常见错误:像在 Web2 中一样使用 useQuery

“如果我在构建 Web2 或 Web3 应用,React Query 有什么区别吗?我只是进行 RPC 调用而不是 HTTP,对吧?”

并不是那么简单。

Web2Web3
数据库 / 后端服务承担繁重工作(计算、关联、格式化)。只有链可用 → 必须在前端完成所有类似数据库的工作。
一个 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),
    }
  );
}

优势

  1. 单一真相来源 – 一个查询返回完整的数据对象。
  2. 无需层层加载标记 – Hook 的 isLoading 直接反映整个 payload 的加载状态。
  3. 更易测试与维护 – Mapper 是纯函数,能够进行单元测试。
  4. 减少 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 行交织的逻辑
}

这种做法的问题

  1. 缓存逻辑分散 – 每个内部 Hook 都需要各自的 enabledstaleTime 等配置。
  2. 需求变更困难 – 添加前置检查(例如 “金库是否已验证?”)需要在 dozens 个地方同步更新。
  3. 性能开销 – 大量 useMemo 调用、依赖数组和查询键会导致不必要的重新渲染。

更清晰的流程

  1. 获取函数 – 纯粹的 async 函数,返回数据(如 fetchOwnerfetchIsVerified)。
  2. 映射层 – 调用所需的多个获取函数(通常使用 Promise.all)。
  3. 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 里吧。”
这只会增加另一层响应式(更多依赖、更多相等性检查、更多内存),并未解决根本问题。

正确的前进方式

  1. 将获取逻辑移出 Hook – 保持 Hook 简洁;让它们只提供响应性。
  2. 简化流水线 – 使用 Promise.all(或类似方式)并行获取。
  3. 利用 React Query – 用于缓存、过期时间、重试以及 UI 状态(加载/错误)。
  4. 每个功能只保留一个 useQuery – 为 UI 响应提供唯一的真相来源。

好处

  • 易于调试 – 纯函数易于单元测试。
  • 可预测 – 组件之间的交互更少,重新渲染更可控。
  • 性能佳 – 占用更少内存,比较次数更少。
  • 可扩展 – 添加新数据需求只需新增一个获取函数。
  • 易维护 – 数据获取与 UI 逻辑明确分离。

TL;DR

  • Fetch functions → 纯异步,通过 queryClient.fetchQuery 缓存。
  • Mapper layer → 组合多个 fetch,处理业务逻辑(例如验证)。
  • Single useQuery → 将 mapper 包装用于 UI 响应。

通过采用这种三层结构,你可以避免“hook 过多”的代码陷阱,让 React Query 发挥其最擅长的功能——管理缓存和状态——同时保持代码库整洁、高性能且易于理解。

Back to Blog

相关文章

阅读更多 »