React组件库实战 - 第三章:揭秘复合组件的魔法!深入 Radix UI 与 Context API,从零构建生产级 DropdownMenu

第三章. Radix组件封装最佳实践:DropdownMenu 与复合组件模式

在第二章中,我们围绕 Button 这一个独立的组件,建立了一套完整的“黄金工作流”。现在,我们将挑战升级,从单个组件深入到由多个部分协同工作的 复合组件

DropdownMenu(下拉菜单)是复合组件最经典的范例。本章,我们将不再从零“发明”一个下拉菜单,而是通过 对比和升级 现有方案,亲手构建一个 生产级别的、结合了 daisyUI 样式、Radix UI 行为和 shadcn/ui 架构DropdownMenu 组件。在这个过程中,您将真正掌握“复合组件”设计模式与 React Context 的核心原理。

3.1. 快速入门:分析 daisyUI 的 Dropdown 组件

我们的第一步,是从我们已有的、最便捷的工具——daisyUI——出发。我们将首先学习如何使用纯 CSS 快速实现一个下拉菜单,并在这个过程中,亲身体会其在便捷性背后的局限性,从而建立起寻求更优方案的“痛点认知”。

3.1.1. 实践 daisyUI Dropdown: 5 分钟实现一个下拉菜单

daisyUI 的一个显著特点是,它的许多组件都是“CSS 驱动”的,无需编写任何 JavaScript 即可实现交互。对于下拉菜单,它巧妙地利用了 HTML 的原生特性来工作。

我们将直接在 Next.js 的首页上添加一个常见的“用户头像”下拉菜单来做演示。

第一步:搭建基础 HTML 结构

daisyUI 的下拉菜单可以基于 divtabindex 实现,但更语义化、更具可访问性的方式是使用 HTML 原生的 <details><summary> 标签。<details> 元素本身就是一个可以展开/折叠的“小部件”,这为我们提供了一个无需 JS 的天然行为基础。

让我们先清空首页,并添加基础结构。

文件路径: src/app/page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default function HomePage() {
return (
<main className="p-24">
{/* 使用 <details> 和 <summary> 搭建基础骨架 */}
<details>
<summary>
点击打开
</summary>
<ul>
<li><a>选项 1</a></li>
<li><a>选项 2</a></li>
</ul>
</details>
</main>
);
}

解析: 此时,如果您运行 pnpm run dev,您会看到一个浏览器原生的、非常简陋的可展开区域。这证明了其底层行为是有效的。

第二步:应用 daisyUI 样式类

接下来,我们将通过添加 daisyUI 的预设类名,将这个原生小部件“点化”成一个美观的下拉菜单。

文件路径: src/app/page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export default function HomePage() {
return (
<main className="p-24">
{/* 为了演示,我们将它放置在右上角 */}
<div className="absolute top-8 right-8">

{/* 1. 在 <details> 标签上添加 'dropdown' 类 */}
<details className="dropdown">
{/* 2. 使用 'summary' 和 'btn' 类来设置触发器样式 */}
<summary className="m-1 btn btn-circle">头像</summary>

{/* 3. 为 <ul> 菜单添加 daisyUI 的样式类 */}
<ul className="p-2 shadow menu dropdown-content z-[1] bg-base-100 rounded-box w-52">
<li><a>个人资料</a></li>
<li><a>设置</a></li>
<li><a>退出登录</a></li>
</ul>
</details>

</div>
</main>
);
}

代码深度解析:

  • className="dropdown": 这是施加在最外层容器上的核心类,它激活了 daisyUI 的下拉菜单布局和行为逻辑。
  • className="m-1 btn": 我们将触发器 <summary> 的样式设置为一个按钮,使其外观更符合用户的交互预期。
  • ul 上的类名:
    • menudropdown-content: menu 提供了列表项的垂直布局和样式,dropdown-content 则负责将 <ul> 定位到触发器的下方。
    • p-2 shadow bg-base-100 rounded-box w-52: 这些都是 daisyUI 或 Tailwind 的工具类,用于定义菜单的内边距、阴影、背景色、圆角和宽度,共同构成了菜单面板的视觉外观。
    • z-[1]: 这是一个 Tailwind 的任意值类,用于设置 z-index,确保菜单能浮动在页面其他内容的上方。

第三步:验证效果

现在,重新运行 pnpm run dev 并访问 http://localhost:3000

您应该可以在页面右上角看到一个名为“用户头像”的按钮。点击它,一个样式精美的下拉菜单会平滑地展开。再次点击按钮,菜单会关闭。

我们几乎没有写任何逻辑,仅仅通过组合 HTML 标签和 CSS 类名,就在几分钟内实现了一个功能性的下拉菜单。这充分展示了 daisyUI 在快速原型开发和简单场景下的巨大威力。

然而,这个看似“完美”的组件,在专业的、要求更高的场景下,很快就会暴露出它的不足。在下一节中,我们将深入剖析它的局限性,并以此为契机,引入更强大的解决方案。


3.1.2. 剖析其优缺点:我们遇到了什么新问题?

daisyUI 的纯 CSS 方案,其最大的优点是无可比拟的 开发效率。对于内部工具、管理后台或对可访问性和交互细节要求不高的场景,它无疑是一个极佳的选择。

然而,我们的目标是构建一个面向最终用户、体验一流的生产级设计系统。这意味着,我们必须用更严苛、更全面的标准来审视我们的组件。现在,让我们对这个“5 分钟”完成的下拉菜单,进行一次专业的“体验测试”。

1. 痛点一:不完整的可访问性 (a11y)

一个专业的组件,必须确保所有用户,包括那些依赖键盘或屏幕阅读器等辅助技术的用户,都能够顺畅地使用。

请您亲自体验一下: 刷新页面,然后完全脱离鼠标,只使用键盘来尝试与我们的下拉菜单交互。

  1. 使用 Tab 键,您可以将焦点移动到“用户头像”按钮上。
  2. 按下 EnterSpace 键,菜单可以正常展开。到目前为止,一切似乎都很好。
  3. 问题来了: 菜单展开后,请尝试按下 ArrowDown (下箭头) 键。您期望的结果是焦点能在“个人资料”、“设置”、“退出登录”这几个菜单项之间移动。但实际情况是,焦点很可能直接跳出了整个组件,或者没有任何响应。
  4. 再次尝试: 菜单展开后,请按下 Escape (Esc) 键。在一个符合用户习惯的下拉菜单中,这个操作应该能关闭菜单。但您会发现,没有任何事情发生。

问题根源:

  • 缺乏焦点管理: 基于纯 CSS 的 :focus<details> 元素,浏览器并不知道“触发器”和“菜单面板”在语义上是一个整体。因此,它无法实现“当菜单打开时,将焦点移入菜单内部;当菜单关闭时,将焦点还给触发器”这样的高级焦点管理逻辑。
  • 缺少键盘事件监听: 纯 CSS 无法响应 ArrowUp/ArrowDown/Escape 等键盘事件,而这些是构成可访问下拉菜单交互规范(WAI-ARIA Best Practices)的核心部分。

此外,由于缺乏必要的 ARIA 属性(如 aria-haspopup, aria-controls, aria-expanded),屏幕阅读器也无法准确地向用户宣告“这是一个可以展开的菜单,它当前是展开/关闭状态”,从而造成了信息障碍。

2. 痛点二:受限的状态管理

一个健壮的组件,其状态变化应该可预测,并能优雅地处理各种边界交互。

请您再次体验一下:

  1. 使用鼠标点击“用户头像”按钮,展开菜单。
  2. 问题来了: 现在,请点击菜单面板之外的任何页面空白区域。在一个设计良好的应用中,您期望菜单会自动关闭。但您会发现,我们的菜单依然固执地停留在那里。
  3. 再次尝试: 重新展开菜单,然后滚动页面。您会看到菜单面板悬浮在原地,而触发它的按钮可能已经滚出了可视区域,造成了非常尴尬的视觉脱节。

问题根源:

  • 简单的状态切换机制: 基于 <details> 标签的方案,其内部状态(open/closed)只能通过再次点击 <summary> 来切换。它没有“外部点击”或“页面滚动”等概念,因此无法响应这些在现代 Web 应用中至关重要的交互事件。这种状态管理的局限性,导致了不符合用户直觉的、粗糙的交互体验。

3. 痛点三:脆弱的组合性

一个优秀的设计系统组件,应该像乐高积木一样,具备高度的灵活性和组合能力,以适应未来不断变化的设计需求。

让我们进行一个思想实验:
假设设计师提出了一个新的需求:“我们需要在‘设置’和‘退出登录’之间,加入一条分割线;并且,‘退出登录’选项需要用红色突出显示,并在前面加上一个‘退出’图标。”

使用我们当前的组件结构,实现这个需求会非常笨拙。我们也许可以在 li 之间硬编码一个 <div class="divider">,或者为最后一个 a 标签添加 text-red-500。但如果需求再复杂一点呢?“我们需要在菜单顶部加入一个不可点击的标题,显示当前用户名和邮箱。”

