切换语言
切换主题

shadcn/ui 组合模式:多个组件协同的最佳实践

凌晨两点,盯着用户管理页面的代码,我有点崩溃。

一个 DataTable 显示用户列表,每行有个 DropdownMenu 操作菜单,点击”编辑”弹出一个 Dialog,里面塞着一个 Form。听起来挺简单的功能,对吧?

但我的代码是这样的:状态传来传去,prop drilling 到了第五层,Dialog 的 open 状态在父组件,Form 的数据在子组件,提交后的回调又要传回父组件更新 DataTable…

说实话,当时我挺怀疑 shadcn/ui 的。“单个组件用起来确实爽,组合起来怎么就这么乱?”

后来翻了翻 shadcn/ui 的设计文档,才发现问题不在组件库,而在我不懂组合模式。shadcn/ui 的核心理念就是”组合优于继承”,每个组件都有统一的可预测接口。但如果你不知道这套接口背后的设计哲学,组合起来就会像我一样一团乱。

今天聊聊我踩过的坑,以及后来学到的组合模式最佳实践。


先搞清楚 shadcn/ui 的设计哲学

在聊具体组合之前,得先理解 shadcn/ui 的设计逻辑。不然你会发现,为什么同样是用 React 组件库,别人组合得清爽利落,你组合得像意大利面条。

shadcn/ui 和传统 UI 库最大的不同在于:它不是 npm 包,你不会在 package.json 里看到 @shadcn/ui 这个依赖。所有的组件代码,你是直接复制到项目里的。

听起来挺原始的,对吧?但这正是它的设计哲学:

Open Code:组件代码完全开放,你想改就改,不用担心版本冲突。比如 Button 组件的某个样式你不喜欢,直接改源码,不用等官方发新版本。

Composition:所有组件使用统一的可组合接口。什么意思呢?就是每个组件的结构都是可预测的。比如 Card 组件一定是 <Card>&lt;CardHeader>&lt;CardTitle>&lt;CardContent> 这种嵌套结构,Dialog 一定是 <Dialog>&lt;DialogContent>&lt;DialogHeader>&lt;DialogTitle>

这种统一接口的好处是:当你组合多个组件时,你知道哪里该嵌套,哪里该并列。不会出现”这个组件要包在那个组件里面,但那个组件又要求在外面”这种矛盾。


基础组合:Dialog + Form

最常见的组合场景:一个弹窗里面塞个表单。

用户点”编辑”按钮,弹出一个 Dialog,里面是 Form,填完提交关闭 Dialog。听起来简单,但我刚开始写的时候,犯了个错误:把 Dialog 和 Form 的状态搅在一起了。

错误示范

