如何组织前端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