问题根源:

  • 固化的内部结构: daisyUImenu 样式,期望它的子元素是 <li><a>...</a></li> 这样的固定结构。任何对这个结构的破坏(例如直接插入一个 divh3),都可能导致样式错乱。组件的内部实现被紧紧地耦合在了一起,它是一个“整体”,而不是一个由多个独立、可自由组合的“部分”构成的集合。

总结:定位差距

我们将上述分析总结如下,以清晰地定位我们当前方案与生产级要求之间的差距。

维度daisyUI 纯 CSS 方案生产级组件要求 (The Gap)
可访问性❌ 键盘导航、焦点管理、ARIA 支持缺失✅ 完整的键盘操作、焦点循环/陷阱、语义化 ARIA
状态管理❌ 无法处理外部点击、页面滚动等场景✅ 响应多种关闭事件,状态可被程序控制
组合性❌ 内部结构固定,难以扩展✅ 灵活的 API,允许自由组合内部元素(如分割线、标题、自定义项)

结论非常明确daisyUI 为我们提供了一个无与伦比的快速起点,但要构建一个真正健壮、易用、可访问且灵活的 DropdownMenu 组件,我们必须引入一个更专业的、由 JavaScript 驱动的底层解决方案。

我们刚刚发现的所有问题——可访问性、状态管理、组合性——正是“无头(Headless)”UI 库所要解决的核心痛点。这为我们下一节引入 Radix UI 提供了充分且必要的动机。


3.2. 深度进阶:引入 Radix UI 实现“无头” DropdownMenu

在上一节中,我们遇到的所有关于可访问性、状态管理和组合性的挑战,都指向了一个共同的根源:一个纯 CSS 驱动的组件,在应对复杂的交互逻辑时能力有限。我们需要一个专业的、由 JavaScript 驱动的 行为层 来弥补这些不足。

而这个问题的完美答案,就是 Radix UI

3.2.1. 解决方案:Radix UI DropdownMenu Primitive 介绍

回顾与展望:Radix UI 在我们项目中的角色演进

在深入 DropdownMenu 之前,我们有必要先回顾一下 Radix UI 在我们项目中的定位。

第二章 构建 Button 组件时,我们已经接触过 Radix UI 的一个成员:@radix-ui/react-slot。当时,我们把它当作一个 小巧的工具 来使用,它的唯一作用是帮助我们解决 asChild 的渲染问题。可以说,它在当时扮演的是一个“配角”。

而从 本章开始,Radix UI 的角色将发生根本性的转变。我们将不再仅仅使用它的某个工具,而是开始全面拥抱它的核心产品——“组件原语 (Primitives)”。一个 “Primitive” 是一套针对特定 UI 模式(如 DropdownMenu, Dialog, Checkbox)的、功能完备、无样式的底层组件集合。Radix UI 将从“配角”正式升级为我们设计系统中 行为与可访问性的核心引擎

核心哲学:“无头 (Headless)” UI

Radix Primitives 的核心设计哲学是“无头 (Headless)”。您可以将其理解为:Radix 提供了一辆汽车所有精密的内部构件——发动机、变速箱、悬挂系统和安全气囊,但完全不提供车身外壳和内饰。

这种彻底的“关注点分离”模式,完美地契合了我们的需求:

  • Radix 负责(我们遇到的难题):
    • 状态管理: DropdownMenu 是展开还是关闭?
    • 事件处理: 用户按下了哪个键?是否点击了外部区域?
    • 焦点管理: 菜单打开后,焦点应该去哪里?关闭后,焦点应该返回何处?
    • 可访问性: 需要动态添加和管理哪些 WAI-ARIA 属性?
  • 我们负责(我们擅长且需要定制的部分):
    • 视觉样式: 这个菜单看起来应该是什么样子?我们将使用 daisyUITailwind 的类名来为 Radix 提供的“骨架”赋予我们 Prorise UI 的“皮肤”。

我们在 3.1.2 节中遇到的问题,根源不在于 daisyUI 的样式不好看,而在于其行为层有所欠缺。Radix UI 恰好能完美补足这一环,让我们保留 daisyUI 的美观与便捷,同时获得世界级的组件行为。

Radix DropdownMenu 如何解决我们的痛点

现在,让我们逐一审视 Radix DropdownMenu 是如何精确地解决我们之前发现的所有问题的。

1. 针对“不完整的可访问性”

Radix DropdownMenu 内置了完全符合 WAI-ARIA 创作实践 的所有功能,开箱即用:

  • 完整的键盘导航: 用户可以使用 ArrowUp/ArrowDown 在菜单项之间移动,使用 Home/End 跳转到首/尾项,使用 Enter/Space 触发选项,完全无需鼠标。
  • 专业的焦点管理: 菜单展开时,焦点会自动移入菜单项中,并被“锁定”在菜单内部,防止意外跳出;菜单关闭时,焦点会自动返回到原来的触发器按钮上。
  • 自动化的 ARIA 属性: Radix 会在运行时自动为各个部分添加和更新正确的 ARIA 属性(如 role="menu"role="menuitem"aria-haspopup="true"aria-expanded="..."),向屏幕阅读器等辅助技术准确地传达组件的结构和状态。

2. 针对“受限的状态管理”

Radix DropdownMenu 的状态管理机制非常成熟和健壮:

  • 智能的关闭行为: 它默认处理了所有符合用户直觉的关闭场景,包括:
    • 点击菜单项后自动关闭。
    • 按下 Escape 键后自动关闭。
    • 点击菜单外部的任何区域后自动关闭。
    • 页面滚动时自动关闭。
  • 可控的状态: 其内部的 open 状态由组件自动管理,同时也支持通过 props 从外部进行受控,以满足更高级的编程需求。

3. 针对“脆弱的组合性”

这正是 Radix DropdownMenu 乃至所有 Radix Primitives 最闪耀的优点。它采用了我们本章即将深入学习的 复合组件 模式,其 API 结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';

<DropdownMenu.Root>
<DropdownMenu.Trigger>...</DropdownMenu.Trigger>

<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Label>...</DropdownMenu.Label>
<DropdownMenu.Item>...</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>...</DropdownMenu.Item>
{/* 还可以嵌套子菜单 */}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>...</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
...
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>

解析: Radix 将一个整体的 DropdownMenu 拆解为了一系列具有明确语义的、可自由组合的子组件,例如:

  • Root: 整个组件的根容器,负责状态管理。
  • Trigger: 触发菜单展开的元素。
  • Content: 浮动菜单面板的容器。
  • Item: 可点击的菜单项。
  • Separator: 分割线。
  • Label: 不可点击的标签/标题。
  • Sub: 用于创建嵌套子菜单的容器。

这种 API 设计,赋予了我们前所未有的灵活性。我们可以在 Content 内部自由地组合这些部件,轻松实现上一节中提到的“添加分割线”、“添加标题”等需求,而无需担心破坏组件的内部结构和行为。

结论:
Radix UI 并非另一个 UI 库,而是我们构建高质量自定义组件的“行为层加速器”。它为我们提供了坚不可摧、完全可访问的组件骨架。

现在我们已经深刻理解了 Radix UI 将为我们带来的巨大价值,下一步,就是正式在项目中引入它,开始构建我们 Prorise UI 自己的、融合了两大体系优点的 DropdownMenu 组件。


3.2.2. 准备工作:安装依赖并创建组件文件

理论学习已经完成,现在我们正式进入 Prorise UI DropdownMenu 的构建阶段。第一步是为我们的项目引入必要的依赖,并创建承载组件代码的文件。

第一步:安装 Radix UI DropdownMenu 依赖

我们需要在项目中安装 @radix-ui/react-dropdown-menu 这个包,它包含了我们上一节讨论的所有 DropdownMenu 相关的“无头”组件原语。

在项目根目录下,执行以下命令:

1
pnpm install @radix-ui/react-dropdown-menu

第二步:创建组件文件

遵循我们在 Button 组件中建立的约定,我们将在 src/components/ui 目录下为新的 DropdownMenu 组件创建家园。

1
2
# 在项目根目录下执行
touch src/components/ui/DropdownMenu.tsx

至此,我们的项目已经准备就绪,迎接 Radix UI 的入驻。有了这个空的文件和安装好的依赖,我们就可以开始我们所熟悉的“黄金工作流”的第一步——在 Storybook 中构思和可视化我们的新组件。

3.2.3. 遵循“黄金工作流”:编写 DropdownMenu.stories.tsx

我们已经为 DropdownMenu 组件准备好了“施工场地”(创建了文件并安装了依赖)。现在,我们将严格遵循在第二章中建立的“黄金工作流”,从第一步——Storybook First——开始。

这意味着,我们将暂时搁置 DropdownMenu.tsx 的内部实现,而是先创建 DropdownMenu.stories.tsx 文件。在这个文件中,我们将以一个 组件消费者 的视角,来设计和构思我们最终想要得到的 DropdownMenu 应该具备什么样的 API 和结构。这份 Story 文件,将成为我们后续编码实现的“蓝图”和“验收标准”。

第一步:创建 Story 文件

Button 组件一样,我们在组件旁边创建它的故事文件。

1
2
# 在项目根目录下执行
touch src/components/ui/DropdownMenu.stories.tsx

第二步:创建临时的“脚手架”实现

