金数据技术博客 · №16

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

· flanker · frontend / nextjs / GraphQL · English version

English Version

背景

金数据是一款在线表单工具的 SaaS 产品。我们的后端基于 Ruby on Rails,通过 GraphQL 对外提供接口;前端则是一个体量不小的 Next.js 应用。

产品已经运行了十年,业务模型非常复杂。这也是我们选择 GraphQL 的核心原因之一:类型系统

GraphQL 强制要求接口定义类型,配合前端的 TypeScript,仅靠类型检查,我们就能提前规避掉大量潜在问题。保守估计,仅在类型层面就减少了 90% 的低级 bug。

另一个现实原因是:我们需要用同一套接口同时服务多个前端形态——

GraphQL 在这方面非常友好。

当然,代价也很明显:
我们的主前端仓库已经相当复杂,路由数量、代码规模都不小。

Turbopack 带来的新问题

随着项目不断增长,Webpack 在本地开发时已经明显吃力了:

这对开发效率的打击非常直接。
开发体验的核心,其实就是反馈速度。

好消息是:
我们最近升级到了 Next.js 16,并正式切换到了 Turbopack

整个切换过程出乎意料地顺利,而且效果非常明显:

效率提升非常显著 👍

但紧接着,一个严重的问题出现了。

“第一次请求要 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)

解释一下这段日志:

也就是说:

只有第一次请求,慢得像卡死了一样。

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

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

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

Image

ReduxProvider 在加载 domain.ts

其中:

光解析这个文件,就吃掉了 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'

简单解释一下这两个插件的区别:

理论上,这是一个很合理、也很常见的组合。

举个例子,假如你定义了一个 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'

含义很直观:

我们把配置改成这样:

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

后续与思考

这次优化:

我们完整跑了一遍:

全部通过。

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

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

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

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

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

总结

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

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