You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何组织前端TypeScript服务调用及关联后端响应的类型?

问题场景与解决方案

问题背景

我有一个带两个外键的数据库表:

CREATE TABLE example (
  id SERIAL PRIMARY KEY,
  foo_id INT REFERENCES foo(foo_id),
  bar_id INT REFERENCES bar(bar_id),
  ...
);

用TypeScript封装了轻量的fetch API包装器,当前对应数据库结构的类型定义是:

type Example = {
  id: number;
  foo_id: number;
  bar_id: number;
  ...
}

POST、PATCH等请求/响应类型都是基于Example,用Partial、Omit这类工具类型来定义。

现在遇到的问题是:要友好展示Example数据时,得把foo_id和bar_id转换成包含展示字段(比如name)的Foo和Bar对象。用React Query的话,需要额外发起两次查询,还要处理更多错误和加载状态。

我考虑过把前端类型改成这样:

type Example = {
  id: number;
  foo: Foo;
  bar: Bar;
  ....
}

让后端返回完整的Foo和Bar对象(比如通过关联查询),但这样会传递很多可能不需要的冗余数据;如果让每个接口返回不同结构,又得为每种结构写前端类型,POST、PATCH这类操作的类型定义也会变乱(比如POST只需要ID而非完整对象,得单独定义类型)。

有人建议用Prisma/GraphQL解决,但我想知道在不能用这些方案、也不能用代码生成器的情况下,该怎么处理这个场景。


可行解决方案

方案1:拆分前端类型,区分"存储型"与"展示型"

不要只用一个Example类型,拆成两个核心类型,各司其职:

  • ExampleRaw:完全对应数据库结构,用于接口的POST/PATCH请求、原始响应
    type ExampleRaw = {
      id: number;
      foo_id: number;
      bar_id: number;
      ...
    }
    
  • ExampleDisplay:用于前端展示的类型,只包含关联实体的必要展示字段,避免冗余
    type ExampleDisplay = Omit<ExampleRaw, 'foo_id' | 'bar_id'> & {
      foo: Pick<Foo, 'id' | 'name'>;
      bar: Pick<Bar, 'id' | 'name'>;
    }
    

这样做的好处:

  • POST/PATCH请求直接用Partial<ExampleRaw>或Omit<ExampleRaw, 'id'>,保持简洁
  • 可以新增一个专门用于展示的接口(比如GET /examples/display/:id),让后端返回ExampleDisplay结构,只传需要的关联字段
  • 如果不想新增接口,也可以在前端拿到ExampleRaw后,复用已缓存的Foo/Bar数据(比如React Query的缓存)来转换,避免重复请求

方案2:封装自定义Hook,合并查询与转换逻辑

基于现有的ExampleRaw类型,写一个自定义Hook把原始数据转换成展示结构,同时利用React Query的缓存和状态合并能力,减少上层组件的复杂度:

// 假设已有获取单个Example、Foo、Bar的基础Query Hook
const useExampleDisplay = (exampleId: number) => {
  const { data: exampleRaw, isLoading, isError, error } = useExampleQuery(exampleId);
  // 只有拿到exampleRaw后才发起关联查询,避免无效请求
  const { data: foo } = useFooQuery(exampleRaw?.foo_id, { enabled: !!exampleRaw });
  const { data: bar } = useBarQuery(exampleRaw?.bar_id, { enabled: !!exampleRaw });

  const exampleDisplay = useMemo(() => {
    if (!exampleRaw || !foo || !bar) return null;
    return {
      ...exampleRaw,
      foo: { id: foo.id, name: foo.name },
      bar: { id: bar.id, name: bar.name },
      foo_id: undefined,
      bar_id: undefined
    } as ExampleDisplay;
  }, [exampleRaw, foo, bar]);

  return { data: exampleDisplay, isLoading, isError, error };
};

上层组件只需要调用这个Hook,不用关心内部的多次查询和状态处理,相当于把关联逻辑封装成了黑盒。

方案3:让后端支持可选关联字段

和后端协商,在查询接口中增加参数控制是否返回关联数据,比如GET /examples/:id?includeFoo=true&includeBar=true,后端根据参数决定是否做关联查询、返回哪些字段。

前端对应定义可选的联合类型,覆盖所有可能的响应结构:

type ExampleBase = Omit<ExampleRaw, 'foo_id' | 'bar_id'>;
type ExampleWithFoo = ExampleBase & { foo_id: number; foo: Pick<Foo, 'id' | 'name'> };
type ExampleWithBar = ExampleBase & { bar_id: number; bar: Pick<Bar, 'id' | 'name'> };
type ExampleWithAll = ExampleBase & { foo: Pick<Foo, 'id' | 'name'>; bar: Pick<Bar, 'id' | 'name'> };
type ExampleResponse = ExampleRaw | ExampleWithFoo | ExampleWithBar | ExampleWithAll;

请求时根据需求传参数,前端拿到数据后用类型守卫判断结构,或者直接在展示组件中处理可选字段即可。

方案4:复用全局缓存的关联数据

如果Foo和Bar是数据量不大、不会频繁更新的常用数据,可以提前把所有需要的Foo/Bar数据缓存到全局状态(比如React Context、Zustand等),当拿到ExampleRaw后直接从缓存中取对应的对象,无需发起新请求:

// 假设全局缓存中有fooList和barList
const getExampleDisplay = (exampleRaw: ExampleRaw, fooList: Foo[], barList: Bar[]): ExampleDisplay => {
  const foo = fooList.find(f => f.id === exampleRaw.foo_id);
  const bar = barList.find(b => b.id === exampleRaw.bar_id);
  // 容错处理:关联数据不存在时用默认值
  return {
    ...exampleRaw,
    foo: foo ? { id: foo.id, name: foo.name } : { id: exampleRaw.foo_id, name: '未知' },
    bar: bar ? { id: bar.id, name: bar.name } : { id: exampleRaw.bar_id, name: '未知' }
  };
};

这种方案适合后台管理系统这类场景,能彻底避免额外查询的开销。


内容的提问来源于stack exchange,提问作者Tom

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.21 16:36:20