目前,DropdownMenu.tsx 文件还是空的。如果我们直接在 Story 文件中尝试从它导入组件,程序将会报错。为了让 Storybook 能够顺利运行,我们需要在 DropdownMenu.tsx 中导出一组最简化的、临时的占位符组件。

这个步骤的目的是为了让我们的 Story 文件有东西可以 import,从而让我们能专注于设计 Story 的 API 结构,而不必关心组件的实际功能。

文件路径: src/components/ui/DropdownMenu.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import * as React from 'react';

// -----------------------------------------------------------------------------
// 警告:这是临时的占位符代码!
// 它的唯一作用是让 Storybook 文件可以成功导入组件,我们将在下一节中用 Radix UI 的实现彻底替换它。
// -----------------------------------------------------------------------------

const DropdownMenu = ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
);

const DropdownMenuTrigger = ({ children }: { children: React.ReactNode }) => (
<>{children}</>
);

const DropdownMenuContent = ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
);

const DropdownMenuItem = ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
);

export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
};

解析: 我们创建了一组与 Radix API 同名的、极其简陋的组件,它们目前只负责渲染子元素。这足以满足我们编写 Story 的前置条件。

第三步:设计并编写第一个 Story

现在,万事俱备,我们可以开始编写 DropdownMenu.stories.tsx 了。我们的目标是,用 JSX 代码来“画出”我们心目中理想的 DropdownMenu 使用方式。

文件路径: src/components/ui/DropdownMenu.stories.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import type { Meta, StoryObj } from '@storybook/nextjs-vite';

// 导入我们刚刚创建的(临时的)DropdownMenu 组件
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './DropdownMenu';

