首页
/ shadcn/ui React Router Monorepo 模板:组件包共享与 pnpm workspace + Turbo 工程实践

shadcn/ui React Router Monorepo 模板:组件包共享与 pnpm workspace + Turbo 工程实践

2026-09-04 18:13:39作者:裴麒琰

本文以 shadcn/ui 仓库中的 React Router monorepo 模板(templates/react-router-monorepo)为主体,讲清这套模板的核心工作流:如何在 web 应用中通过 shadcn CLI 添加组件、组件被放置到 packages/ui 共享包中的哪个位置、以及如何从应用侧导入使用。读完后你能掌握 pnpm workspace + Turborepo + React Router 7 的三层组件共享机制,理解 components.json 别名映射如何决定组件的落盘路径与导入路径,并可据此在真实的 monorepo 中复用同一套 UI 组件体系。

模板定位:把 shadcn/ui 组件集中到共享包

该模板是一个基于 React Router 的 monorepo 结构,web 应用通过共享包 @workspace/ui 使用 shadcn/ui 组件。模板由两个工作区包构成:

  • apps/web:React Router 7 应用("react-router": "7.15.1"),负责路由与页面;
  • packages/ui:命名 @workspace/ui 的私有 UI 包,shadcn/ui 组件、cn 工具函数与全局样式都落在这里。

workspace 的包成员由 pnpm-workspace.yaml 声明:

packages:
  - "apps/*"
  - "packages/*"

allowBuilds:
  esbuild: true
  msw: false

根目录的 package.json 要求 node >= 20pnpm@10.33.4,并通过 turbo 统一驱动 build / dev / format / typecheck 四个脚本。turbo.jsonbuild 任务声明 dependsOn: ["^build"](先构建上游依赖包)、dev 任务为 persistent: truecache: false,这是典型的"包依赖拓扑 + 长驻开发服务器"配置。

添加组件:shadcn CLI 的跨目录安装

模板 README 给出的核心操作是:在 web 应用根目录执行:

pnpm dlx shadcn@latest add button -c apps/web

-c apps/web 指定了 shadcn CLI 的工作目录。组件并不会写进 web 应用自身,而是被放置到 packages/ui/src/components 目录。这一行为由 apps/web 下的 components.json 驱动:

{
  "style": "radix-nova",
  "rsc": false,
  "tsx": true,
  "tailwind": {
    "config": "",
    "css": "../../packages/ui/src/styles/globals.css",
    "baseColor": "neutral",
    "cssVariables": true
  },
  "iconLibrary": "remixicon",
  "aliases": {
    "components": "@/components",
    "hooks": "@/hooks",
    "lib": "@/lib",
    "utils": "@workspace/ui/lib/utils",
    "ui": "@workspace/ui/components"
  },
  "rtl": false,
  "menuColor": "inverted",
  "menuAccent": "subtle"
}

关键字段逐项说明:

  • rsc: false:声明不使用 React Server Components,与 React Router 的框架无关的 lib 组件模式一致(React Router 默认以框架层提供 SSR,而组件本身按客户端组件方式编写);
  • tailwind.css 指向 ../../packages/ui/src/styles/globals.css:CLI 注册/修改 Tailwind 变量时直接改写 ui 包的全局样式文件,而不是 web 应用内的文件;
  • aliases.ui = "@workspace/ui/components":CLI 生成/查找组件时的逻辑别名被解析为 workspace 包导出路径,这正是组件最终落盘到 packages/ui/src/components 的原因;
  • aliases.utils = "@workspace/ui/lib/utils":组件内部的 cn 等工具统一引用共享包,避免每个应用各持一份。

packages/ui 自身还保留了一份 components.json,其别名直接写成包导出路径("components": "@workspace/ui/components"),保证在 ui 包内直接执行 CLI 命令时行为一致。

使用组件:通过包导出路径导入

组件落盘后,在应用中按 README 所示方式导入:

import { Button } from "@workspace/ui/components/button";

模板自带的首页 home.tsx 正是这一用法的示例——它已经预装了 button 组件并直接渲染:

import { Button } from "@workspace/ui/components/button"

export default function Home() {
  return (
    <div className="flex min-h-svh p-6">
      <div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
        <div>
          <h1 className="font-medium">Project ready!</h1>
          <p>You may now add components and start building.</p>
          <p>We&apos;ve already added the button component for you.</p>
          <Button className="mt-2">Button</Button>
        </div>
      </div>
    </div>
  )
}

