在 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 -s到node_modules/@scope/ui不够——Turbopack 不会解析一个 pnpm 没登记过的包。pnpm install配合link:才会把它正确登记。 file:会把包拷贝 / 硬链进 store,改了不实时反映(dogfood 循环直接废)。link:是软链,且会从链接目标自己的 workspacenode_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.resolveAlias 把 react / react-dom / react/jsx-runtime 锚到root 相对路径(如 "./app-site/node_modules/react"),永远别用绝对路径。
一个会浪费你时间的 dev-only 误报
Next 16 + React 19 的 dev 模式,对任何通过 layout 渲染的 <script>(内联 dangerouslySetInnerHTML、next/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 外面消费一次,才会逼出这些「包不完整」的真实缺口。