// 导入我们在第二章中创建的 Button 组件,我们将用它作为触发器
import { Button } from './Button';
const meta: Meta<typeof DropdownMenu> = {
title: 'UI/DropdownMenu',
component: DropdownMenu, // 关联根组件
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof meta>;

// 默认的 Story,展示了我们期望的 API 使用方式
export const Default: Story = {
render: () => (
<DropdownMenu>
{/* 注意这里的asChild十分重要,因为DropDownMenuTrigger本身就有一个按钮作为触发器,我们嵌套我们的按钮的情况下就需要asChild */}
<DropdownMenuTrigger asChild>
<Button buttonStyle="outline">打开菜单</Button>
</DropdownMenuTrigger>

<DropdownMenuContent>
<DropdownMenuItem>个人资料</DropdownMenuItem>
<DropdownMenuItem>账单</DropdownMenuItem>
<DropdownMenuItem>设置</DropdownMenuItem>
<DropdownMenuItem>退出登录</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
};

代码深度解析:

  • render 函数: 对于 Button 这样的简单组件,我们通常使用 args 来传递 props。但对于由多个部分构成的复合组件,使用 render 函数来显式地编写 JSX 结构是更清晰、更常见的方式。
  • API 设计: 请仔细观察 render 函数中的 JSX 结构。这正是我们为 Prorise UI DropdownMenu 设计的 API。它清晰、语义化,并且充满了组合的美感:
    • <DropdownMenu> 作为根容器。
    • <DropdownMenuTrigger> 包裹着触发器。
    • <DropdownMenuContent> 包裹着菜单面板。
    • <DropdownMenuItem> 代表每一个菜单项。
  • 组件复用: 我们将第二章的 Button 组件,通过 asChild prop 传递给 <DropdownMenuTrigger>。这完美地展示了我们设计系统内部组件之间的 可组合性DropdownMenuTrigger 负责交互行为,而 Button 负责视觉表现,职责分离。

第四步:验证“蓝图”

现在,运行 Storybook:

1
pnpm storybook

导航到侧边栏的 UI/DropdownMenu。您会看到一个名为 Default 的 Story。它的渲染结果可能并不像一个真正的下拉菜单(因为它目前是由我们的占位符组件渲染的),但它能够被成功渲染,并且没有报任何错误。

这就是当前阶段我们所期望的、完全正确的结果。

我们已经成功地应用了“黄金工作流”的第一步。我们有了一份用代码写成的、清晰的“组件蓝图”(Story),它精确地定义了我们接下来要去实现的目标。

这份蓝图已经提出了一个深刻的问题:这几个看似独立的 DropdownMenu... 子组件,是如何在不通过 props 显式传递的情况下,就能相互协作,共同管理“展开/关闭”状态的呢?

要回答这个问题,并用 Radix UI 的真实实现替换掉我们的临时脚手架,我们就必须深入探索其背后的核心魔法——这正是我们下一节的主题。


3.3. 设计模式深潜:React Context 与复合组件模式

在上一节中,我们为 DropdownMenu 组件设计出了一套看似“神奇”的 API 蓝图。现在,我们将暂时停下编码的脚步,深入探索其背后的理论基石。本节内容是理解现代 React 组件库设计的核心,也是您从“组件使用者”蜕变为“组件设计者”的关键一环。

3.3.1. 问题引入:为什么 Radix 的 API 是复合形式?

要理解这种 API 形式的优越性,我们首先要审视它的对立面——“巨石型 (Monolithic)” 组件。

“巨石型”组件的困境

假设我们不采用 Radix 的 API,而是尝试将一个 DropdownMenu 的所有功能都封装在一个单一的组件中,并通过 props 来进行配置。它的使用方式可能会是这样:

1
2
3
4
5
6
7
8
9
10
11
// 一个假想的、设计不良的“巨石型”下拉菜单 (错误示范)
<MonolithicDropdown
triggerContent={<Button>Open Menu</Button>}
items={[
{ type: 'item', label: 'Profile' },
{ type: 'item', label: 'Billing', disabled: true },
{ type: 'separator' },
{ type: 'item', label: 'Log out' }
]}
onItemSelect={(item) => console.log(item.label)}
/>

初看起来,这种方式似乎可行。但随着需求的演进,其弊端会迅速暴露:

  1. 僵化且复杂的 API: 我们被迫为 items 属性设计一套复杂的数据结构。如果现在需要在一个菜单项里加入图标和快捷键提示,{ type: 'item', label: '...' } 这个结构就需要被扩展为 { type: 'item', label: '...', icon: <Icon/>, shortcut: '⌘P' }。每增加一个新功能,这个自创的、非标准的 API 就会变得更臃肿一分,学习和使用成本急剧上升。

  2. 有限的灵活性与控制权: 使用者被完全“囚禁”在组件的内部渲染逻辑中。我们无法自由地控制菜单项的渲染,也无法在菜单项之间插入自定义的组件(例如一个搜索框)。任何微小的定制化需求,都可能需要 MonolithicDropdown 组件去新增一个对应的 prop 来支持,最终导致组件的 props 列表无限膨胀,难以维护。

  3. 隐晦的状态管理: 组件的所有状态都封装在内部,对外是一个“黑盒”。我们很难对组件的行为进行精细的控制或扩展。

解决方案:复合组件模式

复合组件模式,正是为了解决上述所有问题而生。

核心定义: 复合组件模式,是指将一个复杂的 UI 组件拆分为多个独立的、协同工作的子组件,它们共享一个隐式的、共同的状态,共同完成一个完整的交互功能。

这个模式最经典、最广为人知的例子,就是 HTML 的 <select><option> 标签:

1
2
3
4
<select>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
</select>

<select><option> 是两个独立的元素,但它们天生就是为了协同工作。您无需向 <select> 传入一个 items 数组,而是可以用最自然、最符合直觉的声明式语法来“组合”它们。<select> 负责管理整体的状态(当前选中的值),而 <option> 则负责展示单个选项。

Radix UI 的 API 正是这一模式在 React 世界中的完美体现:

1
2
3
4
5
6
7
<DropdownMenu.Root>
<DropdownMenu.Trigger>...</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item>...</DropdownMenu.Item>
<DropdownMenu.Separator />
</DropdownMenu.Content>
</DropdownMenu.Root>

这种模式的优势:

  1. 声明式且富有表现力的 API: 使用者无需学习任何自定义的数据结构,而是使用最熟悉的 JSX 语法,像拼搭乐高积木一样,自由地“组合”出所需的 UI 结构。代码即文档,其结构和意图一目了然。

  2. 极致的灵活性与控制权: 每一个部分(Trigger, Content, Item)都是一个真正的 React 组件,这意味着使用者拥有了对其内容的 完全控制权

    • 想让触发器是一个带头像的按钮?没问题:<DropdownMenu.Trigger><Avatar src="..." /></DropdownMenu.Trigger>
    • 想在菜单项中加入图标和快捷键提示?没问题:<DropdownMenu.Item><Icon/> Profile <Kbd>⌘P</Kbd></DropdownMenu.Item>
    • 想在菜单顶部加一个搜索框?没问题,直接在 <DropdownMenu.Content> 内部渲染一个 <Input /> 组件即可。
  3. 隐式的状态共享: 这是该模式的“魔法”所在。DropdownMenu.Root 组件会在其内部创建一个“共享的状态空间”,并将菜单的当前状态(例如是否展开 isOpen)以及操作状态的方法(例如 toggle 函数)放入这个空间。所有嵌套在它内部的子组件(Trigger, Content 等)都能够自动地、隐式地访问到这个共享空间,从而读取状态或触发行为。

  • <Trigger> 组件能从共享空间中拿到 toggle 函数,并将其绑定到自己的 onClick 事件上。
    - <Content> 组件能从共享空间中读取到 isOpen 状态,并据此决定自己是否应该被渲染。

这种实现隐式状态共享的技术,正是 React 的核心特性之一 —— Context API

结论:
我们之所以选择并推崇 Radix UI 这种 API 形式,是因为复合组件模式提供了一种远比“巨石型”组件更优雅、更灵活、更具扩展性的解决方案。它将控制权交还给开发者,同时通过隐式的状态共享,巧妙地隐藏了内部的复杂性。

技术架构深度问答
2025-10-12 21:30,
学习者

老师,我有一个疑问。您刚刚分析了“巨石型”组件的种种弊端,但我发现像 antd 这样的顶级组件库,在最新的版本里 Dropdown 组件恰恰就是使用了 items 数组这种数据驱动的方式。这让我很困惑,难道 antd 的设计是在“退化”吗?它为什么要选择我们刚刚“批判”过的模式呢?,

问得太好了!您的观察完全正确,antd 确实采用了数据驱动的 API。但这并非“退化”,而是一种在不同设计目标下做出的、同样专业且合理的架构选择。,

antd 的选择和 Radix UI 的选择,代表了两种不同的组件库设计哲学,它们各自在不同的场景下表现得更为出色。

Ant Design 的设计哲学与目标场景:

1.优先保证“一致性”与“可控性” antd 作为一个“开箱即用”的、完整的企业级 UI 解决方案,它的首要任务是保证视觉和交互的绝对一致。通过 items 数组这种数据驱动的 API,antdDropdown 组件可以完全掌控每一个菜单项、分割线、子菜单的渲染逻辑。它能确保无论开发者如何传递数据,最终渲染出的 DOM 结构和样式都 100% 符合 Ant Design 的设计规范。而复合组件模式将渲染的控制权交给了用户,这虽然灵活,但也增加了用户“误用”或“破坏”设计一致性的风险。,

2.极度优化“动态”与“数据驱动”的场景 这是选择数据驱动 API 最核心的原因。在真实的企业级应用(尤其是中后台系统)中,下拉菜单的内容极少是静态写死的,它们通常来自于后端的 API 请求。将一份 JSON 数据,通过 .map() 方法转换成 items 数组的结构,是一种非常自然、函数式且数据驱动的编程模式。例如:const items = userRoles.map(role => ({ key: role.id, label: role.name })); 这种模式对于从数据到 UI 的映射非常直接。,

3.便于“序列化”与“配置化” items 数组本质上是一个纯粹的 JavaScript 对象,这意味着它可以被轻松地序列化为 JSON 字符串。这带来了一个巨大的优势:您可以将整个菜单的结构存储在数据库、或者通过一个低代码平台进行可视化配置,然后动态下发给前端进行渲染。这是复合组件的 JSX 结构难以做到的。,

学习者

我明白了。所以 antd 的模式,更适合那些 UI 风格统一、且内容高度动态化的企业级应用。它牺牲了一部分开发者的灵活性,换来了系统的规范性、一致性和对动态数据的亲和力。,

总结得非常到位!现在我们再来看 Radix UI 和我们的课程所选择的复合组件模式:,

1.优先保证“灵活性”与“组合能力” 我们的目标是构建一个设计系统,而不是直接使用一个设计系统。我们希望我们的底层组件(如 DropdownMenu)能像乐高积木一样,被上层业务开发者以任何富有创造力的方式进行组合。复合组件模式将渲染的完全控制权交给了开发者,你可以把任何东西(图标、头像、自定义组件)放进 DropdownMenuItem,这是数据驱动模式难以企及的。,

2.API 更符合 React 的“声明式”直觉 <DropdownMenu><Trigger/><Content/></DropdownMenu> 的写法,与开发者编写 HTML 和 JSX 的心智模型完全一致,学习成本更低,代码的可读性也更强。,

学习者

所以,这两种模式并不是“先进”与“落后”的对立,而是“灵活”与“规范”在天平两端的不同侧重?,

完全正确!这是一个典型的架构权衡。不存在银弹。,

设计模式, 优先目标, 最佳适用场景, 数据驱动 (antd), 一致性、可控性、数据映射, 企业级中后台、风格统一的应用、菜单结构由后端动态生成, 复合组件 (Radix), 灵活性、组合能力、声明式 API, 设计系统底层、需要高度定制化的 UI、构建富有表现力的组件,

在我们的课程中,之所以选择深入讲解复合组件模式,是因为它更能揭示 React 的核心能力(如 Context),更能锻炼我们作为“组件设计者”的内功。掌握了它,您再去使用 antd 的数据驱动模式,就会对其设计背后的权衡有更深刻的理解。,

学习者

豁然开朗!感谢老师,我完全明白了。

现在,我们已经理解了复合组件模式的“是什么”和“为什么”,在下一节中,我们将深入其内部,揭开 React Context 是“如何”实现这一切的神秘面纱。


3.3.2. 核心原理解析:构建一个遵循最佳实践的 React Context

在上一节,我们明确了复合组件模式依赖于一种“隐式状态共享”的机制。本节,我们将亲手构建这个机制的核心——React Context。

我们的目标不是泛泛地介绍 API,而是要 构建一个生产级别的、类型安全的、且包含开发者体验优化 的 Context 结构。这个结构将作为我们下一节实现 DropdownMenu 所有子组件之间通信的“神经网络”。

第一步:创建独立的 Context 文件

一个重要的最佳实践是:对于任何非一次性的、将在多个组件间共享的 Context,都应该将它定义在自己的独立文件中。这使得 Context 的定义变得清晰、可复用,并避免了组件文件的臃肿。

首先,让我们为 DropdownMenu 的 Context 创建一个专属文件。

1
2
# 在项目根目录下执行
touch src/components/ui/DropdownMenuContext.tsx

第二步:定义 Context 的类型与初始值

现在,我们打开这个新文件。我们的第一项任务是定义这个“共享管道”中将要流动的数据的“形状”,然后使用 createContext API 来创建 Context 对象本身。

文件路径: src/components/ui/DropdownMenuContext.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { createContext } from 'react';

// 1. 定义我们希望在 DropdownMenu 组件树中共享的数据结构(类型)
// 这个接口明确了所有子组件可以访问的状态和方法
export interface DropdownMenuContextValue {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
// 未来我们可以添加更多需要共享的状态,例如键盘导航的焦点索引等
}

// 2. 使用 createContext 创建上下文对象
// 初始值为 null,因为在 Provider 之外单独使用子组件是没有意义的
// 我们通过类型 <DropdownMenuContextValue | null> 告知 TypeScript 这一点
export const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);

代码深度解析:

  • interface DropdownMenuContextValue: 我们通过 TypeScript 接口,精确地定义了将在 DropdownMenu 组件家族中共享的所有状态(isOpen)和方法(setIsOpen)。这是一个至关重要的步骤,它为我们的复合组件提供了类型安全的保障。
  • createContext<... | null>(null): 我们调用 React 的 createContext 函数。传入 null 作为默认值是一个标准模式,它清晰地表达了一个意图:这个 Context 的消费者 必须 被包裹在一个提供了真实 valueProvider 内部才能正常工作。

第三步:(最佳实践) 封装自定义消费 Hook

直接导出并让子组件使用 useContext(DropdownMenuContext) 是可行的,但这存在一个隐患:如果开发者忘记将子组件包裹在 Provider 中,useContext 会返回 null,从而在运行时导致 TypeError,这种错误往往难以追踪。

一个更健壮、更专业的做法是,不直接导出 Context 对象本身,而是创建一个并导出 自定义 Hook 来包装 useContext 的调用。

文件路径: src/components/ui/DropdownMenuContext.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { createContext, useContext } from 'react'; // 导入 useContext

export interface DropdownMenuContextValue {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}

export const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);

// 3. 封装一个自定义 Hook 来消费 Context
export const useDropdownMenuContext = () => {
// 调用 React 原生的 useContext
const context = useContext(DropdownMenuContext);

// 这是最关键的一步:进行存在性检查
if (!context) {
// 如果 context 为 null,意味着该 Hook 没有在 Provider 内部被调用
// 我们立即抛出一个明确的、有指导意义的错误
throw new Error(
'useDropdownMenuContext 必须在 DropdownMenu (Provider) 组件内部使用'
);
}

// 如果检查通过,返回 context 值
return context;
};

代码深度解析:

  • useDropdownMenuContext: 我们创建了这个自定义 Hook,它成为了所有子组件消费 Context 的 唯一入口
  • if (!context) { throw new Error(...) }: 这个检查极大地提升了我们组件库的 开发者体验 (DX)。它将一个可能在运行时发生的、模糊的 TypeError,转化为了一个在开发阶段就会立即出现的、带有清晰修复指引的错误。这强制了我们复合组件 API 的正确使用方式。

第四步:简述性能注意事项

在您未来自己构建复杂的 Context 时,有一个核心性能原则需要牢记:当一个 Context Provider 的 value 属性发生引用变化时,所有消费该 Context 的子组件都会被强制重新渲染。

