首页
/ shadcn/ui Vite Monorepo 模板实战:在 packages/ui 中集中管理组件并用 Turborepo 驱动开发

shadcn/ui Vite Monorepo 模板实战:在 packages/ui 中集中管理组件并用 Turborepo 驱动开发

2026-09-04 12:08:19作者:韦蓉瑛

本文基于 shadcn/ui 仓库中的 vite-monorepo 模板,讲解如何在 Vite + Turborepo 的 monorepo 结构中接入 shadcn/ui:理解 apps/web(应用)与 packages/ui(组件包)的职责划分,掌握通过 shadcn add 命令把组件写入共享包的操作细节,以及包导出映射、Tailwind 内容扫描等让“跨包导入组件 + 样式”真正生效的底层机制。读完你可以直接复制该模板起步,并在多包工程里安全地增删、复用 shadcn/ui 组件。

模板定位与目录结构

仓库中的该模板描述非常直接:这是一个带 shadcn/ui 的 Vite monorepo 模板(见 README)。其目录划分为两层:

  • apps/web/:实际的 Web 应用,入口为 App.tsx,使用 Vite 开发/构建;
  • packages/ui/:名为 @workspace/ui 的共享 UI 包,shadcn/ui 组件的“落点”,源码位于 packages/ui/src/components 下。

从模板根目录的 package.jsonpnpm-workspace.yaml 可以确认工作区约定:

{
  "scripts": {
    "build": "turbo build",
    "dev": "turbo dev",
    "lint": "turbo lint",
    "format": "turbo format",
    "typecheck": "turbo typecheck"
  },
  "devDependencies": {
    "turbo": "^2.9.18",
    "typescript": "~6"
  },
  "packageManager": "pnpm@10.33.4",
  "engines": { "node": ">=20" }
}
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

由此可提炼出适用前提:包管理器必须使用 pnpmpackageManager 字段锁定 pnpm 10.x),Node 版本需 >=20;所有顶层脚本都经由 turbo.json 定义的 Turborepo 任务编排,其中 build 声明了 dependsOn: ["^build"]outputs: ["dist/**"] 缓存,dev 则标记为 cache: false, persistent: true(长驻进程、不参与缓存)。根目录 tsconfig.json 统一了 target: ES2022moduleResolution: bundlerstrict: true 的编译基线。

添加组件:把 shadcn/ui 组件写进共享包

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

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

这条命令有几个要点:

  1. -c apps/web--cwd)显式指定了 shadcn CLI 的工作目录为 monorepo 根下的 apps/web。CLI 会在该目录读取 components.json,这是 shadcn/ui 的“项目配置”,决定了组件写到哪、如何导入。
  2. 执行后组件不会落在 apps/web 本地,而是被放置到共享包目录 packages/ui/src/components(README 原文如此)。

为什么组件会“跨目录”落盘?关键在于两份 components.json 的别名配置。应用侧 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": "lucide",
  "aliases": {
    "components": "@/components",
    "hooks": "@/hooks",
    "lib": "@/lib",
    "utils": "@workspace/ui/lib/utils",
    "ui": "@workspace/ui/components"
  }
}

注意 tailwind.css 指向了 ../../packages/ui/src/styles/globals.css——即共享包中的全局样式;而 utilsui 等别名直接指向 @workspace/ui 包的导出路径。共享包侧 packages/ui/components.json 则是把 componentshookslibutils 全部映射到 @workspace/ui/... 的包级路径。两份配置互相呼应,从源码结构看,这正是 CLI “在 web 应用里运行、却把文件写进 packages/ui”这一行为背后的寻址依据。

此外,styleradix-novarscfalsecssVariablestrue,表明该模板生成的是基于 Radix 原语(nova 风格)的非 RSC 纯 Web 组件,主题通过 CSS 变量实现,这也是后文按 d 键切换深色模式得以工作的基础。

使用组件:通过包导出映射跨包导入

README 的第二部分是消费方式——从 ui 包按“文件级”子路径导入:

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