// ❌ 这是我踩坑的版本
function EditUserDialog() {
  const [open, setOpen] = useState(false)
  const [formData, setFormData] = useState&#123;&#123;&#125;&#125;

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button onClick={() => fetchUserData()}>编辑</Button>
      </DialogTrigger>
      <DialogContent>
        <form onSubmit={(e) => {
          e.preventDefault()
          submitForm(formData)
          setOpen(false)
        }}>
          <Input
            value={formData.username}
            onChange={(e) => setFormData(&#123;...formData, username: e.target.value&#125;)}
          />
          <Button type="submit">保存</Button>
        </form>
      </DialogContent>
    </Dialog>
  )
}

问题在哪?Dialog 的 open 状态和 Form 的数据状态混在一个组件里,而且我手动管理 form 状态,没用 React Hook Form,导致校验、错误显示都很乱。

正确做法

shadcn/ui 的 Form 组件是基于 React Hook Form + Zod 的,用这套组合,代码清爽很多:

// ✅ 正确的组合方式
import &#123; useForm &#125; from "react-hook-form"
import &#123; zodResolver &#125; from "@hookform/resolvers/zod"
import * as z from "zod"

// 1. 先定义 Schema(在组件外面)
const userSchema = z.object({
  username: z.string().min(3, "用户名至少 3 个字"),
  email: z.string().email("邮箱格式不对")
})

function EditUserDialog(&#123; user, onSubmit &#125;) {
  const [open, setOpen] = useState(false)
  const form = useForm(&#123;
    resolver: zodResolver(userSchema),
    defaultValues: user // 直接传入用户数据
  &#125;)

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button variant="outline">编辑</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>编辑用户信息</DialogTitle>
        </DialogHeader>

        {/* Form 直接用 shadcn 的 Form 组件 */}
        <Form &#123;...form&#125;>
          <form onSubmit={form.handleSubmit((data) => &#123;
            onSubmit(data)      // 提交数据
            setOpen(false)      // 关闭 Dialog
          &#125;)}>
            <FormField
              name="username"
              render=&#123;(&#123; field &#125;) => (
                <FormItem>
                  <FormLabel>用户名</FormLabel>
                  <FormControl>&lt;Input &#123;...field&#125; /></FormControl>
                  <FormMessage /> {/* 自动显示错误 */}
                </FormItem>
              )&#125;
            />
            <Button type="submit">保存</Button>
          </form>
        </Form>
      </DialogContent>
    </Dialog>
  )
}

关键点:

  1. Dialog 是容器,Form 是内容:Dialog 只管”打开/关闭”,Form 管”数据/校验/提交”,两者职责分离。
  2. Form 用 React Hook Form + Zod:不要手动管理 form 状态,form.handleSubmit 自动处理校验和提交。
  3. FormMessage 自动显示错误:不用手写错误逻辑,Zod 校验失败自动显示错误信息。

这样一来,Dialog 和 Form 的状态就清晰了:Dialog 的 open 在父组件,Form 的数据在 Form 组件内部(通过 React Hook Form 管理)。


DataTable + DropdownMenu:表格行操作

另一个常见场景:表格每行有个操作菜单,点击”编辑”弹出 Dialog。

我踩的坑:不知道怎么把行数据传给 Dialog。DataTable 的 column 定义里,你能拿到 row.original(当前行数据),但 Dialog 在 DataTable 外面,怎么传?

错误示范

// ❌ 我最开始的做法:把 Dialog 嵌在 cell 里
const columns = [
  &#123;
    id: "actions",
    cell: &#123; row &#125; => (
      <Dialog>
        <DialogTrigger asChild>
          <Button>编辑</Button>
        </DialogTrigger>
        <DialogContent>
          {/* 问题:每次渲染 cell 都创建一个 Dialog 实例 */}
          <EditForm user={row.original} />
        </DialogContent>
      </Dialog>
    )
  &#125;
]

这么做的问题:每一行都创建一个 Dialog 实例,100 行数据就 100 个 Dialog,性能很差。而且 Dialog 的状态很难统一管理。

正确做法

用一个全局的 Dialog,通过 Hook 管理状态:

// 1. 先定义一个 Hook 管理 Dialog 状态
const useEditDialog = () => &#123;
  const [open, setOpen] = useState(false)
  const [editingUser, setEditingUser] = useState(null)

  const openEdit = (user) => &#123;
    setEditingUser(user)
    setOpen(true)
  &#125;

  const closeEdit = () => &#123;
    setOpen(false)
    setEditingUser(null)
  &#125;

  return &#123; open, editingUser, openEdit, closeEdit &#125;
&#125;

// 2. DataTable 列定义里只放触发按钮
function UserDataTable(&#123; users &#125;) &#123;
  const &#123; open, editingUser, openEdit, closeEdit &#125; = useEditDialog()

  const columns = [
    &#123;
      id: "actions",
      cell: &#123; row &#125; => (
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="ghost" size="icon">
              <MoreHorizontal />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent>
            <DropdownMenuItem onClick={() => openEdit(row.original)}>
              编辑
            </DropdownMenuItem>
            <DropdownMenuItem onClick={() => deleteUser(row.original.id)}>
              删除
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      )
    &#125;
  ]

  return (
    <>
      <DataTable columns={columns} data={users} />
      {/* 全局唯一的 Dialog */}
      <Dialog open={open} onOpenChange={(o) => !o && closeEdit()}>
        <DialogContent>
          <EditUserForm
            user={editingUser}
            onSubmit={(data) => &#123;
              updateUser(data)
              closeEdit()
              refreshTable() // 刷新表格数据
            &#125;}
          />
        </DialogContent>
      </Dialog>
    </>
  )
&#125;

关键点:

  1. 全局 Dialog:DataTable 外面放一个 Dialog,而不是每行都创建。
  2. Hook 管理状态:openEdit 打开 Dialog 并传入数据,closeEdit 关闭并清空数据。
  3. DropdownMenu 触发:cell 里只放触发按钮,通过 onClick 调用 openEdit(row.original)。

这样结构就清晰了:DataTable 管”显示数据”,DropdownMenu 管”触发操作”,Dialog 管”显示表单”,Hook 管”状态流转”。


进阶:Context 模式避免 Prop Drilling

组合多个组件时,最容易踩的坑是 prop drilling:状态一层层传下去,传到第五层时你已经不知道这个 prop 从哪来的了。

shadcn/ui 的很多组件本身就是 Compound Components(复合组件)模式,比如 Card:

<Card>
  <CardHeader>
    <CardTitle>标题</CardTitle>
    <CardDescription>描述</CardDescription>
  </CardHeader>
  <CardContent>内容</CardContent>
  <CardFooter>底部</CardFooter>
</Card>

这种嵌套结构,你可能会想:“CardTitle 怎么知道它属于哪个 Card?要不要传个 cardId?”

其实不用。Compound Components 的核心是用 Context 共享状态,子组件自动”知道”自己在哪个父组件里。

自己实现一个可折叠的 Card

shadcn/ui 的 Card 默认不能折叠,我们来扩展一个可折叠版本,顺便学习 Context 模式:

// 1. 创建 Context
import &#123; createContext, useContext, useState &#125; from "react"

type CardContextValue = &#123;
  isCollapsed: boolean
  toggle: () => void
&#125;

const CardContext = createContext&lt;CardContextValue | null>(null)

// 2. Root 组件:管理状态,提供 Context
CollapsibleCard.Root = &#123; children, defaultCollapsed = false &#125; => &#123;
  const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)

  return (
    <CardContext.Provider value=&#123;&#123;
      isCollapsed,
      toggle: () => setIsCollapsed(!isCollapsed)
    &#125;}>
      <Card className="border rounded-lg">&#123;children&#125;</Card>
    </CardContext.Provider>
  )
&#125;

// 3. Header 组件:显示标题 + 折叠按钮
CollapsibleCard.Header = &#123; title &#125; => &#123;
  const ctx = useContext(CardContext)
  if (!ctx) throw new Error("Header must be in CollapsibleCard.Root")

  return (
    <CardHeader className="cursor-pointer" onClick={ctx.toggle}>
      <div className="flex items-center justify-between">
        <CardTitle>&#123;title&#125;</CardTitle>
        &#123;ctx.isCollapsed ? <ChevronDown /> : <ChevronUp />&#125;
      </div>
    </CardHeader>
  )
&#125;

// 4. Content 组件:响应折叠状态
CollapsibleCard.Content = &#123; children &#125; => &#123;
  const ctx = useContext(CardContext)
  if (!ctx) throw new Error("Content must be in CollapsibleCard.Root")

  if (ctx.isCollapsed) return null // 折叠时不显示

  return <CardContent>&#123;children&#125;</CardContent>
&#125;

使用:

<CollapsibleCard.Root defaultCollapsed={false}>
  <CollapsibleCard.Header title="用户信息" />
  <CollapsibleCard.Content>
    <p>姓名:张三</p>
    <p>邮箱:[email protected]</p>
  </CollapsibleCard.Content>
</CollapsibleCard.Root>

关键点:

  1. Context 共享状态:Root 组件创建 Context,子组件通过 useContext 自动获取状态,不用 prop drilling。
  2. 子组件自动响应:Header 点击切换状态,Content 自动显示/隐藏,两者无需直接通信。
  3. 强制父组件约束:子组件如果不在 Root 里,会抛错提醒你。

这种模式的好处:你组合多个组件时,不用担心状态怎么传。只要子组件在父组件里面,它就能自动拿到状态。


完整场景:DataTable + Dialog + Form

综合前面学的,来写一个完整的用户管理页面:DataTable 显示列表,点击”编辑”弹出 Dialog,Dialog 里面是 Form,提交后更新表格。

完整代码示例见上方各节,这里总结关键流程:

  1. Schema 定义:Zod 定义用户数据结构和校验规则
  2. Dialog 状态 Hook:统一管理 Dialog 的打开/关闭和数据传递
  3. DataTable 列定义:包含 DropdownMenu 操作列
  4. 编辑表单组件:Form + FormField + 各种 Input
  5. 主页面组件:组合 DataTable 和 Dialog

这个完整示例里,你能看到所有组合模式:

  • DataTable 显示数据
  • DropdownMenu 触发操作
  • Dialog 显示表单
  • Form 校验和提交
  • Hook 管理状态流转

每个组件职责清晰,状态通过 Hook 和 Context 管理,避免了 prop drilling。


高级技巧:性能优化和类型安全

避免 Context 导致的重渲染

Compound Components 用 Context 很方便,但有个坑:Context 值变化时,所有 useContext 的组件都会重渲染。

比如 CollapsibleCard,折叠状态变化时,Header 和 Content 都会重渲染。如果 Content 里有个复杂的列表,重渲染会很慢。

解决方案:分离 Context。

// 状态 Context(频繁变化)
const CardStateContext = createContext&lt;&#123; isCollapsed: boolean &#125;>()

// 配置 Context(不变化)
const CardConfigContext = createContext&lt;&#123; collapsible: boolean &#125;>()

// Root 组件提供两个 Context
CollapsibleCard.Root = &#123; children, collapsible = true, defaultCollapsed = false &#125; => &#123;
  const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)

  return (
    <CardConfigContext.Provider value=&#123;&#123; collapsible &#125;}>
      <CardStateContext.Provider value=&#123;&#123; isCollapsed &#125;}>
        <Card>
          &#123;children&#125;
          {/* Toggle 按钮单独放在这里,避免 Context 变化影响子组件 */}
          &#123;collapsible && (
            <button onClick={() => setIsCollapsed(!isCollapsed)}>
              &#123;isCollapsed ? "展开" : "折叠"&#125;
            </button>
          )&#125;
        </Card>
      </CardStateContext.Provider>
    </CardConfigContext.Provider>
  )