这意味着,如果 value 是一个在父组件每次渲染时都会重新创建的对象(value={{ theme, user }}),那么即使父组件只是因为一个无关的状态(比如 user)更新而重渲染,所有只依赖 theme 的子组件也会被不必要地重新渲染。

最佳实践:

  • 保持 Context 的职责单一: 尽量不要将互不相关、更新频率差异巨大的状态放在同一个 Context 中。
  • 拆分 Context: 对于大型应用,更好的做法是创建多个职责更细分的 Context(例如,ThemeContext, UserContext, AuthContext),让组件只订阅它真正需要的数据。

Radix UI 的内部实现已经为我们处理了极其复杂的性能优化。我们在这里学习这一原则,是为了在我们未来自己从零构建复合组件时,能够写出更高性能的代码。

至此,我们已经构建了一个完整的、遵循最佳实践的 Context 体系。DropdownMenuContext.tsx 这个文件现在为我们提供了:

  • 一个类型安全的 数据结构定义
  • 一个用于数据提供的 Context 对象
  • 一个用于安全消费数据的 自定义 Hook

这个文件就是我们 DropdownMenu 组件内部通信的“神经网络”。有了它,我们终于准备好在下一节中,动手构建真正由 Radix UI 驱动、并通过这个 Context(在 Radix 内部实现)协同工作的各个子组件了。


3.3.3. 动手实践:构建 Prorise UI DropdownMenu (原理与实现)

理论学习已经完成。我们深刻理解了复合组件模式的优势,以及 React Context 是其得以实现的底层技术。现在,是时候将所有知识融会贯通,回到 DropdownMenu.tsx 文件,将我们此前的“脚手架”替换为由 Radix UI 驱动的、真正具备专业功能的实现。

我们的核心任务是:将无样式的 Radix UI 原语,与我们已有的 daisyUITailwind 样式进行结合,创造出我们自己的、完全符合 Prorise UI 设计规范的 DropdownMenu 组件。

第一步:清空文件并设置基础导入

首先,请打开 DropdownMenu.tsx 文件,并 删除所有 我们在 3.2.3 小节中编写的临时模拟代码。我们将从一个干净的文件开始。

文件路径: src/components/ui/DropdownMenu.tsx

1
2
3
4
5
6
'use client'; // 1. 标记为客户端组件

import * as React from 'react';
import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu'; // 2. 导入 Radix UI 包

import { cn } from '@/lib/utils'; // 3. 导入我们的样式合并工具

代码深度解析:

  1. 'use client';: 这是一个至关重要的指令。因为 Radix UI 组件包含 useState, useEffect 等 Hooks,并且需要与用户的浏览器事件交互,所以它们必须在客户端组件中运行。在 Next.js App Router 架构下,我们需要在文件顶部明确声明。
  2. import * as RadixDropdownMenu ...: 我们将整个 Radix DropdownMenu 包导入到一个命名空间 RadixDropdownMenu 中,这能避免命名冲突,并清晰地表明我们正在使用的是 Radix 的底层能力。
  3. import { cn } ...: 导入由 shadcn/ui init 自动生成的 cn 工具函数,它将帮助我们智能地合并默认样式和自定义样式。

第二步:导出无需自定义样式的“直通”组件

Radix DropdownMenu 的一部分原语,如 Root, Trigger, Group 等,主要是逻辑容器,它们本身不需要应用额外的样式。对于这些部分,我们最简单的做法就是直接将它们重命名并导出。

文件路径: src/components/ui/DropdownMenu.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ... 此前的 imports ...


// 直接重命名并导出 Radix 组件,作为我们自己组件库的一部分
// 根组件:提供状态管理,是所有其他组件的父组件
const DropdownMenu = RadixDropdownMenu.Root;
// 触发器组件:用户点击或悬停触发下拉菜单
const DropdownMenuTrigger = RadixDropdownMenu.Trigger;
// 分组组件:用于组织相关的菜单项,将逻辑上相关的菜单项组织在一起
const DropdownMenuGroup = RadixDropdownMenu.Group;
// Portal 组件:将下拉菜单内容渲染到 DOM 树的其他位置(通常是 body),避免 z-index 和 overflow 问题
const DropdownMenuPortal = RadixDropdownMenu.Portal;
// 子菜单组件:用于创建嵌套的下拉菜单(菜单项中包含子菜单)
const DropdownMenuSub = RadixDropdownMenu.Sub;
// 单选组组件:用于创建单选菜单项组,类似 Radio Group 的行为
const DropdownMenuRadioGroup = RadixDropdownMenu.RadioGroup;

解析: 这种直接导出的方式非常高效。当用户从我们的文件中导入并使用 <DropdownMenu> 时,他们实际上得到的就是功能完备的 RadixDropdownMenu.Root 组件。

第三步:封装并注入样式的核心组件 (DropdownMenuContent)

DropdownMenuContent 是下拉菜单的“面板”,是视觉呈现的核心。我们将创建一个新的 React 组件,在内部渲染 Radix 的 Content 原语,并为其注入我们 daisyUI 的样式。

文件路径: src/components/ui/DropdownMenu.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ... 此前的 imports 和直接导出的部分 ...
// 封装并自定义 DropdownMenuContent
const DropdownMenuContent = React.forwardRef<
// 1. 推断 Radix Content 元素的 ref 类型
React.ComponentRef<typeof RadixDropdownMenu.Content>,
// 2. 推断 Radix Content 组件的所有 props 类型
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<RadixDropdownMenu.Portal>
<RadixDropdownMenu.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
// 3. 扁平设计风格干净的背景边框无阴影
'menu bg-base-100 text-base-content z-50 min-w-[10rem] overflow-hidden rounded-lg border border-base-300 p-1',
className
)}
{...props}
/>
</RadixDropdownMenu.Portal>
));
DropdownMenuContent.displayName = RadixDropdownMenu.Content.displayName;

代码深度解析:

  1. React.ComponentRef<...>: 我们从 Radix 的组件类型中推断出其底层 DOM 元素的 ref 类型,确保 forwardRef 的类型安全。
  2. React.ComponentPropsWithoutRef<...>: 我们同样推断出 Radix Content 组件的所有 props 类型,这意味着我们的封装组件可以无缝接收 Radix 原生的所有 props(如 align, onCloseAutoFocus 等),并保持完整的类型提示。
  3. className={cn(...): 这是 样式注入 的核心。我们使用 cn 函数,将一组 daisyUI (menu, rounded-box, bg-base-200) 和 Tailwind (z-50, min-w-[8rem], shadow-lg) 的类名作为基础样式,并允许使用者通过 className prop 传入额外的类名进行覆盖或扩展。
  4. <RadixDropdownMenu.Portal>: 这是一个至关重要的包裹。它会将菜单面板“传送”到 <body> 的末尾进行渲染,从而彻底避免被父元素的 overflow: hiddenz-index 样式所裁剪或遮挡,保证了菜单总能正确地浮动在所有内容的顶层。

第四步:封装菜单项与其他部分

我们使用与 DropdownMenuContent 相同的模式,继续封装 Item, Label, Separator 等其他部分。

文件路径: src/components/ui/DropdownMenu.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// ... 此前的 DropdownMenuContent ...

// 封装 DropdownMenuItem 组件
// 这里将 Radix 原有的 Item props 与我们自定义的 { inset?: boolean } 合并
// inset 是我们添加的自定义属性,用于控制菜单项是否需要缩进(比如在有图标的菜单中对齐文本)
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof RadixDropdownMenu.Item>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<RadixDropdownMenu.Item
ref={ref}
className={cn(
// 扁平设计简洁的背景色变化无边框装饰
'cursor-pointer rounded-md px-3 py-2 text-sm outline-none transition-colors',
'hover:bg-base-200 focus:bg-base-200',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8', // 为带图标的菜单项提供缩进
className
)}
{...props}
/>
));

DropdownMenuItem.displayName = RadixDropdownMenu.Item.displayName;

const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof RadixDropdownMenu.Separator>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Separator>
>(({ className, ...props }, ref) => (
<RadixDropdownMenu.Separator
ref={ref}
className={cn('my-1 h-px bg-base-300', className)} // 简洁的分割线
{...props}
/>
));
DropdownMenuSeparator.displayName = RadixDropdownMenu.Separator.displayName;


// ... 其他子组件如 Label, CheckboxItem 等也可以用类似方式封装 ...

解析: 我们为 DropdownMenuItem 添加了 focusdata-[disabled] 状态下的样式,并为 DropdownMenuSeparator 应用了 muted 颜色,使其与我们的主题系统保持一致。

第五步:整合并导出

最后,我们将所有封装好的组件与之前直接导出的组件一起,从文件中导出,形成我们 Prorise UIDropdownMenu 组件家族。