模板的 App.tsx 已经内置了这条示范链路:

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

export function App() {
  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 className="text-muted-foreground font-mono text-xs">
          (Press <kbd>d</kbd> to toggle dark mode)
        </div>
      </div>
    </div>
  )
}

页面同时提示“已为你添加好 button 组件”,并支持按键 d 切换深色模式(对应 theme-provider.tsx 提供的主题切换能力)。

子路径导出是跨包导入能生效的关键

@workspace/ui/components/button 这类“按文件导出”的写法,依赖 packages/ui/package.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 实际解析到 src/components/button.tsx@workspace/ui/lib/utils 解析到 src/lib/utils.ts,全局样式则以 @workspace/ui/globals.css 暴露。应用侧 apps/web/package.json 通过 "@workspace/ui": "workspace:*" 以 pnpm workspace 协议依赖该私有包("private": true),无需构建发布即可完成本地联调。共享包依赖中包含 reactreact-domzod,与 web 应用保持一致的 React 19 版本区间,避免双 React 实例问题。

让样式真正生效:Vite 别名与 Tailwind 内容扫描

跨包使用 shadcn/ui 组件最容易踩的坑是“组件能跑但样式缺失/类名被树摇掉”。该模板用两处配置解决了它:

  1. Vite 别名vite.config.ts 中仅配置了 @apps/web/src 的本地别名,并启用 @tailwindcss/vite 插件。跨包引用全部走 npm 包名 @workspace/ui,不依赖别名,边界清晰。
  2. Tailwind 的 @source 声明:共享包的 globals.css 开头显式声明了扫描范围:
@import "tailwindcss";
@source "../../../apps/**/*.{ts,tsx}";
@source "../../../components/**/*.{ts,tsx}";
@source "../**/*.{ts,tsx}";

从源码结构看,第一段 @source 指向上层 apps/ 目录,让 Tailwind 能够收集应用代码中使用的工具类;cssVariables: true 的主题变量定义在同一份 globals.css 中,应用侧 components.json 又通过 tailwind.css 字段指向它,从而形成“组件与样式同包存放、应用侧按包路径引用”的闭环。

日常开发与构建流程

结合两份 package.json 与 turbo 任务定义,模板给出的完整工作流是:

场景 位置 命令 说明
启动开发 仓库根目录 pnpm dev 经 turbo 转发到 apps/webvite(persistent 任务)
添加 shadcn/ui 组件 仓库根目录或 apps/web pnpm dlx shadcn@latest add <组件名> -c apps/web 组件写入 packages/ui/src/components
生产构建 仓库根目录 pnpm build turbo 按 ^build 拓扑排序执行,apps/webtsc -b && vite build
类型检查 仓库根目录 pnpm typecheck 两个包各自 tsc --noEmit
Lint / 格式化 仓库根目录 pnpm lint / pnpm format eslint 与 prettier(含 tailwindcss 插件)

apps/webbuild 脚本为 tsc -b && vite build,即先由 TypeScript project references(tsconfig.jsontsconfig.app.jsontsconfig.node.json)完成类型级构建检查,再交给 Vite 打包;@workspace/ui 包本身没有 build 脚本(它作为源码包直接被应用消费,而非先产出 dist),这与 turbo.jsonbuild 依赖 ^build 的声明相符——上游包无 build 任务时不会阻塞下游。

小结与扩展方向

这套模板把 shadcn/ui 的“组件代码即资产”理念搬进了标准 monorepo:组件源码统一沉淀在 packages/ui/src/components,通过 exports 子路径映射对外提供文件级导入;应用侧 components.json 负责把 CLI 的写入路径、别名与 Tailwind 入口指回共享包。后续可以在此骨架上:更换 baseColor 调整基础色板、在 @workspace/ui/components 中继续累积业务组件,并借助 pnpm typecheck / pnpm lint 在 turbo 的缓存下获得快速的多包校验。

模板相关文件的完整清单可参考 READMEpnpm-workspace.yamlturbo.jsonpackages/ui/package.json

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