tva
← 洞察

使用 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 中。发现某个组件发出过多请求,或某条查询实际上从未被使用,只需几秒,无需分析网络请求瀑布图。

拖动和调整大小,是让仪表盘显得精致的可见交互部分,但相比数据基础确实是次要的。任何功能完善的网格库都能处理定位。组件式仪表盘能否在生产环境中稳定使用,取决于数据层是否支持故障时平稳降级,以及各组件独立更新。这种架构让这样的设计成为自然结果,无需额外费力实现。

对于构建客户门户或内部分析工具的团队,这一模式无需修改即可推广使用。组件抽象不必配合复杂的拖放界面才有价值:即使只是可配置的数据卡片网格,也能从查询隔离和组件独立的错误边界中受益。网格布局是增强功能,数据架构才是基础。

相关洞察

延伸阅读