文件路径: src/components/ui/DropdownMenu.tsx (最终版本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
'use client'; // 1. 标记为客户端组件

import * as React from 'react';
import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu'; // 2. 导入 Radix UI 包

import { cn } from '@/lib/utils'; // 3. 导入我们的样式合并工具

// 直接重命名并导出 Radix 组件,作为我们自己组件库的一部分
// 根组件:提供状态管理,是所有其他组件的父组件
const DropdownMenu = RadixDropdownMenu.Root;
// 触发器组件:用户点击或悬停触发下拉菜单
const DropdownMenuTrigger = RadixDropdownMenu.Trigger;
// 分组组件:用于组织相关的菜单项,将逻辑上相关的菜单项组织在一起
const DropdownMenuGroup = RadixDropdownMenu.Group;
// Portal 组件:将下拉菜单内容渲染到 DOM 树的其他位置(通常是 body),避免 z-index 和 overflow 问题
const DropdownMenuPortal = RadixDropdownMenu.Portal;
// 子菜单组件:用于创建嵌套的下拉菜单(菜单项中包含子菜单)
const DropdownMenuSub = RadixDropdownMenu.Sub;
// 单选组组件:用于创建单选菜单项组,类似 Radio Group 的行为
const DropdownMenuRadioGroup = RadixDropdownMenu.RadioGroup;

// 封装并自定义 DropdownMenuContent
const DropdownMenuContent = React.forwardRef<
// 1. 推断 Radix Content 元素的 ref 类型
React.ComponentRef<typeof RadixDropdownMenu.Content>,
// 2. 推断 Radix Content 组件的所有 props 类型
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<RadixDropdownMenu.Portal>
<RadixDropdownMenu.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
// 3. 扁平设计风格干净的背景边框无阴影
'menu bg-base-100 text-base-content z-50 min-w-[10rem] overflow-hidden rounded-lg border border-base-300 p-1',
className
)}
{...props}
/>
</RadixDropdownMenu.Portal>
));
DropdownMenuContent.displayName = RadixDropdownMenu.Content.displayName;

// 封装 DropdownMenuItem 组件
// 这里将 Radix 原有的 Item props 与我们自定义的 { inset?: boolean } 合并
// inset 是我们添加的自定义属性,用于控制菜单项是否需要缩进(比如在有图标的菜单中对齐文本)
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof RadixDropdownMenu.Item>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<RadixDropdownMenu.Item
ref={ref}
className={cn(
// 扁平设计简洁的背景色变化无边框装饰
'cursor-pointer rounded-md px-3 py-2 text-sm outline-none transition-colors',
'hover:bg-base-200 focus:bg-base-200',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8', // 为带图标的菜单项提供缩进
className
)}
{...props}
/>
));

DropdownMenuItem.displayName = RadixDropdownMenu.Item.displayName;

const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof RadixDropdownMenu.Separator>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Separator>
>(({ className, ...props }, ref) => (
<RadixDropdownMenu.Separator
ref={ref}
className={cn('my-1 h-px bg-base-300', className)} // 简洁的分割线
{...props}
/>
));
DropdownMenuSeparator.displayName = RadixDropdownMenu.Separator.displayName;

export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuRadioGroup,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
};

最终验证

现在,最激动人心的时刻到了。您 无需对 src/components/ui/DropdownMenu.stories.tsx 文件做任何修改。直接运行 Storybook:

img

1
pnpm storybook

导航到 UI/DropdownMenu/Default 这个 Story。您会发现,之前那个简陋的、没有交互的模拟组件,已经变成了一个功能完整、样式精美、交互顺滑、且完全可访问的 DropdownMenu

点击 Open Menu 按钮,菜单会平滑地弹出。尝试使用键盘的上下箭头进行导航,按下 Escape 键关闭菜单,或者点击菜单外部区域关闭菜单——所有这些在上一节中失效的功能,现在都完美地工作了。

这完美地印证了我们“黄金工作流”的威力:我们在 Storybook 中定义了“契约”(期望的 API 和外观),然后通过编码实现来“履行契约”。整个过程无缝衔接,目标明确。


3.4. 文档与测试:完善 DropdownMenu 的生态

3.4.1. 编写交互测试:为 Story 添加 play 函数

我们已经通过 play 函数验证了 Button 组件的点击行为。现在,我们将运用相同的技术,来为一个更复杂的复合组件——DropdownMenu——编写一套关键的交互测试脚本。

我们的测试目标是模拟一次完整的用户使用流程,并验证组件的核心行为是否符合预期。

第一步:确定测试流程

对于一个下拉菜单,最核心的用户流程可以分解为:

  1. 打开: 用户找到并点击触发器。
  2. 验证打开: 菜单面板应该从不可见到可见。
  3. 关闭: 用户通过键盘(例如 Escape 键)关闭菜单。
  4. 验证关闭: 菜单面板应该从可见变回到不存在于 DOM 中。

第二步:为 Default Story 添加 play 函数

我们将直接在 DropdownMenu.stories.tsx 文件中,为我们之前创建的 Default Story 附加一个 play 函数来实现上述流程。

文件路径: src/components/ui/DropdownMenu.stories.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import type { Meta, StoryObj } from '@storybook/react';
// <-- 1. 导入 @storybook/test 中的测试工具 -->
// 注意:除了 within,还需要导入 screen,用于查找 Portal 渲染的内容
import { within, userEvent, expect, screen } from '@storybook/test';

import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from './DropdownMenu';
import { Button } from './Button';

const meta: Meta<typeof DropdownMenu> = {
// ... 此前定义的 meta 对象 ...
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
render: (args) => (
<DropdownMenu>
{/*
关键点:使用 asChild 避免按钮嵌套
DropdownMenuTrigger 默认会渲染一个 <button> 元素,
而我们传入的 Button 组件本身也是 <button>。
如果不使用 asChild,会导致 <button><button>...</button></button> 的无效 HTML 结构。
asChild 会让 Trigger 不渲染自己的元素,而是将功能(如 aria-* 属性、事件处理)
合并到子元素上,最终只有一个 <button>。
*/}
<DropdownMenuTrigger asChild>
<Button buttonStyle="outline">打开菜单</Button>
</DropdownMenuTrigger>

<DropdownMenuContent>
<DropdownMenuItem>个人资料</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>账单</DropdownMenuItem>
<DropdownMenuItem>设置</DropdownMenuItem>
<DropdownMenuItem>退出登录</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
// <-- 2. 新增 play 函数,用于定义交互和断言 -->
play: async ({ canvasElement }) => {
// 获取故事的渲染画布
const canvas = within(canvasElement);

// 步骤 A: 找到触发器并模拟用户点击
// 触发器按钮在 canvasElement 内,所以使用 canvas 查找
const triggerButton = canvas.getByRole('button', { name: /打开菜单/i });
await userEvent.click(triggerButton);

// 步骤 B: 断言菜单内容已出现
// 关键点:使用 screen 而不是 canvas
// 因为 DropdownMenuContent 使用了 Portal,会将内容渲染到 document.body,
// 而不是 canvasElement 内部。canvas 只能查找 canvasElement 内的元素。
// screen 可以在整个文档中查找,适合查找 Portal 渲染的内容。
const profileItem = await screen.findByText(/个人资料/i);
await expect(profileItem).toBeVisible();

// 步骤 C: 模拟用户按下 "Escape" 键
await userEvent.keyboard('{escape}');

// 步骤 D: 断言菜单内容已消失
// 同样使用 screen 来查找 Portal 渲染的内容
const closedProfileItem = screen.queryByText(/个人资料/i);
await expect(closedProfileItem).not.toBeInTheDocument();
},
};

3.4.2 Storybook 交互测试核心方法总结

方法描述在此测试中的用途
within(canvasElement)创建一个查询范围,限定在当前 Story 的渲染区域内。初始化测试环境,用于查找 Story 渲染区域内的元素(如触发器按钮)。
screen提供在整个文档范围内查询元素的方法,不限于特定的渲染区域。关键工具:用于查找通过 Portal 渲染到 document.body 的元素(如 DropdownMenuContent)。Portal 元素不在 canvasElement 内,必须使用 screen 而非 canvas
canvas.getByRole(...)同步 查询一个符合指定 ARIA 角色的元素。如果找不到,会立即抛出错误。用于查找初始就可见的触发器按钮 (<button>),它位于 Story 的渲染区域内。
screen.findByText(...)异步 查询整个文档中包含指定文本的元素。它会等待一段时间(默认1秒),直到元素出现。用于验证通过 Portal 渲染的菜单面板是否已打开。因为菜单出现可能伴随动画,find 方法能可靠地等待元素变为可见。
screen.queryByText(...)同步 查询整个文档中包含指定文本的元素。如果找不到,它会返回 null不是 抛出错误。用于验证 Portal 渲染的菜单面板是否已成功关闭并从 DOM 中移除。这是断言元素 不存在 的标准方法。
userEvent.click(...)模拟用户完整的点击动作,包括 mousedownmouseup 等相关事件。模拟用户点击 “打开菜单” 按钮以触发菜单的显示。
userEvent.keyboard(...)模拟用户按下键盘上的按键。模拟用户按下 Escape 键来触发菜单的关闭逻辑。
expect(...).toBeVisible()Jest-DOM 断言,检查一个元素当前是否对用户可见(没有被 display: nonevisibility: hidden 等样式隐藏)。确认当菜单打开后,菜单项(例如 “个人资料”)是真实可见的,而不仅仅是存在于 DOM 中。
expect(...).not.toBeInTheDocument()Jest-DOM 断言,检查一个元素当前是否存在于 DOM 树中。确认当菜单关闭后,菜单项已经从 DOM 中被完全卸载,验证关闭行为的正确性。

第三步:关键陷阱与解决方案

在编写 DropdownMenu 的测试时,我们遇到了两个典型且容易被忽视的问题。理解这些问题对于测试复合组件至关重要。

陷阱 1:按钮嵌套问题

错误现象:测试报错 Found multiple elements with the role "button",显示 DOM 中出现了嵌套的 <button> 元素。

根本原因

  • Radix UI 的 DropdownMenuTrigger 默认渲染为 <button> 元素
  • 我们传入的 <Button> 组件本身也是 <button> 元素
  • 结果产生了无效的 HTML 结构:<button><button>打开菜单</button></button>

解决方案:使用 Radix UI 提供的 asChild prop

1
2
3
4
5
6
7
8
9
{/* ❌ 错误:会产生按钮嵌套 */}
<DropdownMenuTrigger>
<Button>打开菜单</Button>
</DropdownMenuTrigger>

{/* ✅ 正确:使用 asChild */}
<DropdownMenuTrigger asChild>
<Button>打开菜单</Button>
</DropdownMenuTrigger>

asChild 的作用是让 DropdownMenuTrigger 不渲染自己的 DOM 元素,而是将其所有功能(如 aria-expandedaria-haspopup、点击事件处理等)合并到子元素上。这样最终只有一个 <button> 元素,保持了 HTML 结构的正确性。

陷阱 2:Portal 渲染导致的查询失败

错误现象:测试报错 Unable to find an element with the text: /个人资料/i,即使菜单明显已经打开。

根本原因

  • DropdownMenuContent 内部使用了 RadixDropdownMenu.Portal
  • Portal 会将子元素渲染到 document.body,而不是当前组件的位置
  • canvas = within(canvasElement) 只能查找 #storybook-root 内的元素
  • 而菜单内容已经被 Portal “传送” 到了 document.body,不在 canvasElement 的范围内

为什么使用 Portal?

  • 避免父元素的 overflow: hiddenz-index 限制
  • 确保下拉菜单始终显示在页面的最上层
  • 这是浮层组件(Dropdown、Tooltip、Modal 等)的标准做法

解决方案:针对不同位置的元素使用不同的查询工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

// ✅ 触发器在 canvasElement 内,使用 canvas
const triggerButton = canvas.getByRole('button', { name: /打开菜单/i });
await userEvent.click(triggerButton);

// ✅ 菜单内容通过 Portal 渲染到 body,使用 screen
const profileItem = await screen.findByText(/个人资料/i);
await expect(profileItem).toBeVisible();

await userEvent.keyboard('{escape}');

// ✅ 验证关闭时,同样使用 screen
const closedProfileItem = screen.queryByText(/个人资料/i);
await expect(closedProfileItem).not.toBeInTheDocument();
};

记忆要点

  • canvas:查找 Story 渲染区域内的元素(如触发器、表单输入框等)
  • screen:查找整个文档中的元素(如 Portal 渲染的弹出层、对话框等)

第四步:双重验证

现在,我们的交互测试已经编写完毕。

1. 在 Storybook UI 中进行可视化调试

运行 pnpm storybook,导航到 UI/DropdownMenu/Default Story。您会看到:

  • 页面加载后,play 函数会自动执行。您会看到鼠标光标模拟点击按钮,菜单展开,然后又消失。
  • 在底部的 “Interactions” 面板中,整个流程的每一步——click, findByText, keyboard, queryByText 以及 expect 断言——都被清晰地记录下来,并全部标记为通过。

2. 在自动化测试中运行

在终端中运行测试脚本:

1
pnpm test

Vitest 和 Playwright 会在后台启动,并自动执行 Default Story 的 play 函数。您会在终端看到 DropdownMenu.stories.tsx 的测试结果为通过。

我们成功地为这个复合组件的核心交互行为,建立了一道坚实的自动化测试防线。这极大地提升了我们对组件质量的信心,并确保了未来的代码重构不会无意中破坏其基础功能。


3.4.3. 撰写专业文档:创建 DropdownMenu.mdx

我们的目标是创建一份清晰、易用且内容丰富的文档页面,它不仅要展示组件的样子,更要解释它的设计理念和使用方法。

第一步:创建 MDX 文件

Button 组件一样,我们在组件旁边创建对应的 .mdx 文档文件。

1
2
# 在项目根目录下执行
touch src/components/ui/DropdownMenu.mdx

第二步:关联 Meta 并阐述设计理念

DropdownMenu.mdx 文件中,我们首先要将其与 DropdownMenu.stories.tsx 文件进行关联,并开宗明义地向使用者介绍该组件的核心设计模式——复合组件。

文件路径: src/components/ui/DropdownMenu.mdx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Meta, Story, Controls } from '@storybook/addon-docs';
import * as DropdownMenuStories from './DropdownMenu.stories';

<Meta of={DropdownMenuStories} />

# DropdownMenu

一个灵活且完全可访问的下拉菜单组件,用于显示一个操作或选项列表。它基于 Radix UI 构建以确保行为的健壮性,并使用 daisyUI 进行样式化以实现一致的外观。

## 设计理念:复合组件模式

`DropdownMenu` 采用 **复合组件** 模式。这意味着它不是一个单一的组件,而是一个必须协同工作的组件家族(例如 `DropdownMenu`, `DropdownMenuTrigger`, `DropdownMenuContent`, `DropdownMenuItem` 等)。

这种模式提供了极高的灵活性,允许您像拼搭乐高积木一样,自由地组合和构建菜单的内部结构。

**使用规则**: 您必须将所有子组件包裹在 `DropdownMenu` 根组件中,以便它们能够共享内部状态(如“是否打开”)。

代码深度解析:

  • <Meta of={DropdownMenuStories} />: 这一行代码将此 MDX 文档与我们的 DropdownMenu 故事集进行了绑定。Storybook 会因此自动将此页面作为 DropdownMenu 组件的“Docs”标签页内容。
  • 设计理念: 我们没有直接罗列示例,而是首先向用户解释了组件的底层设计模式。这有助于用户建立正确的心智模型,更好地理解和使用组件的 API。

第三步:嵌入交互式示例与 API 参考

接下来,我们嵌入在 Story 文件中定义的 Default Story 作为核心交互示例,并利用 <Controls /> 标签自动生成 API 文档。

文件路径: src/components/ui/DropdownMenu.mdx

1
2
3
4
5
6
7
8
9
10
11
12
13
{/* ... 此前内容 ... */}

## 基础用法与交互式示例

下面是 `DropdownMenu` 的一个基础用法示例。您可以使用下方的 Controls 面板实时调整它的 `props`(如果适用)来探索其功能。

<Story of={DropdownMenuStories.Default} />

## API 参考 (Props)

下表列出了 `DropdownMenu` 组件家族中各个核心部分的主要 Props。它由 Storybook 根据组件的 TypeScript 类型和 `argTypes` 配置自动生成,确保文档与代码时刻同步。

<Controls />

解析:

  • <Story of={DropdownMenuStories.Default} />: 将我们之前编写的、包含 play 函数的 Default Story 嵌入文档中。用户不仅能看到它的样子,还能在 “Interactions” 面板中看到交互测试的回放。
  • <Controls />: 这个标签在这里的作用是渲染一个详尽的 API 属性表格。由于我们的 DropdownMenu 是一个复合组件,这个表格会智能地展示其核心子组件(如 Content, Item 等)的可用 props,非常强大。

第四步:关键陷阱 - 让 Controls 真正工作

在实现复合组件的文档时,有几个极易踩到的坑会导致 <Controls /> 面板无法正常工作。

陷阱 1:<Controls /> 表格不显示

错误现象: MDX 中添加了 <Controls />,但页面上什么都没显示。

原因与解决方案:

  1. argTypes 未定义: 对于复合组件,TypeScript 无法自动推断子组件的 props,必须手动配置 argTypes

  2. 类型系统冲突: 由于 argTypes 包含了多个子组件的 props(而不仅仅是根组件的),直接使用 Meta<typeof DropdownMenu> 会导致类型错误。

❌ 错误做法:使用 as any 绕过类型检查

1
2
3
4
5
6
// 这种做法虽然能工作,但会被 ESLint 阻止提交
const meta: Meta<typeof DropdownMenu> = {
argTypes: {
// ...
} as any, // ❌ 不符合代码规范
};

✅ 正确做法:定义自定义的 Story Args 类型

专业的做法是为复合组件创建一个明确的类型接口,列出所有需要在 Storybook 中控制的 props:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
// src/components/ui/DropdownMenu.stories.tsx
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from './DropdownMenu';
import { Button } from './Button';
import { within, userEvent, expect, screen } from 'storybook/test';

// 为复合组件定义自定义的 Story Args 类型
// 这样可以避免使用 any,同时清晰地表达所有可控制的 props
interface DropdownMenuStoryArgs {
// DropdownMenu (Root) props
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
// DropdownMenuTrigger props
asChild?: boolean;
// DropdownMenuContent props
align?: 'start' | 'center' | 'end';
side?: 'top' | 'right' | 'bottom' | 'left';
sideOffset?: number;
// DropdownMenuItem props
disabled?: boolean;
onSelect?: (event: Event) => void;
}

// 使用自定义类型而不是默认的组件 props 类型
const meta: Meta<DropdownMenuStoryArgs> = {
title: 'UI/DropdownMenu',
component: DropdownMenu,
parameters: { layout: 'centered' },
// 现在可以安全地定义 argTypes,无需使用 any
argTypes: {
// DropdownMenu (Root) - 状态管理
open: {
control: 'boolean',
description: '控制菜单的打开/关闭状态(受控模式)',
table: {
category: 'DropdownMenu (Root)',
type: { summary: 'boolean' },
},
},
defaultOpen: {
control: 'boolean',
description: '菜单的初始打开状态(非受控模式)',
table: {
category: 'DropdownMenu (Root)',
type: { summary: 'boolean' },
defaultValue: { summary: 'false' },
},
},
onOpenChange: {
action: 'openChanged',
description: '当菜单打开/关闭状态改变时触发的回调函数',
table: {
category: 'DropdownMenu (Root)',
type: { summary: '(open: boolean) => void' },
},
},
// DropdownMenuTrigger - 触发器
asChild: {
control: 'boolean',
description: '是否将触发器功能合并到子元素,避免额外的 DOM 包装',
table: {
category: 'DropdownMenuTrigger',
type: { summary: 'boolean' },
defaultValue: { summary: 'false' },
},
},
// DropdownMenuContent - 内容容器
align: {
control: 'select',
options: ['start', 'center', 'end'],
description: '菜单内容相对于触发器的对齐方式',
table: {
category: 'DropdownMenuContent',
type: { summary: '"start" | "center" | "end"' },
defaultValue: { summary: 'center' },
},
},
side: {
control: 'select',
options: ['top', 'right', 'bottom', 'left'],
description: '菜单内容相对于触发器的位置',
table: {
category: 'DropdownMenuContent',
type: { summary: '"top" | "right" | "bottom" | "left"' },
defaultValue: { summary: 'bottom' },
},
},
sideOffset: {
control: 'number',
description: '菜单内容与触发器之间的间距(像素)',
table: {
category: 'DropdownMenuContent',
type: { summary: 'number' },
defaultValue: { summary: '4' },
},
},
// DropdownMenuItem - 菜单项
disabled: {
control: 'boolean',
description: '是否禁用菜单项',
table: {
category: 'DropdownMenuItem',
type: { summary: 'boolean' },
defaultValue: { summary: 'false' },
},
},
onSelect: {
action: 'itemSelected',
description: '当菜单项被选中时触发的回调函数',
table: {
category: 'DropdownMenuItem',
type: { summary: '(event: Event) => void' },
},
},
},
};

export default meta;
type Story = StoryObj<typeof meta>;

代码深度解析:

  • 自定义类型接口: DropdownMenuStoryArgs 明确列出了所有可以在 Storybook 中控制的属性,包括来自不同子组件的 props。
  • 类型安全: 使用 Meta<DropdownMenuStoryArgs> 而不是 Meta<typeof DropdownMenu>,这样 TypeScript 就不会抱怨类型不匹配。
  • 可维护性: 当添加新的可控属性时,只需更新接口定义即可,类型系统会帮助你确保一致性。
  • 通过 Lint 检查: 完全符合 ESLint 规范,避免使用 any

陷阱 2:Controls 面板改变值但组件不更新

这是最隐蔽的陷阱!即使 Controls 表格显示了,用户修改参数时组件也不会响应。

错误现象:

  • Controls 面板能正常显示和修改
  • 但组件表现完全不变
  • 只有硬编码在 Story 中的初始值生效

根本原因:
Story 的 render 函数没有接收 args 参数,代码是完全硬编码的:

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 错误:render 函数不接收参数
export const Default: Story = {
render: () => (
<DropdownMenu> {/* 硬编码,没有使用 args */}
<DropdownMenuTrigger asChild>
<Button>打开菜单</Button>
</DropdownMenuTrigger>
<DropdownMenuContent> {/* 硬编码 */}
<DropdownMenuItem>个人资料</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
};

✅ 正确解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// ✅ 正确:render 接收 args 并传递给组件
export const Default: Story = {
args: {
// 定义默认值,这些值会显示在 Controls 面板中
defaultOpen: false,
align: 'center',
side: 'bottom',
sideOffset: 4,
disabled: false,
},

// render 函数接收我们自定义的类型化 args 参数
render: (args: DropdownMenuStoryArgs) => (
<DropdownMenu
open={args.open} // args 传递给各个组件
defaultOpen={args.defaultOpen}
onOpenChange={args.onOpenChange}
>
{/* 注意这里的 asChild 十分重要,因为 DropdownMenuTrigger
本身就有一个按钮作为触发器,我们嵌套我们的按钮的情况下就需要 asChild */}
<DropdownMenuTrigger asChild>
<Button buttonStyle="outline">打开菜单</Button>
</DropdownMenuTrigger>

<DropdownMenuContent
align={args.align} // 使用 args 中的值
side={args.side}
sideOffset={args.sideOffset}
>
<DropdownMenuItem
disabled={args.disabled}
onSelect={args.onSelect}
>
个人资料
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={args.onSelect}>账单</DropdownMenuItem>
<DropdownMenuItem onSelect={args.onSelect}>设置</DropdownMenuItem>
<DropdownMenuItem onSelect={args.onSelect}>退出登录</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
};

关键要点:

  • render: (args: DropdownMenuStoryArgs) => - 函数签名接收我们自定义的类型化参数
  • args.xxx - 在 JSX 中使用 args 的值,而不是硬编码
  • args: {...} - 定义默认值,无需使用 any,类型会自动推断
  • 类型安全 - 整个过程都有完整的类型检查和 IDE 支持

验证成功的标志:

  1. <Controls /> 表格正常显示,包含所有定义的 props
  2. ✅ 修改 Controls 面板中的任何值,组件立即响应
  3. ✅ 如 alignsidedisabled 等参数的改变都能实时看到效果
  4. ✅ 代码通过 ESLint 检查,无任何 any 类型警告

最佳实践总结:

对于所有复合组件的 Storybook Stories,应该遵循以下模式:

  1. 定义专用的 Args 接口 - 明确列出所有可控制的 props
  2. 使用接口作为 Meta 的泛型 - Meta<YourCustomArgs> 而不是 Meta<typeof Component>
  3. 在 render 函数中使用类型化参数 - render: (args: YourCustomArgs) =>
  4. 让类型推断自动工作 - 无需任何 as any 或类型断言

这不仅是为了满足 Lint 规则,更是为了编写更加健壮、可维护的代码。

第五步:最终验证

现在,运行 pnpm storybook 并导航到 UI/DropdownMenu

您应该看到:

  1. Docs 标签页显示自定义 MDX 内容:包含我们撰写的介绍、设计理念
  2. 交互式示例正常工作:可以点击按钮打开/关闭菜单
  3. API 表格完整显示:按分类(Root、Trigger、Content、Item)展示所有 props
  4. Controls 面板响应正常:修改 alignsidesideOffset 等参数,组件立即更新
  5. Interactions 面板显示测试回放:可以看到 play 函数的执行过程
  6. 代码质量检查通过:运行 git commit 时,所有 ESLint 检查顺利通过
  7. 完整的类型安全:在编写代码时享受 IDE 的智能提示和类型检查

常见的最后一个问题:如果看到两个文档页面(自动生成的和 MDX 的),说明需要移除 Story 文件中的 tags: ['autodocs']

技术债务提示:如果你在互联网上看到关于 Storybook 复合组件的旧教程建议使用 as any,请忽略它们。那些是权宜之计,不符合现代最佳实践。始终使用自定义类型接口,这是经得起时间考验的专业做法。

我们已经为 DropdownMenu 组件,成功地闭环了我们的"黄金工作流"。


3.5 本章小结

在本章中,我们进行了一次从实践到理论、再回归实践的深度探索。

我们从一个简单的、由 daisyUI 提供的纯 CSS 下拉菜单出发,通过深入分析其在可访问性状态管理组合性方面的局限性,建立了寻求更优方案的明确动机。

接着,我们引入了 Radix UI 这一专业的“无头”组件库,并深入学习了其 API 设计背后的核心——复合组件模式,以及实现该模式的技术基石——React Context API。我们不仅理解了其原理,更探讨了其在真实应用中的性能注意事项。

最终,我们将所有知识融会贯通,亲手构建了一个生产级的 Prorise UI DropdownMenu 组件。它完美地结合了 Radix UI 的健壮行为daisyUI 的优美样式,并遵循了 shadcn/ui 的架构模式

最后,我们严格遵循在第二章建立的“黄金工作流”,为这个新组件补充了自动化的交互测试play 函数)和专业的 MDX 文档,完成了从构思到交付的全过程。

通过本章的学习,您不仅收获了一个高质量的 DropdownMenu 组件,更重要的是,掌握了分析、设计和实现复杂复合组件的完整方法论。