&#125;

Header 只读取 CardConfigContext(不变化,不会重渲染),Content 只读取 CardStateContext(折叠时才重渲染)。

TypeScript 类型安全

Compound Components 的类型定义要小心,不然 TypeScript 会报错”子组件可能不在父组件里”。

// 完整类型定义
type CollapsibleCardProps = &#123;
  children: React.ReactNode
  defaultCollapsed?: boolean
  collapsible?: boolean
&#125;

type CollapsibleCardComponents = &#123;
  Root: FC&lt;CollapsibleCardProps>
  Header: FC&lt;&#123; title: string &#125;>
  Content: FC&lt;&#123; children: React.ReactNode &#125;>
&#125;

const CollapsibleCard: CollapsibleCardComponents = &#123;
  Root: &#123; children, defaultCollapsed = false, collapsible = true &#125; => &#123;
    // ...
  &#125;,
  Header: &#123; title &#125; => &#123;
    // ...
  &#125;,
  Content: &#123; children &#125; => &#123;
    // ...
  &#125;
&#125;

这样使用时,TypeScript 会检查你传的 props 是否正确:

// ✅ 正确
<CollapsibleCard.Root defaultCollapsed={true}>
  <CollapsibleCard.Header title="标题" />
  <CollapsibleCard.Content>内容</CollapsibleCard.Content>
</CollapsibleCard.Root>

// ❌ TypeScript 报错:title 必传
<CollapsibleCard.Header />

总结:组合模式清单

说了这么多,总结一下关键原则:

基础组合

  • Dialog + Form:Dialog 是容器,Form 是内容,职责分离
  • DataTable + DropdownMenu:DropdownMenu 触发操作,通过 row.original 传递数据
  • Tabs + Form:Tabs 导航,TabsContent 包含不同表单

进阶技巧

  • Context 模式:避免 prop drilling,子组件自动获取状态
  • Hook 管理状态:统一的 Dialog 状态管理,避免每行创建实例
  • Form + Zod:统一校验 Schema,FormMessage 自动显示错误

高级优化

  • 分离 Context:避免频繁变化的状态导致所有子组件重渲染
  • TypeScript 类型:完整类型定义,避免 props 传错
  • Server/Client 分离:Next.js App Router 下,数据获取在 Server,UI 在 Client

最后一点建议:不要直接修改 shadcn/ui 的组件文件。想定制样式,要么创建包装组件,要么用 variants,要么通过 theme。直接改源码的话,后面想升级就难了。

说实话,shadcn/ui 的组合模式学完之后,我的代码确实清爽了很多。之前那个用户管理页面,从 300 多行缩减到不到 150 行,状态管理也清晰了。当然,Context 模式和性能优化这些高级技巧,刚开始学的时候确实有点绕,多写几次就熟悉了。

不知道你有没有遇到过类似的组合难题?如果有,可以试试这些模式,应该能帮你理清思路。


实现 DataTable + Dialog + Form 组合

完整的用户管理页面实现流程

⏱️ 预计耗时: 45 分钟

  1. 1

    步骤1: 定义 Zod Schema

    在组件外定义数据结构校验:

    • 使用 z.object() 定义字段
    • 添加校验规则(min、email、enum)
    • export schema 和 type
  2. 2

    步骤2: 创建 Dialog 状态 Hook

    统一管理 Dialog 状态:

    • useState 管理 open 和 editingUser
    • openDialog 打开并传入数据
    • closeDialog 关闭并清空数据
  3. 3

    步骤3: 定义 DataTable 列

    在 columns 中添加操作列:

    • cell 里放 DropdownMenu
    • onClick 调用 openDialog(row.original)
    • 不要把 Dialog 嵌在 cell 里
  4. 4

    步骤4: 创建编辑表单组件

    使用 shadcn Form 组件:

    • useForm + zodResolver
    • FormField + FormControl
    • FormMessage 自动显示错误
  5. 5

    步骤5: 组合主页面

    将所有组件组合:

    • DataTable + 全局 Dialog
    • Dialog 内放 Form
    • 提交后更新列表数据

常见问题

为什么不要把 Dialog 嵌在 DataTable cell 里?
每行创建一个 Dialog 实例会导致性能问题。100 行数据就 100 个 Dialog,而且状态难以统一管理。正确做法是用一个全局 Dialog,通过 Hook 管理状态。
Form 应该用 React Hook Form 还是手动管理?
强烈建议用 React Hook Form + Zod:

• 自动校验和错误显示
• 类型安全(z.infer 自动推导)
• 性能更好(减少重渲染)
• FormMessage 自动显示错误信息
Context 模式会导致性能问题吗?
会。Context 值变化时,所有 useContext 的组件都会重渲染。解决方案:

• 分离 Context(状态 Context + 配置 Context)
• 只让需要响应状态的组件读取状态 Context
• 不变的配置放在配置 Context
如何避免 prop drilling?
使用 Context 模式:Root 组件创建 Context,子组件通过 useContext 自动获取状态。子组件无需层层传递 props,只要在父组件里面就能拿到状态。
可以直接修改 shadcn/ui 组件源码吗?
不建议。正确做法:

• 创建包装组件
• 使用 variants 定义变体
• 通过 theme 定制样式

直接改源码会导致后续升级困难。
Compound Components 的 TypeScript 类型怎么定义?
使用完整类型定义:

• 定义 Root/Header/Content 的 Props 类型
• 使用 FC<Props> 约束组件
• 子组件不在 Root 里时抛错提醒

这样 TypeScript 会检查 props 是否正确。

9 分钟阅读 · 发布于: 2026年4月1日 · 修改于: 2026年4月1日

评论

使用 GitHub 账号登录后即可评论

相关文章