使用 TanStack Query 构建可自定义的组件式仪表盘
多数产品仪表盘的构建方式都一样:顶层组件发出十个 API 请求,等待全部完成,再一次性渲染所有内容。演示时这看起来没问题,但到了生产环境,一类问题会随着时间不断累积。一个慢端点就能阻塞整个视图,一个组件的临时错误就能使整个页面失效。实时更新需要协调重新获取数据,最终会变得难以维护。每添加一个组件,都必须梳理原本并未为它设计的全局状态。
TanStack Query(原名 React Query)将数据获取视为核心设计问题,而不是事后附加到组件状态上的逻辑,从而解决这些问题。每个查询都有自己的缓存键、生命周期和错误状态。应用到组件式仪表盘中,就是每个组件负责自己的数据,以及加载、错误和成功状态。应用的其他部分无需了解或处理这些细节。
将布局与数据分离
编写任何数据获取逻辑之前,架构必须明确区分两个关注点:组件获取什么数据,以及组件放在哪里。两者可以独立变化:用户移动组件,不会改变它显示的数据;后端返回新数据,也不会影响布局。将两者混在一起,会在后续造成协调问题。
interface WidgetConfig {
id: string;
type: 'revenue' | 'orders' | 'users' | 'activity-feed';
title: string;
queryKey: string[];
refetchInterval?: number;
}
interface WidgetLayout {
i: string; // matches WidgetConfig.id
x: number;
y: number;
w: number;
h: number;
}
WidgetConfig 中的 queryKey 字段连接着 TanStack Query 的缓存。两个组件使用相同的 queryKey,就会共享缓存结果:一次网络请求,供两个组件使用。键不同的组件则完全独立。在多个视图展示相同底层数据的组合式仪表盘中,这一区别很重要。
每个组件独立获取数据
每个组件用自己的配置调用 useQuery。staleTime 告诉 TanStack Query,缓存数据在被标记为过期前,可以保持有效多久。按组件类型合理设置这个值,可以避免不必要的重新获取,同时保持数据的时效性。
function RevenueWidget({ config }: { config: WidgetConfig }) {
const { data, isLoading, isError } = useQuery({
queryKey: config.queryKey,
queryFn: () => fetchRevenue(),
staleTime: 60_000,
refetchInterval: config.refetchInterval,
});
if (isLoading) return <WidgetSkeleton />;
if (isError) return <WidgetError />;
return (
<div className="widget-content">
<p className="text-3xl font-bold">{formatCurrency(data.total)}</p>
<p className="text-sm text-muted">vs last period: {data.changePercent}%</p>
</div>
);
}
骨架屏和错误状态有意限定在各自组件内部。没有全局加载指示器,也没有在应用层捕获组件故障的错误处理器。每个组件自行向用户展示状态:收入组件显示加载骨架屏,同时订单组件展示实时数据,是正确且符合预期的行为,并不表示出了问题。
每个组件独立设置错误边界
useQuery 的 isError 状态处理数据获取错误,但组件执行期间抛出的渲染错误,需要 React 的错误边界机制。给每个组件单独包一层错误边界,可以避免某个组件在渲染时抛错导致整个仪表盘崩溃。故障组件显示可恢复的错误状态时,用户仍能继续使用其他组件。
class WidgetErrorBoundary extends React.Component<
{ children: React.ReactNode; widgetId: string },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<div className="widget-error">
<p>This widget encountered an error.</p>
<button onClick={() => this.setState({ hasError: false })}>
Retry
</button>
</div>
);
}
return this.props.children;
}
}
function Widget({ config }: { config: WidgetConfig }) {
return (
<WidgetErrorBoundary widgetId={config.id}>
<WidgetRouter config={config} />
</WidgetErrorBoundary>
);
}
重试按钮会重置错误边界状态,让用户无需刷新整页就有机会恢复。实际使用中,组件的大多数渲染错误由 API 返回了意外的空值数据结构引起;触发新查询的重试往往能解决问题。请注意,重置边界只会重新渲染组件,本身不会重新获取数据;如果缓存数据仍处于新鲜状态,组件就会使用相同的数据再次渲染。要通过新查询重试,还需在重试处理函数中重置组件的查询或重新获取其数据(例如使用 queryClient.resetQueries({ queryKey: config.queryKey })),或者在将查询错误抛给边界时使用 TanStack Query 的 QueryErrorResetBoundary。
拖动与调整大小
拖动和调整大小的交互机制,相比数据层是次要的。react-grid-layout 很适合处理定位:它接收布局数组,将子元素渲染到网格位置,并在用户移动组件或调整大小时调用 onLayoutChange。实现很直接。
import GridLayout from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
function Dashboard({ widgets, layouts, onLayoutChange }: DashboardProps) {
return (
<GridLayout
layout={layouts}
cols={12}
rowHeight={60}
width={1200}
onLayoutChange={onLayoutChange}
draggableHandle=".widget-drag-handle"
>
{widgets.map(config => (
<div key={config.id}>
<div className="widget-drag-handle cursor-grab">
<span>{config.title}</span>
</div>
<Widget config={config} />
</div>
))}
</GridLayout>
);
}
draggableHandle 属性值得注意。如果不设置它,点击组件任何位置都会开始拖动,与组件内的图表、按钮和输入框等交互元素冲突。将拖动限定在指定区域,例如标题栏或拖动图标,就能直接解决这一冲突。
持久化布局状态
布局持久化分为两层:即时保存,避免页面刷新后丢失排列;服务端保存,让布局随用户跨设备使用。两者需要不同实现,并且需要有意识地协调组合,避免本地副本和服务器副本相互覆盖。
对于即时保存,localStorage 很合适,而且无需网络往返:
function useDashboardLayout(initialLayouts: WidgetLayout[]) {
const storageKey = 'dashboard-layout';
const [layouts, setLayouts] = useState<WidgetLayout[]>(() => {
try {
const stored = localStorage.getItem(storageKey);
return stored ? JSON.parse(stored) : initialLayouts;
} catch {
return initialLayouts;
}
});
const handleLayoutChange = (newLayouts: WidgetLayout[]) => {
setLayouts(newLayouts);
localStorage.setItem(storageKey, JSON.stringify(newLayouts));
};
return { layouts, handleLayoutChange };
}
对于跨设备保存,将布局视为服务器状态,通过 useQuery 获取、通过 useMutation 更新,可以自然融入现有的 TanStack Query 配置。保存操作采用防抖,避免拖动时每移动一个像素都发出网络请求。下面的 hook 只负责保存;要让返回的 layouts 与用户刚调整的排列保持一致,还需将新布局写入缓存(例如使用 queryClient.setQueryData(['dashboard-layout', userId], newLayouts)),或者在保存成功后使查询失效。
function usePersistedLayout(userId: string, initialLayouts: WidgetLayout[]) {
const { data: layouts } = useQuery({
queryKey: ['dashboard-layout', userId],
queryFn: () => fetchUserLayout(userId),
initialData: initialLayouts,
});
const { mutate: saveLayout } = useMutation({
mutationFn: (newLayouts: WidgetLayout[]) =>
saveUserLayout(userId, newLayouts),
});
const debouncedSave = useMemo(
() => debounce(saveLayout, 1000),
[saveLayout]
);
return { layouts, handleLayoutChange: debouncedSave };
}
实时更新
refetchInterval 选项让每个组件独立控制轮询新数据的频率。活动动态可能需要每十秒更新,月度收入汇总每小时刷新一次就够了。常见替代方案是全局轮询,但这会迫使所有组件使用相同间隔:通常要么为了满足要求最高的组件而过度获取数据,要么获取不足,让时效要求较高的组件显示过期数据。
const WIDGET_REFRESH_INTERVALS: Record<WidgetConfig['type'], number | undefined> = {
'activity-feed': 10_000,
'orders': 30_000,
'revenue': 3_600_000,
'users': 300_000,
};
const config: WidgetConfig = {
id: 'orders-widget',
type: 'orders',
title: 'Recent Orders',
queryKey: ['orders'],
refetchInterval: WIDGET_REFRESH_INTERVALS['orders'],
};
不过,不加区分地轮询也会产生问题:大量组件以短间隔轮询,会产生可观的网络流量,浏览器标签页处于后台时也不例外。TanStack Query 提供了直接的解决办法:refetchIntervalInBackground: false 会暂停隐藏标签页的轮询。结合默认启用的 refetchOnWindowFocus: true,即可覆盖大多数使用场景:用户实际操作时保持数据更新,未操作时暂停网络请求。
组件路由器与全局配置
用一个中心组件将组件类型映射到具体实现,可以将渲染逻辑集中在一处。新增类型只需编写组件并添加一个分支,其他地方无需改动。
function WidgetRouter({ config }: { config: WidgetConfig }) {
switch (config.type) {
case 'revenue': return <RevenueWidget config={config} />;
case 'orders': return <OrdersWidget config={config} />;
case 'users': return <UsersWidget config={config} />;
case 'activity-feed': return <ActivityFeedWidget config={config} />;
default: return <div>Unknown widget type</div>;
}
}
全局 QueryClient 配置提供默认值,各组件可以覆盖。对于仪表盘,默认设置 30 秒的 staleTime,既能避免频繁的后台重新获取,也能在用户离开后返回时保持数据较新。
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 2,
refetchOnWindowFocus: true,
refetchIntervalInBackground: false,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Dashboard />
</QueryClientProvider>
);
}
这种架构能避免哪些问题
这种架构避免的问题,与它支持的能力同样重要。全局加载状态不再必要:不存在“所有数据”都在加载的时刻,只有各组件在加载。级联故障的可能性大幅降低:每个错误边界都限定在单个组件内,因此一个组件的渲染错误不会导致其他组件崩溃;使用不同查询键的组件也不会共享数据获取状态。有意共享同一查询键的组件也会共享该查询的数据和错误状态,因此该查询发生故障时,会影响使用同一键的所有组件。添加新组件时,无需修改共享状态,也不必与现有组件协调数据需求。
性能分析也更容易开展。通过各组件的查询键,可以明确判断网络活动属于哪个组件;这些键会显示在 TanStack Query DevTools 中。发现某个组件发出过多请求,或某条查询实际上从未被使用,只需几秒,无需分析网络请求瀑布图。
拖动和调整大小,是让仪表盘显得精致的可见交互部分,但相比数据基础确实是次要的。任何功能完善的网格库都能处理定位。组件式仪表盘能否在生产环境中稳定使用,取决于数据层是否支持故障时平稳降级,以及各组件独立更新。这种架构让这样的设计成为自然结果,无需额外费力实现。
对于构建客户门户或内部分析工具的团队,这一模式无需修改即可推广使用。组件抽象不必配合复杂的拖放界面才有价值:即使只是可配置的数据卡片网格,也能从查询隔离和组件独立的错误边界中受益。网格布局是增强功能,数据架构才是基础。
相关洞察
- 将 React 应用部署到生产环境:使用 Traefik 反向代理的完整 Docker 配置——仪表盘构建完成后,通过 Traefik 的容器化部署可处理 HTTPS 和路由,无需手动管理证书。
- WooCommerce 性能优化:将加载时间从 8 秒缩短至 0.2 秒——数据获取架构的选择会带来可测量的性能影响;适用于仪表盘组件的原则,也适用于任何数据密集型前端。
- 为 Amazon 卖家构建可用于生产环境的数据基础设施:介绍 tva-fetch——为仪表盘供给数据的后端基础设施,决定了 TanStack Query 能缓存和提供的数据质量。
- 应对 CVE-2025-55182:我们处理 React Server Components 漏洞的经验——React 不断扩展的服务端能力增加了攻击面,而本文这样的客户端仪表盘架构有意避开了这些攻击面。