如果你把项目升级成了 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/web 的 package.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/utils在node_modules里 → 打包器按约定跳过它; - 从内容 上看,它是你上周刚写的、一行没编译的
.ts源码 → 里面全是export/ 类型注解这些浏览器和 Node 直接跑会报错的语法。
打包器以为拿到的是熟牛肉,实际端上来的是生的。它不编译、直接塞进产物,运行时/解析时一碰到 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 源码的本地包,没有 放进 transpilePackages,next 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.json 里 exports 字段指向源码还是指向产物:
| 策略 | 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-modules、transpilePackages这些名字都在描述「要做什么」(去 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 环境下的实测观察,涉及打包器实现细节,请以你所用版本的实际构建结果为准。