从 webpack 到 Turbopack:讲透 monorepo 里的 transpilePackages

如果你把项目升级成了 monorepo(pnpm / yarn / npm workspace 配合 Turborepo 之类),大概率撞过这样一幕:新建一个本地共享包 @repo/utils,在应用里 import 进来,一跑构建就炸:

bash 复制代码
Module parse failed: Unexpected token
You may need an appropriate loader to handle this file type.

或者更直白的:

bash 复制代码
SyntaxError: Unexpected token 'export'

老手会甩你一句「在 transpilePackages 里加一行就好了」。加完果然好了。但没人告诉你为什么------凭什么同样躺在 node_modules 里的包,npm 装的那些不用管,你自己写的这个却要单独「报备」一次?

更魔幻的是:等你把打包器从 webpack 换成 Turbopack,有时候这一行不加也能跑,于是你更懵了:它到底是不是必须的?

这个坑从 webpack 时代就存在,配置项从社区插件 next-transpile-modules 换成内置的 transpilePackages,名字换了,底层那个矛盾却一直没变,而且几乎没有文章把它讲透。这篇就想讲清楚:

transpilePackages 不是玄学开关。它在填一个几乎从没被明说的隐含约定的坑------打包器默认「node_modules 里的东西都是编译好的 JS」,而 monorepo 的本地包偏偏是躺在 node_modules 里、却未编译的 TS 源码。理解了这条「编译边界」,所有选项都会瞬间清晰。

一、现象:一个跨越两个打包器时代的报错

先还原一下现场。你有一个再普通不过的 monorepo:

perl 复制代码
my-monorepo/
├── apps/
│   └── web/            # Next.js 应用
└── packages/
    └── utils/          # 本地共享包 @repo/utils,全是 .ts

apps/webpackage.json 里用 workspace 协议装了它:

json 复制代码
{
  "dependencies": {
    "@repo/utils": "workspace:*"
  }
}

包管理器会在 apps/web/node_modules/@repo/utils 建一个软链接(symlink),指向 packages/utils。看起来一切正常,import { foo } from '@repo/utils' 也能被编辑器正确解析、类型也对。可一旦构建,就报上面那个 Unexpected token

按网上搜到的答案,在 next.config.ts 里补一行:

ts 复制代码
const nextConfig = {
  transpilePackages: ['@repo/utils'],
};

问题消失。但真正让人抓狂的是它的不一致

  • 用 webpack 打包,不加这行必炸;
  • 换成 Turbopack(Next.js 16 已是默认打包器),有时候不加也能构建通过。

同一份代码、同一个包,只是换了打包器,结论就反过来了。这种「时灵时不灵」最消耗信任------你不知道它到底在保护你什么。要解开这个结,得先回到那条没人明说的约定上。

二、根因:node_modules 那条「编译边界」

现代前端打包器(webpack、Turbopack、Vite 背后的 esbuild/Rollup 都一样)为了构建性能,都遵守一条心照不宣的约定:

你自己写的源码(src/)一定编译;node_modules 里的依赖,默认当作「已经编译好、可以直接用的 JS」,跳过不编译。

这条约定在 webpack 时代是写死在几乎每份配置模板里的一行 babel-loader 规则:

js 复制代码
{
  test: /\.[jt]sx?$/,
  exclude: /node_modules/,   // 关键:node_modules 一律不过 babel
  use: 'babel-loader',
}

为什么敢这么武断地跳过?因为在「只从 npm 安装第三方包」的世界里,这个假设几乎永远成立------发布到 npm 的包,作者理应已经把 TS / JSX / 现代语法编译成了目标环境能直接跑的 JS(这也是为什么发布包都有个 dist/)。跳过 node_modules 能省下海量编译时间,是笔非常划算的买卖。

monorepo 恰好把这条约定的前提打破了。

包管理器的 workspace 功能,是用 symlink 把本地包「假装」成一个已安装的依赖塞进 node_modules。于是出现了一个精分的状态:

  • 路径 上看,@repo/utilsnode_modules 里 → 打包器按约定跳过它;
  • 内容 上看,它是你上周刚写的、一行没编译的 .ts 源码 → 里面全是 export / 类型注解这些浏览器和 Node 直接跑会报错的语法。

打包器以为拿到的是熟牛肉,实际端上来的是生的。它不编译、直接塞进产物,运行时/解析时一碰到 TS 语法,就抛出那句 Unexpected token

graph TD A[打包器遇到一个模块] --> B{它在 node_modules 里吗} B -->|不在 视为项目源码| C[用 SWC Turbopack 编译成可运行 JS] B -->|在 视为已编译依赖| D[跳过编译 直接打包] E[本地 workspace 包<br/>symlink 进 node_modules 但仍是 TS 源码] --> B D --> F[碰到 TS 语法 抛出 Unexpected token]

看懂这张图,后面所有选项其实都是在回答同一个问题:怎么让这个躺在边界内、身体却还是源码的包,被正确对待。

三、transpilePackages:给边界开的一道白名单

顺着上面的逻辑,最直接的补法就是:告诉打包器「这几个 node_modules 里的包是例外,请照样编译它们」。这就是 transpilePackages 干的事。

它的前身是 webpack 时代的社区插件 next-transpile-modules------那几年做 Next.js monorepo 的人几乎都装过它,也被它的各种边界 case 折磨过(嵌套依赖、CSS、Symlink 解析都出过问题)。Next.js 从 13.1 起把这个能力内置成官方的 transpilePackages 配置,社区插件随之退役。

它的语义很直白,官方文档一句话概括:自动转译并打包来自本地包(monorepo)或外部依赖的源码。翻译成人话就是:

transpilePackages 是一份「编译边界的例外名单」。名单里的 node_modules 包,打包器不再跳过,而是当成你自己的源码一样过一遍编译。

所以它一点都不玄。你每加一个导出源码的本地包,就得往这份名单里补一个名字------这也正是很多人隐约觉得「不对劲」的地方:难道每加一个共享包,都要手动维护这份名单? 先别急,这个问题第五节有更好的答案。

四、为什么 Turbopack 下常常「不加也行」

回到那个最让人困惑的现象:换了 Turbopack,不加 transpilePackages 有时也能构建。

我在一个 pnpm + Turborepo + Next.js 16(Turbopack)的 monorepo 里实测过:一个导出 .ts 源码的本地包,没有 放进 transpilePackagesnext build 依然通过。这和 webpack 时代「不加必炸」的经验完全相反。

我没有做到逐层隔离去证死它的内部实现,但结合行为可以给出一个可信的解释:pnpm 的 workspace 包是 symlink,Turbopack 在解析模块时会顺着 symlink 找到真实路径packages/utils/src/...)。这个真实路径在仓库根目录内、并不在 node_modules 的物理目录下,于是 Turbopack 倾向于把它当作「项目源码」直接编译,而不是当作「已编译依赖」跳过。换句话说,Turbopack 判断编译边界看的更像是「解析后的真实位置」,而 webpack 传统上看的是「模块说明符是不是 node_modules 依赖」。

听起来是好事,但我的结论是:能 work,不代表你该依赖它。 至少三个理由:

  • 这不是官方承诺的稳定契约。 symlink 解析、编译边界判定属于打包器实现细节,可能随版本、随配置(比如是否保留 symlink)而变。你在赌一个没写进文档的行为。
  • 构建回退路径会翻车。 很多团队保留了 next build --webpack 作为 Turbopack 出问题时的应急出口。一旦切回 webpack,它对 symlink 依赖的处理方式不同,那个「不加也行」的包很可能立刻炸给你看。
  • 换个 import 姿势就可能失效。 上面那个包之所以能被顺带编译,很可能是因为它是被另一个已在名单里 的包 re-export 进来的(顺着编译链被带上了)。哪天某个 app 直接 import '@repo/utils',编译链断了,边界问题就原形毕露。

所以,把 transpilePackages 显式写上,是「跨打包器都稳、且对回退友好」的防御性做法。但如果你觉得「每加一个包都要维护名单」这件事本身就不对------恭喜,你的直觉是对的,下一节换个思路。

五、别停在打补丁:内部包的三种编译策略

transpilePackages 只是某一种包组织策略下的必要配置。换一种策略,它就根本不需要存在。Turborepo 官方把内部包(Internal Packages)的组织方式清晰地分成了三类,这才是「最佳实践」层面的答案。

它们的核心区别,就在包的 package.jsonexports 字段指向源码还是指向产物

策略 exports 指向 需要消费方 transpile 吗 有 build 步骤 dev 体验 能被 Turbo 缓存
Just-in-Time(JIT) src/*.ts 源码 需要(如 Next 的 transpilePackages) 最好,改源码 HMR 直达 否(没有 build 可缓存)
Compiled 条件导出:类型指 src、运行指 dist 不需要(消费的是编译好的 JS) 有(tsc 等) 改源码要重新 build
Publishable 同 Compiled,额外加发布配置 不需要 同 Compiled

JIT 包长这样------直接把 TS 源码当入口暴露出去:

json 复制代码
{
  "name": "@repo/utils",
  "exports": {
    "./format": "./src/format.ts"
  }
}

它最省事、没有 build 步骤,改一行源码应用侧 HMR 立刻生效,dev 体验最好。代价就是那条边界问题:消费它的应用必须自己负责编译它(Next 里就得配 transpilePackages)。另外 Turborepo 也提醒,JIT 包因为没有独立的 build 步骤,无法被缓存 ;而且它不能用 TypeScript 的 paths 别名(因为编译发生在消费方,而不是包自己)。

Compiled 包 则自己负责编译,exports 用条件导出把「类型」和「运行代码」分开指:

json 复制代码
{
  "name": "@repo/utils",
  "exports": {
    "./format": {
      "types": "./src/format.ts",
      "default": "./dist/format.js"
    }
  },
  "scripts": {
    "build": "tsc"
  }
}

这一步是关键:types 仍指向 src,所以你在应用里跳转定义、看类型,依然能直达源码、体验不打折;而 import / default 指向 dist已经编译好的 JS ------于是它对消费方来说就是一个「正常的、编译好的依赖」,完全落在编译边界的正确一侧,根本不需要 transpilePackages。构建产物还能被 Turborepo 缓存,二次构建直接命中。

Publishable 包 是 Compiled 的严格超集:既然要发布到 npm 给不可控的下游用,就得处理最完整的配置,通常还会用 changesets 管版本、生成 changelog 和发布流程。

一句话点破:

是否需要 transpilePackages,取决于「包以什么形态对外暴露」,而不是「包是不是本地的」。让包暴露编译好的产物,边界问题自动消失。

六、怎么选:内部走 JIT,对外走 Compiled

有了这个框架,选择就不再靠感觉:

  • 只被自家应用消费、迭代很快、也不打算发布 → 用 JIT 。享受 HMR 直达的 dev 体验,接受在每个应用里配一次 transpilePackages。绝大多数「就想抽点公共代码」的内部包都属于这一类。
  • 要对外发布、或被多个应用/团队稳定消费、或你在意构建缓存 → 用 Compiled / Publishable 。用条件导出让「类型走源码、运行走产物」,从此和 transpilePackages 说再见。发布包本来就得有 dist,天然适合这条路。

回到开头那个隐藏的痛点------「难道每加一个发布包都要手动维护 transpilePackages 名单?」答案是:不是 。那只是 JIT 策略的副作用。发布包走 Compiled 形态,内外消费的都是 dist,名单自然就不用维护了。

当然,Compiled 不是白拿的,它的 tradeoff 是多了一个 build 步骤 :改了包源码,得重新 build 应用才能看到(不像 JIT 那样 HMR 直达)。好在 Turborepo 的任务编排能兜住这件事------用 dependsOn 声明「应用构建依赖包的构建」,让流水线自动按拓扑顺序先 build 包、再 build 应用:

json 复制代码
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"]
    }
  }
}

^build 表示「先跑所有上游依赖的 build」。dev 时则可以配一个 watch 模式的构建让改动准实时生效。这也解释了为什么纯内部包更爱 JIT(要 dev 爽),对外发布包更爱 Compiled(要边界干净、要缓存)------两种策略服务两种诉求,不是谁取代谁。

七、为什么这件事一直这么难懂

绕了一圈,值得回头想想:一个本质上就是「node_modules 默认不编译」的约定,为什么能困扰前端这么多年、从 webpack 一路困扰到 Turbopack?

我觉得有三个原因叠加:

  • 前提是隐含的。node_modules 是编译好的产物」这条假设,是打包器为性能做的默认取舍,却几乎从没被写进任何入门文档的第一页。你享受了它带来的构建提速,却不知道自己在依赖这个假设,直到 monorepo 把它捅破。
  • 报错在误导你。 Unexpected token 'export' 指向的是「语法」,让你以为是 Babel/TS 配置、target 版本、loader 顺序的问题,满世界翻这些,而真正的原因是「这个模块压根没被送进编译器」------报错的表象和根因隔了一层。
  • 配置项只说 what,不说 why。 next-transpile-modulestranspilePackages 这些名字都在描述「要做什么」(去 transpile 这些 modules),没有一个在解释「为什么需要」。你照着配好了,问题解决了,认知却没建立起来,下次换个场景照样懵。

而一旦你把它归结成一句话------「编译边界默认切在 node_modules 门口,monorepo 的本地包骑在了门槛上」 ------所有解法就都是同一个问题的两个方向:要么把这个包拉进边界内 (用 transpilePackages 让打包器编译它),要么把它变成边界外本该有的样子 (Compiled,自己编译好、暴露 dist)。

概念不难,难的是从没有人把那条边界指给你看。希望这篇把它指清楚了。

参考与数据源

  • Turborepo 官方文档,Internal Packages(JIT / Compiled / Publishable 三种策略与各自 tradeoff):turborepo.com/docs/core-c...
  • Next.js 官方文档,transpilePackages 配置:nextjs.org/docs/app/ap...
  • Next.js 13.1 发布公告(内置 transpilePackages,取代社区插件 next-transpile-modules):nextjs.org/blog/next-1...
  • Turborepo 官方文档,任务依赖与 dependsOn / ^build 拓扑编排:turborepo.com/docs/crafti...
  • 本文第四节的 Turbopack 行为为作者在 pnpm + Turborepo + Next.js 16 环境下的实测观察,涉及打包器实现细节,请以你所用版本的实际构建结果为准。
相关推荐
花椒技术19 小时前
告别狂野式 AI Coding:Harness Engineering 在 H5 项目里的最小闭环实践
agent·ai编程·前端工程化
Patrick_Wilson1 天前
最佳实践是有保质期的:从一次 CDN external 白屏事故说起
前端·性能优化·前端工程化
程序员晓琪1 天前
深入解析:从 Webpack 到 Vite,多入口多端项目的工程化迁移实战
前端·webpack·vite
xiaofeichaichai2 天前
Vite原理与Webpack对比
前端·webpack·node.js
gis开发之家2 天前
vue3组合式函数(Composables)的艺术——将业务逻辑从UI组件中连根拔起
前端·javascript·vue.js·前端框架·vue3·前端工程化·前端架构
至乐活着2 天前
前端工程化最佳实践:从规范到自动化构建的全面指南
webpack·自动化·代码规范·构建工具·前端工程化
漂流瓶jz2 天前
理解Webpack插件机制:从插件使用、各类编译对象、Tapable到自定义插件与钩子开发
前端·javascript·webpack
倾颜3 天前
给 AI Chat 加上长期记忆:模型提取 + 程序把关 + 规则召回
llm·agent·next.js