金数据技术博客 · №16
解决 GraphQL 类型膨胀问题,显著提升 Turbopack 构建速度
背景
金数据是一款在线表单工具的 SaaS 产品。我们的后端基于 Ruby on Rails,通过 GraphQL 对外提供接口;前端则是一个体量不小的 Next.js 应用。
产品已经运行了十年,业务模型非常复杂。这也是我们选择 GraphQL 的核心原因之一:类型系统。
GraphQL 强制要求接口定义类型,配合前端的 TypeScript,仅靠类型检查,我们就能提前规避掉大量潜在问题。保守估计,仅在类型层面就减少了 90% 的低级 bug。
另一个现实原因是:我们需要用同一套接口同时服务多个前端形态——
- legacy 旧版前端
- 新版 Next.js 前端
- 移动端 App
- Mobile Web
GraphQL 在这方面非常友好。
当然,代价也很明显:
我们的主前端仓库已经相当复杂,路由数量、代码规模都不小。
Turbopack 带来的新问题
随着项目不断增长,Webpack 在本地开发时已经明显吃力了:
- 修改一个文件,等待几十秒才能看到结果
- production build 经常需要 5~10 分钟
这对开发效率的打击非常直接。
开发体验的核心,其实就是反馈速度。
好消息是:
我们最近升级到了 Next.js 16,并正式切换到了 Turbopack。
整个切换过程出乎意料地顺利,而且效果非常明显:
- production build:1 分钟左右
- 本地开发:修改后几秒内即可看到反馈
效率提升非常显著 👍
但紧接着,一个严重的问题出现了。
“第一次请求要 70 秒?”
在本地开发时,当你第一次启动服务,然后访问第一个页面,事情开始变得不对劲。
非常慢。
慢到离谱。
$ pnpm dev
▲ Next.js 16.0.10 (Turbopack)
✓ Starting...
✓ Ready in 2.7s
○ Compiling /home ...
GET /home 200 in 79s (compile: 78s, proxy.ts: 336ms, render: 1055ms)
○ Compiling /favorites ...
GET /favorites 200 in 5.4s (compile: 5.4s, proxy.ts: 9ms, render: 17ms)
GET /home 200 in 49ms (compile: 18ms, proxy.ts: 15ms, render: 17ms)解释一下这段日志:
- 服务启动只用了 2.7 秒
- 第一次访问
/home:编译耗时 78 秒 - 第二个页面
/favorites:5.4 秒 - 再次访问
/home:49 毫秒
也就是说:
只有第一次请求,慢得像卡死了一样。
70 多秒。
我一度不知道这段时间该干嘛:
去冲杯咖啡?
还是和同事对视沉默几秒?
第一次尝试:循环依赖
直觉告诉我,问题可能出在循环依赖上。
我们并没有启用非常严格的 lint 规则,项目历史又很久,出现一些循环依赖并不奇怪。而在构建阶段,循环依赖对 compiler 来说,怎么看都不像一件好事。
于是我用 madge 扫了一下:
$ npx madge --circular --extensions ts,tsx src/
Processed 3722 files (27.8s) (1544 warnings)
✖ Found 302 circular dependencies!……
三百多个。
这个结果说实话有点震撼。
循环依赖本身就会让代码:
- 可读性变差
- 职责边界模糊
- 维护成本上升
从工程角度讲,它们本来也该被清理。
我们确实做了一轮拆分和重构,把 shared 代码抽离出来,重新梳理依赖方向。
但结果是:对这个问题完全没有帮助。
第一次 compile 的耗时,没有任何变化。
看起来,Turbopack 对循环依赖已经有了比较成熟的处理机制。
循环依赖值得解决,但它不是这次性能问题的根源。
第二次尝试:Turbopack Tracing
既然靠猜不行,那就上工具。
Next.js 为 Turbopack 提供了官方的 tracing 能力:
https://nextjs.org/docs/app/guides/local-development#turbopack-tracing
在本地启动时,加上一个环境变量即可:
$ NEXT_TURBOPACK_TRACING=1 pnpm dev再次访问 /home,问题依旧——70 多秒。
但这一次,Turbopack 会在本地生成一个 tracing 文件:.next/dev/trace-turbopack
这是一个二进制文件,不能直接查看。Next.js 提供了一个内部工具:
$ npx next internal trace .next/dev/trace-turbopack然后,通过浏览器访问:
(这个“本地起服务 + 在线 UI 查看本地 trace”的设计,老实说有点神奇 😂)
火焰图告诉了我们真相
在 tracing 页面中,把视图切换到 Span in Order,你就能看到完整的编译火焰图。
问题立刻变得非常清晰:
编译 /(shell)/(system)/(withHeader)/(dashboard)/home/page
耗时 70.36 秒
继续点进去看详情:
- 峰值内存使用:20+ GB
- 持久化占用:约 5 GB
进一步展开调用栈后,真正的“元凶”出现了。
在最底部、最耗时的两个分支里:
ReduxProvider 在加载 domain.ts
其中:
- parse domain.ts
- 单次耗时 26 秒
- 执行了两次
光解析这个文件,就吃掉了 52 秒。
domain.ts 到底是什么?
简单来说:
domain.ts 是我们通过 GraphQL Codegen 自动生成的类型文件。
我们希望前端能完整地使用 GraphQL 类型系统,让 TypeScript 的类型推导发挥最大价值,于是把 所有生成的类型 都集中在了这个文件里。
结构大概是这样:
export type Form = {
id: string
title: string
createdAt: Date
...
}
export type User = {
id: string
name: string
email: string
...
}乍一看没什么问题。
直到我看了一眼文件大小:
$ ls -la src/typings/domain.ts
14M Dec 16 10:33 src/typings/domain.ts14MB。
TypeScript 解析一个 14MB 的类型文件,耗时 26 秒,
其实非常合理。
为什么它会膨胀到这么大?
我们使用的 codegen 配置是这样的:
schema: http://localhost:3000/graphql
documents:
- 'src/lib/api/graphql/**/*.graphql'
generates:
src/typings/domain.ts:
plugins:
- 'typescript'
- 'typescript-operations'简单解释一下这两个插件的区别:
typescript- 根据 GraphQL schema
- 生成完整的类型定义
typescript-operations- 根据前端实际使用的 query / mutation
- 生成精确到字段级别的返回类型
理论上,这是一个很合理、也很常见的组合。
举个例子,假如你定义了一个 Form 类型
Form {
id
title
createdAt
}然后你的前端,有一个 query,仅仅获取 Form 的基本信息:
query GetForm {
form {
id
title
}
}这种情况下,typescript 会根据 schema 来生成一个 Form 类型,包含所有 schema 的定义:
export type Form {
id: string
title: string
createdAt: Date
}同时 typescript-operations 会根据前端的 query,生成一个这个 query 实际用到的字段,对应的类型:
export type GetFormQuery {
id: string
title: string
}这样子的好处是提供了更准确的类型定义,因为你如果调用 GetForm,实际上返回的类型,并不是整个 Form,而是你在 query中显式获取的字段。
我最初怀疑:
是不是这两个插件一起用,导致了大量类型重复?
于是我尝试只保留 typescript-operations。
结果呢?
$ ls -la src/typings/domain.ts
13M Dec 16 11:14 src/typings/domain.ts只小了一点点。
显然,问题不在这里。
真正的问题:Fragment 被“无限展开”了
直到我重新仔细看了一眼生成的 domain.ts,才发现一个被忽略已久的细节:
Fragment 的类型被完全 inline 展开了。
在业务中,我们大量使用 Fragment 来复用字段,比如:
fragment FormBasic on Form {
id
title
createdAt
}我们有 GetForm 这个 query,以及 CreateForm、UpdateForm 两个 mutation,他们都最终请求了 FormBasic 类型。
query GetForm($formToken: ID) {
form(id: $formToken) {
...FormBasic
...
}
}
mutation CreateForm($input: CreateFormInput!) {
createForm(input: $input) {
form {
...FormBasic
...
}
}
}
mutation UpdateForm($input: UpdateFormInput!) {
updateForm(input: $input) {
form {
...FormBasic
...
}
}
}但在生成的类型里,它们变成了这样:
export type GetFormQuery {
form: {
id: string
title: string
createdAt: Date
}
}
export type CreateFormMutation {
form: {
id: string
title: string
createdAt: Date
}
}
export type UpdateFormMutation {
form: {
id: string
title: string
createdAt: Date
}
}每一个使用 Fragment 的地方,类型都被重新平铺了一遍。
在一个业务复杂、字段很多、Fragment 被大量复用的系统里——
这几乎注定会导致类型文件“爆炸”。
解决方案:inlineFragmentTypes = combine
GraphQL Codegen 其实早就考虑到了这个问题。
在文档里,我找到了这个配置项:
https://the-guild.dev/graphql/codegen/plugins/typescript/typescript-operations#inlinefragmenttypes
inlineFragmentTypes: 'combine'含义很直观:
- inline(默认):Fragment 类型直接展开
- combine:复用 Fragment 类型引用
- mask:用于 Fragment Masking(这里不展开)
我们把配置改成这样:
schema: http://localhost:3000/graphql
documents:
- 'src/lib/api/graphql/**/*.graphql'
generates:
src/typings/domain.ts:
plugins:
- 'typescript-operations'
config:
inlineFragmentTypes: 'combine'重新生成类型文件。
效果立竿见影
类型变成了我们真正想要的样子:
export type FormBasicFragment {
id: string
title: string
createdAt: Date
}
export type GetFormQuery {
form: FormBasicFragment
}
export type CreateFormMutation {
form: FormBasicFragment
}
export type UpdateFormMutation {
form: FormBasicFragment
}再看文件大小:
$ ls -la src/typings/domain.ts
1.4M Dec 16 11:39 src/typings/domain.ts14MB → 1.4MB
十分之一。
再次启动本地服务:
GET /home 200 in 17.1s (compile: 15.8s)从 70 多秒,直接降到 17 秒。
是的,17 秒依然不完美。
但对于这样规模的项目来说,这已经是一个值得庆祝的进步 🎉
而且在新的 Turbopack tracing 中,parse domain.ts 已经不再是瓶颈。
后续与思考
这次优化:
- 只改了一行配置
- 不影响任何运行时代码
- 不影响业务逻辑
我们完整跑了一遍:
- lint
- type-check
- 自动化测试
全部通过。
值得一提的是,Codegen 文档里也明确说明:
大多数情况下,
inline是默认且更安全的选择。
combine 在极深嵌套、复杂 list 类型下,可能会暴露出一些类型问题。
我们会在后续继续观察、验证,并单独总结。
但至少在当前的业务形态下,它解决了一个非常真实的问题:
类型文件的无节制膨胀,正在拖慢构建系统。
总结
- 复杂 GraphQL Schema + 默认 codegen 行为
→ 生成了巨大的类型文件 - 巨型
domain.ts
→ 成为 Turbopack 编译的性能瓶颈 - 通过调整
inlineFragmentTypes
→ 类型文件体积下降 90%
→ 本地首次编译时间显著缩短
这是一行配置,换来整个团队的开发体验提升。
如果你的项目也遇到了类似的 Turbopack “第一次请求特别慢”的问题,
不妨从这里检查一下。