Skip to content

解决 GraphQL 类型膨胀问题,显著提升 Turbopack 构建速度 #16

Description

@flanker

English Version

背景

金数据是一款在线表单工具的 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

然后,通过浏览器访问:

https://trace.nextjs.org/

(这个“本地起服务 + 在线 UI 查看本地 trace”的设计,老实说有点神奇 😂)

火焰图告诉了我们真相

在 tracing 页面中,把视图切换到 Span in Order,你就能看到完整的编译火焰图。

Image

问题立刻变得非常清晰:

编译 /(shell)/(system)/(withHeader)/(dashboard)/home/page
耗时 70.36 秒

继续点进去看详情:

Image
  • 峰值内存使用:20+ GB
  • 持久化占用:约 5 GB

进一步展开调用栈后,真正的“元凶”出现了。

在最底部、最耗时的两个分支里:

Image

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.ts

14MB。

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.ts

14MB → 1.4MB

十分之一。

再次启动本地服务:

GET /home 200 in 17.1s (compile: 15.8s)

从 70 多秒,直接降到 17 秒。

是的,17 秒依然不完美。
但对于这样规模的项目来说,这已经是一个值得庆祝的进步 🎉

而且在新的 Turbopack tracing 中,parse domain.ts 已经不再是瓶颈。

Image

后续与思考

这次优化:

  • 只改了一行配置
  • 不影响任何运行时代码
  • 不影响业务逻辑

我们完整跑了一遍:

  • lint
  • type-check
  • 自动化测试

全部通过。

值得一提的是,Codegen 文档里也明确说明:

大多数情况下,inline 是默认且更安全的选择。

combine 在极深嵌套、复杂 list 类型下,可能会暴露出一些类型问题。
我们会在后续继续观察、验证,并单独总结。

但至少在当前的业务形态下,它解决了一个非常真实的问题:

类型文件的无节制膨胀,正在拖慢构建系统。

总结

  • 复杂 GraphQL Schema + 默认 codegen 行为
    → 生成了巨大的类型文件
  • 巨型 domain.ts
    → 成为 Turbopack 编译的性能瓶颈
  • 通过调整 inlineFragmentTypes
    → 类型文件体积下降 90%
    → 本地首次编译时间显著缩短

这是一行配置,换来整个团队的开发体验提升。

如果你的项目也遇到了类似的 Turbopack “第一次请求特别慢”的问题,
不妨从这里检查一下。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions