本文提出了一种更适合 Web3 场景的数据获取方法,通过 Fetch -> Mapper -> UI 三层架构,将复杂性隔离,中心化缓存,避免过多的 hooks 和重渲染,从而提高性能和可维护性。这种方法借鉴了后端架构的思想,将前端分为数据获取层、数据转换层和UI展示层,使得代码更清晰、可预测,并易于扩展。
正确的链上数据获取方式
另外,这里的目标不是构建一个适用于所有人的完美、包罗万象的抽象。
目标是创建一个简单、可扩展的基线,任何开发者都可以理解、快速采用,并随着项目的增长进行扩展。
React Query 完全改变了我们许多人对 React 中数据和状态的看法。它是一个令人难以置信的库 - 但在 web3 中,我们很多人(包括我自己)都在无意中使用错误的方式使用它。
让我们来探讨一下原因。
在一个典型的 web2 前端中,你会从后端或数据库中获取准备好的数据 - 一个单一的事实来源:
它很干净。一个请求 → 一个响应 → 一个 useQuery。
但在 web3 中,你的 "后端" 是:
因此,完全相同的 UI 最终看起来更像是这样:
这就是事情出错的地方。
从 3 个合约中获取数据?
你现在有:
enabled 逻辑它变得太复杂了。太 reactive 了。太难维护了。
一个单独的 hook 增长到 500 多行条件和嵌套 hook。
那么我们该怎么办?
我们回到有效的方法。
我们从后端架构中汲取灵感——后端架构通常有三个层:
对于 web3 前端,等价物变为:
此模型恢复了可预测性,提高了性能,简化了调试,并使缓存保持在控制之下。
让我们来分解一下。
Fetcher 层负责:
这是关键的见解:
你不要在这一层使用 hook。直接获取 queryClient,而不是 useQueryClient。
你使用:
queryClient.fetchQuery()
React Query 仍然处理:
staleTimefetchQuery 中的所有其他非 reactive 方面但不会在不应该引入 reactivity 的地方引入 reactivity。
请看一个如何获取 decimals、balance 和使用 mapper 映射这些值的示例:
export const getTokenDecimalsQuery = (token: Address, chainId: number) => ({
...readContractQueryOptions(getWagmiConfig(), {
abi: erc20Abi,
address: token,
chainId,
functionName: "decimals",
args: [],
}),
staleTime: Infinity,
});
export async function fetchTokenDecimals(token: Address, chainId: number) {
return getQueryClient().fetchQuery(getTokenDecimalsQuery(token, chainId));
}
^ 这里,fetcher 永久缓存 token decimals。
export const getTokenBalanceQuery = (
token: Address,
chainId: number,
account: Address,
) => ({
...readContractQueryOptions(getWagmiConfig(), {
abi: erc20Abi,
address: token,
chainId,
functionName: "balanceOf",
args: [account],
}),
staleTime: 1 * 1000, // 1 分钟
});
export async function fetchTokenBalance(
token: Address,
chainId: number,
account: Address,
) {
return getQueryClient().fetchQuery(
getTokenBalanceQuery(token, chainId, account),
);
}
^ 这里,fetcher 将 balance 缓存 1 分钟。
无论:
此 RPC 调用 balanceOf 最多每 1 分钟发生一次。(或者在 decimals 情况下只发生一次)
你的 UI 保持快速,你的应用程序保持稳定,并且缓存保持集中。
Mapper 层是将所有内容整合在一起的地方。
它:
因为 fetcher 是非 hook 函数,mapper 也是非 hook 函数。
因此你可以使用正常的控制流,而没有 React 的限制。
这是一个例子
import { formatBigIntToViewNumber, ViewNumber } from "react-display-value"
export async function displayBalanceMapper(params: {
token: Address;
chainId: number;
account: Address;
}): Promise<ViewNumber> {
const { token, chainId, account } = params;
// 并行获取元数据(永久缓存)+ balance(1 分钟缓存)
const [decimals, symbol, rawBalance] = await Promise.all([\
fetchTokenDecimals(token, chainId), // 通常命中缓存(只有第一次不命中)\
fetchTokenSymbol(token, chainId), // 通常命中缓存(只有第一次不命中)\
fetchTokenBalance(token, chainId, account), // 每分钟刷新一次\
]);
return {
// 为视图格式化(准备)你的值,不要让原始值走得更远
balanceFormatted: formatBigIntToViewNumber(rawBalance, decimals, symbol)
};
}
Mapper 成为你项目的业务逻辑大脑。
这个 mapper 简单而干净,
将它与 "hook 密集型" 方法进行比较,想象一下你尝试使用传统的 "hook 密集型" 模式构建任何更复杂的东西 - 为每个小数据点使用单独的 useQuery - 事情很快就会失控:
enabled 条件return emptyStatethrow error更糟糕的是,当你需要为任何动态事物获取数据时 - token 列表、vault、市场、position 等 - hook 密集型方法就会崩溃。在数组上进行映射现在需要 useQueries,这会引入更多的 reactivity、更多的条件,并且仍然无法干净地解决条件获取问题。
没有简单的旧式 for 循环来获取东西!
你需要用于表格的可排序数据?没问题,让我们创建 displayBalancesMapper:
export async function displayBalancesMapper(params: {
tokens: Address[];
chainId: number;
account: Address;
}): Promise<DisplayBalanceRow[]> {
const { tokens, chainId, account } = params;
if (!tokens?.length) return []; // 简单的空数组返回
// 并行启动所有 token 行;缓存有效地处理重复项
const rows = await Promise.all(
tokens.map((token) =>
displayBalanceMapper({ token, chainId, account }),
),
);
// 示例:按标准化金额降序排序(对于 UI tables)
rows.sort((a, b) => b.sortKey - a.sortKey);
return rows;
}
一旦数据到达 UI 层,一切就又变得简单了。
UI 组件应该:
UI 和 hook 应该是 "dumb" 的:
export function getDisplayBalanceQuery(
token?: Address,
chainId?: number,
account?: Address,
) {
return {
queryKey: ["useDisplayBalance", chainId, token, account] as const,
queryFn: () => displayBalanceMapper({ token: token!, chainId: chainId!, account: account! }),
enabled: Boolean(token && chainId && account),
// UI 级别的“隐藏 mapper”缓存,以减少格式化引起的重新渲染
staleTime: 30_000, // 15 秒窗口;根据需要调整 15 秒–1 分钟
};
}
export function useDisplayBalance(
token?: Address,
chainId?: number,
account?: Address,
) {
return useQuery(getDisplayBalanceQuery(token, chainId, account));
}
并且格式化应该早在渲染之前就完成了:
import { DisplayTokenValue, DisplayTokenAmount } from "react-display-value"
// 处理 loading、error 状态,而且完美地渲染值
// 示例:<span customStyleHere>$</span><span customStyleHere>34.22</span> 等
<DisplayTokenValue {...rest} {...data?.balanceFormatted} />
// 用户看到 '$42.22',可以选择单独设置 $ 符号的样式
例如,使用我们新库中的数字格式化逻辑,该库可以完成繁重的工作,并保持足够广泛,以便 mapper 可以满足任何 UI 需求:
👉 https://github.com/WingsDevelopment/react-display-value
这完全消除了对 useMemo 的需求。
你渲染的每一条数据都已经:
你在 UI 中的 useQuery 变成了 mapper 周围的 reactive 包装器:
useQuery({
queryKey: ["something", params],
queryFn: () => mapper(params),
});
没有复杂性。
没有额外的获取。
没有嵌套 hook。
这里还有另一个强大的技巧:
你可以使用一个带有短缓存窗口(例如 15-60 秒或更长)的 useQuery 来“隐藏”你的 mapper。
这样:
...将每 15-60 秒或任何你需要的时间运行一次,即使组件重新渲染多次。
这会将 mapper 变成一个迷你 "UI 缓存层",从而大大减少复杂仪表板或表格中的重新渲染和 CPU 占用。
这是一个非常简单的优化,但回报是巨大的,尤其是在数据繁重的 DeFi 应用程序中。
React Query 非常强大 - 但 web3 和链上数据获取改变了我们必须使用它的方式。
传统的做法(大量的 hook、大量的 memo、大量的 reactivity)在你应用程序增长到超出少量 RPC 调用时就会崩溃。
通过采用 Fetch → Mapper → UI 架构:
这种方法并不试图解决所有问题。
它为你提供了一个清晰、可扩展的基础,任何团队成员都可以理解、扩展和信任 - 这与大多数 web3 应用程序最终形成的脆弱的 hook 汤相反。
在一个前端必须像后端一样运行的世界中,这种架构决定了一个代码库是在自身重量下崩溃,还是优雅地增长。
本文设定了基线。接下来,我将发布简短、重点突出的深入探讨,这些探讨建立在 Fetch → Mapper → UI 模式之上:
useMemo 实际上是正确的工具
- 原文链接: dev.to/92srdjan/the-web2...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!