这个导入路径能够成立,依赖 uipackage.json 中的 exports 字段定义的子路径映射:

"exports": {
  "./globals.css": "./src/styles/globals.css",
  "./lib/*": "./src/lib/*.ts",
  "./components/*": "./src/components/*.tsx",
  "./hooks/*": "./src/hooks/*.ts"
}

@workspace/ui/components/button 解析到 packages/ui/src/components/button.tsx@workspace/ui/lib/utils 解析到 packages/ui/src/lib/utils.tsui 包是一个不做构建的纯源码包(没有 build 脚本,仅 formattypecheck),由消费端(web 的 Vite)直接编译,这简化了开发体验,也意味着 webui 必须共享一致的 React 版本(两者均为 ^19.2.6)。

类型与路径解析的三重对齐

同一个 @workspace/ui/* 前缀在三处做了对齐配置,缺一即会报错:

  1. Vite 运行时解析vite.config.ts 开启 resolve.tsconfigPaths: true,让 Vite 遵循 tsconfig 的 paths 映射,插件为 tailwindcss() + reactRouter()
  2. TypeScript 编译期解析apps/web/tsconfig.json 中声明 "@workspace/ui/*": ["../../packages/ui/src/*"](另有 "@/*": ["./app/*"]),且 typecheck 脚本为 react-router typegen && tsc,会先生成 .react-router/types 下的路由类型;
  3. workspace 依赖声明webpackage.json@workspace/ui: "workspace:*" 通过 pnpm 的 workspace 协议建立真实依赖关系。

ui 包提供的 cn 工具函数实现于 utils.ts,是标准的 clsx + tailwind-merge 组合:

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

shadcn/ui 组件内部的类名合并全部经由这个 cn,这也是 aliases.utils 必须指向共享包的原因。

样式系统:全局 CSS 与跨包内容扫描

组件的样式依赖 Tailwind CSS 4。ui 包的 globals.css 头部集中声明了模板的技术栈组合与关键工程细节:

@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/outfit";

@custom-variant dark (&:is(.dark *));
@source "../../../apps/**/*.{ts,tsx}";
@source "../../../components/**/*.{ts,tsx}";
@source "../**/*.{ts,tsx}";

这里有两点对 monorepo 用户很关键:

  • @source 显式把 apps/** 纳入 Tailwind 的内容扫描范围。由于样式文件在 packages/ui/src/styles/ 下,默认扫描范围不会覆盖 apps/web,若不声明 @source,应用中使用的工具类将被 purge 掉——这是组件包与应用分离时最容易踩的坑;
  • 暗色模式采用 @custom-variant dark 的类名方案(.dark 作用域),主题变量全部以 oklch 色值写入 :root / .dark 两套 CSS 变量(如 --primary: oklch(0.648 0.2 131.684)),与 components.jsoncssVariables: truebaseColor: "neutral" 的配置对应。

ui 包的依赖列表(radix-uiclass-variance-authorityclsxtailwind-merge@remixicon/reacttw-animate-csszod 等)也说明了组件的运行期支撑:Radix 原语 + CVA 变体 + Remixicon 图标库,与 components.jsoniconLibrary: "remixicon" 一致。

SSR 与运行方式

React Router 侧的默认配置在 react-router.config.ts 中为 ssr: true(如需 SPA 模式可改为 false)。web 包的脚本提供了完整生命周期:

pnpm dev      # turbo 驱动,React Router 开发服务器(persistent)
pnpm build    # turbo 按 ^build 拓扑先构建 ui 包,再构建 web
pnpm typecheck

小结

这套模板的核心骨架可以归纳为三步:apps/web 下执行 pnpm dlx shadcn@latest add <component> -c apps/web → 组件经 components.json 别名映射落入 packages/ui/src/components → 应用通过 @workspace/ui/components/* 包导出导入使用。围绕这一骨架,exports 子路径映射、tsconfig paths、Vite tsconfigPathsglobals.css@source 声明共同保证了"组件包与应用分离"下类型、运行与样式的端到端一致。模板同时提供了同系列的 Vite monorepo 版本(templates/vite-monorepo)等对照参考,若你需要框架无关的组件共享方案可进一步查看。

登录后查看全文
热门项目推荐
相关项目推荐