返回博客

在 monorepo 之外消费自家 Tailwind v4 设计系统包

这个站本身就是 hulianui 的 dogfood 场。但它是一个独立的 git 仓库,不在组件库的 monorepo 里。我想要的循环很贪心:改库里的源码,应用这边立刻看到变化——不发版、不 build、所见即所得。

要做到这一点,应用得直接吃隔壁 workspace 里以 TS 源码形态分发的包(exports 指向 ./src/index.ts,靠 Next 的 transpilePackages 现场编译),而 tokens 包的 preset.css 里还有一句 @import "tailwindcss"。这条路上有三个不那么显然的坑,按你会撞到的顺序来。

坑 0:用 pnpm link:,别手动 symlink、别用 file:

应用的 package.json 里这样写依赖:

"@scope/ui":     "link:../ds-monorepo/packages/ui",
"@scope/tokens": "link:../ds-monorepo/packages/tokens",
  • 手动 ln -snode_modules/@scope/ui 不够——Turbopack 不会解析一个 pnpm 没登记过的包。pnpm install 配合 link: 才会把它正确登记。
  • file: 会把包拷贝 / 硬链进 store,改了不实时反映(dogfood 循环直接废)。link: 是软链,且会从链接目标自己的 workspace node_modules 解析它的依赖。
  • 全程不需要 registry 认证——离线可用,哪怕发布的包是受限访问的。

坑 1:Can't resolve '@scope/ui'——把 Turbopack 的 root 抬到公共父目录

Next.js 16 的 next build / next dev 默认走 Turbopack,它会无视 next.config 里的 webpack: {} 块。

Turbopack 会把软链解析到它的真实路径code/ds-monorepo/packages/ui),发现这在应用根目录之外,于是拒绝。解法是把 root 指到两者共同的祖先:

// next.config.mjs
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
export default {
  output: "export", // 若静态导出;可选
  transpilePackages: ["@scope/ui", "@scope/tokens"],
  turbopack: { root: path.resolve(__dirname, "..") }, // 例如 code/
};

光这一步就解掉坑 1。注意:webpack: { resolve.alias } 在 Turbopack 下被忽略,别指望它。而 Turbopack 的 resolveAlias 要的是模块名 / root 相对路径,不是绝对路径——给绝对路径会变成一个莫名其妙的 ./Users/... 「server relative import」。

坑 2:跨仓 @import "tailwindcss" 解析不到——这个要修在库里

preset.css 里有 @import "tailwindcss"。在 workspace 内部它靠依赖提升(hoisting)解析得到;可一旦是跨仓消费,应用的 Tailwind PostCSS 插件会相对 preset.css 文件自己所在的目录.../packages/tokens/src)去解析这个嵌套 import——然后失败,因为 tokens 包根本没声明 tailwindcss 依赖,那个目录下物理上没有它。

这是任何外部消费者都会撞的真实打包缺口,所以修在库里,不是修在应用里:

// packages/tokens/package.json
"peerDependencies": { "tailwindcss": ">=4" },
"devDependencies":  { "tailwindcss": "^4.3.0" }  // dev 依赖让它本地物理可解析

然后 pnpm install --filter @scope/tokens。现在 packages/tokens/node_modules/tailwindcss 存在了,嵌套的 @import "tailwindcss" 就能从 preset 所在目录解析到。

应用这边的 globals.css 因此能保持干净的两行:

@import "@scope/tokens/tokens.css";
@import "@scope/tokens/preset.css";
@source "../../ds-monorepo/packages/ui/src/**/*.{ts,tsx}"; /* 让 Tailwind 扫到库的 className */
这是库的打包缺口,不是应用的配置问题

跨仓消费时 @import "tailwindcss" 会相对 preset.css 自己所在目录解析,而 tokens 包没声明 tailwindcss。修在库里(tokens 包自己声明依赖),不是在应用里绕路——所有外部消费者都受益。

坑 3:单一 React

只要 turbopack.root 抬到了公共父目录、且应用和库用的是完全同一个 React 版本,实践中不会出现重复 React(SSG 预渲染能跑通 = 没有致命的 hook 重复)。万一真撞上「invalid hook call」,用 turbopack.resolveAliasreact / react-dom / react/jsx-runtime 锚到root 相对路径(如 "./app-site/node_modules/react"),永远别用绝对路径

一个会浪费你时间的 dev-only 误报

Next 16 + React 19 的 dev 模式,对任何通过 layout 渲染的 <script>(内联 dangerouslySetInnerHTMLnext/script 的 beforeInteractive、甚至外链 src)都会弹一个可恢复的 overlay:「Encountered a script tag while rendering React component」。

只在 dev 出现。静态导出的 prod HTML 没有 React 运行时,那段防 FOUC 的脚本跑得好好的。别花时间去「修」它——out/ 构建产物验证,而不是盯着 dev overlay。

一句话外卖

跨仓实时消费自家设计系统包:link: 登记 + turbopack.root 抬到公共父 + 让 tokens 包自己声明 tailwindcss。最后一条尤其重要——那不是应用的配置问题,是库的打包缺口,修在库里,所有外部消费者都受益。

dogfood 的意义正在于此:只有真的在 monorepo 外面消费一次,才会逼出这些「包不完整」的真实缺口。