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 的下拉菜单可以基于 div 和 tabindex 实现,但更语义化、更具可访问性的方式是使用 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 上的类名:
    • menu 和 dropdown-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. 按下 Enter 或 Space 键,菜单可以正常展开。到目前为止,一切似乎都很好。
  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。但如果需求再复杂一点呢?“我们需要在菜单顶部加入一个不可点击的标题,显示当前用户名和邮箱。”

问题根源:

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

总结:定位差距

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

维度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 属性?
  • 我们负责(我们擅长且需要定制的部分):
    • 视觉样式: 这个菜单看起来应该是什么样子?我们将使用 daisyUI 和 Tailwind 的类名来为 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,antd 的 Dropdown 组件可以完全掌控每一个菜单项、分割线、子菜单的渲染逻辑。它能确保无论开发者如何传递数据,最终渲染出的 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 的消费者 必须 被包裹在一个提供了真实 value 的 Provider 内部才能正常工作。

第三步:(最佳实践) 封装自定义消费 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 原语,与我们已有的 daisyUI 和 Tailwind 样式进行结合,创造出我们自己的、完全符合 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: hidden 或 z-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 添加了 focus 和 data-[disabled] 状态下的样式,并为 DropdownMenuSeparator 应用了 muted 颜色,使其与我们的主题系统保持一致。

第五步:整合并导出

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

文件路径: 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(...)模拟用户完整的点击动作,包括 mousedown、mouseup 等相关事件。模拟用户点击 “打开菜单” 按钮以触发菜单的显示。
userEvent.keyboard(...)模拟用户按下键盘上的按键。模拟用户按下 Escape 键来触发菜单的关闭逻辑。
expect(...).toBeVisible()Jest-DOM 断言,检查一个元素当前是否对用户可见(没有被 display: none 或 visibility: 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-expanded、aria-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: hidden 或 z-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. ✅ 如 align、side、disabled 等参数的改变都能实时看到效果
  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 面板响应正常:修改 align、side、sideOffset 等参数,组件立即更新
  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 组件,更重要的是,掌握了分析、设计和实现复杂复合组件的完整方法论。