返回博客

跨 pnpm workspace 消费 Tailwind v4 设计系统包的三连坑

想做一个「改组件库源码 → app 热更新即见效」的即时 dogfood 循环:app 独立 repo,设计系统包(含 UI 组件 + tokens)在旁边的 monorepo 里发布为 TS source。用 symlink 一接,三个坑连环爆,踩完一个下一个在等你。

现象

错误按顺序出现,每解决一个就冒出下一个:

  1. Module not found: Can't resolve '@scope/ui'(Turbopack 下,明明 symlink 存在)
  2. CssSyntaxError: Can't resolve 'tailwindcss' in '.../packages/tokens/src'
  3. (潜伏)运行时 "invalid hook call"——重复 React 实例

根因

坑一:手动 symlink 不够,Turbopack 看的是 pnpm 注册表

Turbopack 解析 node_modules 依赖时,不只检查文件系统路径是否存在,还要求 pnpm 已经把该包注册进依赖图。手动 ln -snode_modules/@scope/ui 绕过了 pnpm,Turbopack 找不到。

此外,即使用 pnpm 的 file: 协议,也会把包复制/hardlink 进 pnpm store,本地编辑无法实时反映——热更新断了。

坑:手动 symlink 被 Turbopack 忽略,file: 协议复制进 store 不实时

ln -s 手动链接或 file: 协议均无法实现「改库源码 → app 即生效」的 dogfood 循环。file: 是快照,ln -s 绕过了 pnpm 注册,两者都被 Turbopack 拒绝或失效。

坑二:Turbopack 把 symlink 解析为真实路径,然后拒绝"越界"

link: 注册后,Turbopack 把 symlink 解析成 real path(如 ../ds-monorepo/packages/ui),发现它在 app 根目录之外,直接拒绝。

同时,webpack: { resolve: { alias } } 配置在 Turbopack 下完全无效,不要浪费时间在这里。

坑三:tokens 包的 preset.css 里有 @import "tailwindcss",但包本身没声明这个依赖

在 monorepo 内部,tailwindcss 通过 hoisting 被透明地找到。一旦跨 repo 消费,Tailwind PostCSS 插件处理 preset.css 时,从 preset.css 所在目录packages/tokens/src/)往上找 tailwindcss,找不到,直接报 CSS 语法错误。这是库的打包缺口,不是 app 的问题。

正解

1. 用 pnpm link: 协议,让 pnpm 正式注册

// app 的 package.json
"dependencies": {
  "@scope/ui": "link:../ds-monorepo/packages/ui",
  "@scope/tokens": "link:../ds-monorepo/packages/tokens"
}

pnpm install 后,pnpm 既建了 symlink 又完成了注册,Turbopack 能找到它。编辑库源码 → app HMR 即刷,dogfood 闭环。

2. 把 Turbopack root 提升到公共祖先目录

// next.config.mjs
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
export default {
  transpilePackages: ["@scope/ui", "@scope/tokens"],
  turbopack: {
    // 把 root 提升到 app 和 ds-monorepo 的公共父目录
    root: path.resolve(__dirname, ".."),
  },
};

这样 Turbopack 的「越界检查」基准变宽,sibling 路径不再被拒绝。

坑:turbopack.resolveAlias 不接受绝对路径

如果需要强制单 React,turbopack.resolveAlias 里要填 root 相对路径(如 "./app-site/node_modules/react"),填绝对路径会变成一个莫名其妙的"server relative import"而失效。

3. 在库的 tokens 包里显式声明 tailwindcss 依赖

// packages/tokens/package.json
"peerDependencies": { "tailwindcss": ">=4" },
"devDependencies": { "tailwindcss": "^4.3.0" }

pnpm install --filter @scope/tokens 后,packages/tokens/node_modules/tailwindcss 物理存在,@import "tailwindcss" 从 preset.css 所在目录就能解析到。

app 侧的 globals.css 保持干净,再加一行 @source 指向库的 TS 源码,让 Tailwind 扫到类名:

@import "@scope/tokens/tokens.css";
@import "@scope/tokens/preset.css";
@source "../../ds-monorepo/packages/ui/src/**/*.{ts,tsx}";
正解:三步走,三坑全清
  1. package.jsonlink: 协议 + pnpm install,让 Turbopack 认识这个包。
  2. next.config.mjsturbopack.root 提升到公共祖先,解除"越界"限制。
  3. tokens 包补声明 tailwindcss devDep,让 @import "tailwindcss" 在包内就地可解析。

附:Vite SPA 消费同款库的额外注意点

若 app 是 Vite 而非 Next.js,需要额外处理:

  • 重复 React:Vite 不像 Turbopack 自动去重,必须在 resolve.dedupe 里列出 ["react", "react-dom", "@emotion/react", "motion"]
  • 文件系统拒绝:链接目标在 project root 外,须在 server.fs.allow 里加上 sibling 的真实路径。
  • @source 指向真实路径:Tailwind 的 fast-glob 不跟随 symlink,@source 要写 real path,不能写 symlink 路径。
  • 跨 repo tsc --noEmit 是死胡同:会拉入库的 TS 源码,爆出重复 @types/reactnoUnusedLocals 等库内部错误,无法从 app 侧修复。改用 vite build(esbuild 转译,只报真实解析错误)作为类型安全门禁。

一句话外卖

pnpm link: 是跨 repo dogfood 的唯一正路;Turbopack root 要提升到公共祖先;库里 @import "tailwindcss" 就必须在库里声明 tailwindcss 依赖,不能靠 monorepo hoisting 隐式解决。