第十四章:React 设计系统全链路实战 —— 聚焦组件模式、主题化、性能优化 掌握 Radix+Tailwind

第一章. 架构师的开篇:从组件库到设计系统

本章的核心目标是建立一个统一且深刻的认知框架。我们将要构建的,并非一个简单的 UI 组件集合,而是一个能够提升团队生产力、保障产品体验一致性、并作为公司数字产品核心资产的系统性工程解决方案。我们将首先精确定义“设计系统”的内涵与外延,深入分析其技术选型背后的逻辑,并最终构建一个标准化的、自动化的工程开发环境。这是后续所有高级实践的坚实基础。

1.1. 超越组件库:什么是“设计系统”?

在启动任何具体的编码工作之前,首要任务是校准我们对目标的理解。许多技术团队投入资源构建了内部的组件库,却发现随着业务扩张,产品不同模块间的视觉差异依旧越来越大,代码冗余度不降反升,设计与开发之间的协作鸿沟难以逾越。

这些问题的根源在于,一个孤立的、缺乏顶层设计与治理规范的组件库,其本质上仍是一个被动的工具集。而我们的目标,是构建一个更高维度的、主动的、具备自我演进能力的产物——设计系统。它是一种思想、一套流程和一个工程产品的有机结合体。

问题背景的深度剖析:
在一个高速发展且有一定规模的组织中,缺少设计系统所引发的问题是系统性的,并会随着时间推移被急剧放大。例如,市场部门为一次营销活动快速搭建的着陆页,其视觉风格、交互模式与主应用存在明显割裂,这不仅稀释了品牌形象,也增加了用户的认知负荷。在开发层面,不同业务线的团队可能基于不同的技术栈或 UI 理解,重复“发明轮子”,分别实现了功能类似的日期选择器或数据表格,导致了巨大的研发资源浪费和后续高昂的维护成本。当产品需要进行品牌升级,要求全局性地调整主色调和字体规范时,这项任务会演变成一场灾难性的、涉及多个代码仓库的、高风险的“替换工程”。

设计系统正是为了从根本上解决这一系列混乱而诞生的结构性解决方案。它将设计决策转化为可复用、可约束、可治理的工程资产,从而解放生产力。

1.1.1. 剖析设计系统的三大支柱

一个功能完备、可持续发展的设计系统,由三个相互支撑、紧密协作的核心支柱构成:设计语言、组件库、以及模式与文档。

首先,设计语言 是整个系统的理论基础与最高纲领。它是一套用于指导产品设计与开发的、体系化的、抽象的规则与美学愿景的集合。其核心是 设计原则,例如“清晰”、“高效”、“一致”等高阶准则,它们是进行具体设计决策时的重要依据。原则之下是更具体的 设计规范,例如无障碍可访问性规范(WCAG)、8 点栅格系统、动画缓动曲线定义等。而将这一切与工程实现连接起来的关键,是 设计令牌 (Design Tokens)。设计令牌是以平台无关的、结构化的变量形式存在的样式定义,是所有视觉表现的单一数据源。例如,一个颜色令牌可能被定义为 color.brand.primary.default,其值为 #007BFF。这种结构化的命名使其具备极高的可读性和可维护性。

设计令牌是平台无关的,这是其能够支持 Web、iOS 和 Android 等多端体验一致性的技术前提。

其次,组件库 是设计语言在代码层面的具象化、标准化实现。它是一套封装了样式、交互逻辑与状态管理的、经过完整测试的、可重用的用户界面工程实体。开发者通过调用这些标准化的组件来高效地构建应用界面。一个高质量的组件库不仅要实现设计稿的视觉效果,更要关注其工程属性。这包括提供设计精良的 API,在易用性与灵活性之间取得平衡;包含完备的 测试套件,覆盖单元、集成和视觉回归测试,以保证其健壮性;以及严格遵循 无障碍标准,确保所有用户都能无障碍地使用。

最后,模式与文档 是连接理论与实践、指导团队成员规模化应用设计系统的规范性文件与最佳实践的集合。它扮演着知识库和使用手册的角色,确保系统能够在团队中被正确理解和高效应用。其内容远超基础的组件 API 文档,核心在于沉淀 使用模式。例如,一个“用户认证模式”会详细规定登录和注册表单的整体布局、所需组件(Input, Button, Checkbox)、错误提示的展示方式、加载状态的处理逻辑等,为开发者提供可直接复用的“解决方案级”指引。此外,它还应包含 贡献与治理流程,明确新组件的提案、代码提交、版本发布以及组件废弃的管理办法,确保设计系统自身的健康演进。

为了更清晰地总结三者关系,我们可以参考下表:

支柱核心价值关键产出
设计语言理论与原则设计原则、规范文档、设计令牌集
组件库工程与实体NPM 包、标准化的 UI 组件
模式与文档指导与知识组件 API 文档、场景化使用模式、贡献流程

1.1.2. 明确我们在课程中扮演的角色:开发者如何与设计师协作

在设计系统驱动的工作流中,开发者的角色定位发生了根本性的转变。开发者不再是设计流程下游的被动执行者,而是以系统共建者的身份,深度参与到从定义到实现的全过程。这种转变要求开发者具备更强的 工程产品化 思维和 跨职能协作 能力。

我们的工作不再是孤立地完成一个页面,而是为整个组织的开发者提供稳定、高效、易用的“开发工具”——组件库。这意味着我们需要对代码质量、API 设计、性能优化和测试覆盖率有更高的要求。同时,我们必须成为设计师最紧密的技术伙伴,将技术的可行性和工程的约束性前置到设计阶段。

设计系统协作
新组件需求评审会
设计师

根据用户研究,我们需要一个新的“步骤条(Stepper)”组件,用于展示多步流程。这是 Figma 中的高保真设计稿,定义了激活、完成和禁用三种状态。

设计稿已收到。关于状态的颜色,我会确保它们精确映射到设计令牌中的 color.interactive.active, color.status.successcolor.interactive.disabled。此外,考虑到可访问性,我会为每个步骤添加 aria-current 等 ARIA 属性,以便屏幕阅读器能够识别当前步骤。

设计师

非常好。交互方面,我们希望点击已完成的步骤可以返回到该步骤。

这个交互逻辑在技术上是可实现的。但我建议将此功能设计为可选的 Prop,例如 isStepClickable。因为在某些线性且不可逆的流程(如支付)中,我们不希望用户能随意跳转。这种 API 设计能让组件适应更多业务场景。

设计师

这个建议很有价值,我们补充到设计规范里。那么,你预估的开发周期是多久?

在 Storybook 中搭建基础版本并完成单元测试需要 2 天。之后我们可以在 StorybooK 环境中进行交互评审,确认无误后再补充集成测试和文档,最终发布到 NPM,总计大约需要 4 天。

以上对话清晰地展示了开发者在设计系统中的核心职责:我们不仅是实现者,更是 技术顾问,负责提供专业的技术可行性评估;是 API 设计师,负责定义组件的编程接口;也是 质量保证者,通过完备的测试和文档,确保交付产物的可靠性。我们深度参与并影响着设计系统的每一个环节,是确保其工程卓越性的关键角色。


1.1.3 本节小结

设计系统是一个由设计语言、组件库、模式与文档三大支柱构成的综合性工程解决方案,其核心目标是通过系统化的方法,解决规模化数字产品开发中的一致性、效率和质量问题。它要求开发者从传统的“功能实现者”转变为系统的“共建者”,深度参与规范制定、技术实现和基础设施维护,以产品化的思维打造高质量的工程资产。


1.2. 技术选型:为什么说 Radix + Tailwind v4 + daisyUI 是现代化的最佳实践?

技术选型并非简单地选择“流行”的工具,而是基于我们第一节所定义的目标——构建一个高度可定制、体验一致、易于维护的设计系统——所做出的审慎的、体系化的决策。我们将要采纳的 Radix + Tailwind v4 + daisyUI 技术栈,代表了一种与传统组件库截然不同的、兼顾了底层灵活性与上层开发效率的先进设计哲学。理解这种哲学上的差异,是理解我们整个课程架构的关键。

1.2.1. 对比分析:与 Ant Design, Material-UI 等传统组件库在设计哲学上的核心差异

业界存在大量成熟的组件库,如 Ant Design (AntD) 和 Material-UI (MUI)。这些库的共同特点是“开箱即用”,它们提供了一整套包含完整样式和交互逻辑的 UI 解决方案。这种模式被称为“All-in-One”或“Opinionated (强观点型)”方案。

  • 传统方案的优势: 对于快速构建后台管理系统、内部工具或完全接纳其设计语言的项目而言,这类组件库的效率极高。它们预设了大量的设计细节,开发者无需过多关注 UI,即可快速搭建功能。

  • 传统方案的局限性: 其核心问题在于 样式与逻辑的强耦合。当产品的品牌视觉需要高度定制化,与这些库的默认风格存在较大差异时,开发者便会陷入困境。为了覆盖原有样式,我们不得不编写大量“覆盖式”的 CSS,这通常意味着与复杂的 CSS 选择器权重作斗争,甚至滥用 !important,最终导致样式代码难以维护。主题定制功能虽然提供了一定灵活性,但其能力边界有限,无法满足深度的结构性或视觉性定制需求。

我们的目标是构建一个服务于特定品牌、具有独特视觉标识的设计系统,因此,样式的高度可控性是我们的首要考量。这就引出了我们所选择的“Unstyled (无样式)”或“Headless (无头)”范式。这种范式的核心思想,是将组件的 行为逻辑视觉表现 彻底分离。

1.2.2. 深入 Radix UI:理解其“无头”特性如何提供极致的灵活性和一流的可访问性

Radix UI 是“无头”组件理念的最佳实践者之一。它并非一个传统意义上的组件库,而是一个专注于提供 行为、交互和可访问性 的底层 UI 原语的集合。

  • 完全的样式控制权: Radix 组件在默认情况下不携带任何 CSS 样式。这意味着,一个 <Dialog.Trigger> 组件在渲染后,除了必要的交互行为外,其视觉表现与一个普通的 <button> 几乎无异。这赋予了我们 100%的样式控制权,我们可以使用任何偏好的样式方案来为其赋予任意的视觉外观,从根本上杜绝了“样式覆盖”的问题。

  • 内建的、专业的无障碍可访问性 (a11y): 这是 Radix 最具价值的部分。构建一个真正具备完全可访问性的复杂组件(如下拉菜单、对话框)是一项极其复杂的工作,需要深入理解 WAI-ARIA 规范,处理键盘导航、焦点管理、状态宣告等诸多细节。Radix 为我们处理了所有这些复杂性。我们只需要组合其提供的原语,即可自动获得符合专业标准的、健壮的无障碍体验。

  • 预置的复杂交互逻辑: 对于 DropdownMenuCombobox 这类组件,Radix 已经内置了所有必需的状态管理逻辑(例如,菜单的展开/关闭状态)和交互逻辑(例如,点击外部区域自动关闭菜单)。

综上,我们选择 Radix UI 作为我们处理复杂组件 行为逻辑层 的武器。它为我们解决了构建组件过程中最困难的部分(行为逻辑与可访问性),同时将视觉样式完全交由我们掌控。

1.2.3. 拥抱 Tailwind CSS v4:探讨其作为设计系统基石的革命性优势

在拥有了处理行为逻辑的方案后,我们需要一套高效、可约束的样式方案。Tailwind CSS v4 带来了根本性的革新,使其成为与“无头”组件和设计系统理念完美结合的 样式基石

  • CSS 优先的配置与强制的设计约束: v4 版本最核心的变化,是用 CSS 原生的 @theme 指令取代了 tailwind.config.js 作为主题配置的主要方式。这个配置文件正是我们设计令牌 (Design Tokens) 的直接载体。开发者在编码时,只能从这些预设的、源于设计令牌的工具类中进行选择,从而在工具层面强制保证了视觉规范的统一实施。

    1
    2
    3
    4
    5
    6
    7
    /* app.css */
    @import "tailwindcss";

    @theme {
    --color-brand-500: oklch(0.637 0.237 25.331);
    --breakpoint-lg: 64rem;
    }
  • 革命性的性能: v4 采用全新的高性能引擎,能将绝大部分项目的编译时间压缩至 100ms 以内。这种近乎实时的反馈速度,极大地提升了开发体验。

  • 杜绝命名冲突与样式冗余: 通过组合原子化的工具类来构建样式,我们不再需要为各种小型组件编写 BEM 风格的类名,从而避免了命名困难和潜在的全局命名冲突。

  • 极致的性能优化: Tailwind 能在生产环境构建时,通过扫描所有源码文件,精确地移除所有未被使用的 CSS 工具类,确保最终产出的 CSS 文件体积达到最小化。

1.2.4. 效率加速器:引入 daisyUI 简化组件样式

虽然 Tailwind 提供了极致的底层灵活性,但从零开始组合原子类来构建每一个组件依然是繁琐的。为了教学的简便性和开发的效率,我们引入 daisyUI 作为 Tailwind 之上的 组件样式封装层

daisyUI 是一个基于 Tailwind CSS 的插件,它并不引入新的技术,而是提供了一套语义化的、预先设计好的组件类名。

  • 从原子到组件: daisyUI 将常见的 Tailwind 原子类组合封装成了高级组件类。开发者可以直接使用 <button class="btn btn-primary"> 来代替冗长的原子类列表,大幅提升了开发速度。

  • 深度主题化: 它内置了数十种主题,并提供了 primary, secondary, accent 等语义化的颜色名称。这些颜色与 Tailwind v4 的主题系统无缝集成,可以轻松实现一键换肤。

  • 无缝的定制能力: 最关键的是,daisyUI 从未剥夺我们使用 Tailwind 的能力。如果一个 daisyUI 组件不完全符合我们的需求,我们随时可以追加 Tailwind 的原子类来进行微调和覆盖,例如 <button class="btn btn-primary rounded-full">。这种“随时可穿透”的特性,是它优于传统组件库的核心优势。

1.2.5. 认识 cva:为自定义组件提供变体管理能力

尽管 daisyUI 提供了丰富的组件,但在构建一个完整的设计系统时,我们必然会遇到需要创建 daisyUI 未涵盖的、具有高度业务特性的自定义组件的场景。这时,我们就需要一个工具来优雅地管理这些自定义组件的多种变体,而这正是 cva (Class Variance Authority) 的用武之地。

cva 是一个微型工具库,它允许我们以一种清晰、可读的方式,将组件的 Props 声明式地映射到一组 Tailwind 工具类上。

一个 cva 定义的核心结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { cva } from "class-variance-authority";

export const customAvatarVariants = cva(
"inline-flex items-center justify-center rounded-full",
{
variants: {
size: {
small: "h-8 w-8 text-xs",
large: "h-12 w-12 text-lg",
},
status: {
online: "ring-2 ring-green-400",
offline: "grayscale",
}
},
defaultVariants: {
size: "small",
},
}
);

通过 cva,当我们需要构建自己的 CustomAvatar 组件时,可以轻松地管理其 sizestatus 等变体,而无需编写复杂的条件判断逻辑。

技术栈的完美协同:
我们的技术栈是一个分层的、各司其职的有机整体:

  • Radix: 在需要处理极其复杂的交互和无障碍性时,作为可选的、最底层的 行为引擎
  • Tailwind v4: 作为设计令牌的载体和高性能的 原子化样式基石
  • daisyUI: 作为日常开发中最高效的 组件样式封装层,加速 80%的开发工作。
  • cva: 作为构建自定义组件时,管理样式变体的 可选工具

这个范式将“做什么”(行为)与“长什么样”(表现)分离,同时提供了从高层抽象到低层控制的完整能力,是构建一个既稳固又灵活的现代设计系统的最佳工程路径。


1.2.3 本节小结

我们的技术选型决策是基于“分层抽象”与“关注点分离”的核心原则。我们采用 Radix 作为处理复杂行为和可访问性的底层保障;以革命性的 Tailwind CSS v4 作为高性能的样式基石,并通过其 @theme 机制承载我们的设计令牌;在此之上,引入 daisyUI 作为高效率的组件样式抽象层,以加速日常开发;最后,保留 cva 作为构建自定义组件时优雅管理变体的可选方案。这套现代化的、高度解耦的架构,不仅极大地提升了定制化能力和开发体验,更从根本上保障了设计系统在长期演进中的可维护性和可扩展性。


1.3. 工程化基石:从零搭建 Prorise UI 项目

至此,我们已经完成了所有必要的理论铺垫,明确了设计系统的“是什么”以及技术选型的“为什么”。现在,我们将进入更激动人心的环节:将理论转化为实践。本节内容将是纯粹的、高强度的动手操作,我们将从一个空无一物的文件夹开始,一步步搭建起一个包含了现代化前端框架、设计系统规范注入、以及自动化代码质量保障体系的、生产级别的项目地基。

1.3.1. 项目初始化:选择 Next.js 作为我们的开发框架

在开始之前,我们需要选择一个承载我们设计系统的项目框架。正如我们之前所探讨的,我们的选择是 Next.js。如果您是一位经验丰富的 React 开发者,但对 Next.js 尚不熟悉,请不必有任何顾虑。Next.js 并非一个需要您从头学习的全新框架,而应被看作是 React 的一套官方推荐的、自带完整工程化方案的最佳实践集合。您将无缝地掌握它,并体会到它为我们免去的繁琐配置工作。

现在,让我们打开终端,执行以下命令来创建我们的项目骨架。我们将项目命名为 prorise-ui

1
npx create-next-app@latest prorise-ui

执行此命令后,会启动一个交互式安装向导。为了确保项目具备最佳初始状态,请参考以下指引完成选择。我们在此不使用对话格式,而是采用更正式的配置清单形式进行说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
✔ Would you like to use TypeScript? … Yes
# 理由:TypeScript 的类型安全对于构建一个健壮、可维护的设计系统至关重要。

✔ Would you like to use ESLint? … Yes
# 理由:我们需要 ESLint 来保证整个项目的代码质量和风格一致性。

✔ Would you like to use Tailwind CSS? … Yes
# 理由:这是我们技术栈的核心基石,Next.js 会为我们完成所有初始配置。

✔ Would you like to use `src/` directory? … Yes
# 理由:使用 src 目录是一种主流的、更清晰的项目结构组织方式。

✔ Would you like to use App Router? (recommended) … Yes
# 理由:App Router 是 Next.js 最新的、功能更强大的路由系统。

✔ Would you like to use Turbopack? (recommended) » Yes
# Turbopack 是一个为 JavaScript 和 TypeScript 优化的新型增量打包工具,主要优点是速度极快

✔ Would you like to customize the default import alias (@/*)? … No
# 理由:默认的 @/* 别名是一个清晰且广泛接受的约定,无需修改。

完成以上步骤后,create-next-app 会自动安装所有依赖并生成项目文件。现在,我们已经拥有了一个名为 prorise-ui 的目录,其中包含了一个配置完备、可立即运行的现代化 Next.js 应用程序。

1.3.2. 设计系统注入:集成 shadcn/ui, daisyUI 与主题规范

项目框架已经就绪,我们的下一步工作是将设计规范的“神经系统”植入其中。

第一步:初始化主题规范与核心工具

我们进入刚刚创建的 prorise-ui 项目目录,并执行 shadcn/ui 的初始化命令。此命令是 项目配置工具,它会为我们的项目写入规范,而不会添加任何运行时依赖。

1
2
3
4
5
# 首先进入项目目录
cd prorise-ui

# 执行初始化命令
npx shadcn@latest init

在弹出的交互式引导中,您只需根据上一步 create-next-app 时选择的路径,确认各项配置即可。此命令的核心动作是:

  1. 生成 components.json 配置文件。
  2. 在全局 CSS 文件中注入一套完整的、基于 CSS 变量的主题(设计令牌)体系。
  3. 自动安装 tailwind-merge, clsx 等必要的辅助工具库。

第二步:集成 daisyUI 组件样式层

接下来,我们安装 daisyUI 作为快速开发的高阶组件样式库。

1
pnpm install -D daisyui@latest

第三步:配置全局样式与设计令牌体系

现在,我们将上述所有配置整合到我们的全局样式文件中。这是本节最关键的一步,我们将在这里看到一个完整的设计令牌体系是如何在代码中呈现的。

文件路径: src/app/globals.css

首先,shadcn-ui init 会将此文件内容 完全替换 为他固定的结构。这就是我们设计系统的令牌基础,它以原生的 CSS 变量形式存在,并已内置完整的亮/暗色模式切换能力。

接下来,我们在此文件顶部引入 daisyUI 插件,并在文件末尾添加自定义主题配置,让 daisyUI 完全由我们刚刚建立的设计令牌驱动,这个部分我们可以使用 daisyui 的主题生成器 daisyUI and Tailwind CSS theme generator,我就使用我们博客的配色风格来了,

文件路径: src/app/globals.css

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
@import "tailwindcss";
@plugin "daisyui"; /* <-- 步骤一:引入 daisyUI 插件 */

@plugin "daisyui/theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0 0);
--color-base-200: oklch(97% 0 0);
--color-base-300: oklch(92% 0 0);
--color-base-content: oklch(20% 0 0);
--color-primary: oklch(0% 0 0);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(70% 0.213 47.604);
--color-secondary-content: oklch(98% 0.016 73.684);
--color-accent: oklch(62% 0.214 259.815);
--color-accent-content: oklch(97% 0.014 254.604);
--color-neutral: oklch(26% 0 0);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(54% 0.245 262.881);
--color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(62% 0.194 149.214);
--color-success-content: oklch(98% 0.018 155.826);
--color-warning: oklch(64% 0.222 41.116);
--color-warning-content: oklch(98% 0.016 73.684);
--color-error: oklch(57% 0.245 27.325);
--color-error-content: oklch(97% 0.013 17.38);
--radius-selector: 1rem;
--radius-field: 0.5rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}


@plugin "daisyui/theme" {
name: "dark";
default: false;
prefersdark: false;
color-scheme: "dark";
--color-base-100: oklch(14% 0.005 285.823);
--color-base-200: oklch(21% 0.006 285.885);
--color-base-300: oklch(27% 0.006 286.033);
--color-base-content: oklch(96% 0.001 286.375);
--color-primary: oklch(44% 0.043 257.281);
--color-primary-content: oklch(98% 0.003 247.858);
--color-secondary: oklch(44% 0.017 285.786);
--color-secondary-content: oklch(98% 0 0);
--color-accent: oklch(55% 0.288 302.321);
--color-accent-content: oklch(97% 0.014 308.299);
--color-neutral: oklch(14% 0.005 285.823);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(68% 0.169 237.323);
--color-info-content: oklch(97% 0.013 236.62);
--color-success: oklch(72% 0.219 149.579);
--color-success-content: oklch(98% 0.018 155.826);
--color-warning: oklch(70% 0.213 47.604);
--color-warning-content: oklch(98% 0.016 73.684);
--color-error: oklch(63% 0.237 25.331);
--color-error-content: oklch(97% 0.013 17.38);
--radius-selector: 1rem;
--radius-field: 0.5rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}

至此,我们的项目地基不仅结构稳固,其“血液”也已经完全统一。无论是未来使用 daisyUI 的便捷组件,还是我们自己构建的复杂组件,都将共享同一套视觉基因。


1.3.3. 质量保障体系:配置自动化代码规范工作流

一个专业的项目,尤其是一个将作为团队基础的设计系统项目,必须从第一天起就建立一套自动化的机制来保障代码质量和协作规范。这不仅能避免个人习惯差异导致的代码风格混乱,更能从源头杜绝潜在的低级错误,防止技术债的累积。

我们将集成业界主流的工具链,在代码提交阶段设立一道坚固的 “质量关卡”:

  • Prettier: 目前最流行的代码格式化工具,支持多种语言,能够确保团队代码风格的绝对统一。
  • ESLint: JavaScript 生态系统的标准代码检查工具,Next.js 已为我们预配置好了核心规则,我们只需将其与 Prettier 整合即可。
  • Husky: 现代化的 Git Hooks 管理工具,让我们能在 commitpush 等 Git 事件发生时执行自定义脚本。
  • lint-staged: 高效的文件过滤工具,确保我们的检查和格式化操作 仅针对 本次提交所修改的文件,而非整个项目,极大地提升了效率。
  • commitlint: 提交信息规范校验工具,强制所有提交都遵循约定式提交规范(Conventional Commits),为后续的版本管理和变更日志(CHANGELOG)自动化生成奠定基础。

第一步:安装开发依赖

我们首先在项目根目录,将所有需要的工具作为开发依赖项进行安装。注意,这里我们需要同时安装 Prettier 以及它与 ESLint 的集成插件。

1
pnpm install -D husky lint-staged @commitlint/cli @commitlint/config-conventional prettier eslint-config-prettier eslint-plugin-prettier prettier-plugin-tailwindcss

各依赖项的作用说明:

  • prettier: 代码格式化工具
  • eslint-config-prettier: 关闭所有与 Prettier 冲突的 ESLint 规则
  • eslint-plugin-prettier: 将 Prettier 规则集成到 ESLint 中
  • prettier-plugin-tailwindcss: Prettier 的 Tailwind CSS 插件,用于自动排序 Tailwind 类名
  • husky: Git Hooks 管理工具
  • lint-staged: 暂存文件过滤工具
  • @commitlint/cli@commitlint/config-conventional: 提交信息规范工具及其约定式提交预设

第二步:配置 Prettier

Prettier 没有类似 init 的初始化命令,我们需要手动创建配置文件。在项目根目录创建 .prettierrc 文件,并复制以下内容:

文件路径: .prettierrc

1
2
3
4
5
6
7
8
9
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"plugins": ["prettier-plugin-tailwindcss"]
}

配置说明:

  • semi: 语句末尾添加分号
  • trailingComma: 在 ES5 兼容的多行语句中添加尾随逗号
  • singleQuote: 使用单引号而非双引号
  • printWidth: 每行代码最大字符数为 80
  • tabWidth: 缩进使用 2 个空格
  • useTabs: 使用空格而非 Tab 缩进
  • plugins: 启用 Tailwind CSS 类名自动排序插件

同时创建 .prettierignore 文件,告诉 Prettier 哪些文件和目录无需格式化:

文件路径: .prettierignore

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 依赖
node_modules
.pnp
.pnp.js

# 构建输出
.next
out
dist
build

# 测试覆盖率
coverage

# 其他
.DS_Store
*.pem
.env*.local
.turbo
package-lock.json
pnpm-lock.yaml
yarn.lock

第三步:集成 ESLint 与 Prettier

Next.js 项目已经自带了 ESLint 配置,我们只需将 Prettier 规则整合进去即可。打开 eslint.config.mjs 文件,将其修改为如下内容:

文件路径: eslint.config.mjs

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
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const compat = new FlatCompat({
baseDirectory: __dirname,
});

const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'),
{
ignores: [
'node_modules/**',
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
],
},
{
plugins: {
prettier: (await import('eslint-plugin-prettier')).default,
},
rules: {
'prettier/prettier': 'warn',
},
},
];

export default eslintConfig;

关键改动说明:

  1. compat.extends 中添加了 'prettier',这会引入 eslint-config-prettier 的配置,关闭所有与 Prettier 冲突的 ESLint 规则。
  2. 添加了一个新的配置对象,引入 eslint-plugin-prettier 插件,并将 prettier/prettier 规则设置为 warn 级别,这样格式问题会以警告形式显示,不会中断开发流程。

接下来,在 package.json 中添加几个便捷的脚本命令,方便我们手动触发代码检查和格式化:

文件路径: package.json(部分)

1
2
3
4
5
6
7
8
9
10
11
12
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint",
"lint:fix": "eslint --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"prepare": "husky"
}
}

新增的脚本说明:

  • lint:fix: 运行 ESLint 并自动修复问题
  • format: 使用 Prettier 格式化所有文件
  • format:check: 检查所有文件是否符合 Prettier 格式规范(不修改文件)

第四步:配置 lint-staged

lint-staged 的核心价值在于精准打击,避免了每次提交都对全量代码进行检查的漫长等待。我们在 package.json 中添加 lint-staged 字段:

文件路径: package.json(部分)

1
2
3
4
5
6
7
8
9
10
11
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,css}": [
"prettier --write"
]
}
}

这份配置的含义是:

  • 对于所有提交的 js, jsx, ts, tsx 文件,先执行 eslint --fix 尝试自动修复代码问题,然后执行 prettier --write 进行格式化。
  • 对于 json, md, css 等文件,仅执行 prettier --write 进行格式化。

第五步:配置 commitlint

commitlint 确保每一条提交信息都清晰、规范,让团队成员和工具都能轻易读懂提交历史。我们将采用包含自定义规则的配置,以建立更明确的团队规范。

在项目根目录创建 commitlint.config.js 文件:

文件路径: commitlint.config.js

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
/** @type {import('@commitlint/types').UserConfig} */
const configuration = {
extends: ['@commitlint/config-conventional'],
rules: {
// 定义允许的提交类型
'type-enum': [
2,
'always',
[
'feat', // 新功能(feature)
'fix', // 修复 bug
'docs', // 文档更新
'style', // 代码格式调整(不影响代码运行)
'refactor', // 重构(既不是新功能也不是修复 bug)
'perf', // 性能优化
'test', // 添加或修改测试
'build', // 构建系统或外部依赖的变更
'ci', // CI/CD 配置文件的变更
'chore', // 其他不修改 src 或测试文件的变更
'revert', // 回退之前的提交
],
],
// 主题不能为空
'subject-empty': [2, 'never'],
// 主题不能以句号结尾
'subject-full-stop': [2, 'never', '.'],
// 提交信息头部最大长度
'header-max-length': [2, 'always', 100],
},
};

module.exports = configuration;

此配置不仅继承了社区的通用规范,还详细定义了我们项目中允许的 11 种提交类型,确保了提交信息的标准化。

提交信息格式说明

约定式提交的基本格式为:

1
2
3
4
5
<type>: <subject>

[可选的正文]

[可选的脚注]

注意type 和冒号之后必须有一个空格,这是规范的强制要求。

示例:

1
2
3
4
5
6
7
# 正确格式
git commit -m "feat: 添加用户登录功能"
git commit -m "fix: 修复导航栏在移动端的显示问题"
git commit -m "docs: 更新 README 安装说明"

# 错误格式(缺少空格)
git commit -m "feat:添加用户登录功能" # ❌ 会被拦截

第六步:初始化 Husky v9 并创建 Hooks

接下来是最关键的一步:激活 husky 并让它来执行我们上面配置好的工具。

版本重要提示: 我们使用的是 Husky v9。相较于 v8 及更早版本,v9 极大地简化了配置流程。它废弃了 husky addhusky set 命令,转而采用更直观、更符合 Shell 习惯的方式——直接创建和编辑钩子文件。如果您参考旧的教程,可能会遇到命令无法使用的情况。

6.1 初始化 Husky

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

1
npx husky init

这条命令会自动完成三件事:

  1. 创建 .husky/ 目录。
  2. package.json 中添加 "prepare": "husky" 脚本。这个脚本会在 pnpm install 后自动运行,确保团队新成员拉取项目后,Husky 能被自动激活。
  3. .husky/ 目录下创建一个名为 pre-commit 的钩子文件示例。

6.2 创建 Git Hooks

现在,我们需要创建或修改两个核心的钩子文件。

Windows 用户特别注意:以下操作 必须在 Git Bash 中执行,而非 PowerShell 或 CMD。

这是因为 Windows 的 PowerShell 使用 echo 命令创建文件时,默认采用 UTF-16LE 编码,而 Husky 的钩子文件必须使用 UTF-8 编码才能被正确执行。如果您在 PowerShell 中执行了 echo 命令创建钩子文件,在提交时会遇到如下错误:

1
2
.husky/pre-commit: .husky/pre-commit: cannot execute binary file
husky - pre-commit script failed (code 126)

解决方案

  1. 推荐做法:右键项目文件夹,选择 “Git Bash Here”,在打开的 Git Bash 终端中执行下面的命令。
  2. 替代方案:如果您坚持使用 PowerShell,请在 VS Code 中手动创建钩子文件,并确保文件编码为 UTF-8,换行符为 LF。

此问题是 Windows 系统特有的,macOS 和 Linux 用户可以忽略。

Git Bash 中,执行以下命令创建 pre-commit 钩子,让它在提交前执行 lint-staged

1
echo "npx lint-staged" > .husky/pre-commit

然后,创建 commit-msg 钩子,让它在提交时校验提交信息:

1
echo 'npx --no -- commitlint --edit $1' > .husky/commit-msg

执行完毕后,我们的 .husky 目录结构如下:

1
2
3
4
.husky/
├── _/ # Husky 内部使用的辅助文件目录
├── commit-msg # 在提交时,用 commitlint 校验信息
└── pre-commit # 在提交前,用 lint-staged 检查文件

如果您在 VS Code 中打开这两个钩子文件,请确认右下角显示的文件编码为 UTF-8,换行符为 LF。如果不是,请点击右下角的编码或换行符标识进行切换。

第七步:测试自动化工作流

现在,我们的自动化质量保障体系已全部配置完毕。让我们来亲自验证一下它的效果。

测试 1:验证 pre-commit 钩子 (lint-staged)

  1. 打开任意一个 .tsx 文件,故意破坏其代码格式,例如添加多余的空格、使用双引号等。
    1
    2
    3
    4
    5
    6
    7
    8
    // src/app/page.tsx
    export default function Home() {
    return (
    <main>
    <h1 className="text-2xl" >Prorise UI</h1>
    </main>
    )
    }
  2. 将文件添加到暂存区,并尝试提交。
    1
    2
    git add .
    git commit -m "feat: add title to homepage"
  3. 预期结果: 您会看到终端输出了 lint-staged 的运行信息,ESLint 和 Prettier 依次对文件进行了检查和格式化。提交成功后,再次查看刚才的文件,会发现多余的空格已经被自动清除了。如果存在 ESLint 无法自动修复的错误,提交则会被中止,您需要手动修复后才能继续提交。

测试 2:验证 commit-msg 钩子 (commitlint)

  1. 随便修改一个文件,然后尝试使用不规范的提交信息进行提交。
    1
    2
    git add .
    git commit -m "更新了一下页面"
  2. 预期结果: 提交 失败commitlint 会立即报错,提示您 typesubject 存在问题。
    1
    2
    3
    4
    5
    ⧗   input: 更新了一下页面
    ✖ subject may not be empty [subject-empty]
    ✖ type may not be empty [type-empty]

    ✖ found 2 problems, 0 warnings
  3. 使用规范的格式再次提交:
    1
    git commit -m "docs: 更新页面文案"
    提交成功。

测试 3:验证格式错误提示

尝试提交一个缺少空格的提交信息:

1
git commit -m "feat:添加新功能"

预期结果: 提交失败,提示格式错误。正确的格式应为:

1
git commit -m "feat: 添加新功能"

注意冒号后面的空格,这是约定式提交规范的强制要求。

至此,我们的自动化 “防火墙” 已成功建立。从现在开始,每一次 git commit 都会自动触发代码质量检查和格式化,每一条提交信息都必须符合团队规范,确保了代码库的长期健康和可维护性,我们现在正确的将项目提交到我们的 github 仓库

1
2
3
4
5
6
7
git commit -m "chore: 配置代码质量保障工具链

- 添加 Husky 9 用于 Git Hooks 管理
- 添加 lint-staged 用于暂存文件检查
- 添加 commitlint 用于提交信息规范
- 添加 Prettier 用于代码格式化
- 集成 ESLint 与 Prettier"

image-20251011222421520

一个比较有意思的是 next 会将默认的脚手架文件做一个提前标记,以做区分是否改动过原脚手架的内容

第八步:引入 conventional-changelog 实现自动化变更日志

在规范化提交信息之后,我们还可以更进一步,利用这些结构化的提交历史自动生成项目的变更日志(CHANGELOG)。conventional-changelog 正是为此而生的工具,它能够读取符合约定式提交规范的 Git 历史,自动生成格式规范、内容清晰的 CHANGELOG.md 文件。

这不仅为团队成员和用户提供了清晰的版本演变记录,更为后续的版本发布、语义化版本管理(Semantic Versioning)奠定了坚实基础。

1.安装 conventional-changelog-cli

在项目根目录执行以下命令,安装 conventional-changelog-cli 作为开发依赖:

1
pnpm install -D conventional-changelog-cli

2 配置 CHANGELOG 生成脚本

打开 package.json,在 scripts 字段中添加以下命令:

文件路径: package.json(部分)

1
2
3
4
5
6
{
"scripts": {
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
"version": "conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md",
}
}

新增脚本的说明:

  • changelog: 手动生成或更新 CHANGELOG.md 文件

    • -p angular: 使用 Angular 提交规范(与我们的 commitlint 配置一致)
    • -i CHANGELOG.md: 将生成的日志写入 CHANGELOG.md 文件
    • -s: 增量模式,只生成自上次发布以来的变更,不会覆盖已有内容
  • version: 与 npm/pnpm version 命令配合使用,在版本升级时自动生成 CHANGELOG

    • 执行完 conventional-changelog 后会自动将 CHANGELOG.md 添加到暂存区
    • 通常在执行 pnpm version patch/minor/major 时会自动触发

3. 生成第一份 CHANGELOG

在首次使用时,我们可以生成包含所有历史版本的完整 CHANGELOG:

1
2
# 生成所有历史版本的 CHANGELOG(仅首次)
npx conventional-changelog -p angular -i CHANGELOG.md -s -r 0

参数说明:

  • -r 0: 生成所有版本的日志(release count = 0 表示全部)

执行后,项目根目录会生成 CHANGELOG.md 文件,内容按照版本和提交类型(feat、fix、perf 等)自动分类整理。

4. 日常使用流程

配置完成后,您的日常版本发布流程可以如下进行,在这之前我们有必要要了解一下版本号的规范:

  • 主版本号 (Major):当你做了不兼容的 API 修改时,需要升级主版本号。这通常意味着软件发生了重大的变化,可能会导致依赖该软件的旧版本代码无法正常工作。例如,从版本 1.7.0 升级到 2.0.0。
  • 次版本号 (Minor):当你以向下兼容的方式添加了新功能时,需要升级次版本号。“向下兼容”意味着新版本虽然增加了功能,但不会破坏依赖旧版本软件的代码。例如,从版本 1.7.0 升级到 1.8.0。
  • 修订号 (Patch):当你做了向下兼容的问题修复时,需要升级修订号。这通常指的是修复了软件中的 bug,而没有引入新功能或不兼容的改动。例如,从版本 1.7.0 升级到 1.7.1。
1
2
3
4
5
6
7
8
9
10
11
12
# 1. 确保所有代码已提交
git add .
git commit -m "feat: 完成某个新功能"

# 2. 使用 pnpm version 升级版本(会自动生成 CHANGELOG)
pnpm version patch # 修订号升级 1.0.0 -> 1.0.1
pnpm version minor # 次版本号升级 1.0.0 -> 1.1.0
pnpm version major # 主版本号升级 1.0.0 -> 2.0.0

# 3. 推送代码和标签
git push
git push --tags

执行 pnpm version 时,会依次触发以下自动化流程:

  1. 更新 package.json 中的版本号
  2. 执行 version 脚本,生成最新的 CHANGELOG
  3. CHANGELOG.md 添加到暂存区
  4. 创建一个 Git commit 和 tag

如果您只想手动生成 CHANGELOG 而不升级版本,可以单独执行:

1
pnpm run changelog

5 CHANGELOG 示例

生成的 CHANGELOG.md 文件格式示例如下:

1
2
3
4
5
6
7
8
9
## [0.1.2](https://github.com/Prorise-cool/Prorise-UI/compare/v0.1.1...v0.1.2) (2025-10-12)

## 0.1.1 (2025-10-12)

### Features

- 完成某个新功能 ([a1bcc83](https://github.com/Prorise-cool/Prorise-UI/commit/a1bcc83c4d11392962cb62c2a24bd2f5aa5df030))

# 0.1.0 (2025-10-12)

可以看到,所有提交按类型自动分类(Features、Bug Fixes、Performance Improvements 等),并且包含了提交信息和 commit hash 链接,方便追溯具体改动。

注意事项

  1. 提交信息必须规范:只有符合约定式提交规范的 commit 才会被收集到 CHANGELOG 中。不规范的提交信息会被忽略。

  2. 常规改动会被包含:默认情况下,featfixperf 类型的提交会出现在 CHANGELOG 中,而 docsstylerefactortestchore 等类型不会出现(但它们仍然是有效的提交类型,只是不会展示给最终用户)。

  3. Breaking Changes 会突出显示:如果提交信息中包含 BREAKING CHANGE: 标识,该提交会在 CHANGELOG 中被特别标注,提醒用户这是一个破坏性变更。

示例:

1
2
3
git commit -m "feat: 重构用户认证模块

BREAKING CHANGE: 用户登录接口的响应格式已改变,需要更新客户端代码"

至此,我们不仅建立了规范化的提交信息体系,还拥有了自动化的变更日志生成能力。项目的每一次演进都将被清晰地记录和呈现。


1.3.4 本节小结

在本节中,我们完成了从零到一的飞跃。我们首先使用 create-next-app 初始化了一个功能完备的现代化 React 项目。紧接着,我们利用 shadcn/ui init 命令,为项目注入了一套专业、基于 CSS 变量的底层设计令牌体系。随后,我们集成了 daisyUI 作为高效率的组件样式层,并通过自定义主题配置,使其完全由我们的设计令牌驱动,实现了视觉规范的顶层统一。

最后,我们配置了一套完整的代码质量保障工具链:Prettier 负责代码格式化,ESLint 负责代码质量检查,两者完美集成;Husky 管理 Git Hooks,lint-staged 确保只检查修改的文件,commitlint 强制提交信息规范化。这套自动化工作流在每次提交时都会自动运行,确保入库的每一行代码都符合最高质量标准。

至此,一个结构清晰、规范统一、质量可靠的专业设计系统项目基座已搭建完毕,为后续的组件开发铺平了道路。


第二章. StoryBook 与 Mdx 初识:Button 组件的全景开发

在第一章中,我们投入了大量精力,从零开始构建了一个坚实、专业且自动化的项目基座。现在,万事俱备,是时候真正开始“建设”我们的设计系统了。

本章的核心目标,并非仅仅是实现一个按钮,而是要通过开发这个最基础、最通用的 Button 组件,来完整地建立和演练一套世界级的组件开发 工作流。我们将依次经历 组件编码与变体设计交互式文档编写自动化单元测试 三个阶段。完成本章后,您将拥有一套可以复用到任何组件开发中的、高效、可靠的“肌肉记忆”。

2.1. 工厂模式:结合 daisyUIcva 实现 Button 的变体

我们首先来解决组件开发中的第一个核心问题:如何优雅地管理组件的多种视觉形态?

2.1.1. 痛点剖析:混乱的条件样式

一个 Button 组件在真实业务场景中,通常需要支持多种变体。例如,按功能分,有主按钮、次按钮、危险按钮;按尺寸分,有大、中、小三种尺寸。在传统的开发模式中,我们可能会写出类似下面这样的代码:

1
2
3
4
5
6
7
8
// 一个难以维护的 Button 组件示例 (错误示范)
function BadButton({ variant, size, ...props }) {
// ...
// 使用 if/else 或三元运算符来拼接类名
const variantClass = variant === 'primary' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-800';
const sizeClass = size === 'small' ? 'h-9 px-3' : 'h-11 px-8';
// ...
}

这种写法的弊端显而易见:当变体增加或需要引入新状态(如 disabled)时,组件内部的条件判断逻辑会迅速膨胀,变得难以阅读和扩展。这种将样式判断逻辑与组件渲染逻辑强耦合的方式,违背了“关注点分离”的软件设计基本原则。

2.1.2. 解决方案:daisyUI + cva 的双层抽象

我们的解决方案分为两层,完美地结合了第一章铺垫的技术:

  1. 样式层: 我们将直接使用 daisyUI 提供的高语义、预设好的组件类名(如 btn, btn-primary)作为样式的“原子单位”。这让我们无需手动组合大量的 Tailwind 工具类,极大地提升了开发效率。
  2. 变体管理层: 我们将使用 cva (Class Variance Authority) 作为“工厂”,它的任务是根据传入的 props,动态地、声明式地选择并组合正确的 daisyUI 类名。

这种模式,让我们既能享受 daisyUI 的便捷,又能拥有 cva 带来的类型安全和优雅的变体管理能力。

2.1.3. 准备工作:创建文件与安装依赖

在开始编码前,我们先准备好环境。

1. 创建组件文件

遵循 shadcn/ui 的约定,我们将所有 UI 组件都放置在 src/components/ui 目录下。

1
2
3
# 在项目根目录下执行
mkdir -p src/components/ui
touch src/components/ui/Button.tsx

2. 安装 Slot 依赖

为了让我们的 Button 组件更具灵活性(例如,有时我们希望它渲染为一个 <a> 标签),我们需要 Radix UI 提供的 Slot 组件。

1
pnpm install @radix-ui/react-slot

2.1.4. 逐步构建:Button 组件的实现

现在,我们打开 src/components/ui/Button.tsx 文件,开始我们的渐进式构建之旅。每一步我们都会展示文件的 完整 状态,并用注释标明该步骤的 新增或修改 之处,确保您清楚每一行代码的上下文。

1. 阶段一:搭建最小化组件骨架

我们的起点,是一个最纯粹的 React 组件骨架。我们使用 React.forwardRef,这是构建可复用组件库的最佳实践,它允许父组件将 ref 直接传递到我们内部渲染的 DOM 元素上。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import * as React from 'react';

// 使用 React.forwardRef 创建一个可转发 ref 的组件
const Button = React.forwardRef<HTMLButtonElement>(
(props, ref) => {
return (
<button ref={ref} {...props} />
);
}
);
// 设置 displayName 便于在 React DevTools 中调试
Button.displayName = 'Button';

export { Button };

解析: 至此,我们有了一个功能上等同于原生 <button> 的 React 组件。

2. 阶段二:引入 daisyUI 基础样式

现在,让我们为这个骨架穿上 daisyUI 的“基础皮肤”。只需添加 btn 这个核心类名。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import * as React from 'react';

const Button = React.forwardRef<HTMLButtonElement>(
(props, ref) => {
return (
<button
className="btn" // <-- 修改此行添加 daisyUI 的基础按钮类
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';

export { Button };

解析: 仅仅一个类名,我们的按钮就已经拥有了由 daisyUI 提供的一致的尺寸、字体、内外边距和交互效果,这得益于我们在第一章的全局配置。

3. 阶段三:创建 cva 样式工厂

为了管理 primary, secondary 等变体,我们现在引入 cva 来创建一个样式工厂。这个工厂的职责是根据 props,输出对应的 daisyUI 变体类名。

文件路径: src/components/ui/Button.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
import * as React from 'react';
// <-- 新增代码块 开始 -->
import { cva, type VariantProps } from 'class-variance-authority';

import { cn } from '@/lib/utils';

// 1. 创建样式工厂
const buttonVariants = cva(
// 第一个参数:所有变体共享的基础类名
'btn',
{
// 第二个参数:定义变体
variants: {
// "variant" 轴:定义按钮的功能和视觉风格(颜色)
variant: {
default: '', // 默认情况,daisyUI 不需要额外类名
neutral: 'btn-neutral',
primary: 'btn-primary',
secondary: 'btn-secondary',
accent: 'btn-accent',
info: 'btn-info',
success: 'btn-success',
warning: 'btn-warning',
error: 'btn-error',
},
// "buttonStyle" 轴:定义按钮的样式风格(重命名以避免与 React.CSSProperties 的 style 冲突)
buttonStyle: {
default: '', // 默认实心样式
outline: 'btn-outline',
dash: 'btn-dash',
soft: 'btn-soft',
ghost: 'btn-ghost',
link: 'btn-link',
},
// "size" 轴:定义按钮的尺寸
size: {
xs: 'btn-xs',
sm: 'btn-sm',
md: 'btn-md', // 默认尺寸
lg: 'btn-lg',
xl: 'btn-xl',
},
// "behavior" 轴:定义按钮的行为状态
behavior: {
default: '',
active: 'btn-active',
disabled: 'btn-disabled',
},
// "modifier" 轴:定义按钮的修饰符
modifier: {
default: '',
wide: 'btn-wide',
block: 'btn-block',
square: 'btn-square',
circle: 'btn-circle',
},
},

// 第三部分:指定默认值
defaultVariants: {
variant: 'default',
buttonStyle: 'default',
size: 'md',
behavior: 'default',
modifier: 'default',
},
}
);
// <-- 新增代码块 结束 -->

const Button = React.forwardRef<HTMLButtonElement>(
// ... 此前代码 ...
);
Button.displayName = 'Button';

export { Button };

解析: 我们在文件顶部定义了 buttonVariants。注意,cva 的第一个参数是 daisyUI 的基础类 btn,而 variants 对象中的值则是 daisyUI 提供的高级变体类,如 btn-primary。这正是两大工具协同工作的核心体现。

4. 阶段四:定义类型安全的 Props 接口

接下来,我们要创建一个 ButtonProps 接口。它必须能接收所有原生按钮属性,并且能以类型安全的方式,理解我们刚刚用 cva 定义的 variantsize

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ... imports 和 buttonVariants 定义 ...

// <-- 新增代码块 开始 -->
// 2. 定义类型安全的 Props 接口
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, // 继承原生 button 所有属性
VariantProps<typeof buttonVariants> {
// 从 cva 工厂自动推断 variant 和 size 类型
asChild?: boolean;
}
// <-- 新增代码块 结束 -->

const Button = React.forwardRef<HTMLButtonElement>(
// ... 此前代码 ...
);
// ...

解析: VariantProps<typeof buttonVariants>cva 的一个高级工具类型。它会自动分析 buttonVariantsvariants 对象,并生成一个包含 variantsize 及其所有可能值的联合类型,确保了我们的组件 props 与样式定义永远保持同步。

5. 阶段五:连接 Props 与样式工厂

现在,我们将 Propscva 工厂连接起来,让组件根据传入的 props 动态生成最终的类名。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ... imports, buttonVariants, ButtonProps 定义 ...

// <-- 修改 Button 组件的实现 -->
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
// 从 props 中解构出我们的变体属性和 className
({ className, variant, buttonStyle, size, behavior, modifier, ...props }, ref) => {
return (
<button
// 调用样式工厂并用 cn 函数智能合并类名
className={cn(buttonVariants({ variant, buttonStyle, size, behavior, modifier, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';

// <-- 修改导出,额外导出 buttonVariants -->
export { Button, buttonVariants };

解析: 我们修改了 Button 组件的实现。buttonVariants({ variant, size, className }) 会根据传入的 props,从工厂中选择正确的 daisyUI 类名并组合起来。cn 函数则确保这些生成的类名能和任何从外部传入的 className 优雅地合并,并自动解决类名冲突。

6. 阶段六:实现 asChild 组合功能

最后,我们来添加 asChild 这个高级功能,让我们的 Button 组件拥有“附身”到其他元素(如链接 <a>)上的能力。这是最终的完整形态。

文件路径: src/components/ui/Button.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
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot'; // <-- 确保 Slot 已导入
import { cva, type VariantProps } from 'class-variance-authority';

import { cn } from '@/lib/utils';

const buttonVariants = cva(/* ... */);

export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean; // <-- 确保 asChild 已在接口中定义
}

// <-- 修改 Button 组件的实现 -->
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
// <-- 修改点 1:从 props 中解构出 asChild -->
({ className, variant, buttonStyle, size, behavior, modifier, asChild = false, ...props }, ref) => {
// <-- 修改点 2:动态决定渲染的组件 -->
const Comp = asChild ? Slot : 'button';
return (
// <-- 修改点 3:使用动态组件 Comp 进行渲染 -->
<Comp
// 调用样式工厂并用 cn 函数智能合并类名
className={cn(buttonVariants({ variant, buttonStyle, size, behavior, modifier, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';

export { Button, buttonVariants };

解析: 当 asChildtrue 时,Comp 会变成 Slot 组件。Slot 会将其接收到的所有属性(包括我们生成的 className)“克隆”并传递给它的直接子元素,而自身不渲染任何 DOM 节点。这使得 <Button asChild><a href="/">Home</a></Button> 这样的写法成为可能,最终渲染出的会是一个带有完整按钮样式的 <a> 标签。

至此,一个集 daisyUI 的高效、cva 的优雅和 Radix Slot 的灵活于一体的、生产级的 Button 组件已构建完毕。

7. 阶段七:验证与消费组件

理论和代码构建已经完成,现在是收获成果的时候。我们将修改 Next.js 应用的首页,来实际使用和展示我们刚刚创建的 Button 组件的所有变体。这个过程不仅能验证我们的工作成果,也能让您直观地感受到 cva 变体系统的强大之处。

首先,为了更好地展示图标按钮,我们需要安装一个图标库。lucide-reactshadcn/ui 生态中最常用的图标库,我们来安装它。

1
pnpm install lucide-react

接下来,我们清空并修改首页文件,将其变为一个 Button 组件的“陈列室”。

文件路径: 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
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
import { Button } from '@/components/ui/Button';
import { Mail } from 'lucide-react'; // 导入一个图标用于演示

export default function HomePage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-8 bg-background p-24">
<div className="flex flex-col items-center gap-2">
<h1 className="text-2xl font-bold">Button Variant Showcase</h1>
<p className="text-muted-foreground">功能与视觉风格</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-4">
<Button variant="default">Default</Button>
<Button variant="neutral">Neutral</Button>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="accent">Accent</Button>
<Button variant="info">Info</Button>
<Button variant="success">Success</Button>
<Button variant="warning">Warning</Button>
<Button variant="error">Error</Button>
</div>

<div className="flex flex-col items-center gap-2">
<h2 className="text-xl font-bold">Button Style Showcase</h2>
<p className="text-muted-foreground">样式风格</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-4">
<Button buttonStyle="default" variant="primary">Default</Button>
<Button buttonStyle="outline" variant="primary">Outline</Button>
<Button buttonStyle="dash" variant="primary">Dash</Button>
<Button buttonStyle="soft" variant="primary">Soft</Button>
<Button buttonStyle="ghost" variant="primary">Ghost</Button>
<Button buttonStyle="link" variant="primary">Link</Button>
</div>

<div className="flex flex-col items-center gap-2">
<h2 className="text-xl font-bold">Button Size Showcase</h2>
<p className="text-muted-foreground">尺寸与规格</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-4">
<Button size="xs">Extra Small</Button>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
<Button size="xl">Extra Large</Button>
</div>

<div className="flex flex-col items-center gap-2">
<h2 className="text-xl font-bold">Button Behavior Showcase</h2>
<p className="text-muted-foreground">行为状态</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-4">
<Button behavior="default" variant="primary">Default</Button>
<Button behavior="active" variant="primary">Active</Button>
<Button behavior="disabled" variant="primary">Disabled</Button>
</div>

<div className="flex flex-col items-center gap-2">
<h2 className="text-xl font-bold">Button Modifier Showcase</h2>
<p className="text-muted-foreground">修饰符</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-4">
<Button modifier="default" variant="primary">Default</Button>
<Button modifier="wide" variant="primary">Wide</Button>
<Button modifier="block" variant="primary">Block</Button>
<Button modifier="square" variant="primary">
<Mail className="h-4 w-4" />
</Button>
<Button modifier="circle" variant="primary">
<Mail className="h-4 w-4" />
</Button>
</div>

<div className="flex flex-col items-center gap-2">
<h2 className="text-xl font-bold">asChild Prop Showcase</h2>
<p className="text-muted-foreground">组合与渲染</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-4">
<Button asChild buttonStyle="outline" variant="primary">
<a href="https://github.com/google/gemini-pro" target="_blank" rel="noopener noreferrer">
I am a Link
</a>
</Button>
</div>
</main>
);
}

运行与验证

现在,回到您的终端,确保您在 prorise-ui 项目的根目录下,然后启动开发服务器:

1
pnpm run dev

打开浏览器并访问 http://localhost: 3000。您应该能看到一个清晰展示了所有按钮变体和尺寸的页面。您可以尝试点击、悬浮,并观察不同变体的交互效果。

这个页面的成功渲染,不仅证明了我们的 Button 组件代码是正确的,更重要的是,它验证了我们从第一章开始搭建的整个技术体系——从 Next.js 框架,到 Tailwind CSS 和 shadcn/ui 变量的注入,再到 daisyUI 插件的集成——已经完全打通并协同工作。

img


2.1.5 本节小结

在本节中,我们严格遵循“渐进式构建”的原则,成功创建了设计系统的第一个核心组件 Button。我们没有直接堆砌原子类,而是巧妙地将 daisyUI 的高语义组件类作为 cva 样式工厂的输出,完美结合了二者的优点。通过分步实现组件骨架、类型接口、样式映射和组合功能,我们将一个复杂的组件构建过程拆解为一系列聚焦单一概念的、易于理解的步骤。这个过程不仅产出了一个具体的组件,更重要的是,为我们后续所有组件的开发,奠定了一套可复用的、优雅的、层次分明的编码范式。

技术架构深度问答
2025-10-12 11:00
学习者

老师,我有一个疑问。在 2.1 节构建 Button 的过程中,我们用到了 React、cva、Tailwind 的类,甚至 Radix 的 Slot。但是我好像没有在 Button.tsx 的 import 语句或代码的任何地方看到 shadcn/ui。它在我们的项目中到底扮演了什么角色?

问得非常好!您之所以在组件代码中“看不到”它,正是因为它和 React 这些库的性质完全不同。shadcn/ui 不是一个运行时依赖的组件库,而是一套构建和管理我们自有组件的工作流与工具集。

我们可以从两个方面来理解它已经为我们所做的工作:

第一,它是我们设计系统的奠基者 (init 命令)。

所以,虽然 Button.tsx 没有 import ‘shadcn-ui’,但它完全运行在 shadcn/ui 所构建的基础设施之上。

学习者

我明白了,所以它更像是一个项目初始化器和环境配置器,而不是一个像 daisyUI 那样提供现成类名的库。

完全正确!这就引出了它的第二个角色。

第二,它是我们未来获取复杂组件源码的传送门 (add 命令)。

未来当我们遇到更复杂的组件,比如 Dialog 时,就可以使用 npx shadcn-ui@latest add dialog 命令。这个命令不会安装一个无法修改的黑盒组件,而是会将遵循同样最佳实践的 Dialog.tsx 源代码文件,直接复制到我们的 src/components/ui 目录下。

学习者

啊,所以我明白了!我们之所以要手动构建一次 Button,是为了彻底理解 shadcn/ui 体系下组件的内部构造原理。这样,以后我们用 add 命令添加任何组件,都能完全看懂它的源码,并可以随心所欲地进行修改!

正是如此!您的总结非常到位。shadcn/ui 的哲学就是“组件是你自己的”。它赋予我们对代码的 100% 所有权。

因此,shadcn/ui 在我们项目中的角色可以总结为:在初始阶段,它是一个环境配置器和设计令牌生成器。在开发阶段,它是一套我们遵循的架构模式,以及一个按需获取高质量组件源码的工具。

我们在 2.1 节所做的一切,本质上就是在亲手实践并内化这套先进的架构模式。


2.2. 文档驱动开发:集成 Storybook 8 与组件可视化

现在,我们已经拥有了一个功能完备的 Button 组件。但一个孤立的组件代码文件,对于使用它的其他开发者、以及与之协作的设计师来说,仍然是一个“黑盒”。如何直观地展示它的所有功能、变体和交互状态?如何为它创建一份易于维护、永远与代码同步的“活文档”?

答案就是 Storybook。本节,我们将为项目集成最新版的 Storybook 8,并将其配置为我们组件的“可视化工作台”和文档中心。

2.2.1. 一键安装:使用 Storybook 8 CLI 初始化工作环境

1.核心理念:组件驱动开发

在集成工具之前,我们先要理解其背后的思想——组件驱动开发 (Component-Driven Development, CDD)。其核心主张是,将 UI 组件作为开发的基本单元,在独立、隔离的环境中进行构建和测试,然后再集成到复杂的应用中。

Storybook 正是实践这一理念的行业标准工具。它为我们提供了一个脱离主应用的、专门用于开发和展示组件的“实验室”环境。在这个环境中,我们可以轻松地模拟组件的每一种状态和变体,而无需为了看到一个特定状态的按钮,而去手动操作整个复杂的业务流程。

2.初始化命令

Storybook 8 提供了强大的 CLI 工具,可以自动检测我们的项目技术栈(Next.js, React, Tailwind CSS)并完成所有必需的配置。

在您的项目 prorise-ui 的根目录下,执行以下命令:

1
npx storybook@latest init

该命令会执行一系列自动化操作:

  • 探测项目配置,并询问您是否确认安装。
  • 安装所有必要的 Storybook 依赖包,例如 @storybook/react-nextjs, @storybook/addon-essentials 等。
  • 在项目根目录下创建一个 .storybook 目录,其中包含 Storybook 的核心配置文件。
  • src/ 目录下创建一个 stories 目录,并生成一些示例故事文件,以验证安装是否成功。

3.分析生成的文件

安装完成后,您的项目目录结构会新增以下关键部分:

1
2
3
4
5
6
7
8
9
10
prorise-ui/
├── .storybook/
│ ├── main.ts # ⬅️ Storybook 的主配置文件
│ └── preview.ts # ⬅️ 全局配置文件,用于“包裹”所有故事
├── src/
│ └── stories/ # ⬅️ Storybook 自动生成的示例故事
│ ├── Button.stories.ts
│ ├── Header.stories.ts
│ └── Page.stories.ts
└── ...

我们重点关注 .storybook 目录下的两个核心文件:

  1. .storybook/main.ts: 这是 Storybook 的“控制面板”。
  • stories: 定义了 Storybook 去哪里寻找我们的故事文件(.stories.tsx)。

    • addons: 注册了需要加载的插件,例如 addon-essentials 包含了一系列核心功能(如 Controls, Actions, Viewport 等)。

    • framework: 指定了 Storybook 需要适配的框架,这里它会自动配置为 @storybook/nextjs-vite

  1. .storybook/preview.ts: 这是所有故事的“全局画布”。我们可以在这里定义全局参数、添加全局装饰器(Decorators),或者导入全局样式。这个文件对于后续的主题集成至关重要。

4.首次运行

注意: postcss.config.mjs 使用的是数组格式,这是 Next.js 的默认格式,但 Storybook(使用 Vite)不兼容。我们需要将它改为对象格式:

1
2
3
4
5
6
7
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};

export default config;

Storybook 的 init 命令会自动在 package.json 中添加一个 storybook 脚本。现在,让我们运行它。

请注意,根据您的指示,我们项目的包管理器统一使用 pnpm

1
pnpm run storybook

执行后,Storybook 会启动一个本地开发服务器,并自动在浏览器中打开。您应该能看到 Storybook 的欢迎界面,以及左侧导航栏中由 CLI 生成的 Button, Header, Page 等示例故事。

image-20251012113023945

5.清理工作

这些自动生成的示例故事很好地验证了我们的安装,但它们并不是我们 Prorise UI 设计系统的一部分。为了保持项目整洁,我们将它们删除,为下一节编写我们自己的第一个故事做准备。

在终端中执行以下命令(或手动删除该文件夹):

1
2
3
4
5
# Linux / macOS
rm -rf src/stories

# Windows (PowerShell Prompt)
Remove-Item -Path "src\stories" -Recurse -Force

现在,我们拥有了一个纯净、与项目深度集成的 Storybook 8 环境。但您可能会发现,示例故事中的按钮样式似乎并不正确——这正是我们下一节要解决的问题。


2.2.2. 主题集成:让 Storybook 理解我们的全局样式与暗色模式

痛点剖析:隔离的预览环境

当您运行 pnpm storybook 时,您所看到的组件预览区域(通常称为 “Canvas”)实际上是 一个独立的 iframe 页面。这个页面与我们的 Next.js 应用是完全隔离的,因此它默认不会加载 src/app/globals.css 文件。这就导致了两个问题:

  1. 所有由 shadcn/ui 生成的 CSS 变量(如 --primary, --background)都未定义。
  2. Tailwind 和 daisyUI 的样式虽然被 Storybook 的构建过程所编译,但由于缺少基础变量,渲染效果会完全错乱。
  3. Tailwind CSS 的暗色模式依赖于 <html> 标签上是否存在一个 dark 类,Storybook 默认也不知道如何控制这个类。

我们的任务就是解决这三个问题,让 Storybook 的预览环境与我们的主应用环境完全同步。

前置准备:确保构建工具兼容性

在开始配置之前,我们需要处理一个潜在的兼容性问题。当您使用 npx storybook@latest init 安装 Storybook 时,它会自动选择 @storybook/nextjs-vite 框架。这意味着 Storybook 将使用 Vite 作为构建引擎,而不是 Next.js 的默认构建工具。

这带来了一个问题:Next.js 15 生成的 postcss.config.mjs 文件使用的是数组格式的插件配置,而 Vite 要求使用对象格式。我们需要先修正这个配置,否则 Storybook 将无法正常启动。

文件路径: postcss.config.mjs

请将您的 PostCSS 配置文件修改为以下格式:

1
2
3
4
5
6
7
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};

export default config;

解析: 将 plugins 从数组格式(["@tailwindcss/postcss"])改为对象格式({ "@tailwindcss/postcss": {} })。这种对象格式同时兼容 Next.js 和 Vite,不会影响您的 Next.js 应用的正常运行,只是写法上的差异。

现在,您可以成功运行 pnpm storybook 了,虽然此时 Storybook 可能会提示 “找不到任何故事”,这是正常的,因为我们还没有编写任何故事文件。

第一步:注入全局样式

这是最关键、也是最简单的一步。我们需要告诉 Storybook,在渲染任何故事之前,都必须先加载我们的全局样式文件。这个配置在 .storybook/preview.ts 文件中完成。

文件路径: .storybook/preview.ts

1
2
3
4
import type { Preview } from "@storybook/react";

// <-- 新增:导入项目的全局样式文件 -->
import "../src/app/globals.css";

解析: 只需在文件顶部添加 import "../src/app/globals.css"; 这一行,Webpack (或 Vite) 在打包 Storybook 时就会将我们的全局样式包含进去。此时,如果您重新运行 pnpm storybook,会发现组件的颜色、字体等基础样式已经基本正确,因为 CSS 变量已经被正确加载。

第二步:安装并注册主题切换插件

为了在 Storybook 中方便地切换亮/暗色模式,我们需要一个专门的插件。官方的 @storybook/addon-themes 是最佳选择。

首先,安装它作为开发依赖:

1
pnpm add -D @storybook/addon-themes

然后,我们需要在 Storybook 的主配置文件中“注册”这个插件,让 Storybook 加载它。

文件路径: .storybook/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import type { StorybookConfig } from "@storybook/nextjs-vite";
const config: StorybookConfig = {
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
"@chromatic-com/storybook",
"@storybook/addon-docs",
"@storybook/addon-onboarding",
"@storybook/addon-a11y",
"@storybook/addon-vitest",
"@storybook/addon-themes", // <-- 新增:在此处注册主题插件
],
"framework": {
"name": "@storybook/nextjs-vite",
"options": {}
},
"staticDirs": [
"..\\public"
]
};
export default config;

解析: 在 addons 数组中添加 @storybook/addon-themes,即可激活该插件。

第三步:配置主题切换装饰器

插件激活后,我们还需要告诉它我们的主题是如何工作的。这里有一个重要的技术决策需要做出。

在我们的项目中,我们使用了 daisyUI 作为 UI 组件库。daisyUI 的主题系统是基于 HTML 的 data-theme 属性来工作的,而不是 CSS 类。当您在 HTML 根元素上设置 data-theme="dark" 时,daisyUI 会自动应用该主题中定义的所有 CSS 变量(如 --color-primary--color-accent 等)。

因此,我们需要使用 @storybook/addon-themes 提供的 withThemeByDataAttribute 工具,而不是常见的 withThemeByClassName

我们将使用一个 “装饰器 (Decorator)” 来完成这个配置。装饰器是 Storybook 的一个强大功能,它像一个包装纸,可以将我们所有的故事都包裹在里面,从而提供统一的上下文或功能。

文件路径: .storybook/preview.ts

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
import type { Preview } from "@storybook/nextjs-vite";
import { withThemeByDataAttribute } from "@storybook/addon-themes"; // <-- 1. 导入正确的工具

import "../src/app/globals.css";

const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
// <-- 2. 新增 decorators 配置 -->
decorators: [
withThemeByDataAttribute({
themes: {
// 为每个主题起一个名字,并指定它对应的 data-theme 属性值
light: "light",
dark: "dark",
},
// 指定默认主题
defaultTheme: "light",
// 指定要操作的属性名
attributeName: "data-theme",
}),
],
};

export default preview;

代码深度解析:

  • decorators: 这是 preview.ts 中的一个关键属性,它是一个数组,可以包含多个装饰器。
  • withThemeByDataAttribute: 这是 @storybook/addon-themes 导出的一个装饰器工厂函数,专门用于处理基于 HTML 属性的主题切换。
  • themes 对象: 我们在这里定义了我们拥有的主题。键名(如 light, dark)将作为工具栏中选项的名称,值(如 "light", "dark")则是当该主题被选中时,需要设置到 HTML 根元素 data-theme 属性上的值。
  • attributeName: 明确指定我们要操作的属性名为 data-theme。这与 daisyUI 的主题系统完全匹配。
  • defaultTheme: 指定 Storybook 默认加载时使用的主题。

为什么选择 withThemeByDataAttribute?能否同时使用两个装饰器?

您可能会看到很多 Storybook 教程使用 withThemeByClassName,那是因为纯 Tailwind CSS 项目通常通过 .dark 类来切换主题。而我们的项目使用了 daisyUI,它有自己的主题系统,需要通过 data-theme 属性来激活。

理论上,您确实可以同时使用两个装饰器:

1
2
3
4
5
6
7
8
9
10
11
decorators: [
withThemeByDataAttribute({
themes: { light: "light", dark: "dark" },
defaultTheme: "light",
attributeName: "data-theme",
}),
withThemeByClassName({
themes: { light: "", dark: "dark" },
defaultTheme: "light",
}),
],

这样配置后,切换主题时会同时:

  1. 设置 data-theme="dark" 属性(激活 daisyUI 主题)
  2. 添加 .dark 类(激活 Tailwind 的暗色模式工具类)

那么什么时候需要两个装饰器,什么时候只需要一个?

这取决于您的代码中是否使用了 Tailwind 的 dark: 变体类。让我们分析两种场景:

场景 1:只使用 daisyUI 组件

如果您的组件完全依赖 daisyUI 的类名(如 btn, btn-primary),这些类名的样式是由 daisyUI 的 CSS 变量驱动的。只要 data-theme 属性正确设置,daisyUI 就会应用正确的颜色。此时,只需要 withThemeByDataAttribute 就足够了。

1
2
// 这个组件只需要 data-theme 属性
<button className="btn btn-primary">按钮</button>

场景 2:混合使用 Tailwind 暗色模式类

如果您的组件中使用了 Tailwind 的 dark: 前缀类名,例如:

1
2
3
<div className="bg-white dark:bg-gray-900 text-black dark:text-white">
<button className="btn btn-primary">按钮</button>
</div>

在这种情况下,您需要同时使用两个装饰器。因为:

  • dark:bg-gray-900dark:text-white 需要 .dark 类才能生效
  • btn btn-primary 需要 data-theme 属性才能获得正确的颜色

我们项目的选择

在我们当前的项目中,由于 Button 组件是基于 daisyUI 的 btn 类构建的,并且 globals.css 中已经通过 @layer base 定义了全局的背景和文字颜色:

1
2
3
4
5
6
7
8
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

这些基础样式会自动根据 CSS 变量变化,而这些变量在 globals.css:root.dark 选择器中已经定义。因此,使用单个 withThemeByDataAttribute 装饰器已经足以满足需求。

但如果您在后续开发中,在组件内部大量使用了 dark: 前缀的 Tailwind 类,那时再添加 withThemeByClassName 装饰器也不迟。Storybook 的装饰器系统支持随时添加或移除装饰器,这是一个可以根据项目需求灵活调整的配置。

第四步:最终验证

所有配置都已就绪。现在让我们重新启动 Storybook(如果它正在运行,请先停止它):

1
pnpm storybook

Storybook 应该能够成功启动。虽然此时您可能还看不到任何组件(因为我们还没有编写故事文件),但您会在 Storybook 界面的右上角工具栏中看到一个新的图标 —— 这就是主题切换器,通常显示为画笔或太阳/月亮图标。

点击这个图标,您会看到两个选项:lightdark。当您在它们之间切换时,整个 Storybook 预览区域的背景色会随之变化。这证明了以下几点:

  1. globals.css 已经成功加载到 Storybook 的预览环境中。
  2. @storybook/addon-themes 插件正在正常工作。
  3. data-theme 属性正在被正确设置和切换。

理解主题切换的工作原理:

当您切换到暗色主题时,Storybook 会在预览区域的根 HTML 元素上设置 data-theme="dark"。此时,daisyUI 会读取这个属性,并应用 globals.css 中定义的暗色主题配置(第 162-195 行的 @plugin "daisyui/theme" { name: "dark"; ... } 部分)。这个配置会覆盖所有的颜色变量,例如:

  • --color-accent 从亮色模式的 oklch(62% 0.214 259.815)(柔和的蓝紫色)变为 oklch(55% 0.288 302.321)(鲜艳的紫色)
  • --color-base-100oklch(98% 0 0)(接近白色)变为 oklch(14% 0.005 285.823)(深灰色)

这就是为什么使用 withThemeByDataAttribute 如此重要 —— 它确保了 daisyUI 的整套主题系统能够被正确激活,而不仅仅是改变背景色。

至此,我们的 Storybook 环境已经配置完毕,它成为了一个能够 100% 精确反映我们应用真实样式的、强大的可视化开发与调试平台。在下一章中,我们将学习如何为组件编写故事文件,让它们在这个完美的环境中得以展现。


2.2.3. 编写第一个 Story:Button 组件的 CSF 3.0 实践

在上一节中,我们成功地将 Storybook 的预览环境与我们的项目主题完全同步。现在,这个“可视化工作台”已经准备就绪,是时候将我们精心打造的第一个“产品”——Button 组件——放上展台了。

本节我们将学习如何使用最新、最主流的 组件故事格式 3.0,为 Button 组件编写它的第一个“故事 (Story)”。

核心理念:什么是 Story?什么是 CSF?

  • Story: 一个 Story 代表一个组件的 单一、可被渲染的状态。例如,“一个主色、大尺寸的按钮”就是一个 Story,“一个禁用状态的、次要颜色的按钮”是另一个 Story。通过为组件编写一系列 Stories,我们就能完整地记录和展示它的所有可能性。

  • CSF (Component Story Format): 这是 Storybook 官方推荐的、基于标准 ES 模块的 Story 编写规范。CSF 3.0 是其最新版本,它通过一个 meta 对象来定义组件级的元数据,并通过具名导出 (Named Exports) 来定义每一个独立的 Story。这种格式不仅写法简洁,而且与 TypeScript 的结合极为出色,能提供强大的类型推断和自动补全能力。

第一步:创建 Story 文件

按照社区的最佳实践,组件的 Story 文件应该与组件源文件并置存放,并以 .stories.tsx 作为后缀。这样做的好处是便于查找和维护。

让我们为 Button 组件创建它的 Story 文件:

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

第二步:构建基础结构 (Meta 对象)

每一个 Story 文件都必须有一个默认导出 (default export),我们称之为 meta 对象。它负责告诉 Storybook 这个文件是关于哪个组件的,以及如何对它进行分类和展示。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button'; // 1. 导入我们需要为其编写故事的组件

// 2. 定义 Meta 对象,描述我们的组件故事集
const meta = {
title: 'Components/Button', // 故事在 Storybook 侧边栏中的显示路径
component: Button, // 将这个 Story 文件与 Button 组件本身进行强关联
parameters: {
layout: 'centered', // 让组件在 Canvas 中居中显示
},
tags: ['autodocs'], // 开启自动文档生成
} satisfies Meta<typeof Button>;

export default meta;

代码深度解析:

  • title: 定义组件在 Storybook 侧边栏中的显示路径。'Components/Button' 会在侧边栏生成 Components 文件夹,其下有 Button 条目。
  • component: 将 Story 文件与组件进行强关联。这使得 Storybook 能够读取组件的 TypeScript 类型,自动生成 Controls 控件和文档。
  • parameters.layout: 控制组件在 Canvas 中的布局方式,'centered' 让组件居中显示,便于观察。
  • tags: ['autodocs']: 启用 Storybook 的自动文档生成功能,会为组件创建一个 “Docs” 标签页。
  • satisfies Meta<typeof Button>: TypeScript 的类型断言语法,确保 meta 对象符合 Storybook 的 Meta 类型要求,同时保留类型推断能力。

第三步:编写第一个 Story (Primary)

现在,我们来定义第一个具体的 Story。一个 Story 就是一个简单的、具名导出的对象。

文件路径: src/components/ui/Button.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
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

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

export default meta;

// <-- 新增代码块 开始 -->
// 为我们的 Story 对象定义一个基础类型,以获得更好的 TypeScript 智能提示
type Story = StoryObj<typeof meta>;

// 导出一个名为 Default 的 Story(最简单的状态)
export const Default: Story = {
args: {
children: 'Button',
},
};

// 导出一个名为 Primary 的 Story
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Primary Button',
},
};
// <-- 新增代码块 结束 -->

代码深度解析:

  • type Story = StoryObj<typeof meta>;: 这是一个 TypeScript 的最佳实践。StoryObj 是 Storybook 提供的泛型,typeof meta 会将我们 meta 对象中关于组件类型的信息传递给它,从而创建一个与 Button 组件 props 完全匹配的 Story 类型。
  • export const Primary: Story = { ... };: 我们通过具名导出的方式创建了一个名为 Primary 的 Story。这个导出的名称会成为它在 Storybook 侧边栏中的显示名称。
  • args: 这是定义一个 Story 最核心的部分。args 对象中的每一个键值对,都会被作为 props 传递给被渲染的 Button 组件。在 Default Story 中,我们只设置了 children,让组件使用其默认样式。在 Primary Story 中,我们指定了 variantprimary,这会应用 daisyUI 的 btn-primary 类名。

第四步:为更多变体编写 Stories

Storybook 的强大之处在于能够清晰地罗列和展示组件的所有状态。让我们继续为 Button 的其他重要变体添加对应的 Stories。

文件路径: src/components/ui/Button.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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// ... 此前的 meta, Story 类型, Default 和 Primary Stories ...

// <-- 新增代码块 开始 -->
// 不同颜色变体
export const Secondary: Story = {
args: {
variant: 'secondary',
children: 'Secondary Button',
},
};

export const Accent: Story = {
args: {
variant: 'accent',
children: 'Accent Button',
},
};

export const Success: Story = {
args: {
variant: 'success',
children: 'Success Button',
},
};

export const Warning: Story = {
args: {
variant: 'warning',
children: 'Warning Button',
},
};

export const Error: Story = {
args: {
variant: 'error',
children: 'Error Button',
},
};

// 不同样式风格
export const Outline: Story = {
args: {
variant: 'primary',
buttonStyle: 'outline',
children: 'Outline Button',
},
};

export const Ghost: Story = {
args: {
variant: 'primary',
buttonStyle: 'ghost',
children: 'Ghost Button',
},
};

// 不同尺寸
export const Small: Story = {
args: {
size: 'sm',
children: 'Small Button',
},
};

export const Large: Story = {
args: {
size: 'lg',
children: 'Large Button',
},
};

// 不同状态
export const Disabled: Story = {
args: {
variant: 'primary',
behavior: 'disabled',
children: 'Disabled Button',
},
};

export const Active: Story = {
args: {
variant: 'primary',
behavior: 'active',
children: 'Active Button',
},
};
// <-- 新增代码块 结束 -->

解析: 我们的 Button 组件基于 daisyUI 构建,拥有丰富的变体选项。我们为不同的 variant(颜色变体如 secondaryaccentsuccess 等),不同的 buttonStyle(样式风格如 outlineghost),不同的 size(尺寸如 smlg),以及不同的 behavior(行为状态如 disabledactive)都创建了独立的 Story。每一个具名导出都会在 Storybook 的侧边栏生成一个对应的条目,让我们可以轻松地在不同状态之间切换查看。

注意我们使用了 buttonStyle 而不是将 outline 放在 variant 中,这是因为 daisyUI 将颜色和样式风格分离为两个独立的维度,使得组合更加灵活。例如,您可以创建一个 “accent 颜色的 outline 样式按钮”,只需同时设置 variant="accent"buttonStyle="outline"

第五步:最终验证

现在,再次运行 Storybook(如果它已经在运行,通常会自动热更新):

1
pnpm storybook

打开浏览器,您现在应该能在左侧的侧边栏中看到 Components/Button 条目,展开它,下面会列出我们刚刚编写的所有 Stories:Default, Primary, Secondary, Accent, Success, Warning, Error, Outline, Ghost, Small, Large, Disabled, Active 等。点击其中任意一个,右侧的 Canvas 区域都会立刻渲染出对应状态的 Button 组件。

特别值得注意的是,当您切换 Storybook 的主题(使用右上角的主题切换器)时,按钮的颜色会根据 daisyUI 的主题配置自动变化。例如,Accent 按钮在亮色模式下显示为柔和的蓝紫色,而在暗色模式下则变为鲜艳的紫色,这证明了我们在上一节配置的 data-theme 属性正在正常工作。

img

我们已经成功地为组件创建了一份 “可视化说明书”。但这还不够,目前我们只能查看预设的状态。如何才能在 Storybook 界面上自由地、动态地组合所有 props 来探索组件的全部潜力呢?这正是我们下一节要解决的问题。


2.2.4. 交互式调试:利用 Controls Addon 动态修改 Props

核心理念:Props 的自动化 UI

您可能会想,Button.stories.tsx 文件中定义的 args 对象,仅仅是为组件传递了固定的 props。这背后隐藏着 Storybook 一个极其强大的特性:它能够动态地解析这些 args 以及组件本身的 TypeScript 类型,并自动为它们生成一套可交互的图形界面。这个功能由 @storybook/addon-essentials 中包含的 Controls Addon 提供。

它的工作原理如下:

  1. Storybook 读取 meta.component,找到我们的 Button 组件。
  2. 它分析 Button 组件的 ButtonProps 接口,识别出 variant, size, children, disabled 等所有可配置的 props 及其类型。
  3. 对于 variantsize 这种拥有明确联合类型 ('primary' | 'secondary' ...) 的 prop,它能智能地判断出这是一个“选择题”。
  4. 最终,它在 Storybook 界面的附加面板中,为我们动态生成一个包含下拉菜单、单选框、文本输入框等控件的表单,让我们能够实时修改这些 props。

第一步:改造 Story 为交互式模板

为了激活 Controls 功能,我们不需要为每个变体都编写一个 Story,而是只需要一个“模板 Story”。这个 Story 的 args 定义了组件的默认状态,然后由 Controls 来动态改变这些 args

让我们来改造 Button.stories.tsx 文件。我们将 Primary 这个 Story 重命名为 InteractivePlayground,并暂时移除其他冗余的 Story,使其成为我们唯一的、可交互的“实验台”。

文件路径: src/components/ui/Button.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
import type { Meta, StoryObj } from '@storybook/react';

import { Button } from './Button';

const meta: Meta<typeof Button> = {
title: 'UI/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
// 我们将在下一步中配置 argTypes
};

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

// 交互式实验台 - 可以通过 Controls 面板动态调整所有属性
export const InteractivePlayground: Story = {
args: {
variant: 'primary',
buttonStyle: 'default',
size: 'md',
behavior: 'default',
modifier: 'default',
children: 'Button',
},
};

// 默认按钮
export const Default: Story = {
args: {
children: 'Button',
},
};

// 不同尺寸
export const Sizes: Story = {
render: () => (
<div className="flex items-center gap-4">
<Button size="xs">Extra Small</Button>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
<Button size="xl">Extra Large</Button>
</div>
),
};

// 不同颜色变体
export const Variants: Story = {
render: () => (
<div className="flex flex-col gap-4">
<div className="flex gap-2">
<Button variant="default">Default</Button>
<Button variant="neutral">Neutral</Button>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
</div>
<div className="flex gap-2">
<Button variant="accent">Accent</Button>
<Button variant="info">Info</Button>
<Button variant="success">Success</Button>
<Button variant="warning">Warning</Button>
<Button variant="error">Error</Button>
</div>
</div>
),
};

解析: 我们将 Primary Story 重构为了 InteractivePlayground,并为其 args 提供了更完整的默认值。现在,当您查看这个 Story 时,Storybook 的 Controls 插件将会被激活。

验证: 运行 pnpm storybook,在侧边栏中导航到 UI/Button/InteractivePlayground。在底部的 Addons 面板中,切换到 “Controls” 标签页。您会看到 Storybook 已经为 variantsize 自动生成了下拉选择框,为 children 生成了文本输入框,为 disabled 生成了开关。尝试修改这些控件的值,您会看到画布中的按钮实时地发生变化!

第二步:优化交互体验:配置 argTypes

自动生成的下拉框已经很好用了,但我们可以做得更好。例如,对于选项较少的 size,使用单选框(Radio buttons)可能比下拉框更直观。我们可以通过在 meta 对象中配置 argTypes 属性,来精确地定制每一个 prop 在 Controls 面板中的外观和行为。

文件路径: src/components/ui/Button.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
73
74
75
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
title: 'UI/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
// <-- 新增:配置 argTypes 来优化 Controls 面板 -->
argTypes: {
variant: {
control: 'select',
options: ['default', 'neutral', 'primary', 'secondary', 'accent', 'info', 'success', 'warning', 'error'],
description: '按钮的颜色变体',
table: {
type: { summary: 'string' },
defaultValue: { summary: 'default' },
},
},
buttonStyle: {
control: 'select',
options: ['default', 'outline', 'dash', 'soft', 'ghost', 'link'],
description: '按钮的样式风格',
table: {
type: { summary: 'string' },
defaultValue: { summary: 'default' },
},
},
size: {
control: 'radio',
options: ['xs', 'sm', 'md', 'lg', 'xl'],
description: '按钮的尺寸',
table: {
type: { summary: 'string' },
defaultValue: { summary: 'md' },
},
},
behavior: {
control: 'radio',
options: ['default', 'active', 'disabled'],
description: '按钮的行为状态',
table: {
type: { summary: 'string' },
defaultValue: { summary: 'default' },
},
},
modifier: {
control: 'radio',
options: ['default', 'wide', 'block', 'square', 'circle'],
description: '按钮的修饰符',
table: {
type: { summary: 'string' },
defaultValue: { summary: 'default' },
},
},
children: {
control: 'text',
description: '按钮显示的文本或子元素',
},
},
};

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

export const InteractivePlayground: Story = {
args: {
variant: 'primary',
size: 'default',
children: 'Button',
disabled: false,
},
};

代码深度解析:

  • argTypes: meta 对象中的这个属性允许我们对每一个 prop 进行详细的配置。
  • control: 指定了该 prop 在 UI 中使用哪种类型的控件。'select' 是下拉框,'radio' 是单选框,'boolean' 是开关。
  • options: 为 selectradio 控件提供了明确的选项列表。虽然 Storybook 能自动推断,但显式定义能保证顺序和完整性。
  • description: 为该 prop 添加一段描述性文字,它会显示在 Controls 面板中,极大地增强了文档的可读性。
  • table: 这个对象用于配置在“Docs”页面(我们将在下一节学习)中自动生成的 Props 表格的显示内容,如类型摘要和默认值。

最终验证

刷新您的 Storybook 页面。再次查看 InteractivePlayground 故事的 Controls 面板,您会发现:

img

  • variant 仍然是下拉框(因为选项较多)。
  • size 已经变成了更直观的单选框。
  • asChilddisabled 变成了易于操作的开关。
  • 每个控件旁边都出现了我们编写的描述文字。

我们已经将 Storybook 变成了一个功能强大、文档清晰、高度交互的组件“实验室”。任何团队成员现在都可以无需编写任何代码,就能自由地探索 Button 组件的所有功能组合,这对于组件的测试、评审和推广都具有不可估量的价值。


2.3. 文档深化:使用 MDX 编写专业组件文档

本节,我们将从单纯的“组件示例”升级到创建“组件文档”。我们将学习如何使用 MDX(一种允许在 Markdown 中编写 JSX 的强大格式),为 Button 组件创建一个内容丰富、图文并茂、且包含交互式示例和 API 表格的专业文档页面。

2.3.1. 核心变更:理解 MDX 与 CSF 的协同模式

在开始编写文档之前,我们必须先理解 Storybook 8 带来的一个至关重要的 破坏性变更,它彻底改变了我们组织文档和故事的方式。

历史回顾:*.stories.mdx 的旧模式及其弊端

在 Storybook 的旧版本(v6 及更早)中,一种流行的做法是使用 *.stories.mdx 文件。在这种模式下,开发者会将 Markdown 叙述内容和 Story 的代码定义 混合在同一个文件里

这种“一体化”的文件格式虽然在当时看似便捷,但很快暴露出一系列问题:

  • 工具链支持不佳: 在一个 .mdx 文件中嵌入复杂的 React/TSX 代码,对于 TypeScript 的类型检查、ESLint 的静态分析和 Prettier 的代码格式化等工具来说都是一场噩梦,开发者体验很差。
  • 可复用性差: 定义在 MDX 文件内部的 Story 很难被外部引用,例如,我们无法在单元测试中方便地导入和复用这个 Story。
  • 职责混淆: 它将“文档编写”和“组件示例定义”这两个完全不同的关注点混杂在了一起,违背了软件工程的“关注点分离”原则。

Storybook 8 的新范式:示例与文档的彻底分离

为了解决以上问题,Storybook 8 强制推行了一种全新的、职责更清晰的协同模式。其核心思想是:示例归示例,文档归文档。

  1. .stories.tsx (CSF 文件): 它的 唯一职责,就是以纯粹的、类型安全的 ES 模块形式,导出一系列组件的可交互状态(即我们上一节编写的 Story 对象)。它是我们所有组件示例的“单一事实来源”

  2. .mdx (文档文件): 它的职责是编写叙事性的文档内容,如设计理念、使用指南等。它本身不再定义任何 Story,而是像一个“消费者”一样,从 .stories.tsx 文件中 导入嵌入 这些已经定义好的交互式示例。

我们可以通过下表来清晰地理解这种职责划分:

文件类型核心职责产出物
.stories.tsx定义组件的各种可交互状态 (CSF)一系列具名导出的 Story 对象
.mdx撰写叙事性文档,并嵌入 Story一个图文并茂的文档页面

连接的桥梁:@storybook/addon-docs 的核心标签

那么,.mdx 文件是如何 “消费” .stories.tsx 文件的呢?答案是依靠 Storybook 的 @storybook/addon-docs 插件提供的一系列专用 MDX 文档组件:

  • <Meta of={...} />: 这是 MDX 文档与 CSF 文件之间的 “连接器”。它通常放在文件顶部,通过 of 属性指向从 .stories.tsx 文件中导入的整个 stories 模块,从而将整个文档页与 Button 组件关联起来。

  • <Story of={...} />: 这是 “嵌入器”。在文档的正文中,我们可以使用这个标签,通过 of 属性指向从 .stories.tsx 中导入的某个具体 Story(例如 PrimarySizes),从而在当前位置渲染出那个交互式的 Story 示例。

  • <Controls />: 这是 “文档生成器”。这个标签可以自动读取与当前文档关联的组件(通过 <Meta> 标签指定)的 TypeScript Props 接口,并生成一份详尽的 API 属性表格。

重要提示:这些文档组件需要从 @storybook/addon-docs/blocks 导入。addon-docs 是 Storybook 的官方文档插件,在安装 Storybook 时会自动安装,无需单独配置。

总结而言,Storybook 8+ 的新范式通过强制分离,让我们的项目结构变得更加清晰和健壮。.stories.tsx 成为了高度可复用、类型安全的 “组件示例库”,而 .mdx 则成为了纯粹的 “文档层”。


2.3.2. 创建 Button.mdx 文档页

第一步:创建 MDX 文件

我们首先在 Button 组件所在的目录中,创建一个与它同名的 .mdx 文件。

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

第二步:关联 Meta 并撰写简介

Button.mdx 文件中,我们要做的第一件事就是导入 Button.stories.tsx 文件中的所有导出,并使用 <Meta> 标签将本文档与这些故事关联起来。

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

1
2
3
4
5
6
7
8
9
10
11
import { Meta, Story, Controls } from '@storybook/addon-docs/blocks';
import * as ButtonStories from './Button.stories';

<Meta of={ButtonStories} />

# Button
Button allow users to take actions and make choices with a single tap
They are one of the most fundamental component in our design system, designed to be flexible and
consistent across the application.

按钮能让用户通过一次点击就能执行操作并做出选择。它们是我们设计系统中最基本的组成部分之一,旨在在整个应用程序中保持灵活性和一致性。

代码深度解析:

  • import { Meta, Story, Controls } from '@storybook/addon-docs/blocks';: 从 Storybook 的 addon-docs 插件中导入所需的文档组件。这个路径是官方推荐的导入方式。
  • import * as ButtonStories from './Button.stories';: 我们将 Button.stories.tsx 中所有 export 的内容(包括默认导出的 meta 和每一个具名导出的 Story 对象)全部导入,并聚合到名为 ButtonStories 的命名空间对象中。
  • <Meta of={ButtonStories} />: 这是实现 “协同模式” 的 关键链接of 属性指向整个 stories 模块,Storybook 会自动从中读取默认导出的 meta 信息。完成这一步后,Storybook 会自动将侧边栏中 Button 条目的 “Docs” 标签页替换为此 MDX 文件的内容。

第三步:嵌入交互式主 Story

一份好的文档应该开门见山,首先展示一个功能最全、可供用户随意把玩的“主示例”。在我们的例子中,InteractivePlayground Story 正是为此而生。

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

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

## Interactive Playground

This is the primary story for the Button component. You can use the Controls panel below to interact with all available props in real-time. This demonstrates the full range of the component's capabilities.

这是 Button 组件的最主要功能集合,你可以使用我们底下提供的交互式面板来实时调整我们提供好的所有 props属性,这将展示组件的所有功能

{/* 使用 <Story> 标签嵌入一个具体的 Story */}
<Story of={ButtonStories.InteractivePlayground} />

代码深度解析:

  • <Story of={ButtonStories.InteractivePlayground} />: 我们使用 <Story> 标签,并通过 of 属性精确地指定要嵌入 ButtonStories 命名空间中的 InteractivePlayground 这个 Story。Storybook 会在此处渲染出该 Story 的交互式预览界面,下方还会自动附带我们在上一节中精心配置的 Controls 面板。

第四步:展示核心变体

接下来,我们用更少的篇幅,集中展示组件最核心的变体分类,并辅以简短的说明。我们将 VariantsSizes 这两个聚合型 Story 嵌入进来。

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

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

## Variants (Colors)

The `variant` prop determines the button's color and semantic meaning. Use `primary` for the main call-to-action on a page. Use `error` for dangerous operations like 'Delete' or 'Remove'. Use `secondary` or `ghost` for less prominent actions.

variant 属性决定按钮的颜色和语义,使用 `primary` 用于页面上的主要操作按钮
使用 `error` 用于危险操作,如 '删除' 或 '移除'
使用 `secondary` 或 `ghost` 用于不太突出的操作
<Story of={ButtonStories.Variants} />

## Sizes

The `size` prop controls the button's dimensions. The default size is `md`. Use `xs` or `sm` for dense UIs, and `lg` or `xl` for more prominent calls-to-action.

size 属性控制按钮的尺寸,默认尺寸为 `md`。使用 `xs` 或 `sm` 用于紧凑的界面,使用 `lg` 或 `xl` 用于更突出的操作按钮

<Story of={ButtonStories.Sizes} />

解析: 通过这种方式,我们将叙事性的使用指南(Markdown 文本)与可视化的、真实渲染的组件示例(<Story> 标签)完美地结合在了一起。

第五步:自动生成 API 参考

最后,一份专业文档必不可少的是详尽的 API 属性参考。我们无需手动编写这个表格,@storybook/addon-docs 插件可以为我们自动生成。

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

1
2
3
4
5
6
7
8
{/* ... 此前内容 ... */}

## API Reference (Props)

The following table lists all the available props for the `Button` component. It is automatically generated from the component's TypeScript interface and the `argTypes` configuration in the `Button.stories.tsx` file.

{/* 使用 <Controls /> 标签自动生成 Props 表格 */}
<Controls />

代码深度解析:

  • <Controls />: 当在 MDX 文件中使用时,这个标签的功能会扩展。它不仅会像在 <Story> 标签下方那样显示交互式控件,更会在此处渲染一个 完整的 API 属性表格。表格的内容完全来自于 ButtonProps 的 TypeScript 类型定义,以及我们在 Button.stories.tsx 文件的 meta.argTypes 中提供的 description, defaultValue 等元数据。这确保了我们的 API 文档永远与代码保持 100% 同步。

最终验证

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

我们之前配置了 Storybook 的自动文档生成,会根据 Typescript 自动生成文档,我们现在使用了自定义的文档格式,需要将文档中的 tags: ['autodocs'] 行删除以让 Storybook 使用我们的自定义文档

1
2
3
4
5
6
7
8
9
// 组件元数据配置
const meta = {
title: 'Components/Button',
component: Button,
parameters: {
layout: 'centered',
},
... 删除tag配置
} satisfies Meta<typeof Button>;

现在,运行 pnpm storybook 并导航到 Components/Button。您会看到,默认展示的 “Docs” 标签页已经变成了我们刚刚编写的、内容丰富的 MDX 文档页面。它包含了清晰的介绍、可实时交互的主示例、分类展示的核心变体,以及一份完整、精确的 API 参考表格。

img

我们已经成功地为 Button 组件创建了一份世界级的、图文并茂、交互丰富且永不过时的“活文档”。这完成了我们专业工作流中“文档驱动”这一环。接下来,我们将为它建立最后一道防线——自动化测试。


2.4. 质量保障:Storybook 的集成测试方案

本节,我们将了解 Storybook 为我们自动配置的测试基础设施,以及如何在此基础上扩展传统的组件单元测试。

2.4.1. Storybook 的测试配置:开箱即用的优势

当我们使用 npx storybook@latest init 安装 Storybook 时,它不仅为我们配置了可视化开发环境,还自动集成了一套基于 Vitest 的测试解决方案。

Storybook 已为我们做了什么?

让我们先查看项目的 package.json,您会发现以下测试相关的包已经被自动安装:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"devDependencies": {
"@storybook/addon-vitest": "9.1.10",
"@vitest/browser": "^3.2.4",
"@vitest/ui": "^3.2.4",
"@testing-library/react": "^16.3.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/user-event": "^14.6.1",
"playwright": "^1.56.0",
"vitest": "^3.2.4",
"jsdom": "^27.0.0"
}
}

此外,Storybook 还为我们创建了以下配置文件:

  1. vitest.config.ts - Vitest 的主配置文件
  2. .storybook/vitest.setup.ts - Storybook 测试的设置文件
  3. vitest.shims.d.ts - TypeScript 类型定义

这意味着我们的测试基础设施已经完全就绪,无需从零开始配置。

Storybook 测试方案的特点

查看 vitest.config.ts 文件,我们会发现 Storybook 配置的是一种特殊的测试方式:

文件路径: vitest.config.ts

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
import { defineConfig } from 'vitest/config';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import path from 'node:path';

export default defineConfig({
test: {
projects: [
{
plugins: [
// Storybook 插件会自动将每个 Story 转换为测试用例
storybookTest({ configDir: path.join(__dirname, '.storybook') }),
],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: 'playwright',
},
setupFiles: ['.storybook/vitest.setup.ts'],
},
},
],
},
});

配置深度解析:

这个配置使用了 Storybook 的 @storybook/addon-vitest 插件,它提供了一种创新的测试方式:

  • Browser Mode(浏览器模式): 使用真实的 Playwright 浏览器来运行测试,而不是传统的 jsdom 模拟环境。这意味着测试会在真实的浏览器 DOM 中执行,能够捕获更多实际环境中的问题。

  • Story-based Testing(基于 Story 的测试): storybookTest 插件会自动扫描所有的 .stories.tsx 文件,并将每个导出的 Story 转换为一个测试用例。例如,我们的 Primary Story 会自动生成一个测试,验证该 Story 能否正确渲染。

  • 继承 Storybook 配置: 测试会完全继承 Storybook 的所有配置,包括装饰器(decorators)、全局样式、主题等。这确保了测试环境与 Storybook 预览环境完全一致。

这种测试方式的优势与局限:

优势:

  • 零额外代码:所有 Story 自动成为测试用例,无需手动编写
  • 真实环境:在真实浏览器中运行,包括 CSS 渲染、布局计算等
  • 可视化测试:可以测试可访问性(a11y)、视觉回归等

局限性:

  • 速度较慢:浏览器启动和渲染需要时间
  • 适用场景:主要用于验证组件能够正确渲染,不适合复杂的业务逻辑测试

理解 Storybook 的测试理念

Storybook 的测试方案基于一个核心理念:如果一个 Story 能够在 Storybook 中正确显示,那么它就应该能够通过测试。 这种 “Story 即测试” 的思想,让我们编写 Story 的同时,也自动积累了测试覆盖率。

这与传统的单元测试(使用 React Testing Library 直接测试组件)是互补的关系。Storybook 测试关注 “组件能否正确渲染各种状态”,而传统单元测试关注 “组件的交互逻辑是否正确”。

验证配置

Storybook 已经为我们配置好了一切。我们可以通过以下方式验证配置是否正常工作。在后续章节中,我们将学习如何为 Stories 添加交互测试,以及如何在需要时扩展传统的组件单元测试。

img


2.4.2. 交互测试:为 Story 编写 play 函数

核心理念:可重放的交互脚本

Storybook 提供了一个名为 play 函数的强大特性。它是一个可以附加到任意 Story 对象上的异步函数,其核心作用是:在 Story 渲染完成后,以编程方式模拟用户与组件的交互,并对交互结果进行断言。

play 函数的独特之处在于它的“双重价值”:

  1. 在 Storybook UI 中: 它会在 “Interactions” 插件面板中,可视化地、一步步地重放 您的交互脚本。这提供了一个无与伦比的交互式调试工具。
  2. 在自动化测试中: Storybook 的测试运行器会自动执行 play 函数,并将其中的断言(assertions)作为测试结果。这使得您的交互逻辑能够被集成到 CI/CD 流程中。

第一步:理解 play 函数的工具集

play 函数依赖于 @storybook/test 包提供的工具集,这个包在 storybook init 时已经被自动安装。它重新导出并整合了业界标准的测试库:

  • within: 源自 @testing-library/dom,用于将查询范围限定在特定元素内。
  • userEvent: 源自 @testing-library/user-event,用于以更接近真实用户行为的方式模拟交互(如点击、输入)。
  • expect: 源自 vitestjest,是进行断言的核心函数。
  • fn: 一个创建“间谍”或“模拟函数”的工具,用于追踪函数是否被调用、以及如何被调用。

第二步:为 Button 组件编写交互测试 Story

现在,让我们回到 Button 的故事文件,为其添加一个专门用于测试点击行为的新 Story。

文件路径: src/components/ui/Button.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
import type { Meta, StoryObj } from '@storybook/react';
// <-- 1. 从 @storybook/test 导入测试工具 -->
import { within, userEvent, expect, fn } from '@storybook/test';

import { Button } from './Button';

const meta: Meta<typeof Button> = {
// ... 此前定义的 meta 对象 ...
// 为所有 Story 添加 onClick action,以便在 Actions 面板中追踪
argTypes: {
onClick: { action: 'clicked' },
},
};

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

// ... 此前定义的其他 Stories ...
export const Variants: Story = { /* ... */ };


// <-- 2. 新增一个专门用于交互测试的 Story -->
export const ClickInteraction: Story = {
args: {
variant: 'primary',
children: 'Click Me!',
// 【关键】必须使用 fn() 创建可被追踪的 mock 函数
onClick: fn(),
},
play: async ({ canvasElement, args }) => {
// 3. 获取 Story 渲染的画布
const canvas = within(canvasElement);

// 4. 在画布中通过角色(role)查找我们的按钮
// 'name' 选项使用正则表达式,忽略大小写
const button = canvas.getByRole('button', { name: /click me/i });

// 5. 模拟用户点击按钮
await userEvent.click(button);

// 6. 断言:验证 onClick 回调函数是否被调用过
await expect(args.onClick).toHaveBeenCalled();
},
};

代码深度解析:

  1. 导入工具: 我们从 @storybook/test 导入了编写交互测试所需的所有核心函数。
  2. 创建新 Story: 我们创建了一个名为 ClickInteraction 的新 Story。一个最佳实践是,将带有 play 函数的复杂交互测试,与用于视觉展示的简单 Story 分离开。
  3. 获取画布: play 函数接收一个包含 canvasElement 的上下文对象。within(canvasElement) 将我们的查询范围限定在这个 Story 的渲染区域内。
  4. 查询元素: 我们使用 canvas.getByRole('button', ...) 来查找按钮。这是 React Testing Library 推荐的最佳实践,因为它最接近真实用户(特别是辅助技术用户)查找元素的方式。
  5. 模拟交互: await userEvent.click(button) 以一种高保真的方式模拟了用户的点击行为,它会触发 mousedown, mouseup, click 等一系列事件。
  6. 断言 (Assertion): await expect(args.onClick).toHaveBeenCalled() 是测试的核心。这里必须使用 fn() 创建的 mock 函数,而不能依赖 argTypes 中的 action 配置。我们在这里断言这个函数 已经被调用过,从而验证了组件的点击功能是正常的。

⚠ 常见错误与修复:

如果您在 args 中没有显式使用 onClick: fn(),而是依赖 argTypesaction 配置,会遇到以下错误:

1
TypeError: [Function actionHandler] is not a spy or a call to a spy!

错误原因: argTypes: { onClick: { action: 'clicked' } } 仅仅是让 Storybook 在 UI 的 Actions 面板中记录事件,它 不会 自动将 args.onClick 转换为可被 expect 断言的 spy/mock 函数。

解决方案: 必须在 Story 的 args 中显式使用 fn():

1
2
3
4
5
6
7
8
9
export const ClickInteraction: Story = {
args: {
onClick: fn(), // ✅ 正确:创建可追踪的 mock 函数
},
play: async ({ args }) => {
// ...
await expect(args.onClick).toHaveBeenCalled(); // ✅ 现在可以断言了
},
};

第三步:配置测试脚本

在运行自动化测试之前,我们需要在 package.json 中添加测试脚本。虽然 Storybook 已经为我们配置好了 vitest.config.ts,但测试命令需要手动添加。

文件路径: package.json

1
2
3
4
5
6
7
8
9
10
{
"scripts": {
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
// 添加以下三个测试脚本
"test": "vitest run", // 运行一次所有测试(适用于 CI/CD)
"test:watch": "vitest", // 监听模式,文件变化时自动重新测试
"test:ui": "vitest --ui" // 打开可视化测试界面
}
}

脚本说明:

  • test: 执行一次性测试,运行所有 Stories,完成后退出。这是用于持续集成(CI)的标准命令。
  • test:watch: 启动 Vitest 的监听模式,每当源文件或测试文件发生变化时,自动重新运行相关测试。适合开发阶段使用。
  • test:ui: 启动 Vitest 的图形化界面(通常在浏览器中打开),提供更直观的测试结果查看、筛选和调试体验。

第四步:双重验证

现在,我们的 play 函数已经编写完毕,测试脚本也配置好了。我们可以通过两种方式来验证它的成果。

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

运行 npm run storybook,导航到 Components/Button/ClickInteraction 这个 Story。您会看到:

  • 画布中的按钮被正常渲染。
  • 底部的 Addons 面板会自动切换到 “Interactions” 标签页。
  • 您会看到 play 函数中的每一步——clickexpect——都被清晰地列出,并带有一个绿色的对勾,表示执行成功。您可以点击每一步来查看组件在交互前后的 DOM 快照。

2. 在自动化测试中运行

在终端中运行我们刚刚配置的测试脚本:

1
2
3
4
npm test
# 或使用其他包管理器
# pnpm test
# yarn test

预期结果: Vitest 会启动 Playwright 浏览器,在后台无头模式(headless mode)下运行您的所有 Stories。由于 storybookTest 插件的存在,ClickInteraction 这个 Story 的 play 函数会被自动执行。

您会看到类似以下的测试报告:

1
2
3
4
5
6
7
8
9
✓ Button.stories.tsx (595ms)
✓ Interactive Playground (279ms)
✓ Default (38ms)
✓ Sizes (47ms)
✓ Variants (175ms)
✓ Click Interaction (54ms)

Test Files 1 passed (1)
Tests 5 passed (5)

这证明了我们的 play 函数不仅是一个强大的可视化调试工具,更是一个 可被 CI/CD 流水线集成的、可靠的自动化集成测试用例

当您运行 npm run test:ui 并查看可视化界面时,您可能会注意到测试结果显示为 PASS (1)Tests 5 passed (5)。这可能会让人困惑:为什么文件数是 1,但测试用例数是 5?

测试统计详解:

  • Test Files 1 passed (1): “1” 指的是 1 个测试文件 通过,即 Button.stories.tsx
  • Tests 5 passed (5): “5” 指的是 5 个测试用例,对应您在 Button.stories.tsx 中导出的 5 个 Story:
    • ✓ Interactive Playground
    • ✓ Default
    • ✓ Sizes
    • ✓ Variants
    • ✓ Click Interaction

测试通过的判定标准:

Storybook 的 storybookTest 插件会自动为每个 Story 创建测试用例,测试标准分为两个层级:

  1. 基础渲染测试(所有 Story 都执行)

    • ✅ Story 能在 Playwright 浏览器中正确渲染
    • ✅ 组件没有抛出运行时错误
    • ✅ DOM 结构正确生成

    这就是为什么即使 Interactive PlaygroundDefaultSizesVariants 这些 Story 没有 play 函数,它们也能通过测试——因为它们都成功渲染了。

  2. 交互逻辑测试(仅带有 play 函数的 Story)

    • play 函数中的所有步骤成功执行
    • ✅ 所有 expect 断言都通过

    对于 Click Interaction Story,测试验证了:

    • 按钮能被找到(通过 getByRole
    • 按钮能被点击(通过 userEvent.click
    • onClick 函数确实被调用了(通过 expect(...).toHaveBeenCalled()

这意味着什么?

您的测试覆盖率实际上包含了两个维度:

  • 视觉覆盖: 所有 5 个 Story 都验证了组件在不同状态下能正确渲染(基础测试)
  • 行为覆盖: 1 个 Story (Click Interaction) 验证了组件的交互逻辑(深度测试)

这种测试策略非常高效:通过编写 Story,您自然地积累了视觉测试用例;当需要深入测试交互逻辑时,只需为特定 Story 添加 play 函数即可。


2.4.3 本节小结

在本节中,我们系统地学习了 Storybook 的完整测试解决方案:

  1. 测试基础设施: 理解了 Storybook 通过 @storybook/addon-vitest 插件为我们自动配置的测试环境,包括 Vitest、Playwright 和 Testing Library 的集成。

  2. 交互测试核心: 掌握了 play 函数的编写方法,学会使用 withinuserEventexpectfn() 等工具来模拟用户行为并进行断言。

  3. 常见问题解决: 明确了 onClick: fn() 的必要性,理解了 argTypes 中的 action 配置与测试用 mock 函数的区别。

  4. 测试脚本配置: 在 package.json 中添加了 testtest:watchtest:ui 三个脚本,让测试能够在不同场景下运行。

  5. 测试结果解读: 理解了测试统计的含义——每个 Story 都是一个测试用例,基础测试验证渲染,带 play 函数的 Story 额外验证交互逻辑。

通过这些学习,我们不仅实现了一个可在 Storybook UI 中可视化重放的交互调试脚本,更重要的是,我们建立了一套能够在真实浏览器环境中运行的、可被持续集成的自动化测试体系。这为我们的组件库提供了坚实的质量保障,让 “Story 即测试” 的理念真正落地。


2.5. 流程闭环:文档驱动开发与黄金工作流

至此,我们已经完整地经历了一个专业级组件——Button——从诞生到拥有完备文档和自动化测试的全过程。在这一节,我们将不再编写新的代码,而是后退一步,从更高的维度来审视和复盘我们刚刚走过的路。

本节的核心目标,是将我们在实践中摸索的零散步骤,提炼并固化为一套清晰、可复用、可推广的 方法论。这套方法论,就是我们贯穿整个课程的“黄金工作流”。

黄金工作流:一套以 Storybook 为中心的开发模式

回顾我们开发 Button 组件的整个过程,可以清晰地划分为四个核心阶段,它们环环相扣,构成了一个以 Storybook 为中心的、高效的开发闭环。

1. 阶段一:构思与可视化 (Storybook First)

我们的起点并非直接编写 Button.tsx 的实现代码,而是在 Button.stories.tsx 文件中进行构思。

  • 我们做了什么: 我们首先定义了 meta 对象,思考并确定了组件的公开 API(即 props),并通过 argTypes 精确地描述了它们。然后,我们通过编写一系列独立的 Story 对象(Primary, Sizes, Variants 等),在隔离的环境中将组件的各种视觉形态 可视化 出来。
  • 核心价值: 这种“Storybook 优先”的方法,迫使我们从一开始就站在 组件消费者 的角度来思考问题。它让我们在投入大量精力进行具体实现之前,就能清晰地定义组件的“契约”(API),并在一个独立的“实验室”中预览和评审其视觉表现,极大地降低了后期返工的风险。

2. 阶段二:定义与验证行为 (Interaction Testing)

在组件的视觉形态被定义后,我们紧接着通过 play 函数为其核心交互行为编写了测试。

  • 我们做了什么: 我们创建了 ClickInteraction Story,并利用 @storybook/test 提供的工具集,编写了一个模拟用户点击并断言 onClick 回调被触发的交互脚本。
  • 核心价值: play 函数不仅是一个自动化测试用例,它更是一份“可执行的交互文档”。它以代码的形式,精确地、无歧义地定义了组件在特定交互下应该如何响应。这种做法将测试环节从流程的末端,提至与组件定义紧密结合的核心位置,是行为驱动开发(BDD)思想在组件开发中的绝佳实践。

3. 阶段三:编码实现 (Make it Pass)

当前两步完成后,我们进行具体编码实现的目标变得异常明确和纯粹。

  • 我们做了什么: 我们逐步构建了 Button.tsx 的内部逻辑。我们的编码过程,本质上就是为了让在 Storybook 中定义的所有视觉 Story 能够正确渲染,以及让 play 函数中的所有断言能够顺利通过。
  • 核心价值: 在这个阶段,我们不再需要“猜测”组件应该是什么样、或者应该怎么工作。所有的需求都已经被前两个阶段以“可视化”和“可执行”的形式明确下来。开发过程从充满不确定性的“创造”,转变为目标明确的“实现”,效率和准确性都得到了极大的提升。

4. 阶段四:叙事与文档 (MDX Enrichment)

当组件的功能被完全实现和验证后,我们才开始编写长篇的叙事性文档。

  • 我们做了什么: 我们创建了 Button.mdx 文件,在其中撰写了关于组件的设计理念和使用指南,并利用 <Story><Controls> 标签,将我们早已准备好的交互式示例和自动生成的 API 表格无缝地嵌入其中。
  • 核心价值: 文档工作不再是开发流程结束后令人头疼的“补充材料”。相反,它变成了一个轻松的“组装”工作。核心的交互示例和 API 文档早已存在或能够自动生成,我们只需专注于补充那些最需要人类智慧的、关于“为什么”和“如何更好地使用”的叙事性内容。

理念升华:文档驱动开发

我们刚刚实践的这套“黄金工作流”,正是 文档驱动开发 (Documentation-Driven Development, DDD) 理念在现代前端组件开发中的具体体现。

在传统工作流中,文档是代码的附属品,往往在开发完成后才被动地编写,导致其内容常常滞后于代码,甚至被完全忽略。

而在我们的新范式中,文档(以 Storybook 中的 Stories 和 MDX 形式存在)成为了整个开发过程的中心枢纽和驱动力。它是我们构思 API 的草稿板,是验证视觉效果的试验台,是定义交互行为的规约,也是最终交付给用户的产品的一部分。代码实现和自动化测试,都围绕着这份“活文档”来展开。


第三章. 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 组件,更重要的是,掌握了分析、设计和实现复杂复合组件的完整方法论。


第四章. 双系统主题配色 与 Iconify 最佳实践:管理与扩展我们的设计系统

在第一章,我们通过 shadcn/uidaisyUI 的集成为项目快速“注入”了一套功能完备的主题。本章,我们将不再是主题的被动使用者,而是要成为 主题的“管理者”和“扩展者”。我们将深入剖析我们项目中真实存在的主题系统的工作原理,亲手构建一个功能完备的动态主题切换器,并最终建立一套自动化的图标工作流,彻底掌握设计系统的“皮肤”与“静态资源”两大核心。

4.1. 架构深度解析:剖析项目的双轨主题系统

“知其然,必先知其所以然”。在我们动手修改或扩展任何功能之前,必须先彻底理解我们项目中这套双主题系统并存的精密架构。globals.css 文件,正是我们整个设计系统视觉身份的“中央神经系统”,本节我们将对其进行一次深度解剖。

4.1.1. 系统一:shadcn/ui 风格的语义化令牌 (:root & .dark)

我们首先聚焦于 globals.css 文件中的 :root.dark 这两个核心代码块,以及与之配套的 Tailwind v4 配置。这部分构成了我们主题系统的第一套、也是最底层的规范体系,主要服务于我们的自定义布局和遵循 shadcn/ui 风格的组件。

文件路径: src/app/globals.css

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
/* ... imports ... */
@custom-variant dark (&:is(.dark *));

@theme inline {
  --color-primary: var(--primary);
/* ... 其他变量映射 ... */
}

:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.13 0.028 261.692);
  --primary: oklch(0.21 0.034 264.665);
  --destructive: oklch(0.577 0.245 27.325);
/* ... */
}

.dark {
  --background: oklch(0.13 0.028 261.692);
  --foreground: oklch(0.985 0.002 247.839);
  --primary: oklch(0.928 0.006 264.531);
  --destructive: oklch(0.704 0.191 22.216);
/* ... */
}
/* ... */

1. 结构与机制::root.dark@custom-variant 的协同

  • :root: 这个 CSS 伪类代表了文档的根元素 (<html>),在这里定义的 CSS 变量具有全局作用域。在我们的体系中,:root 块定义的,就是 默认的亮色(Light)主题
  • .dark: 这是一个标准的类选择器。当 <html> 标签上被添加了 class="dark" 时,这个选择器内的同名变量会因其更高的 CSS 特异性而 覆盖 :root 中的值。
  • @custom-variant dark (&:is(.dark *)): 这是 Tailwind v4 的一个高级特性。它定义了一个名为 dark 的自定义变体。&:is(.dark *) 的意思是:当任意元素是 .dark 类的 后代 时,这个 dark: 变体就会被激活。这使得我们可以在 <html> 标签上添加一个 .dark 类,就能让我们在项目任何地方使用的 dark:bg-red-500 这样的工具类生效。

2. 技术前沿:OKLCH 颜色空间的优势

我们的主题配置采用了更为现代和专业的 OKLCH 颜色模型,而非传统的 HSL 或 HEX。

  • L (Lightness): 感知亮度 (0% 至 100%)。
  • C (Chroma): 色度/彩度,表示颜色的鲜艳程度。
  • H (Hue): 色相角度。

OKLCH 的核心优势在于 感知均匀性。这意味着,当您固定 C 和 H,只改变 L 值时,颜色的亮暗变化非常平滑且符合人眼的真实感知。这对于系统性地、可预测地生成一套和谐的派生色(如悬浮色、禁用色)至关重要,是构建专业设计系统时更优越的颜色模型。

3. 核心桥梁:@theme inline 的作用

一个关键问题是:我们定义了 --primary 这个 CSS 变量,Tailwind 的 bg-primary 工具类是如何知道要使用它的呢?答案就在 @theme inline 这个配置块中。

1
2
3
4
5
6
@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
/* ... 将所有语义化变量映射到 Tailwind 的主题键 ... */
}

这是 Tailwind v4 的核心特性之一。这个配置块扮演了一个“翻译官”的角色。它告诉 Tailwind 引擎:“当你遇到像 bg-primary 这样的工具类时,这个 primary 对应的颜色键是 --color-primary,而这个键的值,又被我定义为了 var(--primary)。”

通过这个桥梁,我们成功地让 Tailwind 的原子类消费了我们在 :root / .dark 中定义的 语义化 CSS 变量

4. 语义化命名:从实现到意图的抽象

最后,这套体系的命名(--background, --destructive, --radius)体现了设计令牌的核心思想:抽象化。开发者在使用 border-border 时,无需关心在当前主题下,边框的具体颜色值是什么。如果未来品牌色需要调整,我们只需在 globals.css 这一个文件中修改变量值,整个应用的视觉表现就会随之统一更新,这正是“单一事实来源”的强大之处。

好的,我们继续。


4.1.2. 系统二:daisyUI 的独立主题 (@plugin "daisyui/theme")

在理解了 shadcn/ui 风格的底层令牌系统之后,我们现在将目光转向 globals.css 文件中的另一大块配置——@plugin "daisyui/theme"。这部分构成了我们主题系统的第二条轨道,它专为 daisyUI 提供的组件(如 btn, card, dropdown 等)服务。

文件路径: src/app/globals.css

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
/* ... 此前内容 ... */

@plugin "daisyui/theme" {
  name: 'light';
  default: true;
  prefersdark: false;
  color-scheme: 'light';
  --color-base-100: oklch(98% 0 0);
  --color-base-content: oklch(20% 0 0);
  --color-primary: oklch(0% 0 0);
  --color-primary-content: oklch(100% 0 0);
/* ... 其他 daisyUI light 主题的、硬编码的 OKLCH 值 ... */
}

@plugin "daisyui/theme" {
  name: 'dark';
  default: false;
  prefersdark: true; /* 修正:此项应为 true 以便 next-themes 正确识别系统偏好 */
  color-scheme: 'dark';
  --color-base-100: oklch(14% 0.005 285.823);
  --color-base-content: oklch(96% 0.001 286.375);
  --color-primary: oklch(44% 0.043 257.281);
  --color-primary-content: oklch(98% 0.003 247.858);
/* ... 其他 daisyUI dark 主题的、硬编码的 OKLCH 值 ... */
}

1. 核心洞察:一个独立且并行的系统

这是理解我们项目主题架构最关键的一点:在我们当前的配置中,daisyUI 的主题系统,是一个与 shadcn/ui 风格的变量体系 完全独立、并行运行 的系统。

  • 它有自己的变量命名体系: daisyUI 使用一套以 --color- 为前缀的 CSS 变量,例如 --color-primary, --color-base-100, --color-accent 等。请注意它与系统一中的 --primary 在命名上的区别。
  • 它有自己的颜色值: 在我们的配置中,这些变量的值是 直接硬编码的 oklch。它并 没有 通过 var(--primary) 的方式去引用我们在系统一(:root / .dark)中定义的变量。这意味着两套系统的颜色值是独立维护的。
  • 它有自己的切换机制: daisyUI 的主题切换依赖于在 <html> 标签上设置 data-theme 属性(例如 data-theme="dark")。这与系统一所依赖的 class="dark" 是两种不同的机制。

2. 工作原理:从 data-theme 到组件样式

daisyUI 的这套主题系统工作流程非常清晰:

  1. <html> 标签上存在 data-theme="dark" 属性时,@plugin "daisyui/theme" { name: 'dark', ... } 这个配置块就会被激活。
  2. 该配置块内部定义的所有 CSS 变量(如 --color-primary: oklch(44% 0.043 257.281);)会被应用到全局。
  3. daisyUI 的组件类名,例如 btn-primary,在其内部的 CSS 实现中,其背景色被预先定义为 background-color: var(--color-primary);
  4. 因此,当 dark 主题被激活时,btn-primary 就会自动获取到 oklch(44% 0.043 257.281) 这个颜色值。

这个流程确保了所有 daisyUI 的组件都能在 data-theme 属性变化时,正确地、一致地切换它们的视觉外观。

现在我们已经分别剖析了两个并行的系统,在下一节中,我们将对这个“双轨制”架构进行总结,并理解其背后的设计意图。


4.1.3. 架构统一:将 daisyUI 链接到单一事实来源

我们当前 globals.css 的配置中,daisyUI 主题使用了硬编码的 oklch 颜色值,这导致它与 :root / .dark 中定义的 shadcn/ui 风格变量体系是脱钩的。现在,我们将修复这个问题。

重构逻辑:使用 var() 函数建立链接

我们的重构策略非常简单直接:修改两个 @plugin "daisyui/theme" 配置块,将其中所有硬编码的 oklch 值,替换为对我们核心语义化 CSS 变量的引用。我们将使用 CSS 的 var() 函数来建立这个“指针”。

第一步:重构 light 主题

我们首先修改名为 lightdaisyUI 主题。

文件路径: src/app/globals.css

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
/* ... 此前内容 ... */

/* 位于文件末尾的 daisyUI 主题配置 */
@plugin "daisyui/theme" {
  name: 'light';
  default: true;
  prefersdark: false;
  color-scheme: 'light';

/* 将所有硬编码的 oklch 值,
替换为对 :root 中定义的语义化变量的引用。
*/
  --color-base-100: var(--background);
  --color-base-200: var(--card); /* 使用 card 作为 base-200,语义上更贴近 */
  --color-base-300: var(--secondary);
  --color-base-content: var(--foreground);

  --color-primary: var(--primary);
  --color-primary-content: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-content: var(--secondary-foreground);
  --color-accent: var(--accent);
  --color-accent-content: var(--accent-foreground);
  --color-neutral: var(--muted);
  --color-neutral-content: var(--muted-foreground);

  --color-info: oklch(54% 0.245 262.881); /* 对于 shadcn/ui 未定义的,可暂时保留或补充 */
  --color-info-content: oklch(97% 0.014 254.604);
  --color-success: oklch(62% 0.194 149.214);
  --color-success-content: oklch(98% 0.018 155.826);
  --color-warning: oklch(64% 0.222 41.116);
  --color-warning-content: oklch(98% 0.016 73.684);
  --color-error: var(--destructive);
  --color-error-content: var(--destructive-foreground);
 
/* 同样,将圆角等规范也进行链接 */
  --radius-box: var(--radius);
  --radius-field: var(--radius);
  --radius-selector: 1rem; /* 可保留 daisyUI 的特定值 */

/* ... 其他变量可根据需要进行映射或保留 ... */
}

/* ... dark 主题配置 ... */

解析: 完成修改后,daisyUIlight 主题不再拥有自己独立的颜色值。它的 --color-primary 现在直接指向了 :root 中定义的 --primary 变量。

第二步:重构 dark 主题

我们以完全相同的方式,重构名为 darkdaisyUI 主题。

文件路径: src/app/globals.css

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
/* ... light 主题配置 ... */

@plugin "daisyui/theme" {
  name: 'dark';
prefersdark: true;
  color-scheme: 'dark';

/* 使用完全相同的 var() 引用!
因为当 .dark 类或 data-theme="dark" 生效时,
var(--primary) 会自动解析为 .dark 块中定义的值。
*/
  --color-base-100: var(--background);
  --color-base-200: var(--card);
  --color-base-300: var(--secondary);
  --color-base-content: var(--foreground);

  --color-primary: var(--primary);
  --color-primary-content: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-content: var(--secondary-foreground);
  --color-accent: var(--accent);
  --color-accent-content: var(--accent-foreground);
  --color-neutral: var(--muted);
  --color-neutral-content: var(--muted-foreground);

  --color-info: oklch(68% 0.169 237.323);
  --color-info-content: oklch(97% 0.013 236.62);
  --color-success: oklch(72% 0.219 149.579);
  --color-success-content: oklch(98% 0.018 155.826);
  --color-warning: oklch(70% 0.213 47.604);
  --color-warning-content: oklch(98% 0.016 73.684);
  --color-error: var(--destructive);
  --color-error-content: var(--destructive-foreground);

  --radius-box: var(--radius);
  --radius-field: var(--radius);
  --radius-selector: 1rem;
}

核心机制解析: 这里体现了 CSS 变量的强大之处。我们为 dark 主题的 --color-primary 设置的值,与 light 主题 完全一样,都是 var(--primary)。因为当主题切换时,--primary 变量本身的值已经被 .dark 选择器所覆盖,所以 daisyUI 的主题会自动地、响应式地继承这个变化。

最终的统一架构

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
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
@import 'tailwindcss';
@import 'tw-animate-css';
@plugin "daisyui"; /* <-- 步骤一:引入 daisyUI 插件 */

@custom-variant dark (&:is(.dark *));

@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}

:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.13 0.028 261.692);
--card: oklch(1 0 0);
--card-foreground: oklch(0.13 0.028 261.692);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.13 0.028 261.692);
--primary: oklch(0.21 0.034 264.665);
--primary-foreground: oklch(0.985 0.002 247.839);
--secondary: oklch(0.967 0.003 264.542);
--secondary-foreground: oklch(0.21 0.034 264.665);
--muted: oklch(0.967 0.003 264.542);
--muted-foreground: oklch(0.551 0.027 264.364);
--accent: oklch(0.967 0.003 264.542);
--accent-foreground: oklch(0.21 0.034 264.665);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0.002 247.839);
--border: oklch(0.928 0.006 264.531);
--input: oklch(0.928 0.006 264.531);
--ring: oklch(0.707 0.022 261.325);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.002 247.839);
--sidebar-foreground: oklch(0.13 0.028 261.692);
--sidebar-primary: oklch(0.21 0.034 264.665);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-accent: oklch(0.967 0.003 264.542);
--sidebar-accent-foreground: oklch(0.21 0.034 264.665);
--sidebar-border: oklch(0.928 0.006 264.531);
--sidebar-ring: oklch(0.707 0.022 261.325);
}

.dark {
--background: oklch(0.13 0.028 261.692);
--foreground: oklch(0.985 0.002 247.839);
--card: oklch(0.21 0.034 264.665);
--card-foreground: oklch(0.985 0.002 247.839);
--popover: oklch(0.21 0.034 264.665);
--popover-foreground: oklch(0.985 0.002 247.839);
--primary: oklch(0.928 0.006 264.531);
--primary-foreground: oklch(0.21 0.034 264.665);
--secondary: oklch(0.278 0.033 256.848);
--secondary-foreground: oklch(0.985 0.002 247.839);
--muted: oklch(0.278 0.033 256.848);
--muted-foreground: oklch(0.707 0.022 261.325);
--accent: oklch(0.278 0.033 256.848);
--accent-foreground: oklch(0.985 0.002 247.839);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0.002 247.839);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.034 264.665);
--sidebar-foreground: oklch(0.985 0.002 247.839);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-accent: oklch(0.278 0.033 256.848);
--sidebar-accent-foreground: oklch(0.985 0.002 247.839);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}

@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

@plugin "daisyui/theme" {
name: 'light';
default: true;
prefersdark: false;
color-scheme: 'light';
--color-base-100: var(--background);
--color-base-200: var(--card);
--color-base-300: var(--secondary);
--color-base-content: var(--foreground);
--color-primary: var(--primary);
--color-primary-content: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-content: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-content: var(--accent-foreground);
--color-neutral: var(--muted);
--color-neutral-content: var(--muted-foreground);
--color-info: oklch(54% 0.245 262.881);
--color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(62% 0.194 149.214);
--color-success-content: oklch(98% 0.018 155.826);
--color-warning: oklch(64% 0.222 41.116);
--color-warning-content: oklch(98% 0.016 73.684);
--color-error: var(--destructive);
--color-error-content: var(--destructive-foreground);
--radius-selector: 1rem;
--radius-field: var(--radius);
--radius-box: var(--radius);
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}

@plugin "daisyui/theme" {
name: 'dark';
default: false;
prefersdark: true;
color-scheme: 'dark';
--color-base-100: var(--background);
--color-base-200: var(--card);
--color-base-300: var(--secondary);
--color-base-content: var(--foreground);
--color-primary: var(--primary);
--color-primary-content: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-content: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-content: var(--accent-foreground);
--color-neutral: var(--muted);
--color-neutral-content: var(--muted-foreground);
--color-info: oklch(68% 0.169 237.323);
--color-info-content: oklch(97% 0.013 236.62);
--color-success: oklch(72% 0.219 149.579);
--color-success-content: oklch(98% 0.018 155.826);
--color-warning: oklch(70% 0.213 47.604);
--color-warning-content: oklch(98% 0.016 73.684);
--color-error: var(--destructive);
--color-error-content: var(--destructive-foreground);
--radius-selector: 1rem;
--radius-field: var(--radius);
--radius-box: var(--radius);
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}

经过这次重构,我们项目的主题体系从“双轨并行”演进为了清晰的“单一来源”架构:

  1. 唯一事实来源: :root.dark 代码块中定义的语义化 CSS 变量。
  2. 分发桥梁:
    • @theme inline: 将变量“翻译”给 Tailwind 工具类(如 bg-primary)。
    • @plugin "daisyui/theme": 将变量“翻译”给 daisyUI 的主题系统(如 btn-primary)。
  3. 最终消费者: 项目中所有的组件,无论是我们自定义的,还是来自 daisyUI 的。

验证:
现在,您可以去 :root 中尝试修改 --primary 变量的 oklch 值,例如将色相(最后一个数字)调整一下。刷新应用后,您会发现,不仅使用 bg-primary 的元素的颜色变了,所有使用了 btn-primarydaisyUI 按钮颜色 也同时、一致地 发生了变化。

我们成功地构建了一个健壮、可维护、真正统一的主题化架构。有了这个坚实的基础,我们现在可以满怀信心地开始构建一个能够控制整个系统的动态主题切换器了。


4.2. 工程化实践:构建一个兼容双轨制的动态主题切换器

我们已经对项目的主题架构有了“X 光”级别的理解。现在,我们将基于这个理解,开始本章的核心实战任务:构建一个允许用户在应用界面中自由切换主题的功能。

4.2.1. 引入 next-themes: 专业的 Next.js 主题管理库

痛点剖析:为什么不能简单地使用 useState

一个自然而然的想法是:既然主题切换只是改变 <html> 标签上的 classdata-theme 属性,我们为什么不能用一个 React useState 来管理当前的主题状态,然后通过 useEffect 来手动操作 DOM 呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
// 一个看似可行,但充满陷阱的“手动”实现 (错误示范)
const [theme, setTheme] = useState('light');

useEffect(() => {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
root.setAttribute('data-theme', 'dark');
} else {
root.classList.remove('dark');
root.setAttribute('data-theme', 'light');
}
}, [theme]);

这种“手动”实现的方式,在生产环境中会立刻暴露出三个致命的问题:

1. 痛点一:状态无法持久化
useState 的状态是存在于组件内存中的,它是临时的。当用户选择“暗色模式”后,只要一刷新页面(F5),或者关闭浏览器再重新打开,组件状态就会重置,主题将无情地跳回默认的“亮色模式”。为了解决这个问题,我们需要手动编写使用 localStorage 读取和存储主题偏好的逻辑,这无疑增加了代码的复杂性。

2. 痛点二:服务端渲染 (SSR) 的主题闪烁
这是最严重、也最影响用户体验的问题。我们的应用是基于 Next.js 的,它会在 服务端 进行首次页面渲染。

  • 当用户请求页面时,服务端开始渲染 HTML。但 服务端无法访问用户浏览器的 localStorage,因此它无从知晓用户上次选择的是“暗色模式”。
  • 服务端只能根据默认设置,渲染并返回一份“亮色模式”的 HTML。
  • 浏览器接收到这份 HTML 后,会立刻展示一个亮色的页面。
  • 紧接着,客户端的 JavaScript 开始执行(这个过程称为“水合(Hydration)”),此时我们的 useEffect 终于运行,它读取到 localStorage 中的“暗色”偏好,然后匆忙地将主题切换为暗色。
  • 最终结果: 用户会先看到一个刺眼的亮色页面,然后页面“闪烁”一下,才变成他们想要的暗色。这种“错误主题闪烁”是极其糟糕的用户体验。

3. 痛点三:无法响应系统偏好
现代操作系统都内置了深色模式。一个体验优秀的应用,应该能自动识别并匹配用户的系统偏好。要手动实现这一点,我们需要使用 window.matchMedia('(prefers-color-scheme: dark)') API 并监听其变化事件,这会进一步增加我们主题逻辑的复杂度。

解决方案:next-themes

next-themes 是一个轻量、专为 Next.js 设计的库,它以一种极其优雅的方式,一次性地解决了上述所有问题。

  • 状态持久化: 自动处理 localStorage 的读写。
  • SSR 兼容性: 内部采用了一种巧妙的策略(在 <body> 标签上设置初始主题,并在 hydration 前执行一个微型脚本),从根本上杜绝了主题闪烁问题。
  • 系统偏好同步: 内置了对 system 主题的支持,可以自动响应操作系统的设置。
  • 简洁的 API: 仅通过一个 ThemeProvider 组件和一个 useTheme Hook,就将所有复杂性都封装了起来。

安装依赖

理解了 next-themes 的巨大价值后,让我们将它添加到项目中。

1
pnpm install next-themes

选择并使用 next-themes 这样的专业库,是资深工程师的思维方式:将通用的、复杂的问题,委托给社区中经过验证的、专注的解决方案,从而让自己能聚焦于真正独特的业务逻辑。

依赖安装完成后,我们已经为构建一个无闪烁、可持久化、体验一流的主题切换功能做好了充分的准备。


4.2.2. 创建 ThemeProvider 客户端组件

next-themes 库的核心是一个名为 ThemeProvider 的 React Context Provider 组件。然而,在 Next.js App Router 架构中,我们不能直接在根布局(一个默认为服务端组件的文件)中使用它。

核心理念:服务端组件 vs. 客户端组件

  • 服务端组件: 是 Next.js App Router 的默认组件类型。它们在服务器上渲染,可以执行访问数据库、文件系统等操作,但 不能 使用 useState, useEffect 等 React Hooks,也 不能 访问浏览器特有的 API(如 window, localStorage)。
  • 客户端组件: 通过在文件顶部添加 'use client'; 指令来声明。它们在客户端渲染,可以使用所有 React Hooks 和浏览器 API。

next-themes 库需要使用 Hooks 来管理状态,并需要访问 localStorage 来持久化主题。因此,它的 Provider 必须 在一个客户端组件中使用。

最佳实践是,将这类需要在应用根部使用的客户端 Provider,封装到一个独立的客户端组件文件中,而不是将整个根布局(layout.tsx)都转换为客户端组件,以保持服务端渲染的优势。

第一步:创建 Provider 文件

我们将创建一个专门的目录 src/components/providers 来存放所有全局的 Provider 组件。

1
2
3
# 在项目根目录下执行
mkdir -p src/components/providers
touch src/components/providers/theme-provider.tsx

第二步:编写 ThemeProvider 封装组件

现在,我们来编写这个封装组件。它的作用很简单:将 next-themesThemeProvider 包装在一个声明了 'use client' 的文件中。

文件路径: src/components/providers/theme-provider.tsx

1
2
3
4
5
6
7
8
9
10
11
12
'use client'; // 1. 声明这是一个客户端组件

import * as React from 'react';
// 2. 导入 next-themes 的 Provider,并使用 'as' 关键字重命名
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ThemeProviderProps } from 'next-themes';

// 3. 创建我们自己的 ThemeProvider 组件
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
// 4. 在内部渲染并返回重命名后的 NextThemesProvider
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

代码深度解析:

  1. 'use client';: 这是最关键的一行。它告诉 Next.js 的打包器,这个文件及其导入的所有依赖(包括 next-themes)都应该作为客户端 JavaScript 的一部分被发送到浏览器。
  2. import { ThemeProvider as NextThemesProvider } from 'next-themes';: 我们从 next-themes 导入了 ThemeProvider,但立刻使用 as 关键字将其重命名为 NextThemesProvider。这样做是为了避免命名冲突,因为我们自己封装的组件也叫 ThemeProvider。这是一种非常清晰和常用的模式。
  3. export function ThemeProvider(...): 我们定义了自己的 ThemeProvider。它接收的 props 类型 ThemeProviderProps 直接来自于 next-themes,确保了我们的封装是类型安全的,并且可以传递 next-themes 支持的所有配置项。
  4. return <NextThemesProvider ...>: 我们的组件是一个纯粹的“直通”组件。它将接收到的所有 props(包括 children)原封不动地传递给内部的 NextThemesProvider

通过这个简单的封装,我们成功地将一个客户端逻辑限定在了一个独立的模块中,保持了我们应用主架构的整洁。现在,这个 ThemeProvider 组件已经准备好被用在我们的根布局中了。


4.2.3. 在根布局中应用 ThemeProvider

在上一节中,我们创建了一个遵循 Next.js 最佳实践的 ThemeProvider 客户端组件。现在,我们需要将这个“供应商”放置在应用的最高层级,以便它能够将其提供的“主题上下文(Context)”分发给应用中的所有子组件。

在 Next.js App Router 架构中,这个最高层级就是根布局文件 src/app/layout.tsx

核心理念:全局上下文的注入点

next-themesThemeProvider 利用 React Context API 来工作。为了让应用中的任何一个组件(无论它嵌套多深)都能通过 useTheme Hook 访问到当前的主题状态和切换函数,Provider 必须被放置在组件树的根部,包裹住所有的页面内容。RootLayout 正是实现这一目标的完美位置。

第一步:修改根布局文件

我们将打开 src/app/layout.tsx 文件,导入我们刚刚创建的 ThemeProvider,并用它来包裹 <body> 标签内的 {children}

文件路径: src/app/layout.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
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';

// 1. 导入我们自己封装的 ThemeProvider
import { ThemeProvider } from '@/components/providers/theme-provider';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
title: 'Prorise UI',
description: 'A modern design system.',
};

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
{/* 2. 使用 ThemeProvider 将所有子内容包裹起来 */}
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}

代码深度解析:

我们向 ThemeProvider 传递了一系列重要的配置属性,这些属性直接由 next-themes 提供:

  • attribute="class": 这是至关重要的一步。它告诉 next-themes,主题切换的行为是 修改 <html> 标签上的 class 属性。当主题为 dark 时,它会添加 class="dark"。这正是驱动我们第一套主题系统(shadcn/ui 风格的 CSS 变量)工作的核心机制。

  • defaultTheme="system": 我们将默认主题设置为 "system"。这意味着当用户首次访问时,应用会自动采用用户操作系统的亮/暗色模式偏好,提供了更原生的用户体验。

  • enableSystem: 这个属性必须为 true,才能激活 defaultTheme="system" 的功能。

  • disableTransitionOnChange: 这是一个推荐的最佳实践。它可以在主题切换时,临时禁用所有的 CSS 过渡效果,避免了在颜色、背景等属性变化时可能出现的闪烁或不自然的过渡动画,让主题切换更加平滑、瞬时。

  • suppressHydrationWarning: 我们在 <html> 标签上添加了这个 React 属性。因为 next-themes 会在服务器端和客户端渲染不同的 classdata-theme,这会造成一个 React 期望之外的差异。这个属性会告知 React 忽略这个特定的不匹配警告,是配合 next-themes 使用的标准做法。

思考: 您可能已经注意到,我们配置 attribute="class" 只激活了我们的第一套主题系统。那 daisyUI 依赖的 data-theme 属性怎么办?这是一个非常好的问题。next-themes 默认只能控制一个属性。我们将在下一节构建 ThemeToggle 组件时,通过巧妙的方式,让 useTheme Hook 的一次调用,能够 同时同步 classdata-theme 两个属性,从而完美驱动我们的“双轨制”系统。

至此,我们的应用已经具备了完整的主题管理能力。虽然界面上还没有任何可供点击的切换按钮,但 next-themes 已经在后台默默工作,为我们处理好了状态持久化、SSR 兼容性等所有复杂问题。

现在,万事俱备,只欠一个“开关”。


4.2.4. 构建 ThemeToggle 组件

至此,next-themesThemeProvider 已经像一个看不见的“中央空调”一样,在我们的应用底层准备就绪,提供了全局的主题管理能力。现在,万事俱备,我们只需要为用户提供一个可以操作这个“中央空调”的“遥控器”——一个主题切换组件。

本节,我们将构建一个功能完备的 ThemeToggle 组件。这个过程不仅会让我们学会使用 next-themes 提供的 useTheme Hook,还将完美地解决我们在上一节末尾留下的悬念:如何同步驱动 classdata-theme 两个属性,让我们的双轨主题系统协同工作。

第一步:(关键) 升级 ThemeProvider 以兼容双轨制

在构建 ThemeToggle 这个“遥控器”之前,我们必须先升级我们的“中央空调”本身,让它具备同时控制两种主题机制的能力。

next-themes 默认只能操作一个 HTML 属性(我们在 layout.tsx 中指定了 class)。为了让它在改变 class 的同时,也能改变 daisyUI 所需的 data-theme 属性,我们需要在我们的 ThemeProvider 封装组件中增加一点“魔法”。

文件路径: src/components/providers/theme-provider.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
'use client';

import * as React from 'react';
import { ThemeProvider as NextThemesProvider, useTheme } from 'next-themes';
import type { ThemeProviderProps } from 'next-themes';

/**
* 一个内部辅助组件,它的唯一职责是监听 next-themes 的主题变化,
* 并将该变化同步到达 daisyUI 所需的 data-theme 属性上。
*/
function DaisyUIThemeSynchronizer() {
const { theme, resolvedTheme } = useTheme();

React.useEffect(() => {
// `resolvedTheme` 会在我们选择 'system' 时,解析出实际的主题('light' 或 'dark')
const currentTheme = theme === 'system' ? resolvedTheme : theme;

// 在 <html> 标签上设置 data-theme 属性
document.documentElement.setAttribute('data-theme', currentTheme || 'light');

}, [theme, resolvedTheme]);

return null; // 这个组件不渲染任何 UI
}


export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider {...props}>
<DaisyUIThemeSynchronizer />
{children}
</NextThemesProvider>
);
}

代码深度解析:

  • DaisyUIThemeSynchronizer: 我们创建了一个新的内部组件。因为它需要使用 useTheme Hook,所以它也必须是客户端组件(其父组件 ThemeProvider 已经是了)。
  • useTheme(): 我们从 next-themes 调用 useTheme Hook,它返回了包含 theme(用户的选择,可能是 'light', 'dark', 'system')和 resolvedTheme(实际应用的主题,只可能是 'light''dark')的对象。
  • React.useEffect(...): 我们使用 useEffect 来执行一个“副作用”。这个副作用就是手动操作 DOM,将 <html> 标签的 data-theme 属性设置为当前解析出的主题。
  • 依赖项 [theme, resolvedTheme]: 我们告诉 useEffect,只有当用户的选择 theme 或系统解析出的 resolvedTheme 发生变化时,才需要重新执行这个副作用。
  • ThemeProvider 中使用: 我们将这个同步器组件放置在 NextThemesProvider 内部,这样它就能够通过 useTheme Hook 访问到正确的主题上下文了。

通过这次升级,我们的 ThemeProvider 现在成为了一个真正意义上的“双轨制控制器”。任何通过 next-themes 引发的主题变更,都会被它捕获,并同时更新 classdata-theme 两个属性。

第二步:创建 ThemeToggle 组件文件

现在可以开始构建我们的 UI 组件了。

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

第三步:实现 ThemeToggle 组件

我们将利用在第三章构建的 DropdownMenu 组件,来实现一个包含“亮色”、“暗色”和“跟随系统”三个选项的高级主题切换器。

文件路径: src/components/ui/ThemeToggle.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
'use client';

import * as React from 'react';
import { useTheme } from 'next-themes';
import { Sun, Moon, Laptop } from 'lucide-react'; // 导入我们之前安装的图标

import { Button } from '@/components/ui/Button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/DropdownMenu';

export function ThemeToggle() {
// 从 next-themes 获取 setTheme 函数
const { setTheme } = useTheme();

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
<Sun className="mr-2 h-4 w-4" />
<span>Light</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<Moon className="mr-2 h-4 w-4" />
<span>Dark</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<Laptop className="mr-2 h-4 w-4" />
<span>System</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

代码深度解析:

  • 'use client';: 同样,因为要使用 useTheme Hook,这个组件也必须是客户端组件。
  • const { setTheme } = useTheme();: 我们调用 useTheme Hook,并只解构出我们需要的 setTheme 函数。
  • DropdownMenu: 我们复用了第三章的成果,构建了一个功能完整的下拉菜单。
  • 图标与样式:
    • 触发器按钮内部,我们巧妙地放置了 SunMoon 两个图标。
    • 通过 Tailwind 的 dark: 变体类,我们实现了平滑的图标切换动画:在亮色模式下,太阳图标 scale-100(可见),月亮图标 scale-0(不可见);当 .dark 类存在时,太阳 scale-0,月亮 scale-100
    • sr-only 类用于可访问性,它会将“Toggle theme”这段文字在视觉上隐藏,但屏幕阅读器仍然可以读取它。
  • onClick={() => setTheme('...')}: 在每个菜单项上,我们绑定了 onClick 事件,调用 setTheme 并传入对应的主题名称。当用户点击时,next-themes 会更新它的内部状态,这个变化随即被我们升级后的 ThemeProvider 捕获,并同时更新 classdata-theme

第四步:在应用中使用 ThemeToggle

最后,我们将这个切换器放置到一个合适的位置,例如导航栏。为了演示,我们先简单地将它放在首页。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { ThemeToggle } from '@/components/ui/ThemeToggle'; // 1. 导入组件

export default function HomePage() {
return (
<main className="relative flex min-h-screen flex-col items-center justify-center">
{/* 2. 将组件放置在页面右上角 */}
<div className="absolute top-8 right-8">
<ThemeToggle />
</div>

<div className="text-center">
<h1 className="text-4xl font-bold">Welcome to Prorise UI</h1>
<p className="mt-4 text-muted-foreground">
Theme switching is now fully functional.
</p>
</div>
</main>
);
}

最终验证

运行 pnpm run dev。您现在应该可以在页面右上角看到一个包含太阳图标的按钮。点击它,会弹出一个包含三个选项的下拉菜单。尝试在 “Light”, “Dark”, “System” 之间切换。

img

您会观察到:

  1. 整个页面的背景色、文字颜色会平滑地变化。
  2. 打开浏览器开发者工具,检查 <html> 元素,您会发现它的 classdata-theme 两个属性总是在同步地、正确地更新。

我们成功地构建了一个功能完备、体验优雅、且能完美驱动我们双轨主题系统的动态主题切换器。


4.3. 图标系统战略:多方案对比与架构决策

4.3.1. 痛点与演进:图标方案的“前世今生”

一个专业的设计系统,必须有一套清晰、高效、可扩展的图标解决方案。在选择我们的技术方案之前,我们有必要回顾一下 Web 图标技术的演进历程,理解每种方案的诞生背景、核心优势以及最终被取代或改进的原因。

1. 传统方案:字体图标 (Icon Fonts)

这是以 Font Awesome (v4/v5) 为代表的、曾经统治一个时代的技术方案。

核心理念: 将矢量图标图形打包成一个字体文件(如 .woff2)。浏览器像加载自定义字体一样加载它,然后通过 CSS 伪元素(::before)和特定的类名,在页面上“写”出对应的“图标字符”。

代码示例:

1
2
3
4
5
6
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">

<button class="btn">
<i class="fa fa-home" aria-hidden="true"></i>
<span>Home</span>
</button>

深度剖析:

  • 优点:
    • 样式控制简单: 作为“字体”,它可以直接使用 CSS 的 colorfont-size 属性来控制颜色和大小。
  • 缺点 (痛点):
    • 性能糟糕: 即使页面只用了 1 个图标,也必须下载包含成百上千个图标的、体积庞大的整个字体文件。它 无法被摇树优化 (Tree-shaking)
    • 可访问性 (a11y) 差: 屏幕阅读器可能会尝试朗读伪元素中的私有字符编码,造成困惑。需要开发者手动添加 aria-hidden="true" 来修复,但这常常被遗忘。
    • 渲染问题: 受浏览器字体抗锯齿效果的影响,图标有时会显得模糊。如果字体文件加载失败,用户将看到一个难看的占位方框。
    • 功能受限: 无法实现多色图标,因为一个“字符”只能有一个颜色。

结论: 对于 2025 年的现代化前端项目,字体图标是一种 已经过时且不推荐 的技术方案。

2. 现代基石:SVG 组件化 (SVG Componentization)

随着 React 等组件化框架的兴起,一种更强大、更符合组件化思想的方案成为了主流:将每一个 SVG 图标都视为一个独立的 React 组件。我们在项目中已经使用的 lucide-react 正是此方案的杰出代表。

核心理念: 每个图标都是一个自包含的 React 组件,它在渲染时直接输出 <svg>...</svg> 标签和路径数据到 DOM 中。

A) 实践:使用预封装的图标库 (lucide-react)

这是我们已经在 ThemeToggle 组件中实践过的方式。

代码示例:

1
2
3
4
5
6
7
8
9
import { Sun, Moon } from 'lucide-react'; // 1. 像导入普通组件一样导入图标

// 2. 像使用普通组件一样使用图标,并通过 className 传递样式
function ThemeToggleSwitch({ theme }: { theme: string }) {
if (theme === 'dark') {
return <Moon className="h-6 w-6 text-yellow-300" />;
}
return <Sun className="h-6 w-6 text-orange-500" />;
}

B) 实践:使用 SVGR 自动化转换自定义图标

对于我们设计系统私有的、品牌相关的图标(如 Logo),我们可以使用 SVGR 这样的工具,自动将 .svg 文件批量转换为 React 组件,其用法与 lucide-react 完全一致。

深度剖析 (SVG 组件化方案):

  • 优点:
    • 性能极佳: 完全支持 Tree-shaking。构建工具只会将您 import 的图标打包到最终产物中,体积被最小化。
    • 样式灵活: 作为原生 SVG,可以通过 className 接受所有 Tailwind 工具类,fillstroke 可以被设置为 currentColor 以继承父元素的文本颜色,并且完美支持多色图标。
    • 可访问性友好: 可以轻松地为 <svg> 标签添加 title 等元素,提供良好的无障碍体验。
    • 可靠且可控: 图标代码是项目的一部分,不依赖外部网络,且开发者对组件有 100% 的控制权。
  • 缺点:
    • 库内容有限: 对于 lucide-react 这样的预封装库,您只能使用它提供的图标集。
    • 需要构建步骤: 对于 SVGR,需要额外配置和维护一个构建脚本。

结论: SVG 组件化是构建高性能、高可控性设计系统的 基石,尤其适合承载核心的、需要精细控制的图标集合。

3. 云端未来:按需图标服务 (Iconify)

Iconify 解决了 SVG 组件化方案的主要痛点——图标库内容有限的问题。

核心理念: 提供一个统一的组件 API,通过一个字符串 ID,按需从云端 API 获取海量图标集中的任意一个图标的 SVG 数据,并在客户端渲染和缓存。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Icon } from '@iconify/react'; // 1. 导入唯一的 Icon 组件

function UserActions() {
return (
<div className="flex gap-4">
{/* 2. 通过 "icon-set:icon-name" 格式的字符串指定图标 */}
<button className="btn btn-square">
<Icon icon="lucide:user" className="h-6 w-6" />
</button>
<button className="btn btn-square">
<Icon icon="mdi:github" className="h-6 w-6" />
</button>
<button className="btn btn-square">
<Icon icon="logos:figma" className="h-6 w-6" />
</button>
</div>
);
}

深度剖析:

  • 优点:
    • 图标库极度丰富: 可访问超过 200,000 个来自上百个图标集的图标,选择几乎是无限的。
    • 加载性能优异: 初始包体积极小。只有当某个图标首次需要渲染时,才会发起一次网络请求获取其数据。数据随后会被缓存到 localStorage,后续访问将是瞬时的。
    • API 统一: 无论图标来自哪个图标集(Material Design Icons, Lucide, Logos 等),API 调用方式完全一致。
  • 缺点:
    • 依赖网络: 首次加载图标时必须联网。不适用于纯内网或有严格离线要求的应用。
    • 不适合私有图标: 其公共 API 不适用于承载公司内部的、具有品牌知识产权的私有图标。

总结对比

为了便于回顾和决策,我们将三种主流方案的核心特性总结如下:

特性维度字体图标 (过时)SVG 组件化 (lucide-react/SVGR)按需服务 (Iconify)
性能差 (全量加载)极佳 (Tree-shaking)优 (按需加载)
图标库大小有限有限 (预封装) 或 自定义近乎无限
自定义能力极佳 (完全可控)差 (无法使用私有图标)
网络依赖依赖 CDN首次加载时依赖
样式化有限 (单色)极佳 (Tailwind/CSS, 多色)极佳 (Tailwind/CSS, 多色)
开发体验一般极佳

结论:
通过对比,我们发现没有任何一种方案是能够通吃所有场景的“银弹”。对于 Prorise UI 这样一个既要体现自身品牌独特性,又要为开发者提供极大便利的专业设计系统而言,单一的技术选型是不够的。

因此,一个“混合”战略将是我们的最佳选择。


4.3.2. 架构决策:Prorise UI 的混合图标战略

在上一节中,我们对三种主流图标方案进行了深入的对比分析,结论显而易见:没有任何一种方案是能够通吃所有场景的“银弹”。

  • 如果我们 只选择 SVGR 方案,虽然能完美地处理私有品牌图标,但我们将不得不手动收集、管理和转换成百上千个通用图标(如设置、用户、箭头等),这将是一项巨大的、毫无创造性的维护负担。
  • 反之,如果我们 只选择 Iconify 方案,虽然能轻松访问海量的通用图标,但我们将失去对核心品牌图标的 100% 控制权,并为这些最关键的视觉资产引入了不必要的网络依赖。

一个专业的、成熟的设计系统,应该追求的是“取长补短”,而非“一刀切”。

因此,我们的 Prorise UI 图标系统将采纳一种 双轨并行的混合战略,以求在品牌控制力、开发效率和应用性能之间,达到最佳平衡。

轨道一:SVGR - 负责品牌与核心图标

  • 职责: 这条轨道专门负责处理那些对 Prorise UI 具有 独一无二身份标识 的、私有的需要被严格版本控制 的图标。
  • 范围:
    • Prorise UI 的 Logo。
    • 由我们的设计师专门绘制的、体现产品独特性的业务图标。
    • 对应用核心功能至关重要,必须保证在任何网络环境下都能瞬时加载的图标。
  • 价值: 这条生产线确保了我们品牌资产的独立、安全与可靠,并享有 Tree-shaking 带来的极致性能。

轨道二:Iconify - 负责海量通用图标

  • 职责: 这条轨道作为我们的“公共图标资源库”,满足日常开发中 95% 以上的通用图标需求。
  • 范围:
    • 所有常见的界面图标,如用户、设置、邮件、关闭、搜索等。
    • 为了保持视觉风格的一致性,我们将主要选用一个高质量的图标集,例如 lucide
    • 在特殊情况下,可以随时调用其他图标集(如 mdi, logos)中的图标,而无需增加任何额外的项目依赖。
  • 价值: 这条生产线为开发者提供了近乎无限的图标选择,极大地提升了开发速度,并凭借其按需加载和缓存机制,保证了优异的加载性能。

通过这套混合战略,我们为 Prorise UI 的图标系统制定了清晰的架构蓝图。它既保证了品牌核心的稳固与独立,又赋予了日常开发极大的自由和效率。

现在,战略已经明确,在下一节中,我们将开始着手工程实现,亲手搭建这两条“图标生产线”,并最终将它们封装在一个统一的、优雅的 API 之后。


4.4. 工程化实现:构建统一的 Icon 组件

战略已经明确,现在我们进入工程实现阶段。本节,我们将亲手搭建起支撑混合图标战略的两条“生产线”,并最终将它们封装在一个优雅、统一的 Icon 组件之后,为开发者提供极致简洁的使用体验。


4.4.1. 生产线 A:配置 SVGR 自动化工作流

我们的第一项任务,是为我们设计系统 私有的、具有品牌属性的 图标,建立一条自动化的“组件生产线”。

痛点剖析:手动转换 SVG 的繁琐与不一致

如果我们没有自动化工作流,每当需要一个新图标时,开发者都必须经历一个痛苦的手动过程:

  1. 从设计师那里获取 .svg 文件。
  2. src/components/icons 目录下创建一个新的 .tsx 文件。
  3. 将 SVG 代码复制粘贴到一个 React 组件的 JSX 中。
  4. 手动将所有 kebab-case 属性(如 stroke-width)修改为 camelCasestrokeWidth)。
  5. 手动移除 SVG 文件中由设计软件生成的、不必要的元数据(如 id, xmlns:xlink)。
  6. 手动将所有 fillstroke 属性修改为 currentColor,以确保图标颜色能被 CSS 控制。

这个过程不仅 极其耗时,而且 极易出错,难以保证所有图标组件的格式和优化程度都保持一致。我们的目标,就是将这个过程完全自动化。

解决方案:SVGR

SVGR 是一个功能强大的工具,它可以将 SVG 文件作为输入,并输出一个经过优化的、随时可用的 React 组件。我们将使用它的命令行(CLI)版本来建立我们的自动化脚本。

第一步:安装 SVGR 命令行工具

我们将 @svgr/cli 添加为项目的开发依赖。

1
pnpm add -D @svgr/cli

第二步:准备 SVG 源文件

我们需要一个专门的目录来存放我们所有原始的 .svg 图标文件。这将是我们自动化生产线的“原料入口”。

首先,创建这个目录:

1
mkdir -p src/assets/icons

然后,我们在这个目录中放入第一个代表我们品牌身份的图标。假设我们有一个 prorise-logo.svg 文件。

文件路径: src/assets/icons/prorise-logo.svg

1
2
3
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 2V22H10V14H15C18.3137 14 21 11.3137 21 8C21 4.68629 18.3137 2 15 2H7ZM10 5H15C16.6569 5 18 6.34315 18 8C18 9.65685 16.6569 11 15 11H10V5Z" fill="currentColor"/>
</svg>

最佳实践: 请注意,我们在 SVG 源文件的 <path> 中直接使用了 fill="currentColor"。这会告知 SVGR 保留这个值,使得最终生成的 React 组件的颜色可以被 CSS 的 color 属性(即 Tailwind 的 text-* 工具类)轻松控制。

第三步:创建 SVGR 配置文件

在使用 SVGR 之前,我们需要先创建一个配置文件来避免潜在的兼容性问题。

陷阱警告:Node.js 22 与 Prettier 2.x 的兼容性问题

如果你直接运行 SVGR 而不进行配置,在使用 Node.js 22 时可能会遇到以下错误:

1
Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await.

问题根源

  • @svgr/cli 默认使用 @svgr/plugin-prettier 插件来格式化生成的代码
  • 该插件依赖 Prettier 2.x,而 Prettier 2.x 使用 CommonJS (require())
  • Node.js 22 对 ESM 模块有更严格的要求,不支持在 ESM 上下文中使用 require()
  • 即使你的项目已升级到 Prettier 3.x,@svgr/plugin-prettier 仍会引入 Prettier 2.x

解决方案:禁用 SVGR 的 Prettier 插件

我们创建一个 .svgrrc.js 配置文件,禁用内置的 Prettier 插件,改为在生成后使用项目的 Prettier 3.x 格式化代码。

文件路径: .svgrrc.js

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
module.exports = {
// 禁用 Prettier 插件,避免与 Node.js 22 的兼容性问题
prettierConfig: false,
prettier: false,

// TypeScript 支持
typescript: true,

// 使用现代 React JSX 转换
jsxRuntime: 'automatic',

// 优化为图标模式(1em x 1em)
icon: true,

// 导出为默认导出
exportType: 'default',

// 添加 ref 转发支持
ref: true,

// 为 SVG 添加语义化属性
svgProps: {
role: 'img',
},

// 自动将颜色值替换为 currentColor,确保图标颜色可被 CSS 控制
replaceAttrValues: {
'#000': 'currentColor',
'#000000': 'currentColor',
},
};

配置深度解析:

  • prettierConfig: false & prettier: false: 这是关键配置,完全禁用 SVGR 内置的 Prettier 2.x 插件,避免兼容性问题。
  • typescript: true: 生成 TypeScript (.tsx) 文件,带完整类型定义。
  • jsxRuntime: 'automatic': 使用 React 17+ 的新 JSX 转换,无需 import React
  • icon: true: 将 widthheight 设为 1em,使图标像文字一样可缩放。
  • ref: true: 为组件添加 forwardRef,支持 ref 传递(某些场景必需)。
  • svgProps.role: 'img': 添加 ARIA 角色,提升可访问性。
  • replaceAttrValues: 自动将硬编码的黑色值替换为 currentColor,使图标颜色继承父元素的 color CSS 属性。

第四步:在 package.json 中定义自动化脚本

现在,我们将 SVGR 命令封装成一个 pnpm 脚本,并在生成后用项目的 Prettier 3.x 格式化代码。

文件路径: package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"build:icons": "svgr --out-dir src/components/icons -- src/assets/icons && prettier --write src/components/icons"
}
}

脚本深度解析:

  1. svgr --out-dir src/components/icons -- src/assets/icons:

    • svgr 会读取 .svgrrc.js 配置文件
    • src/assets/icons 目录下的所有 SVG 文件转换为 React 组件
    • 输出到 src/components/icons 目录
    • 由于禁用了 Prettier 插件,生成的代码可能格式不完美
  2. && prettier --write src/components/icons:

    • 在 SVGR 转换完成后,使用项目的 Prettier 3.x 格式化生成的代码
    • 这样既避免了兼容性问题,又确保了代码格式的一致性
    • Windows PowerShell 用户注意:&& 在某些 PowerShell 版本中可能不可用,可以分两步手动执行

为什么这样设计?

这种 “先生成、后格式化” 的两步走策略是当前的最佳实践:

  • ✅ 避免了 Node.js 版本兼容性问题
  • ✅ 使用项目统一的代码格式规范(Prettier 3.x)
  • ✅ 保持了自动化流程的完整性
  • ✅ 未来当 @svgr/plugin-prettier 更新后,只需修改配置即可切换回单步流程

第五步:运行脚本并验证成果

一切准备就绪,让我们来运行这条生产线。

1
pnpm build:icons

命令执行完毕后,您会发现项目中多出了一个新的 src/components/icons 目录,其中包含了一个 ProriseLogo.tsx 文件。让我们检视一下 SVGR 的工作成果。

文件路径: src/components/icons/ProriseLogo.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
import type { SVGProps } from 'react';
import { Ref, forwardRef } from 'react';
const SvgProriseLogo = (
props: SVGProps<SVGSVGElement>,
ref: Ref<SVGSVGElement>
) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
fill="none"
viewBox="0 0 24 24"
role="img"
ref={ref}
{...props}
>
<path
fill="currentColor"
d="M7 2v20h3v-8h5a6 6 0 0 0 0-12zm3 3h5a3 3 0 1 1 0 6h-5z"
/>
</svg>
);
const ForwardRef = forwardRef(SvgProriseLogo);
export default ForwardRef;

成果解析:
SVGR 为我们生成了一个完美的 React 组件:

  • ✅ 它是一个标准的函数组件,使用 forwardRef 支持 ref 传递
  • ✅ 它通过 SVGProps<SVGSVGElement> 获得了完整的 TypeScript 类型支持,可以接收所有标准的 SVG 属性
  • ✅ 它被正确地设置为 width="1em" height="1em",便于缩放
  • ✅ 它保留了 fill="currentColor",使其颜色可被外部 CSS 控制
  • ✅ 它添加了 role="img" 属性,符合可访问性标准
  • ✅ 代码已被 Prettier 3.x 格式化,符合项目代码规范

关键收获与最佳实践总结

我们成功地建立了一条全自动的、高质量的私有图标生产线。在这个过程中,我们学到了几个重要的工程实践:

  1. 版本兼容性意识

    • 在使用 Node.js 22+ 这样的新版本时,要警惕依赖链中的旧版本包(如 Prettier 2.x)
    • 问题往往出现在间接依赖中(@svgr/plugin-prettierprettier@2.x
    • 通过查看错误堆栈可以定位到真正的问题源头
  2. 配置文件的重要性

    • 创建 .svgrrc.js 不仅是为了配置,更是为了显式控制工具行为
    • 配置文件使我们能够绕过默认行为中的问题点
    • 良好的配置文件也是项目文档的一部分
  3. 工作流的拆分与组合

    • 将复杂任务拆分成独立步骤(SVGR 转换 → Prettier 格式化)
    • 每个步骤使用最合适的工具版本
    • 这种设计更加健壮且易于维护
  4. 跨平台兼容性

    • 在编写 npm 脚本时要考虑不同操作系统的差异
    • PowerShell 的 && 语法限制是一个常见陷阱
    • 提供多种执行方式让所有开发者都能顺利工作

现在,每当有新的品牌图标需要添加时,我们只需:

  1. .svg 文件放入 src/assets/icons
  2. 运行 pnpm build:icons(或分步执行)
  3. 自动生成的组件就可以直接在项目中使用了

4.4.2. 生产线 B:集成 Iconify 图标宇宙

4.3.1 节的对比分析中我们得出结论,Iconify 是满足海量通用图标需求的最佳方案。本节,我们将把它集成到项目中。

第一步:安装 Iconify 核心依赖

我们需要安装 @iconify/react,这个包提供了在 React 项目中使用 Iconify 生态的 Icon 组件。

1
pnpm install @iconify/react

第二步:理解与使用 Icon 组件

Iconify 的使用方式极其简洁和直观。

  1. 寻找图标: 访问 Iconify 官方图标库Icônes 等网站,您可以浏览来自上百个图标集的、超过二十万个图标。
  2. 复制 ID: 找到您需要的图标后,只需复制它的 ID。这个 ID 通常采用 [icon-set-prefix]:[icon-name] 的格式。例如,lucide 图标集中的 home 图标,其 ID 就是 lucide:home
  3. 在代码中使用: 在您的组件中,导入 Icon 组件,并通过 icon prop 传入您复制的 ID。

代码示例:

让我们创建一个临时页面来测试 Iconify 的用法。

文件路径: src/app/icon-test/page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
'use client'; // Iconify 组件是客户端组件

import { Icon } from '@iconify/react';

export default function IconTestPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-8 bg-background p-24">
<h1 className="text-2xl font-bold">Iconify Showcase</h1>
<div className="flex items-center gap-4 text-4xl">
{/* 使用来自 Lucide 图标集的图标 */}
<Icon icon="lucide:home" />
<Icon icon="lucide:settings" className="text-primary" />

{/* 使用来自 Material Design Icons 的图标 */}
<Icon icon="mdi:github" />

{/* 使用来自 Logos 的多色图标 */}
<Icon icon="logos:react" />
</div>
</main>
);
}

运行与验证:
创建文件后,运行 pnpm run dev 并访问 http://localhost:3000/icon-test。您会看到页面上正确地渲染出了来自不同图标集的四个图标。

核心工作原理解析:
IconTestPage 组件首次渲染时:

  1. <Icon icon="lucide:home" /> 组件被挂载。
  2. 它检查本地缓存(sessionStoragelocalStorage)中是否已有 lucide:home 的 SVG 数据。
  3. 如果缓存中没有,它会向 Iconify 的公共 API 发起一次极小的网络请求,获取 lucide:home 的 SVG 数据。
  4. 获取成功后,它将 SVG 数据渲染到 DOM 中,并将其存入缓存。
  5. 当您下次在应用的任何地方再次请求 lucide:home 图标时,它将直接从缓存中读取,实现瞬时加载。

通过 Iconify,我们以极低的成本,为 Prorise UI 的开发者接入了一个几乎取之不尽、用之不竭的图标资源库。

现在,我们两条独立的图标“生产线”都已搭建完毕。但问题也随之而来:开发者在使用时,需要去区分一个图标是来自我们私有的 SVGR 流程,还是来自公共的 Iconify,并导入不同的组件。这显然不是一个优雅的 API 设计。

在下一节中,我们将解决这个“最后一公里”的问题,通过一次终极封装,为开发者提供一个统一、简洁的图标 API。


4.4.3. 终极封装:构建统一的 Icon 组件

本节目标: 创建一个统一的、智能的 Icon 组件。它将作为我们设计系统中所有图标的 唯一入口,能够自动识别并渲染来自 SVGRIconify 的图标,从而为开发者提供一个极其简单、无心智负担的 API。

第一步:建立一个简单的约定

我们的统一 Icon 组件将接收一个 name prop。我们将通过这个 name prop 的格式来区分图标的来源:

  • 如果 name 包含冒号 : (例如 "lucide:home"), 我们就认为它是一个 Iconify 图标。
  • 如果 name 不包含冒号 : (例如 "prorise-logo"), 我们就认为它是一个我们私有的、由 SVGR 生成的图标。

第二步:创建 Icon 组件文件

我们在 src/components/ui 目录下创建 Icon.tsx 文件。

1
touch src/components/ui/Icon.tsx

第三步:编写统一 Icon 组件的实现

这个组件的核心逻辑,就是根据我们刚刚建立的约定,条件性地渲染不同的底层组件。

文件路径: src/components/ui/Icon.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
'use client';

import * as React from 'react';
import {
Icon as IconifyIcon,
type IconProps as IconifyIconProps,
} from '@iconify/react';
import ProriseLogo from '@/components/icons/ProriseLogo'; // 示例:导入一个本地图标

// 1. 定义一个映射表,用于关联本地图标名称和它们的组件
// 注意:这是一个手动的映射,未来可以优化为自动化生成
const localIcons = {
'prorise-logo': ProriseLogo,
// 当有新图标时,在此处添加映射
};

// 2. 定义我们统一 Icon 组件的 Props
// 使用 Omit 排除 IconifyIconProps 中的 'icon' 属性,用我们自己的 'name' 替代
export interface IconProps extends Omit<IconifyIconProps, 'icon'> {
name: string;
}

// 3. 实现统一 Icon 组件
export function Icon({ name, className, ...props }: IconProps) {
// 4. 检查 name prop 是否包含冒号
const isIconify = name.includes(':');

if (isIconify) {
// 5. 如果是 Iconify 图标,渲染 IconifyIcon 组件
return <IconifyIcon icon={name} className={className} {...props} />;
}

// 6. 如果是本地图标,从映射表中查找对应的组件
const LocalIconComponent = localIcons[name as keyof typeof localIcons];

if (LocalIconComponent) {
// 7. 如果找到,则渲染本地图标组件
return <LocalIconComponent className={className} {...props} />;
}

// 8. 如果都找不到,可以渲染一个 null 或一个默认的“未找到”图标
return null;
}

代码深度解析:

  1. localIcons 映射表: 我们创建了一个简单的 JavaScript 对象,它的键是我们在 SVGR 流程中定义的图标名称(小写、连字符格式),值是对应的 React 组件的导入。这是我们连接“名称字符串”和“真实组件”的桥梁。
  2. IconProps: 我们定义了 Icon 组件的 props 接口,它接收一个必需的 name 字符串,并继承了所有标准的 SVG 属性,以便可以传递 width, height, onClick 等。
  3. isIconify 判断: 通过一行简单的 name.includes(':'),我们就完成了对图标来源的智能判断。
  4. 条件渲染:
    • 如果 isIconifytrue,我们直接渲染从 @iconify/react 导入的 IconifyIcon 组件(我们使用 as 关键字重命名了它以避免混淆),并将 name 作为 icon prop 传递进去。
    • 如果 isIconifyfalse,我们尝试从 localIcons 映射表中查找对应的本地组件。
    • 如果找到了 LocalIconComponent,我们就渲染它。
    • 如果最终什么都没找到,我们返回 null,避免应用因一个不存在的图标而崩溃。

对于 localIcons 这个映射表,在大型项目中,可以通过编写一个 Node.js 脚本来自动扫描 src/components/icons 目录并生成这个映射文件,从而实现完全的自动化。在课程的当前阶段,手动维护是完全可以接受的。

第四步:验证统一 API 的效果

现在,我们 IconTestPage 页面可以被极大地简化。

文件路径: src/app/icon-test/page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
'use client';

import { Icon } from '@/components/ui/Icon'; // <-- 只需导入我们统一的 Icon 组件

export default function IconTestPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-8 bg-background p-24">
<h1 className="text-2xl font-bold">Unified Icon Component Showcase</h1>
<div className="flex items-center gap-4 text-4xl text-primary">
{/* 使用我们私有的 prorise-logo 图标 */}
<Icon name="prorise-logo" />

{/* 使用来自 Iconify (Lucide) 的图标 */}
<Icon name="lucide:home" />
<Icon name="lucide:settings" />

{/* 使用来自 Iconify (MDI) 的图标 */}
<Icon name="mdi:github" />
</div>
</main>
);
}

解析:
看到区别了吗?我们的页面代码现在变得极其干净和统一。开发者不再需要关心图标的具体来源,他们只需要从设计规范中查找到所需图标的 name,然后通过 <Icon name="..." />唯一 的 API 来使用它。

我们成功地将复杂的底层实现(两条生产线)隐藏在了一个简洁的、优雅的 API 之后。这正是“封装”这一软件设计核心思想的最佳体现。我们的图标系统,现在才真正称得上是一个“系统”。


4.4.4. 流程闭环:实战与文档

我们已经成功地构建了一个功能强大、API 统一的 Icon 组件,我们的图标系统在工程层面已经构建完毕。然而,遵循我们第二章建立的“黄金工作流”,一个未经实战检验和文档化的组件,还不能称之为“已交付”。

本节,我们将完成工作流的最后闭环:将新组件应用到实际场景中,并为它们创建专业、可交互的 Storybook 文档。

第一步:实战应用 —— 升级 ThemeToggle 组件

我们的第一个任务,是回到 4.2.4 节创建的 ThemeToggle 组件,用我们刚刚封装的、统一的 <Icon /> 组件,来替换其中硬编码的、直接从 lucide-react 导入的图标。这将是体验我们新组件抽象能力所带来便利性的最佳实践。

文件路径: src/components/ui/ThemeToggle.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
'use client';

import * as React from 'react';
import { useTheme } from 'next-themes';

// <-- 1. 移除对 lucide-react 的直接导入 -->
// import { Sun, Moon, Laptop } from 'lucide-react';

// <-- 2. 导入我们统一的 Icon 组件 -->
import { Icon } from '@/components/ui/Icon';
import { Button } from '@/components/ui/Button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/DropdownMenu';

export function ThemeToggle() {
const { setTheme } = useTheme();

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
{/* <-- 3. 使用 <Icon /> 组件替换原来的 Sun 和 Moon --> */}
<Icon
name="lucide:sun"
className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"
/>
<Icon
name="lucide:moon"
className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"
/>
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
<Icon name="lucide:sun" className="mr-2 h-4 w-4" />
<span>Light</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<Icon name="lucide:moon" className="mr-2 h-4 w-4" />
<span>Dark</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<Icon name="lucide:laptop" className="mr-2 h-4 w-4" />
<span>System</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

重构解析:
我们成功地将 ThemeToggle 组件与具体的图标库 lucide-react 解耦。现在,这个组件只依赖于我们设计系统内部的 Icon 组件。如果未来我们决定将 lucide 图标集更换为 material-symbols,我们只需修改 Icon 组件的 name prop (例如,从 lucide:sun 改为 mdi:sun),而无需改动 ThemeToggle 组件本身。这正是良好封装带来的可维护性的体现。

第二步:文档驱动 —— 为 Icon 组件创建 Story

现在,让我们为我们强大的新 Icon 组件创建 Storybook 文档,让团队其他成员可以了解它的用法和能力。

首先,创建 Story 文件:

1
touch src/components/ui/Icon.stories.tsx

然后,我们编写故事内容,确保能同时展示其加载 Iconify 图标和本地 SVGR 图标的能力。

文件路径: src/components/ui/Icon.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
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Icon } from './Icon';

const meta: Meta<typeof Icon> = {
title: 'UI/Icon',
component: Icon,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
// 为核心的 name prop 添加详细的文档和交互控件
argTypes: {
name: {
control: 'text',
description:
'图标名称。Iconify 图标请使用 `icon-set:icon-name` 格式,本地图标请使用文件名 (如 `prorise-logo`)。',
},
},
};

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

// 一个可交互的主 Story
export const Interactive: Story = {
args: {
name: 'lucide:home',
className: 'h-8 w-8 text-primary',
},
};

// 展示本地私有图标的 Story
export const LocalBrandIcon: Story = {
args: {
name: 'prorise-logo',
className: 'h-10 w-10 text-secondary',
},
name: 'Local Brand Icon (SVGR)', // 在 Storybook 侧边栏中显示更清晰的名称
};

// 集中展示多个图标的 Story
export const Showcase: Story = {
render: () => (
<div className="flex items-center gap-4 text-4xl text-accent">
<Icon name="lucide:thumbs-up" />
<Icon name="mdi:github" />
<Icon name="logos:figma" />
<Icon name="prorise-logo" />
</div>
),
};

第三步:为 ThemeToggle 组件创建 Story

最后,我们为 ThemeToggle 组件也创建一份 Storybook 文档。

创建 Story 文件:

1
touch src/components/ui/ThemeToggle.stories.tsx

由于 ThemeToggle 是一个自包含的、没有外部 props 的组件,它的 Story 编写起来非常简单。

文件路径: src/components/ui/ThemeToggle.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
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { ThemeToggle } from './ThemeToggle';

const meta: Meta<typeof ThemeToggle> = {
title: 'UI/ThemeToggle',
component: ThemeToggle,
parameters: {
// 将其放置在右上角,更接近真实使用场景
layout: 'fullscreen',
},
// decorators 装饰器,通过他可以修改组件的渲染方式
decorators: [
(Story) => (
<div className="flex justify-end p-8">
<Story />
</div>
),
],
};

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

export const Default: Story = {};

解析:

  • layout: 'fullscreen': 我们使用了全屏布局,以便更好地控制组件的位置。
  • decorators: 我们添加了一个装饰器,简单地将 ThemeToggle 放置在容器的右上角,使其在 Storybook 中的预览效果更接近真实应用场景。得益于我们在 2.2.2 节中配置的主题插件,这个组件在 Storybook 中将具备完全真实的亮/暗色模式切换功能。

现在,运行 pnpm storybook,您将在侧边栏中看到我们新增的 IconThemeToggle 两个组件,它们都拥有了可交互的示例和清晰的文档。


4.5 本章小结

在本章中,我们完成了一次从理论到实践、再到工程化管理的深度主题化探索。

  1. 架构剖析: 我们首先深入 globals.css 文件,彻底理解了项目中由 shadcn/ui 风格的 CSS 变量daisyUI 独立主题 构成的“双轨”主题系统。
  2. 架构统一: 我们通过一次关键的重构,使用 var() 函数将 daisyUI 的主题链接到了 shadcn/ui 的核心设计令牌上,建立了**“单一事实来源”**的统一架构。
  3. 工程实践: 基于统一的架构,我们引入了 next-themes 库,并亲手构建了一个功能完备、体验流畅的 动态主题切换器 (ThemeToggle),解决了 SSR 主题闪烁等生产级难题。
  4. 战略决策: 我们将目光转向 图标系统,通过对多种主流方案的深度对比,确立了采用 SVGR (私有图标) + Iconify (通用图标) 的混合战略
  5. 优雅封装: 我们不仅实现了两条独立的图标“生产线”,更通过构建一个 统一的 <Icon /> 组件,将复杂的底层实现封装在简洁的 API 之后。
  6. 流程闭环: 最后,我们严格遵循“黄金工作流”,将新创建的 ThemeToggleIcon 组件纳入了 Storybook 文档体系,完成了从开发到文档的完整闭环。

通过本章的学习,您已经掌握了管理和扩展一个现代设计系统视觉层所需的全部核心技能——从颜色令牌,到主题化逻辑,再到可伸缩的、自动化的静态资源管理。


第五章. shadcn 核心工作流:从 Input 原语到业务组件的封装

本章目标: 本章将是 shadcn 的主场。我们将学习 shadcn 最核心的工作流——使用 add 命令来获取组件 原语 (Primitive)。然后,我们将以 Ant Design 成熟的 Input 组件作为我们汲取灵感和需求的“参照系”,学习如何从一个高质量的基础原语出发,结合常见的业务场景,逐步封装和增强,最终打造出 真正适合我们 Prorise UI 设计系统的、功能丰富的 Input 组件

这个过程,将完美诠释 shadcn “为我所有,随我所改”的哲学精髓。

5.1. 奠定基石:通过 add 命令获取 InputLabel 原语

我们封装的第一步,不是从零编写,而是从 shadcn 的“组件注册表”中,获取一个高质量的、遵循了我们项目所有规范的起点。

5.1.1. add 命令实战:获取 InputLabel

在项目根目录下,执行以下命令,分别获取 InputLabel 组件的源代码。

1
2
# 获取 Input 组件原语
npx shadcn@latest add input
1
2
# 获取 Label 组件原语
npx shadcn@latest add label

5.1.2. 代码考古:剖析生成的 Input.tsxLabel.tsx

add 命令执行完毕后,我们的 src/components/ui 目录下会新增两个文件。现在,让我们对这份由官方工具生成的、真实的代码进行一次“X 光”级别的深度剖析。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import * as React from "react"

import { cn } from "@/lib/utils"

function Input({ className, type, ...props }: React.ComponentProps<"input">) {
  return (
    <input
      type={type}
      data-slot="input"
      className={cn(
        "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
        "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
        "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
        className
      )}
      {...props}
    />
  )
}

export { Input }

Input.tsx 核心洞察:
这不仅仅是一个简单的样式化包装器,它是一个 高度工程化的“原语”

  • aria-invalid 驱动的样式: 注意 aria-invalid:... 这一行!shadcnInput 内置了对 可访问性属性 的样式响应。当 Input 因校验失败而被外部逻辑(如表单库)设置了 aria-invalid="true" 属性时,它的边框和 ring 颜色会自动变为“破坏性”颜色(红色)。这是一种极其现代和优雅的实践,将校验状态的视觉反馈与可访问性标准紧密绑定。
  • 精细的伪元素样式: 它通过 file:placeholder:selection: 等 Tailwind 伪元素选择器,对输入框的每一个细节都进行了精心的样式化。
  • focus-visible: 它使用了 focus-visible 而不是 focus,这意味着只有在通过键盘 (Tab) 聚焦时,才会显示醒目的 ring 效果,而鼠标点击聚焦则不会,这提供了更自然的用户体验。
  • 简洁的 Props 类型: 使用 React.ComponentProps<"input"> 直接获取原生 input 标签的所有属性类型,简洁高效。

文件路径: src/components/ui/Label.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
"use client"

import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"

import { cn } from "@/lib/utils"

const Label = ({
  className,
  ...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) => {
  return (
    <LabelPrimitive.Root
      data-slot="label"
      className={cn(
        "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
        className
      )}
      {...props}
    />
  )
}

export { Label }

Label.tsx 核心洞察:

  • 基于 Radix: 它印证了 shadcn 的核心理念——Label 的行为和可访问性完全委托给了 @radix-ui/react-label 这个专业的“无头”原语。
  • peer-disabled: 这是 Tailwind CSS 一个极其巧妙的特性。peer 类通常被添加到兄弟元素上(例如,我们可以在 <input> 上添加 peer 类)。peer-disabled:opacity-50 的意思是:“如果我的‘同伴’(peer)处于 disabled 状态,那么就把我(Label)的透明度设为 50%。” 这使得 Label 的禁用样式能够 仅通过 CSS,根据其关联 Inputdisabled 状态自动更新,无需任何 JavaScript 逻辑。这是现代 CSS 驱动的响应式 UI 的绝佳范例。

结论: shadcn add 命令为我们提供的,是蕴含了大量前端最佳实践、高度凝练的组件原语。我们的任务,是在这个坚实的起点上进行扩展。

5.2. 需求分析:从 Ant Design 汲取封装灵感

一个裸的 Input 原语在真实业务中是远远不够的。用户需要引导、需要反馈、需要便捷的操作。Ant Design 的 Input API 为我们展示了一个功能完备的输入框应该具备哪些能力。

我们将从中汲取灵感,挑选出几个最常见、最有价值的功能点,作为我们 Prorise UI Input 的封装目标:

功能点 (启发自 Ant Design)业务场景
prefix / suffix在输入框内前置或后置图标,如搜索图标、金额符号 ¥ 等。
allowClear提供一个“一键清除”按钮,在输入框有内容时自动显示。

核心思想: 我们 追求 1:1 复刻 antd 的所有功能,而是学习它的设计思路,然后 按需实现 我们自己版本。这正是“封装适合自己的组件”的精髓所在。

5.3. 渐进式封装:构建 Prorise UI 的增强型 Input

现在,我们开始对 shadcn 为我们生成的 Input.tsx 进行“魔改”,逐步为其添加新功能。

5.3.1. 最佳实践升级:添加 forwardRef

shadcn 生成的 Input 是一个简单函数组件,不支持 ref 转发。在一个专业的设计系统中,允许父组件获取对底层 input DOM 的引用(例如,用于手动聚焦)是一项必备功能。因此,我们的第一步是为其升级,添加 React.forwardRef

文件路径: src/components/ui/Input.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';

import { cn } from '@/lib/utils';
// 1. 扩展原始的 Props 接口
export interface InputProps extends React.InputHTMLAttributes<HTMLElement> {}


// 2. 使用 forwardRef 创建组件,forwardRef接收两个参数,第一个是 ref 的类型,第二个是 props 的类型
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
data-slot="input"
ref={ref}
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
{...props}
/>
);
}
);

export { Input };

解析: 通过 React.forwardRef 的包裹,我们的 Input 组件现在可以接受一个 ref prop,并将其正确地传递给底层的 <input> 元素,使其具备了完整的可控性。

5.3.2. 功能增强一:实现 prefixsuffix 图标

为了容纳 prefixsuffix,我们需要一个 <div> 作为外层容器,并使用 Flex 布局。

文件路径: src/components/ui/Input.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
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Icon } from '@/components/ui/Icon';

// 1. 扩展 Props 接口
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
prefix?: string;
suffix?: string;
}

const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, prefix, suffix, ...props }, ref) => {
// 2. 总是渲染带容器的结构,以统一 DOM 结构和样式行为
return (
<div
className={cn(
// 将原 input 的大部分外观样式移到外层 div
'flex h-9 items-center rounded-md border border-input bg-transparent px-3 text-sm shadow-xs ring-offset-background transition-[color,box-shadow]',
// 处理焦点状态
'focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50',
// 处理校验失败状态
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
// 处理禁用状态
props.disabled && 'cursor-not-allowed opacity-50'
)}
>
{prefix && <Icon name={prefix} className="mr-2 h-4 w-4 text-muted-foreground" />}
<input
type={type}
// 3. 重置内部 input 的样式让它"隐形"在容器中
className={cn(
'h-full w-full flex-1 bg-transparent p-0 text-base placeholder:text-muted-foreground outline-none disabled:cursor-not-allowed md:text-sm',
className // 外部 className 应用在这里
)}
ref={ref}
{...props}
/>
{suffix && <Icon name={suffix} className="ml-2 h-4 w-4 text-muted-foreground" />}
</div>
);
}
);
Input.displayName = 'Input';

export { Input };

解析: 我们不再使用条件渲染,而是 总是渲染div 容器的结构。这是一种更健壮的设计,可以避免因 props 变化导致 DOM 结构突变而引发的意外问题(例如输入框失去焦点)。我们将原 Input 的边框、背景、ring 等样式,都转移到了外层 div 上,并使用了 focus-within 伪类来响应内部 input 的聚焦。

5.3.3. 功能增强二:实现 allowClear 清除按钮

文件路径: src/components/ui/Input.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
// ... imports ...

// 1. 扩展 Props 接口
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
prefix?: string;
suffix?: string;
allowClear?: boolean;
onClear?: () => void;
}

const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, prefix, suffix, allowClear, onClear, value, ...props }, ref) => {

// 2. 判断清除按钮是否显示
const showClear = allowClear && value && String(value).length > 0;

// 3. 决定最终的 suffix 内容:优先显示清除按钮
const finalSuffix = showClear ? (
<button
type="button"
onClick={onClear}
className="ml-2 focus:outline-none"
aria-label="Clear input"
>
<Icon name="lucide:x-circle" className="h-4 w-4 text-muted-foreground hover:text-foreground" />
</button>
) : suffix ? (
<Icon name={suffix} className="ml-2 h-4 w-4 text-muted-foreground" />
) : null;

return (
<div /* ... 容器 div 结构 ... */ >
{prefix && <Icon name={prefix} className="mr-2 h-4 w-4 text-muted-foreground" />}
<input
type={type}
className={cn( /* ... 内部 input 样式 ... */ )}
ref={ref}
value={value} // 确保 value 被传递
{...props}
/>
{finalSuffix} {/* <-- 使用 finalSuffix */}
</div>
);
}
);
Input.displayName = 'Input';

export { Input };

解析:
我们增加了 allowClearonClear 两个 prop。组件现在会检查 allowClear 是否为 true 以及 value prop 是否有值,来决定是否渲染一个带清除功能的 <button>。点击这个按钮会触发 onClear 回调。

这个功能的实现,引出了一个至关重要的问题,也是 React 表单处理的核心——受控与非受控模式。为了能判断 value 是否有值,并能通过 onClear 清空它,我们的 Input 组件必须以“受控模式”来工作。这正是我们下一节将要深入探讨的主题。


5.4. 模式再思考:受控与非受控及状态管理决策

在上一节中,我们为 Input 组件成功添加了 allowClear 功能。然而,这个功能的实现(showClear 依赖于 value prop,onClear 回调需要能改变 value)将我们引向了一个无法回避、也至关重要的架构岔路口:输入框的状态(即它的当前值),应该由谁来管理?

这个问题的答案,引出了 React 中处理表单状态的两种核心设计模式——受控组件非受控组件。本节,我们将深入这两种模式,并建立一个清晰的决策模型。

5.4.1. 深度剖析:两种表单状态管理模式

1. 受控组件

核心理念: 在此模式中,React 组件的状态 (state) 成为表单元素值的“单一事实来源”。输入框的当前值完全由 React 的 state 来驱动和控制。

工作机制:

  1. 父组件在 state 中(通常使用 useState)维护输入框的值。
  2. 父组件将这个 state 值通过 value prop 传递给 <Input /> 组件。
  3. 父组件将一个能够更新 state 的函数(如 setValue)通过 onChange prop 传递给 <Input /> 组件。
  4. 当用户在输入框中键入时,<Input /> 组件触发 onChange 事件,调用父组件传递过来的函数来更新 state,从而引发父组件重渲染,并将新的 value 再次传递给 <Input />,完成数据流的闭环。

代码示例:
为了清晰演示,我们可以在 src/app 目录下创建一个新的页面文件。

文件路径: src/app/form-patterns/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
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
'use client';

import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

function ControlledExample() {
const [value, setValue] = useState('');

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
};

return (
<div className="w-full max-w-sm space-y-2">
<h3 className="font-semibold">受控组件示例</h3>
<Label htmlFor="controlled-input">输入:</Label>
<Input
id="controlled-input"
placeholder="值由 React state 控制..."
value={value}
allowClear
onChange={handleChange}
/>
<p className="text-muted-foreground text-sm">
来自 React state 的当前值:{' '}
<span className="text-primary font-mono">{value}</span>
</p>
</div>
);
}

export default function FormPatternsPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-12 p-24">
<ControlledExample />
</main>
);
}

解析:
在这个模式下,Input 组件本身变成了一个“哑”组件。它不自己存储值,只负责忠实地显示父组件通过 value prop 传递过来的值,并在用户输入时,通过 onChange 通知父组件。数据的流动是单向的(父 -> 子),事件的流动也是单向的(子 -> 父)。我们 allowClear 功能之所以能工作,正是因为它依赖于这种父组件对 value 的完全控制。

  • 优点: 单一数据源清晰,状态始终与 React 组件树同步,便于实现实时校验、字符计数、条件禁用按钮等复杂交互。
  • 缺点: 每次输入(onChange)都会触发一次父组件的重渲染。在绝大多数情况下这不是问题,但在极其复杂的、包含大量输入框的表单中,可能会有性能考量。

2. 非受控组件

核心理念: 在此模式中,DOM 节点本身成为表单元素值的“单一事实来源”。React 负责初始渲染,但之后输入框的值由其自己的 DOM 状态管理。

工作机制:

  1. 我们 <Input /> 传递 value prop。
  2. 我们可以通过 defaultValue prop 设置其初始值。
  3. 当我们需要读取输入框的值时(例如,在表单提交时),我们通过 ref 来直接访问底层的 DOM 节点并获取其 value

代码示例:
让我们在 FormPatternsPage 中继续添加非受控组件的例子。

文件路径: src/app/form-patterns/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
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { useRef } from 'react'; // 引入 useRef
import { Button } from '@/components/ui/Button';

function UncontrolledExample() {
// 1. 创建一个 ref 来引用 input DOM 元素
const inputRef = useRef<HTMLInputElement>(null);

const handleSubmit = () => {
// 3. 在需要时,通过 ref 直接从 DOM 读取值
alert(`从 DOM 中读取的值: ${inputRef.current?.value}`);
};

return (
<div className="w-full max-w-sm space-y-2">
<h3 className="font-semibold">非受控组件示例</h3>
<Label htmlFor="uncontrolled-input">输入:</Label>
<Input
id="uncontrolled-input"
// 2. ref 传递给 Input 组件
ref={inputRef}
placeholder="值由 DOM 自身管理..."
defaultValue="这是一个初始值"
/>
<Button onClick={handleSubmit} className="mt-2">
提交并读取值
</Button>
</div>
);
}

export default function FormPatternsPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-12 p-24">
<ControlledExample />
<UncontrolledExample /> {/* <-- 添加新示例 */}
</main>
);
}

解析:
Input 组件的值现在完全由浏览器 DOM 管理。我们只在需要时(点击提交按钮)才通过 ref 去“查询”它的当前值。

  • 优点: 实现简单直接,对于“一次性”读取值的简单表单非常高效。由于输入时不会触发 React 的重渲染,理论上性能更好。
  • 缺点: 数据状态与 React 组件树脱节,难以实现实时交互。像我们之前实现的 allowClear 功能,在这种模式下就变得非常棘手,因为它需要组件能“感知”到自己是否有值。

5.4.2. 最佳实践:建立状态管理决策模型

那么,在实际开发中我们应该如何选择?您可以根据以下决策模型来判断:

问题一:“在用户提交之前,我是否需要知道输入框的实时值?”

  • 如果答案是“是”:

    • 你需要实现 实时输入校验(例如,用户名是否可用)。
    • 你需要实现 实时字符计数
    • 你需要根据一个输入框的值,动态地改变 另一个 UI 元素的状态(例如,输入框有内容时才点亮提交按钮)。
    • => 结论:必须使用受控组件。 这是现代 Web 应用中最常见的情况。
  • 如果答案是“否”:

    • 你只需要一个简单的登录表单、评论框或搜索框。
    • 你只在用户点击“提交”按钮的那一刻才关心输入框的值。
    • => 结论:非受控组件是一个更简单、性能可能更好的选择。

问题二:“这个表单的状态是否非常复杂,或者需要在多个远距离组件间共享?”

  • 如果答案是“是”:
    • 你正在构建一个多步骤的向导式表单。
    • 表单的状态需要影响到页面顶部的导航栏或侧边栏。
    • 你有大量的、相互依赖的动态表单域。
    • => 结论:是时候停止手动管理状态了,应该将状态管理委托给专业的库。

专业方案:

  • 全局状态管理器 (如 Zustand, Redux): 适用于表单状态需要与应用的其他部分进行深度交互的场景。
  • 专业表单库 (如 react-hook-form, formik): 这是 绝大多数复杂表单的最佳实践。这类库在内部通常采用非受控模式以实现极致性能,同时通过 Hooks 提供了完整的、类型安全的状态管理、校验(通常结合 Zod)和提交处理能力,极大地减少了模板代码。

我们将在第九章中,专门深入 react-hook-formZod 的结合使用,来学习构建复杂表单的终极解决方案。

最终结论:
默认选择受控组件,因为它提供了最强的灵活性和最清晰的数据流,能够满足绝大部分现代 UI 的交互需求。只有在确认是极简的“一次性”表单,且性能成为瓶颈时,才考虑使用非受控组件。对于任何中等及以上复杂度的表单,都应该优先考虑使用专业的表单库。


5.5. 流程闭环:为增强型 Input 编写 Story 与文档

至此,我们已经完成了 Input 组件的功能增强,并深入理解了其背后的“受控”与“非受控”设计模式。现在,我们必须完成本章“黄金工作流”的最后一步:为我们这个全新的、更强大的 Input 组件,创建一套能够完整展示其所有功能的专业 Storybook 文档。

第一步:创建 Story 文件

如果 src/components/ui 目录下已存在 Input.stories.tsx (可能由 add 命令的早期版本创建),请先清空其内容。如果不存在,则创建它。

1
touch src/components/ui/Input.stories.tsx

第二步:更新 Meta 对象以反映新 Props

我们的第一项任务是在 Story 文件中定义 meta 对象。我们将特别为新增的 prefix, suffix, allowClear 等 props 添加详尽的文档描述和交互控件。

文件路径: src/components/ui/Input.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
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Input } from './input';

const meta: Meta<typeof Input> = {
title: 'UI/Input',
component: Input,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
// 为我们的 props 配置交互式控件和文档
argTypes: {
prefix: {
control: 'text',
description: '输入框前缀图标的名称 (例如: "lucide:search")',
},
suffix: {
control: 'text',
description: '输入框后缀图标的名称 (例如: "lucide:calendar")',
},
allowClear: {
control: 'boolean',
description: '是否启用一键清除功能 (需要以受控模式使用)',
},
disabled: {
control: 'boolean',
description: '是否禁用输入框',
},
// 将 onClear 标记为一个 action,以便在 Storybook 的 Actions 面板中追踪其调用
onClear: { action: 'onClear' },
},
};

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

// 后续的故事定义将写在这里...

解析: 我们通过 argTypes 详细配置了每个重要 prop 在 Controls 面板中的行为和描述。特别地,为 onClear 设置 action: 'cleared',可以让 Storybook 自动“监听”这个回调函数,每当它被触发时,都会在 “Actions” 面板中打印一条日志,这对于调试交互非常有用。

第三步:编写展示新功能的 Stories

现在,我们来创建一系列的故事,分别展示 Input 组件的核心功能和不同状态。

文件路径: src/components/ui/Input.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
import * as React from 'react'; // 引入 React 用于受控组件的 Story
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Input } from './Input';

// ... 此前定义的 meta 对象 ...
const meta: Meta<typeof Input> = { /* ... */ };
export default meta;
type Story = StoryObj<typeof meta>;


// 1. 基础的、可交互的 Story
export const Default: Story = {
args: {
placeholder: '请输入内容...',
},
};

// 2. 带前缀图标的 Story
export const WithPrefix: Story = {
args: {
placeholder: '搜索...',
prefix: 'lucide:search',
},
};

// 3. 带后缀图标的 Story
export const WithSuffix: Story = {
args: {
placeholder: '选择日期',
suffix: 'lucide:calendar',
type: 'date',
},
};

// 4. (关键) 演示 allowClear 功能的受控 Story
export const WithClearButton: Story = {
// 我们必须使用 render 函数来模拟一个受控组件的父级环境
render: function Render(args) {
// 使用 React Hook 来管理 value 状态
const [value, setValue] = React.useState('这是一段可以被清除的文本');

return (
<Input
{...args}
value={value}
onChange={(e) => setValue(e.target.value)}
onClear={() => setValue('')} // 将 onClear 回调连接到状态更新
/>
);
},
name: '可清除 (受控模式)', // 为 Story 设置一个更清晰的显示名称
args: {
allowClear: true,
className: 'w-80', // 给一个更宽的尺寸以便展示
},
};

// 5. 禁用状态的 Story
export const Disabled: Story = {
args: {
placeholder: '此输入框已被禁用',
prefix: 'lucide:lock',
disabled: true,
}
}

代码深度解析:

  • 我们为每个核心功能(prefix, suffix, disabled)都创建了清晰、独立的 Story。
  • WithClearButton Story 是本节的重点。由于 allowClear 功能依赖于“受控模式”,我们不能简单地在 args 中提供一个静态的 value。因此,我们使用了 render 函数,在 Story 内部创建了一个小型的 React 环境,使用 useState 来真正地“控制”Input 组件。我们将 valueonChange 传递给 Input,并将 onClear 回调与 setValue('') 绑定。这不仅完美地演示了 allowClear 的功能,更是在 Storybook 的上下文中,对上一节学习的“受控组件”理论进行了一次绝佳的实战演练。

第四步:创建 MDX 文档

最后,我们创建 Input.mdx 文件,将所有内容整合到一份专业的文档中。

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

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
import { Meta, Story, Controls } from '@storybook/addon-docs';
import * as InputStories from './Input.stories';

<Meta of={InputStories} />

# Input (输入框)

输入框是我们设计系统中最基础的表单域包装器,用于通过鼠标或键盘输入内容。

## 设计哲学

我们的 `Input` 组件始于 `shadcn` 提供的、遵循最佳实践的高质量原语。在此基础上,我们借鉴了 Ant Design 等成熟组件库的设计经验,为其逐步封装了 `prefix`/`suffix` 图标、`allowClear` 清除按钮等在真实业务场景中常见的功能。

这种“从原语启动,按需增强”的模式,是我们 `Prorise UI` 的核心封装理念。

## 交互式示例

使用下方的 `Default` Story 来探索 `Input` 组件的所有可用 props。

<Story of={InputStories.Default} />

## 功能展示

### 前后缀图标

通过 `prefix` 或 `suffix` 属性,可以轻松地在输入框内部添加引导性图标。属性的值应为一个 `Iconify` 格式的图标名称字符串。

<Story of={InputStories.WithPrefix} />
<br />
<Story of={InputStories.WithSuffix} />

### 可清除内容

当 `allowClear` 属性为 `true` 时,输入框会在有内容时显示一个清除按钮。请注意,此功能要求您以 **受控模式** 来使用 `Input` 组件,即同时提供 `value` 和 `onChange` 属性,并将 `onClear` 回调用于清空您的状态。

<Story of={InputStories.WithClearButton} />

### 禁用状态

<Story of={InputStories.Disabled} />


## API 参考 (Props)

<Controls />

最终验证

运行 pnpm storybook 并导航到 UI/Input。现在,Input 组件拥有了内容丰富、示例清晰、API 文档完整的专业文档页。我们为 Input 组件的开发工作,画上了一个圆满的句号。


5.6 本章小结

在本章中,我们完成了一次从“获取”到“创造”的完整旅程,深刻践行了 shadcn 的核心哲学。

  1. shadcn 核心工作流: 我们学习了使用 npx shadcn@latest add 命令来获取高质量的组件原语,并对生成的代码进行了深度“考古”,理解了其背后蕴含的 aria-invalidpeer-disabled 等高级实践。
  2. 业务驱动的封装: 我们以 Ant Design 为参照,学习了如何分析真实业务场景,从而确定我们对基础原语的增强方向。
  3. 渐进式增强: 我们亲手为 Input 原语添加了 prefix/suffixallowClear 等功能,掌握了从一个简单组件逐步演进为一个复杂组件的封装技巧。
  4. 核心模式辨析: 通过 allowClear 功能的实现,我们深入探讨了 React 中 受控与非受控组件 的本质区别,并建立了一套清晰的状态管理决策模型。
  5. 黄金工作流闭环: 最后,我们为这个全新的、功能更强大的 Input 组件,创建了完整的 Storybook 文档,再一次巩固了“开发即文档”的专业工作流。

通过本章,您掌握的已不仅仅是一个 Input 组件,而是一套完整的、从获取开源方案、分析业务需求、进行二次封装、再到沉淀为文档 的方法论。这是构建任何复杂设计系统的核心能力。

第六章. 逻辑的抽象:为我们的组件库注入可复用的“行为”

本章目标: 本章假定您已熟练掌握 useStateuseEffect 的基础用法。我们将不再赘述入门概念,而是直击痛点,聚焦于 三种在构建高阶设计系统中至关重要的高级 Hooks 模式。我们将学习如何使用 useReducer 管理复杂状态机,如何规避 useEffect 的性能陷阱,并最终通过一个综合性的 useAutoComplete 业务 Hook,将所有模式融会贯通,真正掌握以 Hooks 为中心的现代 React 架构思想。

6.1. 为什么要用自定义 Hooks?

在深入学习高级模式之前,我们必须先统一思想,回答一个根本性问题:为什么我们需要自定义 Hooks 这样一层额外的抽象?答案,就在于我们对“高质量组件”的追求。

6.1.1. 痛点:臃肿的 UI 组件

一个高质量的组件,应该遵循 单一职责原则。然而,随着交互变得复杂,我们的组件常常会变得“臃肿”——即,将 渲染逻辑 (View)行为逻辑 (Behavior) 混杂在了一起。

让我们通过一个思想实验来揭示这个痛点。

场景: 回顾我们在第三章构建的 DropdownMenu。我们当时直接使用了 Radix UI,它已经为我们完美处理了“点击菜单外部区域自动关闭”的交互。现在,让我们设想一下,如果 没有 Radix,需要我们 手动 实现这个功能,代码会变成什么样?

我们来看一个示例代码:

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
'use client';

import { useState, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/Button';

export function HypotheticalDropdown() {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

// ==========================================
// == 行为逻辑 (Behavior) ==
// ==========================================
useEffect(() => {
// 如果菜单没打开,则不执行任何操作
if (!isOpen) return;

function handleClickOutside(event: MouseEvent) {
// 检查点击事件是否发生在 dropdownRef 元素之外
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
console.log('检测到外部点击,正在关闭菜单...');
setIsOpen(false);
}
}

// 在 document 上添加全局事件监听
document.addEventListener('mousedown', handleClickOutside);

// 关键:返回一个清理函数,在 effect 结束时移除事件监听
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]); // 这个 effect 依赖于 isOpen 状态

// ==========================================
// == 渲染逻辑 (View) ==
// ==========================================
return (
<div ref={dropdownRef} className="relative inline-block">
<Button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? 'Close' : 'Open'} Menu
</Button>

{isOpen && (
<ul className="absolute top-full mt-2 p-2 shadow menu dropdown-content z-[1] bg-base-100 rounded-box w-52">
<li><a>Item 1</a></li>
<li><a>Item 2</a></li>
</ul>
)}
</div>
);
}

这段代码能够正常工作。但是,从架构设计的角度看,它存在三个严重的问题:

  1. 职责混淆: HypotheticalDropdown 组件现在同时承担了两个完全不同的职责。它既要负责 如何渲染 一个下拉菜单的 UI(JSX 部分),又要负责 如何管理“点击外部关闭”这个交互行为(useEffectuseRef 部分)。这使得组件的意图变得模糊,复杂度显著增加。

  2. 逻辑难以复用: “点击外部关闭”是一个极其通用的交互模式,在 Popover, Select, Dialog 等无数组件中都会用到。在我们当前的设计中,这段包含 useEffectuseRef 的逻辑被“囚禁”在了 HypotheticalDropdown 组件内部。如果想在 Popover 组件中复用它,唯一的办法就是 复制粘贴。这严重违反了 DRY (Don’t Repeat Yourself) 原则,是滋生 Bug 和增加维护成本的温床。

  3. 可测试性差: 我们如何为“点击外部关闭”这个 纯粹的逻辑 编写一个单元测试?在当前结构下,我们几乎无法做到。我们必须完整地渲染整个 HypotheticalDropdown 组件,模拟 DOM 事件,然后断言 isOpen 状态的变化。我们将无法对这个行为逻辑进行独立的、轻量的、快速的单元测试。

结论:
这个“臃肿的”组件暴露了一个核心的架构问题:当一段行为逻辑具有通用性时,将它与某个特定的 UI 渲染实现绑定在一起,是一种糟糕的设计。

我们需要一种机制,能将这段行为逻辑——“当在指定元素外部发生点击时,执行一个回调函数”——从 UI 组件中 抽离 出来,成为一个独立的、可复用的单元。而这个机制,正是 React Hooks 带来的最大变革之一:自定义 Hooks


6.2. 实战演练:为我们的组件库添砖加瓦

现在,我们将正式进入自定义 Hooks 的实战环节。本节的目标,是亲手编写一系列在现代组件库中不可或缺的、具有代表性的通用 Hooks。我们将从最简单,但也最常用的 useToggle 开始。

6.2.1. 构建 useToggle: 简化开关状态管理

核心理念与应用场景

在 UI 开发中,我们随处可见需要管理的布尔(boolean)开关状态:Modal 的打开/关闭,Accordion 的展开/折叠,SwitchCheckbox 的选中/未选中。

useToggle Hook 的目标,就是将“维护一个布尔值,并提供一个能切换它的函数”这一通用逻辑,封装成一个可复用的单元。

第一步:创建 Hook 文件

我们将在 src/hooks 目录下创建我们的新 Hook 文件。

1
2
# 在项目根目录下执行
touch src/hooks/use-toggle.ts

第二步:定义并实现 Hook

文件路径: src/hooks/use-toggle.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useState, useCallback } from 'react';

// 1. 为 Hook 的返回值定义一个清晰的、符合直觉的元组类型
// [当前状态, 切换状态的函数]
type UseToggleReturn = [boolean, () => void];

/**
* 一个用于管理布尔开关状态的 Hook。
* @param defaultValue - 状态的初始值,默认为 false
* @returns 返回一个元组,包含当前的状态值和用于切换状态的函数。
*/
export function useToggle(defaultValue: boolean = false): UseToggleReturn {
// 2. 内部使用 useState 来持有状态
const [value, setValue] = useState(defaultValue);

// 3. (关键) 使用 useCallback 来记忆化 toggle 函数
const toggle = useCallback(() => {
// 使用函数式更新,确保状态更新的可靠性
setValue(prevValue => !prevValue);
}, []); // 空依赖数组,确保 toggle 函数在组件的整个生命周期内引用地址保持不变

// 4. 返回状态值和稳定的切换函数
return [value, toggle];
}

代码深度解析:

  1. 返回元组 [boolean, () => void]: 我们有意地将返回值设计成一个元组,这完全模仿了 React 原生 useState Hook 的 API。这种设计符合开发者的直觉,可以通过数组解构方便地使用:const [isOn, toggleIsOn] = useToggle();
  2. useCallback 的应用: 这是本节的一个 核心知识点。我们没有直接返回一个 () => setValue(v => !v) 的匿名函数。而是使用 useCallback 将其包裹。
    • 原因: 如果不使用 useCallback,那么每次消费 useToggle 的组件重渲染时,useToggle 都会被重新调用,从而创建一个 全新的 toggle 函数实例
    • 痛点: 如果这个 toggle 函数被作为 prop 传递给一个被 React.memo 包裹的子组件,那么即使子组件的其他 props 都没有变化,这个不稳定的 toggle 函数引用也会导致 React.memo 的优化 完全失效,引发不必要的重渲染。
    • 结论: 遵循我们在之前章节讨论的“性能契约”,一个设计良好的自定义 Hook,其返回的函数必须是引用稳定的。useCallback 在这里是必不可少的。

第三步:演示用法与未来展望

为了直观地看到 useToggle 的效果,我们可以快速创建一个测试页面。

文件路径: src/app/hooks-test/page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
'use client';

import { useToggle } from '@/hooks/use-toggle';
import { Button } from '@/components/ui/Button';

export default function HooksTestPage() {
// 使用 useToggle,API 和 useState 非常相似
const [isOn, toggle] = useToggle(false);

return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 bg-base-100 p-24">
<p className="text-4xl font-bold">
The light is: {isOn ? 'ON' : 'OFF'}
</p>
<div className={`h-24 w-24 rounded-full transition-colors ${isOn ? 'bg-yellow-300' : 'bg-gray-700'}`} />
<Button onClick={toggle} variant="primary">
Toggle Light
</Button>
</main>
);
}

解析:
HooksTestPage 组件中,我们看不到任何 useState(prev => !prev) 的实现细节。所有逻辑都被优雅地封装在了 useToggle 内部。组件的职责变得极其纯粹:消费状态 isOn,并通过 toggle 函数触发状态变更。

未来展望:
我们构建的这个看似简单的 useToggle Hook,现在成为了我们 Prorise UI 工具库中的第一块逻辑积木。在未来,当我们构建 ModalSwitchAccordion 等任何包含“开关”语义的组件时,它们的内部状态管理都将由 useToggle 来驱动,从而保证了逻辑的一致性和代码的简洁性。


6.2.2. 构建 useDebounce: 赋能 AutoComplete 组件

现在,我们来构建一个在处理用户输入、优化性能方面至关重要的自定义 Hook——useDebounce (防抖)。

核心理念与应用场景

痛点: 设想一个 AutoComplete 或搜索框组件。如果我们在用户每次按键 (onChange) 时都立即触发一次 API 请求,当用户快速输入 “react” 这 5 个字母时,就会在极短时间内连续触发 5 次 API 调用(r, re, rea, reac, react)。这会造成:

  • 服务端压力: 产生大量不必要的服务器负载。
  • 网络资源浪费: 前 4 次请求几乎都是无效的。
  • 竞态条件 (Race Condition): search('re') 的响应可能比 search('react') 的响应更晚到达,导致界面最终显示了过时的、错误的结果。

解决方案: “防抖 (Debounce)”是一种经典的性能优化策略。其核心思想是:延迟一个函数的执行,直到某个事件(例如按键)停止触发一段时间后。如果在延迟期间事件再次被触发,则重新开始计时。

useDebounce Hook 的目标,就是将这个复杂的计时器逻辑封装起来。

第一步:创建 Hook 文件

1
2
# 在项目根目录下执行
touch src/hooks/use-debounce.ts

第二步:定义并实现 Hook

这个 Hook 将接收两个参数:需要被防抖的 value,以及一个可选的 delay (延迟时间)。它将返回经过防抖处理后的、稳定的 value

文件路径: src/hooks/use-debounce.ts

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
import { useState, useEffect } from 'react';

/**
* 一个用于获取值的防抖版本的 Hook。
* @param value - 需要进行防抖处理的值 (例如,搜索框的输入字符串)。
* @param delay - 延迟时间,单位为毫秒,默认为 500ms。
* @returns 返回经过延迟后、稳定的值。
*/
export function useDebounce<T>(value: T, delay: number = 500): T {
// 1. 创建一个内部 state,用于存储最终的防抖值。
const [debouncedValue, setDebouncedValue] = useState<T>(value);

useEffect(
() => {
// 2. 每次 `value` 或 `delay` 变化时,创建一个新的定时器。
const timer = setTimeout(() => {
// 在 `delay` 时间结束后,用最新的 `value` 更新我们的 state。
setDebouncedValue(value);
}, delay);

// 3. (关键) 返回一个清理函数。
// 这个函数会在下一次 effect 重新运行时(即 value 或 delay 再次变化时),
// 或者在组件卸载时被调用。
return () => {
clearTimeout(timer); // 清除上一个未完成的定时器。
};
},
[value, delay] // 4. 依赖项:只有当 value 或 delay 变化时,才重新执行 effect。
);

// 5. 始终返回当前存储的(可能是旧的)防抖值。
return debouncedValue;
}

代码深度解析 (工作流):

  1. 用户在输入框中按下第一个键,value 变为 'r'useDebounce Hook 重新执行,useEffect 启动一个 500ms 的定时器,准备在 500ms 后将 debouncedValue 更新为 'r'
  2. 在 100ms 后,用户按下第二个键,value 变为 're'。Hook 再次执行。
  3. 在新的 useEffect 运行 之前,上一个 useEffect清理函数 return () => { clearTimeout(timer); } 被调用。这会取消掉准备更新值为 'r' 的那个定时器。
  4. 新的 useEffect 启动了一个全新的 500ms 定时器,准备在 500ms 后将 debouncedValue 更新为 're'
  5. 这个“清除旧定时器 -> 创建新定时器”的过程,会在用户每次快速按键时重复。
  6. 当用户最终停止输入后,最后一个定时器(例如,value'react' 的那个)将不会被清除,它会在 500ms 后成功触发 setDebouncedValue('react')
  7. 此时,debouncedValue 状态更新,Hook 返回最新的 'react',消费这个 Hook 的组件随之重渲染。

第三步:演示用法

让我们在 hooks-test 页面中,通过一个搜索框的例子来直观地感受 useDebounce 的威力。

文件路径: src/app/hooks-test/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
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
'use client';

import * as React from 'react';
import { useToggle } from '@/hooks/use-toggle';
import { useDebounce } from '@/hooks/use-debounce'; // 1. 导入 useDebounce
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';

function DebounceExample() {
const [inputValue, setInputValue] = React.useState('');
// 2. 使用 useDebounce Hook,监听 inputValue 的变化,延迟 500ms
const debouncedSearchTerm = useDebounce(inputValue, 500);

// 3. 模拟一个 API 请求的 effect
React.useEffect(() => {
// 只有在 debouncedSearchTerm 有值且发生变化时,才“发送请求”
if (debouncedSearchTerm) {
console.log(`🚀 (API Request) Searching for: "${debouncedSearchTerm}"`);
// 在真实应用中,这里会是 fetch(...) 或其他数据请求逻辑
}
}, [debouncedSearchTerm]); // 关键:effect 依赖的是防抖后的值,而不是 inputValue

return (
<div className="w-full max-w-sm space-y-2">
<h3 className="font-semibold">useDebounce 示例</h3>
<Label htmlFor="search-input">搜索</Label>
<Input
id="search-input"
placeholder="快速输入,然后停顿..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<p className="text-sm">
实时输入值: <span className="font-mono text-primary">{inputValue}</span>
</p>
<p className="text-sm">
防抖后的值 (延迟 500ms): <span className="font-mono text-success">{debouncedSearchTerm}</span>
</p>
</div>
);
}

// 修改主页面组件以包含新示例
export default function HooksTestPage() {
const [isOn, toggle] = useToggle(false);

return (
<main className="flex min-h-screen flex-col items-center justify-center gap-12 bg-base-100 p-24">
{/* ... useToggle 示例 ... */}
<div className="w-full max-w-sm">
<div className="divider" />
</div>
<DebounceExample />
</main>
);
}

运行与验证:
运行 pnpm run dev 并访问 /hooks-test 页面,然后打开浏览器控制台。

  • 当您在搜索框中 快速连续输入 “react” 时,您会看到“实时输入值”在实时变化。
  • 但是,控制台中不会有任何输出
  • 当您 停止输入 500ms 后,您会看到“防抖后的值”更新为 “react”,同时控制台 只打印出一次 API 请求日志:
    🚀 (API Request) Searching for: "react"

这完美地证明了我们的 useDebounce Hook 正在正常工作。它有效地过滤掉了中间过程的无效输入,只在我们真正需要的时候才提供最终的、稳定的值。这个 Hook 将是我们在下一章构建 AutoComplete 组件时不可或缺的核心武器。


6.2.1. (高级) 构建 useControllableState: 统一受控与非受控模式

核心理念与痛点

一个设计精良的、可复用的 UI 组件(如 Switch, Select, Input)通常需要同时支持两种状态管理模式:

  1. 非受控 (Uncontrolled): 组件拥有自己的内部状态,自我管理。使用者只需设置一个 defaultValue,之后便“放任不管”。这种模式简单快捷。
  2. 受控 (Controlled): 组件不维护自身状态。它的值完全由父组件通过 value prop 传入,并通过 onChange 回调将变更通知父组件。这种模式让父组件能够精确控制子组件的行为,适用于复杂的表单和状态联动场景。

痛点: 要在 同一个组件 中优雅地实现这两种模式,是一件非常棘手的事情。一个“天真”的实现,通常会在组件内部充斥着大量混乱的 if/else 判断和 useEffect 来同步状态,代码难以维护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 一个设计不良的、试图同时支持两种模式的 Switch 组件 (错误示范)
function BadSwitch({ value: propValue, defaultValue, onChange }) {
const [internalValue, setInternalValue] = useState(defaultValue);
const isControlled = propValue !== undefined;
const value = isControlled ? propValue : internalValue;
// ... 此处还需要更多复杂的 useEffect 来同步状态 ...

const handleClick = () => {
if (isControlled) {
onChange(!propValue);
} else {
setInternalValue(!internalValue);
}
};
// ...
}

解决方案: 我们将这个复杂的模式判断逻辑,完全抽离并封装到一个独立的、可复用的自定义 Hook——useControllableState 中。

第一步:创建 Hook 文件

我们将所有通用的自定义 Hooks 都存放在 src/hooks 目录中。

1
2
# 在项目根目录下执行
touch src/hooks/use-controllable-state.ts

第二步:定义 Hook 的“契约” (函数签名) 与实现

useControllableState 的 API 设计目标是,无论内部逻辑多复杂,它向外暴露的接口都应该和 useState 一样简洁直观:返回一个 [state, setState] 的元组。

文件路径: src/hooks/use-controllable-state.ts

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
import { useState, useCallback, useRef, useEffect } from 'react';
// 1. 定义 Hook 接收的参数类型

export interface UseControllableStateProps<T> {
value?: T;
defaultValue?: T;
onChange?: (value: T) => void;
}

/**
* 一个用于管理受控与非受控状态的 Hook。
* @returns 返回一个类似 useState 的元组 [state, setState]。
*/
export function useControllableState<T>({
value: propValue,
defaultValue,
onChange = () => {},
}: UseControllableStateProps<T>) {
// 2. 判断组件是否受控,并用 useRef 确保该判断在组件生命周期内不变
const { current: isControlled } = useRef(propValue !== undefined);

// 3. 仅在非受控模式下,才使用 useState 来创建和管理内部状态
const [internalState, setInternalState] = useState(defaultValue);

// 4. 决定最终向外暴露的状态值
const state = isControlled ? propValue : internalState;

// 5. 使用 useCallback 封装统一的状态更新函数,以保证引用稳定
const setState = useCallback(
(nextState: T) => {
if (isControlled) {
// 如果是受控模式,调用父级传入的 onChange
onChange(nextState);
} else {
// 如果是非受控模式,更新自己的内部 state
setInternalState(nextState);
}
},
[isControlled, onChange]
);

// 将状态断言为常量,不允许修改
return [state, setState] as const;
}

代码深度解析:

  1. isControlled 的判断: propValue !== undefined 是判断组件是否受控的依据。我们用 useRef 将这个布尔值缓存起来,以防止组件在受控和非受控模式之间意外切换,这是一种更健壮的设计。
  2. state 的确定: const state = isControlled ? propValue : internalState; 这一行是“读”操作的核心。Hook 返回的 state,其数据源是动态的:受控时来自 props,非受控时来自内部 state
  3. setState 的统一: 这是“写”操作的核心。我们创建了一个统一的 setState 函数。在内部,它会检查 isControlled 标志,然后智能地决定是将状态变更的“指令”向上传递给父组件(调用 onChange),还是在组件内部消化(调用 setInternalState)。
  4. useCallback: 我们将 setState 函数用 useCallback 包裹,并传入 [isControlled, onChange] 作为依赖。这履行了自定义 Hook 的“性能契约”,确保了返回的 setState 函数具有稳定的引用,不会在不必要时破坏消费它的子组件的 React.memo 优化。

第三步:正确的实战应用 —— 封装 Radix UI Switch

现在我们已经理解了如何从零构建一个 useControllableState Hook,是时候揭示一个更重要的工程实践了:当一个专业的、久经考验的 Headless UI 库(如 Radix)已经为某个组件提供了完美的受控/非受控实现时,我们应该直接封装它,而不是使用我们自己手写的 Hook。

我们构建 useControllableState 的目的,是为了 理解其内部的架构思想,从而能够读懂并更好地利用像 Radix 这样的库。现在,我们就来实践这一点。

1. 安装 Radix UI Switch 依赖

首先,为我们的项目添加 @radix-ui/react-switch 依赖。

1
pnpm install @radix-ui/react-switch

2. 创建并实现 Switch 组件

我们创建 Switch.tsx 文件,并在其中封装 Radix 的原语,为其注入 daisyUI 和 Tailwind 的样式。

文件路径: src/components/ui/Switch.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
'use client';

import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch'; // 1. 导入 Radix Switch 原语

import { cn } from '@/lib/utils';

// 2. 封装 Radix Switch.Root 和 Switch.Thumb
const Switch = React.forwardRef<
React.ComponentRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
// 基础样式设置宽度高度圆角边框等
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent',
// 过渡动画
'transition-colors',
// 焦点样式
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-base-100',
// 禁用样式
'disabled:cursor-not-allowed disabled:opacity-50',
// 使用 data-state 伪类来控制选中时的颜色
'data-[state=checked]:bg-primary',
'data-[state=unchecked]:bg-base-300',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
// Thumb 的样式主要控制白色滑块的外观和位置
'pointer-events-none block h-5 w-5 rounded-full bg-base-100 shadow-lg ring-0',
// 过渡动画
'transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;

export { Switch };

代码深度解析:

  • 封装 Radix: 我们的 Switch 组件现在是一个对 SwitchPrimitives.Root 的样式化封装。它内部包含了 SwitchPrimitives.Thumb(那个可以滑动的“拇指”)。
  • data-state 驱动样式: Radix 组件的一个核心特性是,它会根据内部状态,在 DOM 元素上添加 data-state 属性。例如,当 Switch 被选中时,Root 元素会得到 data-state="checked"。我们正是利用这一点,通过 Tailwind 的 data-* 变体(data-[state=checked]:...)来精确地控制选中和未选中时的样式。
  • daisyUI 的协同: 我们巧妙地将 daisyUItoggle 类作为基础样式应用在 Root 上,以获取其基础尺寸和形状,然后再通过 data-state 变体来覆盖颜色,实现了两者的完美融合。

现在,请观察 Radix SwitchProps 类型。它原生就支持 checked, defaultChecked, onCheckedChange 这三个属性。

这揭示了一个核心要点
Radix UI Switch内部,已经为我们实现了一套与我们手写的 useControllableState逻辑上完全等价 的功能!

我们之所以要花时间去构建 useControllableState,其 真正的教学目的 不是为了让我们在每个组件中都去使用它,而是为了“揭秘”:通过亲手实现这个高级模式,我们现在能够深刻地理解像 Radix 这样的专业库是如何设计它们的 API 的,以及它们在幕后为我们处理了多么复杂的逻辑。

结论:

  • 当有专业的 Radix 原语可用时(如 Switch, Checkbox, Select),永远优先封装 Radix 原语,因为它不仅包含了受控/非受控逻辑,还提供了完整的可访问性和键盘交互。
  • 当我们构建一个 没有现成 Radix 原语可用 的、独特的、需要支持受控/非受控模式的自定义组件时(例如一个自定义的评分组件 StarRating),我们自己构建的 useControllableState Hook 就将派上大用场。

通过这一节,我们不仅学会了如何正确地构建一个 Switch 组件,更重要的是,我们建立了一种架构决策能力:知道何时应该“集成”,何时需要“创造”


6.3. 副作用管理进阶:useEvent 模式与性能陷阱

在本章中,我们致力于将“行为”从“视图”中分离。然而,一个设计优秀的自定义 Hook,不仅要封装逻辑,更要 保证自身的性能,确保它不会成为消费它的组件的性能瓶颈。

本节,我们将深入探讨 React 中一个经典且棘手的性能问题,并学习一种前沿的 Hooks 模式来完美地解决它。

6.3.1. 痛点重现:当 React.memo 遇上事件回调

让我们通过一个最简单的计数器案例,来重温这个无处不在的性能陷阱。

场景: 我们有一个 Counter 父组件,它管理着 count 状态。它渲染一个 Display 组件来显示计数值,以及一个 ResetButton 组件来重置计数。为了优化性能,ResetButton 是一个被 React.memo 包裹的“昂贵”组件。

第一步:创建问题场景

文件路径: src/components/dev/PerformancePitfall.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
'use client';
import { useState, memo } from 'react';
import { Button } from '@/components/ui/Button';

// 1. 这是一个被 memo 包裹的、昂贵的子组件
const ResetButton = memo(({ onReset }: { onReset: () => void }) => {
console.log('... 昂贵的重置按钮(ResetButton)组件正在被渲染 ...');
return <Button onClick={onReset} variant="outline">Reset</Button>;
});
ResetButton.displayName = 'ResetButton';


export function Counter() {
const [count, setCount] = useState(0);
console.log('父组件(Counter)正在被渲染');

// 2. 一个在父组件中的事件处理函数
const handleReset = () => {
setCount(0);
};

return (
<div className="w-full max-w-sm space-y-4 rounded-lg bg-base-200 p-6">
<p className="text-center text-4xl font-bold">{count}</p>
<div className="flex justify-center gap-4">
<Button onClick={() => setCount(c => c + 1)}>Increment</Button>
{/* 3. 将函数作为 prop 传递给 memoized 子组件 */}
<ResetButton onReset={handleReset} />
</div>
</div>
);
}

第二步:暴露问题

在您的 hooks-test 页面中使用这个 Counter 组件,并打开控制台。

  • 首次渲染时,CounterResetButton 都会被渲染,正常。
  • 现在,点击 “Increment” 按钮count 状态更新,Counter 父组件重渲染,这符合预期。
  • 问题来了: 您会发现,控制台 每一次 都会打印出:
    ... 昂贵的重置按钮(ResetButton)组件正在被渲染 ...

ResetButtonReact.memo 包裹,并且它不依赖 count,它的 onReset prop 的“功能”也从未改变。那为什么 React.memo 失效了?

原因: React.memo 进行的是 浅比较。在 Counter 组件每次因 count 变化而重渲染时,const handleReset = () => { ... } 这一行代码都会创建一个 全新的函数对象。虽然新旧函数的功能完全 40666 一样,但它们在内存中的 引用地址 是不同的。因此,React.memo 认为 onReset 这个 prop 发生了变化,从而导致了不必要的重渲染。

6.3.2. 标准解决方案 useCallback (及其局限性)

解决上述问题的“标准答案”,是使用 useCallback 来稳定函数的引用。

文件路径: src/components/dev/PerformancePitfall.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ... imports ...
import { useState, memo, useCallback } from 'react'; // 引入 useCallback

// ... ResetButton 组件不变 ...

export function Counter() {
const [count, setCount] = useState(0);
console.log('父组件(Counter)正在被渲染');

// 使用 useCallback 包裹 handleReset
// 空依赖数组 [] 意味着这个函数只在组件首次渲染时创建一次
const handleReset = useCallback(() => {
setCount(0);
}, []);

// ... return ...
}

再次测试,问题解决了!点击 “Increment” 时,ResetButton 不再重渲染。

但是,新的痛点随之而来。

新需求: 我们希望 handleReset 在重置前,能 console.log 出当前的 count 值。

1
2
3
4
5
const handleReset = useCallback(() => {
// 🔴 陷阱:这里的 count 是第一次渲染时的 count,即 0
console.log(`准备从 ${count} 重置...`);
setCount(0);
}, []); // ESLint 会警告你需要将 count 添加到依赖数组

问题: 由于依赖数组是空的,useCallback 内部的函数形成了一个“陈旧的闭包”,它捕获的是组件首次渲染时的 count 值(永远是 0)。

“修复”这个问题: 我们遵循 ESLint 的建议,将 count 加入依赖数组。

1
2
3
4
const handleReset = useCallback(() => {
console.log(`准备从 ${count} 重置...`);
setCount(0);
}, [count]); // ✅ 逻辑正确了,总能读到最新的 count

然而,我们回到了原点! 现在,count 每一次变化,useCallback 都会因为依赖变化而返回一个 新的 handleReset 函数实例React.memo 再一次失效了。

我们陷入了一个两难的困境:要么接受陈旧的闭包,要么接受不稳定的引用。

6.3.3. 高级解决方案:构建并使用 useEvent Hook

要打破这个困境,我们需要一个“鱼与熊掌兼得”的工具:一个 引用永久稳定,但内部逻辑 总能访问到最新 state/props 的函数。

这正是 React 官方在未来版本中提出的 useEffectEvent Hook 所要解决的问题。现在,我们来亲手实现一个功能等价的 Polyfill——useEvent

文件路径: src/hooks/use-event.ts (此文件在 6.1.2 节已创建)

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
import { useLayoutEffect, useRef, useCallback } from 'react';


export function useEvent<T extends (...args: any[]) => any>(handler: T): T {
// 1. 创建一个 ref 容器
// useRef 创建一个可变的 "盒子",它可以在组件的整个生命周期内持久存在。
// 我们将传入的事件处理函数 handler 立即存入这个盒子。
const handlerRef = useRef<T>(handler);

// 2. 使用 useLayoutEffect 同步更新 ref
// useLayoutEffect 会在每次组件渲染完成、浏览器绘制到屏幕之前同步执行。
// 这意味着,每次父组件(如 Counter)因为状态变化而重新渲染时,
// 这个 effect 都会立即执行,将最新版本的 handler 函数(包含了最新的 count 状态)
// 存入 handlerRef.current 中。
// 为什么用 useLayoutEffect 而不是 useEffect?
// 因为它能确保在任何事件触发回调之前,ref 中的函数一定是最新的,避免了极端的边界情况。
useLayoutEffect(() => {
handlerRef.current = handler;
});

// 3. 返回一个引用地址永久稳定的函数
// useCallback(fn, []) 的作用是:在组件初次渲染时创建一个函数,
// 并且在后续的渲染中,永远返回这同一个函数实例(因为依赖数组 [] 是空的)。
// 这个返回的函数就是我们最终在组件中使用的那个 handleReset 函数。它的内存地址永远不会变。
return useCallback((...args: any[]) => {
// 4. 执行 ref 中最新的函数
// 当这个稳定函数(例如 handleReset)被调用时,它并不执行自己定义时的逻辑。
// 而是去读取 handlerRef.current 中存储的那个“最新”的 handler 函数,并执行它。
// 这就巧妙地实现了:函数引用是旧的(稳定的),但执行的逻辑是最新的。
return handlerRef.current(...args);
}, []) as T;
}

解析: useEvent 的精髓在于,它巧妙地利用 useRef 作为中介,将函数的“引用” 函数的“实现”离开。它返回的函数引用是永久不变的,但这个函数在执行时,总是能通过 ref.current 调用到最新的那个函数实现。

现在,让我们用这个终极武器来解决 Counter 组件的困境。

文件路径: src/components/dev/PerformancePitfall.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
// ... imports ...
import { useEvent } from '@/hooks/use-event'; // 导入我们自己的 Hook

// ... ResetButton 组件不变 ...

export function Counter() {
const [count, setCount] = useState(0);
console.log('父组件(Counter)正在被渲染');

// 使用 useEvent 来包装 handleReset
const handleReset = useEvent(() => {
// 这里的 count 总能读取到最新的值
console.log(`准备从 ${count} 重置...`);
setCount(0);
});
// handleReset 函数的引用现在是永久稳定的!

return (
<div className="w-full max-w-sm space-y-4 rounded-lg bg-base-200 p-6">
<p className="text-center text-4xl font-bold">{count}</p>
<div className="flex justify-center gap-4">
<Button onClick={() => setCount(c => c + 1)}>Increment</Button>
<ResetButton onReset={handleReset} />
</div>
</div>
);
}

最终验证:
现在,再次测试 Counter 组件。

  • 点击 “Increment”:Counter 重渲染,但 ResetButton 不再重渲染
  • 点击 “Reset”:控制台打印出正确的 准备从 [当前计数值] 重置...,然后 count 变为 0。

我们成功地解决了“陈旧闭包”和“引用不稳定”的两难问题。useEvent 是一个在处理复杂副作用、自定义 Hooks 和性能优化时,极其强大的高级模式。

第七章. 专业的 API Mocking:从 json-serverMSW

本章目标: 现代前端开发与 API 紧密相连。本章,我们将深入探讨“API Mocking”这一核心工程化议题。我们将 横向对比 Mock.jsjson-server 等传统方案,并最终聚焦于当前 React 社区的最佳实践——MSW (Mock Service Worker)。我们将深入其工作原理,并以构建一个智能 AutoComplete 组件为实战载体,将 MSW 与我们在第六章学习的高级 Hooks 模式(useControllableState, useReducer 模式等)进行完美结合。

7.1. 痛点与需求:为什么前端需要 API Mocking?

在深入探讨解决方案之前,我们必须先对问题本身建立一个清晰、深刻的认知。为什么一个临时的假数据文件或简单的 Mock 方式,在专业开发中是远远不够的?

7.1.1. 开发流程中的“阻塞”问题

让我们从一个在任何采用前后端分离的敏捷团队中,都几乎每周都会上演的经典场景开始。

场景: 新的 Sprint 开始,产品经理提出了一个需求:“开发用户个人资料页面”。经过评审,前后端工程师共同协定了一个 API 接口契约:

  • Endpoint: GET /api/user/:id
  • Success Response (JSON):
    1
    2
    3
    4
    5
    6
    {
    "id": "12345",
    "name": "Prorise",
    "email": "contact@prorise.com",
    "registeredAt": "2025-10-15T10:30:00Z"
    }

任务分配完毕,后端同学去进行数据库设计和接口开发,而我们,作为前端工程师,则开始构建 UI。

前端的困境
凭借我们强大的组件库,我们可以在几个小时内就迅速搭建出页面的静态 UI 骨架。

文件路径: src/app/profile-page/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
24
function UserProfileCard() {
const userName = "Loading...";
const userEmail = "loading...";
const registeredAt = "loading...";

return (
<div className="card w-96 bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">User Profile</h2>
<p><strong>Name:</strong> {userName}</p>
<p><strong>Email:</strong> {userEmail}</p>
<p><strong>Member Since:</strong> {registeredAt}</p>
</div>
</div>
);
}

export default function ProfilePage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24">
<UserProfileCard />
</main>
);
}

UI 骨架已经就绪,下一步就是编写 useEffect 数据请求逻辑。于是,我们与后端同学进行了如下沟通:

Team Communication
Sprint Day 2, 11:00 AM

Hey @后端, 个人资料页面的 UI 已经准备好了。/api/user/:id 这个接口的开发环境地址出来了吗?我需要连接它来完成后续的数据绑定和加载、错误状态的处理。

后端

抱歉,数据库那边遇到点迁移问题,比预想的要复杂一些。这个接口的 dev 环境估计要到周五才能提供。

好的,没问题…

“阻塞”发生了。

在这一刻,我们作为前端工程师的开发流程被硬生生地打断了。我们 无法

  • 编写和调试真实的数据请求逻辑 (fetch / axios)。
  • 开发和预览组件在“加载中 (Loading)”状态下的 UI (例如,一个骨架屏 Skeleton)。
  • 开发和验证组件在“请求失败 (Error)”状态下的 UI (例如,一个错误提示)。
  • 使用真实结构的数据来完成后续的 UI 开发。

我们陷入了被动的等待,或者只能转向一些临时的、会遗留技术债的“脏”办法,例如在组件里硬编码一个假数据对象。这种对外部服务的 强依赖,使得我们的开发流程变得脆弱、低效且充满不确定性。

我们需要一种方法,来 在前端项目内部,可靠地、真实地模拟出后端 API 的行为,从而将我们的开发流程与后端同学的进度 解耦,实现真正的并行开发。这,就是 API Mocking 的首要价值。

7.1.2. 边界测试的挑战

我们已经明确了 API Mocking 的第一个核心价值:解耦前后端,打破开发流程中的“阻塞”

现在,我们来看第二个同样重要的理由。即使后端 API 已经完美地按时交付,我们依然需要一个专业的 Mocking 方案,因为一个健壮的前端组件,不仅要能正确处理“成功”的情况,更要能优雅地应对各种“异常”和“边界”情况。

请思考以下几个在日常开发中非常常见,但又极其难以测试的场景:

  1. 如何测试“加载中 (Loading)”状态?
    如果后端 API 响应速度极快(例如,在本地开发环境中只有 10ms),我们的加载动画(如骨架屏或 Spinner)可能只会一闪而过,我们根本无法在视觉上验证它的样式和行为是否正确。

  2. 如何测试“请求失败 (Error)”状态?
    我们的 UI 需要在一个漂亮的 Alert 组件中,向用户展示“服务暂不可用,请稍后重试”的提示。我们如何触发这个场景?难道要请求后端工程师为了配合我们的前端调试,手动去关闭或弄崩溃他们的服务器吗?这在协作中显然是不现实的。

  3. 如何测试“空状态 (Empty State)”?
    当用户搜索一个不存在的关键词时,API 应该返回一个空数组 [],此时界面需要展示一个“未找到相关结果”的提示。我们或许可以通过输入一些随机字符串来触发这个场景,但这并不稳定和可靠。

核心痛点:
一个真实、健康的后端 API,其行为是 难以被前端开发者精确控制 的。我们无法轻易地让它“变慢”、让它“出错”、或者让它“返回空数据”。这种不可控性,导致我们对组件在这些边界情况下的表现,要么是凭空想象,要么是放弃测试,最终将未经充分验证的代码发布到生产环境,留下巨大的隐患。

我们需要一个“API 遥控器”,让我们能够随心所欲地控制接口的行为,精确地模拟出任何我们想要测试的场景。这,就是 API Mocking 的第二个核心价值:实现全面的、可靠的边界条件测试

7.1.3. 故事驱动开发的障碍

最后,我们来看第三个痛点,它与我们课程的核心——“黄金工作流”——息息相关。

核心问题: 在我们以 Storybook 为中心的开发模式中,如何为一个依赖异步数据的组件(如 UserProfileCard)编写独立、稳定、可复用的 Stories?

设想一下,我们需要为 UserProfileCard 组件编写三个 Stories,分别对应“加载中”、“请求成功”和“请求失败”这三种状态。我们会立刻陷入两难的境地:

方案一:污染组件 API (错误示范)
我们可能会尝试为组件添加一些“仅供测试”的 props:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 在 Storybook 中这样使用
export const Loading: Story = {
args: { mockState: 'loading' },
};
export const Success: Story = {
args: { mockState: 'success', mockData: { name: 'Test User' } },
};

// 组件内部的逻辑会变得非常混乱
function UserProfileCard({ mockState, mockData }) {
if (mockState === 'loading') return <Skeleton />;
if (mockState === 'error') return <Alert type="error" />;
// ...
}

弊端: 这种做法严重 污染 了组件的生产环境 API,在组件内部塞满了与业务无关的测试逻辑,是一种非常糟糕的实践。

方案二:在 Story 中真实请求 API (错误示范)
我们也可以让 Story 在渲染时,真实地去调用 /api/user/:id 接口。
弊端:

  • 依赖外部服务: 我们的 Storybook 现在依赖于一个正在运行的、健康的后端服务。如果后端环境崩溃,我们的整个组件文档和可视化测试系统也会随之瘫痪。
  • 状态不确定: API 返回的数据可能会变化,这使得 Story 不再是一个确定性的、可预测的组件状态快照。这对于后续进行视觉回归测试是致命的。
  • 无法模拟边界: 我们仍然无法通过这种方式,来创建一个稳定展示“请求失败”状态的 Story。

我们真正需要的:
我们需要一种能力,能够 在 Storybook 的环境中,为每一个 Story 单独提供一个特定的、可控的 API 响应

  • 对于 Loading Story,我们需要能让 /api/user/:id 这个接口“假装”正在请求中,并且永远不返回结果。
  • 对于 Error Story,我们需要能让这个接口立刻返回一个 500 错误。
  • 对于 Success Story,我们需要能让这个接口立刻返回一份我们预设好的、稳定的 JSON 数据。

这,就是 API Mocking 在“组件驱动开发”流程中不可替代的价值:保障 Story 的隔离性、确定性和可维护性


7.1.4 本节小结

通过以上分析,我们明确了在一个专业的、现代化的前端工程中,我们需要 API Mocking 来解决的三大核心痛点:

痛点核心问题导致的后果
开发流程阻塞前端依赖未就绪的后端 API开发停滞,效率低下,交付延迟
边界测试困难无法在前端精确控制 API 的响应状态 (如错误、延迟)组件鲁棒性不足,对异常情况的处理未经检验
Storybook 隔离失效异步组件的故事要么污染代码,要么依赖真实网络文档不稳定,无法进行可靠的视觉回归测试

带着这三个明确的问题,我们现在已经做好了充分的准备,将在下一节中,开始对业界主流的 Mocking 解决方案进行一次全面的横向对比,以找到那个能够同时完美解决所有这些问题的“终极武器”。


7.2. Mocking 方案横向对比

在明确了我们需要专业的 API Mocking 工具来解决的三大痛点之后,我们来快速地对业界主流的几种方案进行横向对比,以理解为什么 MSW 是我们当前的不二之选。

方案一 (Mock.js):应用层拦截

这是早期前端 Mocking 的代表。它的核心原理是通过在代码层面覆写(Monkey-patching)浏览器原生的 XMLHttpRequest 对象,来拦截应用发出的 Ajax 请求并返回预设的假数据。

核心痛点: 这种方式 侵入性极强,直接修改了浏览器的全局对象。它默认不支持 fetch API,与 Node.js 测试环境(如 Vitest)不兼容,且社区已基本停止维护,是一种 完全过时 的方案。

方案二 (json-server):外部 Mock 服务器

json-server 提供了一种快速搭建独立 Mock 服务器的方案。

核心理念: 通过一个 db.json 文件,快速启动一个功能完整的、符合 RESTful 规范的 Node.js API 服务器。

1
2
3
4
# 1. 创建 db.json 文件: { "users": [{ "id": 1, "name": "Prorise" }] }

# 2. 启动独立的服务器进程
json-server --watch db.json

核心痛点: 它的主要问题在于 隔离性。它是一个完全独立的外部进程,难以与我们的前端项目(特别是 Storybook 和单元测试)进行无缝集成。同时,用它来模拟复杂的业务逻辑(例如,根据请求头返回不同内容)或特定的错误状态(如 500 错误)也相对繁琐。

现代答案 (MSW):网络层拦截

MSW (Mock Service Worker) 代表了当前最先进的 Mocking 思想。

核心理念: 利用浏览器标准的 Service Worker API,在 网络层 (Network Level) 对请求进行拦截。这意味着,我们的应用代码(无论是 fetch 还是 axios)在发起请求时,是完全无感知的,它认为自己正在与一个真实的服务器通信。

1
2
3
4
5
6
7
8
9
10
// MSW 的 Handler 定义 (概念演示)
import { http, HttpResponse } from 'msw'

export const handlers = [
// 拦截一个 GET /api/user 请求
http.get('/api/user', () => {
// 并返回一个 JSON 响应
return HttpResponse.json({ name: 'Prorise' })
}),
]

MSW 成为社区首选的核心优势:

  1. 无侵入性: 应用代码无需任何修改,保持了生产环境的纯净性。
  2. 环境通用性: 通过 Service Worker (浏览器) 和请求拦截器 (Node.js),实现了 一套 Mock 定义,处处可用。无论是 Next.js 开发、Storybook 可视化,还是 Vitest 单元测试,都可以共享同一套 Mock 逻辑。
  3. 高保真度: 在网络层面工作,对所有类型的请求(REST, GraphQL)和所有请求库都有效。
  4. 强大的逻辑能力: Handler 可以是纯函数,能轻松模拟任何复杂的业务逻辑、延迟和错误状态。

对比总结

特性维度Mock.js (过时)json-server (独立服务)MSW (现代标准)
工作原理覆写 XHR 对象独立的 Node.js 服务器网络层拦截 (Service Worker)
侵入性低 (但需修改请求 URL)
环境通用性差 (仅浏览器, 不支持 fetch)差 (需要独立进程)极佳 (浏览器 & Node.js 通用)
逻辑/错误模拟有限困难非常强大

结论: 凭借其无侵入、环境通用和功能强大的特性,MSW 能够完美地解决我们在 7.1 节中提出的所有痛点,是我们在 2025 年构建专业前端应用时进行 API Mocking 的不二之选。

7.3. MSW 实战:为 AutoComplete 搭建 Mock API

理论学习结束,我们马上进入 MSW 的实战配置。

7.3.1. MSW 安装与初始化

首先,我们将 msw 添加为项目的开发依赖。

1
pnpm add -D msw

接下来,是 MSW 最具特色的一步:初始化 Service Worker 脚本。我们需要运行一个 CLI 命令,它会在我们指定的、存放静态资源的目录(在 Next.js 中是 public 目录)中,创建一个 Service Worker 文件。浏览器将会注册并运行这个文件,从而赋予 MSW 拦截网络请求的能力。

1
pnpm msw init public/

验证: 执行成功后,您会发现 public 目录下多出了一个 mockServiceWorker.js 文件。请 不要 手动修改这个文件。

7.3.2. 编写 AutoComplete 的 Mock Handlers

现在,我们来创建 Mock API 的核心——请求处理器 (Request Handlers)。一个良好的实践是,将所有 Handlers 存放在一个专门的目录中。

第一步:创建 Handlers 文件

1
2
3
# 在项目根目录下执行
mkdir -p src/mocks
touch src/mocks/handlers.ts

第二步:编写处理器逻辑

我们将为 AutoComplete 组件虚构一个 /api/search 接口,并为其编写一个能够响应 GET 请求的处理器。

文件路径: src/mocks/handlers.ts

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
import { http, HttpResponse, delay } from 'msw';

// 模拟一个简单的数据库或数据源
const fruits = [
'Apple',
'Banana',
'Orange',
'Pineapple',
'Grape',
'Strawberry',
'Blueberry',
'Raspberry',
'Mango',
'Peach',
];

// 创建 /api/search 接口的 GET 请求处理器
export const handlers = [
http.get('/api/search', async ({ request }) => {
// 1. 模拟网络延迟
await delay(500);

// 2. 从请求 URL 中获取查询参数
const url = new URL(request.url);
const query = url.searchParams.get('q')?.toLowerCase() ?? '';

// 3. 模拟服务器 500 错误 (用于测试)
if (query === 'error') {
return new HttpResponse(null, {
status: 500,
statusText: 'Internal Server Error',
});
}

// 4. 根据查询参数过滤数据
const filteredFruits = query
? fruits.filter((fruit) => fruit.toLowerCase().includes(query))
: [];

// 5. 返回一个成功的 JSON 响应
return HttpResponse.json(filteredFruits);
}),
];

代码深度解析:

  • http.get(...): 我们使用 msw 提供的 http 对象,来定义一个针对 GET 请求的处理器。第一个参数是需要拦截的 URL 路径。
  • async ({ request }) => { ... }: 处理器函数接收一个包含 request 信息的上下文对象。
  • await delay(500): 我们使用 delay 工具函数,来强制每次请求都至少延迟 500 毫秒,这便于我们在 UI 中观察“加载中”的状态。
  • 错误模拟: 我们加入了一个逻辑,当搜索查询为 'error' 时,返回一个 500 的 HTTP 响应。这为我们后续测试组件的错误状态提供了极大的便利。
  • 数据过滤: 我们模拟了真实的后端行为,根据查询参数 q 来过滤数据。
  • HttpResponse.json(...): 返回一个 Content-Typeapplication/json 的成功响应。

7.3.3. 在开发环境中启动 MSW

最后,我们需要让我们的 Next.js 应用在开发模式下加载并启动 MSW。这需要我们创建一个 Mocking 的“启动文件”。

第一步:创建启动文件

1
2
3
4
5
# 在 mocks 目录下创建浏览器环境的启动文件
touch src/mocks/browser.ts

# 创建一个用于条件化加载 MSW 的组件
touch src/app/msw-provider.tsx

第二步:配置启动文件

文件路径: src/mocks/browser.ts

1
2
3
4
5
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

// 配置一个包含我们所有请求处理器的 Service Worker
export const worker = setupWorker(...handlers);

文件路径: src/app/msw-provider.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
'use client';

import { useEffect } from 'react';

// 仅在开发环境中加载和启动 MSW
export function MSWProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
// 动态导入并启动 worker
import('@/mocks/browser').then(({ worker }) => {
worker.start();
});
}
}, []);

return <>{children}</>;
}

第三步:在根布局中应用 MSWProvider

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ... imports ...
import { ThemeProvider } from '@/components/providers/theme-provider';
import { MSWProvider } from './msw-provider'; // 1. 导入 MSWProvider

export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider /* ... */ >
{/* 2. 将 MSWProvider 包裹在应用的最外层 */}
<MSWProvider>{children}</MSWProvider>
</ThemeProvider>
</body>
</html>
);
}

最终验证:
现在,重新运行 pnpm run dev 并打开浏览器控制台。您应该会看到一条来自 MSW 的日志,提示 [MSW] Mocking enabled.

这标志着我们的 API Mocking 基础设施已全部搭建完毕。我们的 Next.js 应用现在拥有了一个“虚拟后端”,它能可靠地响应 /api/search 请求,并且其行为完全由我们前端开发者掌控。我们终于为构建 AutoComplete 组件的异步逻辑,扫清了所有外部依赖的障碍。


7.4. 终极实战:构建与集成 useAutoComplete Hook

我们已经拥有了一个由 MSW 驱动的、行为可预测的“虚拟后端”。现在,是时候构建 AutoComplete 组件的“逻辑大脑”了。

遵循我们在第六章中确立的架构思想,我们将把所有复杂的业务逻辑,全部封装到一个独立的、与 UI 无关的 useAutoComplete 自定义 Hook 中。这将是对我们高级 Hooks 知识的一次终极检验和综合应用。

7.4.1. 构建 useAutoComplete Hook

第一步:创建 Hook 文件与类型定义

首先,创建我们的 Hook 文件。

1
touch src/hooks/use-auto-complete.ts

接下来,我们打开这个文件,并首先定义好这个 Hook 将要管理的所有状态的“形状”。这是一个 状态机 设计的核心步骤,我们使用 TypeScript 的类型系统来精确地描述我们的数据结构。

文件路径: src/hooks/use-auto-complete.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1. 定义异步请求的几种可能状态
type Status = 'idle' | 'loading' | 'success' | 'error';

// 2. 定义我们 Hook 内部的完整状态结构
// 使用泛型 <T> 使其可以处理任何类型的返回数据
interface State<T> {
status: Status;
data: T[] | null;
error: Error | null;
}

// 3. 定义 useReducer 将要接收的所有可能的 action 类型
type Action<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: T[] }
| { type: 'FETCH_ERROR'; payload: Error }
| { type: 'RESET' };

解析: 我们通过类型,严格地定义了一个异步请求的生命周期。State 接口确保了我们不会有无效状态(例如,dataerror 不能同时存在值),Action 则枚举了所有可能触发状态变更的事件。

第二步:实现管理异步状态的 Reducer

Reducer 是一个纯函数,它是我们状态机的“规则手册”。它接收当前的状态和一个 action,并根据规则返回一个全新的状态。

文件路径: src/hooks/use-auto-complete.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ... 此前的类型定义 ...

// 4. 实现 reducer 纯函数
function fetchReducer<T>(state: State<T>, action: Action<T>): State<T> {
switch (action.type) {
case 'FETCH_START':
// 状态刚开始,我们将状态设置为 loading,并清除错误
return { ...state, status: 'loading', error: null };
case 'FETCH_SUCCESS':
// 数据获取成功,我们将状态设置为 success,并设置数据
return { ...state, status: 'success', data: action.payload };
case 'FETCH_ERROR':
// 数据获取失败,我们将状态设置为 error,并设置错误
return { ...state, status: 'error', error: action.payload };
// 重置状态,我们将状态设置为 idle,并清除数据和错误
case 'RESET':
return { status: 'idle', data: null, error: null };
default:
// 在 TypeScript 中,一个完备的 switch 应该不需要 default
// 但为了代码健壮性,我们抛出一个错误
throw new Error('Unknown action type in fetchReducer');
}
}

解析: 这个 Reducer 函数清晰地定义了状态转换的逻辑。例如,当 FETCH_START action 被派发时,状态会立即切换到 loading,并清除任何历史的 error 信息。这种集中式的管理,使得我们的状态变更变得完全可预测。

第三步:构建 useAutoComplete Hook 骨架并组合 Hooks

现在,我们来构建 Hook 的主体,并将我们在第六章中构建的 useDebounce 和刚刚定义的 useReducer 组合起来。

文件路径: src/hooks/use-auto-complete.ts

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
import { useState, useEffect, useReducer } from 'react';
import { useDebounce } from './use-debounce';

// ... 此前的类型定义和 reducer 函数 ...

// 5. 定义并导出我们的主 Hook
export function useAutoComplete<T>() {
// a. 使用 useState 管理用户输入的实时值
const [inputValue, setInputValue] = useState('');

// b. (Hooks 组合) 使用 useDebounce 对输入值进行防抖
const debouncedInputValue = useDebounce(inputValue, 500);

// c. (Hooks 组合) 使用 useReducer 管理异步数据状态
const [state, dispatch] = useReducer(fetchReducer<T>, {
status: 'idle',
data: null,
error: null,
});

// d. 异步请求逻辑将在此实现...

// e. 返回所有 UI 组件所需的状态和方法
return {
inputValue,
setInputValue,
suggestions: state.data,
isLoading: state.status === 'loading',
isSuccess: state.status === 'success',
error: state.error,
};
}

解析: 我们将不同的职责委托给了最适合的 Hook:useState 负责高频更新的 UI 状态,useDebounce 负责性能优化,useReducer 负责复杂但更新频率较低的异步状态。这就是 Hooks 组合 的威力。

第四步:实现核心异步逻辑 (useEffect)

最后一步,是编写 useEffect 来监听 防抖后 的输入值变化,并据此触发 API 请求和状态派发。

文件路径: src/hooks/use-auto-complete.ts (最终版本)

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
// ... 此前的 imports, 类型定义, reducer ...

export function useAutoComplete<T>() {
const [inputValue, setInputValue] = useState('');
const debouncedInputValue = useDebounce(inputValue, 500);
const [state, dispatch] = useReducer(fetchReducer<T>, {
status: 'idle',
data: null,
error: null,
});

// 6. 实现数据请求的副作用
useEffect(() => {
// 如果防抖后的值为空字符串,则重置状态并终止操作
if (!debouncedInputValue) {
dispatch({ type: 'RESET' });
return;
}

let isCancelled = false; // 用于处理组件卸载时的竞态条件

const fetchData = async () => {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch(`/api/search?q=${debouncedInputValue}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();

// 如果在请求期间组件已卸载,则不进行状态更新
if (!isCancelled) {
dispatch({ type: 'FETCH_SUCCESS', payload: data });
}
} catch (error) {
if (!isCancelled) {
dispatch({ type: 'FETCH_ERROR', payload: error as Error });
}
}
};

fetchData();

// 7. Effect 的清理函数
return () => {
isCancelled = true;
};
}, [debouncedInputValue]); // 关键:Effect 只依赖于防抖后的值

return {
inputValue,
setInputValue,
suggestions: state.data,
isLoading: state.status === 'loading',
isSuccess: state.status === 'success',
error: state.error,
};
}

代码深度解析:

  • useEffect 的依赖: 这是整个模式的核心。useEffect 只依赖 debouncedInputValue。这意味着只有当用户停止输入 500ms 后,这个副作用才会运行,从而实现了请求的防抖。
  • isCancelled 标志: 这是一个处理异步副作用的 最佳实践。设想用户在请求还未返回时就离开了页面,组件被卸载。如果没有 isCancelled 保护,当请求最终返回时,dispatch 会尝试去更新一个已经不存在的组件的状态,这会导致 React 报错(内存泄漏警告)。通过在清理函数中设置 isCancelled = true,我们确保了只有在组件仍然挂载时,才会派发状态更新。

至此,我们已经成功地构建了一个功能完备、逻辑内聚、性能优化的 useAutoComplete Hook。它是一个完全独立的“逻辑引擎”,现在,我们只需要为它打造一个“UI 外壳”即可。


7.4.2. 构建 AutoComplete UI 组件

本节,我们将回归到我们熟悉的 UI 组件构建工作。我们的任务非常纯粹:创建一个“哑” 的 AutoComplete UI 组件

这个组件自身不包含任何复杂的业务逻辑。它的唯一职责,就是接收来自 useAutoComplete Hook 的状态(如 isLoading, suggestions),并将其渲染为用户可见的界面。我们将在这个过程中,综合运用 shadcn 工作流、组件组合以及 daisyUI 样式。

第一步:shadcn add 实战,获取 Popover 原语

AutoComplete 的下拉建议列表,是一个需要“浮动”在所有内容之上、且需要处理复杂定位和焦点管理的浮层。手动实现它非常困难,因此,我们将再次借助 shadcnRadix UI 的力量,获取一个专业的“无头”Popover 组件作为其基础。

在项目根目录下执行:

1
pnpm dlx shadcn@latest add popover

解析: 这个命令会自动为我们安装 @radix-ui/react-popover 依赖,并在 src/components/ui 目录下创建一个经过样式化封装的 Popover.tsx 文件。它为我们处理了所有关于浮层定位、可访问性(a11y)和外部点击关闭的逻辑。

第二步:创建 AutoComplete UI 组件文件

1
touch src/components/ui/AutoComplete.tsx

第三步:搭建静态 UI 骨架

我们首先在 AutoComplete.tsx 文件中,通过 组件组合,搭建出它的静态 UI 结构。

文件路径: src/components/ui/AutoComplete.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
'use client';

import * as React from 'react';
import { cn } from '@/lib/utils';
import { Input, type InputProps } from './Input';

import { Popover, PopoverContent, PopoverTrigger } from './popover';

import { Icon } from './Icon';

// 1. 定义 AutoComplete 组件的 Props 接口
// 它将接收所有原生 input 属性,以及我们自定义的 props
export interface AutoCompleteProps extends InputProps {
suggestions?: string[];
isLoading?: boolean;
onSuggestionClick?: (suggestion: string) => void;
}

// 2. 构建 UI 组件骨架
const AutoComplete = React.forwardRef<HTMLInputElement, AutoCompleteProps>(
({ className, suggestions, isLoading, onSuggestionClick, ...props }, ref) => {
// 根据是否有建议来决定 Popover 是否打开
const open = !!suggestions && suggestions.length > 0;

return (
// 3. 使用 Popover 作为根容器
<Popover open={open}>
{/* 4. 使用 PopoverTrigger 自动获取宽度 */}
<PopoverTrigger asChild>
<div className={cn('relative', className)}>
{/* 5. 使用我们第五章增强的 Input 组件 */}
<Input
ref={ref}
// 根据加载状态显示不同的后缀图标
suffix={isLoading ? 'lucide:loader-circle' : 'lucide:search'}
className={isLoading ? 'animate-pulse' : ''}
{...props}
/>
</div>
</PopoverTrigger>

{/* 6. PopoverContent 用于渲染浮动面板 */}
<PopoverContent
align="start"
className="!w-[--radix-popover-trigger-width] p-0"
// 我们不希望 Popover 自动管理焦点因为焦点应始终在 Input
onOpenAutoFocus={(e) => e.preventDefault()}
>
{/* 7. 使用 daisyUI 的 menu 样式来渲染建议列表 */}
<ul className="menu bg-base-200 rounded-box max-h-60 overflow-y-auto">
{suggestions?.map((suggestion) => (
<li
key={suggestion}
onClick={() => onSuggestionClick?.(suggestion)}
>
<a>{suggestion}</a>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}
);
AutoComplete.displayName = 'AutoComplete';
export { AutoComplete };

代码深度解析

  1. AutoCompleteProps: 我们定义了一个丰富的 props 接口,它继承了 Input 的所有属性,并增加了 suggestions (建议列表), isLoading (加载状态)等我们 useAutoComplete Hook 将会提供的新状态。
  2. 组件组合: 这个组件完美地体现了组件组合的思想。它像一个“总装厂”,将 Popover (来自 shadcn/Radix)、Input (我们自己的增强版)和 Icon (我们自己的统一版) 组合在了一起。
  3. Popover:
    • PopoverAnchor: 我们使用它包裹住 Input 的容器,它会成为 PopoverContent 定位的“锚点”。
    • PopoverContent: 我们为其添加了 w-[--radix-popover-trigger-width] 这个 CSS 变量类,这是一个 shadcn Popover 提供的技巧,能让浮动面板的宽度与触发器(即我们的 Input)的宽度完全一致。
    • onOpenAutoFocus={(e) => e.preventDefault()}: 这是一个关键的交互细节。默认情况下,Popover 打开时会尝试将焦点移入面板内部。但在 AutoComplete 场景中,我们希望用户的焦点 始终保持在输入框中,以便继续输入。这行代码阻止了默认的焦点转移行为。
  4. daisyUI 样式: 我们再次使用了 daisyUImenu 类来快速、一致地样式化我们的建议列表。

结论:
我们已经成功地构建了一个结构清晰、样式统一的 AutoComplete UI 组件。它是一个纯粹的、由 props 驱动的“哑”组件,已经为下一步注入“逻辑大脑”做好了充分的准备。


7.4.3. 最终组装:将“逻辑”与“视图”合二为一

我们已经拥有了两个独立的、各自都很强大的部分:

  1. 一个负责所有复杂逻辑的 useAutoComplete “逻辑引擎”
  2. 一个负责所有视觉呈现的 AutoComplete “UI 外壳”

现在,是时候将它们进行最终的组装,见证“关注点分离”架构模式所带来的真正魅力了。我们将把“逻辑大脑”植入“UI 躯体”中,创造出一个完整、智能的组件。

第一步:精简 Props 接口

一个关键的架构优势是:在引入 useAutoComplete Hook 后,我们的 UI 组件本身不再需要从外部接收 isLoading, suggestions 等状态。这些状态将由 Hook 在内部进行管理和提供。因此,我们可以先简化 AutoComplete 组件的 Props 接口。

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

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

// 修改前:
// export interface AutoCompleteProps extends InputProps {
//   suggestions?: string[];
//   isLoading?: boolean;
//   onSuggestionClick?: (suggestion: string) => void;
// }

// 修改后:
// 我们 Omit 了 'value' 和 'onChange',因为它们将由 Hook 内部管理
// 组件的调用者无需关心这些受控属性,只需传入原生的 input 属性即可
export interface AutoCompleteProps extends Omit<InputProps, 'value' | 'onChange'> {}

// ... 组件实现 ...

第二步:注入 Hook 并连接状态与视图

现在,我们在 AutoComplete 组件内部调用 useAutoComplete Hook,然后将 Hook 返回的状态和方法,与我们已经写好的 JSX 进行“连接”。

文件路径: src/components/ui/AutoComplete.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
'use client';

import * as React from 'react';
import { useAutoComplete } from '@/hooks/use-auto-complete'; // 1. 导入我们创建的 Hook
import { cn } from '@/lib/utils';
import { Input, type InputProps } from './Input';
import { Popover, PopoverContent, PopoverTrigger } from './Popover';
import { Icon } from './Icon';

export interface AutoCompleteProps extends Omit<InputProps, 'value' | 'onChange'> {}

const AutoComplete = React.forwardRef<HTMLInputElement, AutoCompleteProps>(
({ className, ...props }, ref) => {
// 2. 在组件顶部调用 Hook,获取所有状态和方法
const {
inputValue,
setInputValue,
suggestions,
isLoading,
error, // 我们也获取 error 状态以备后用
} = useAutoComplete<string>(); // 指定建议项的类型为 string

// Popover 的打开状态现在由 suggestions 是否有内容来驱动
const open = !!suggestions && suggestions.length > 0;

return (
<Popover open={open}>
<PopoverTrigger asChild>
<div className={cn('relative', className)}>
<Input
ref={ref}
// 3. Input value onChange 连接到 Hook 的状态和方法
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
// 4. 使用 isLoading 状态来显示加载动画和图标
suffix={isLoading ? 'lucide:loader-circle' : 'lucide:search'}
className={isLoading ? 'animate-pulse' : ''}
{...props}
/>
</div>
</PopoverTrigger>

{error && <div className="text-red-500">{error.message}</div>}

<PopoverContent
align="start"
className="!w-[--radix-popover-trigger-width] p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<ul className="menu bg-base-200 rounded-box max-h-60 overflow-y-auto">
{/* 5. 使用 suggestions 状态来渲染列表 */}
{suggestions?.map((suggestion) => (
<li
key={suggestion}
// 6. 将点击事件连接到 Hook 的方法以更新输入框的值
onClick={() => setInputValue(suggestion)}
>
<a>{suggestion}</a>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}
);
AutoComplete.displayName = 'AutoComplete';
export { AutoComplete };

代码深度解析:

  1. 调用 Hook: const { ... } = useAutoComplete<string>(); 这一行代码,就是我们植入“逻辑大脑”的瞬间。所有关于防抖、请求、状态机的复杂逻辑,都被包含在了这个简单的函数调用中。
  2. 连接 Input: 我们将 Input 组件变成了一个完全由 useAutoComplete Hook 控制的 受控组件value 来自 inputValue 状态,onChange 事件则调用 setInputValue 方法。
  3. 连接状态到 UI: 我们使用从 Hook 中获取的 isLoading 状态,来动态地改变 Inputsuffix 图标和 className,实现了加载状态的可视化反馈。
  4. 连接数据到 UI: 我们使用 suggestions 数组来动态地渲染下拉列表中的 <li> 元素。
  5. 连接交互到方法: 当用户点击某个建议项时,我们调用 Hook 返回的 setInputValue 方法来更新输入框的值。这个更新会再次触发 useDebounceuseEffect,形成一个完整的数据流闭环(通常,选择后 debouncedValue 变为空,suggestions 数组清空,Popover 随之关闭)。

架构优势:“胖 Hooks,瘦组件”
请再次审视 AutoComplete.tsx 这个文件。您会发现,在完成了最终组装后,这个 UI 组件本身变得非常“轻薄”和“纯粹”。它不包含任何 useEffect, useReducer, setTimeout 等复杂逻辑,其唯一的职责就是根据从 Hook 中获取的 props,忠实地渲染出对应的 UI。

这就是**“胖 Hooks,瘦组件 (Fat Hooks, Thin Components)”**的现代 React 架构思想。这种模式带来了巨大的好处:

  • 高度可读性: UI 组件的渲染逻辑一目了然。
  • 逻辑可复用性: useAutoComplete Hook 可以被用于任何其他需要自动完成功能的组件,无论它长什么样。
  • 独立可测试性: 我们可以独立地为 useAutoComplete 编写单元测试,而无需渲染任何 UI。

最终验证

现在,回到我们在 7.3.3 中创建的、用于启动 MSW 的 Next.js 应用。为了方便测试,我们可以简单地将新组件添加到首页。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { AutoComplete } from '@/components/ui/AutoComplete';

export default function HomePage() {
return (
<main className="flex min-h-screen flex-col items-center p-24">
<div className="w-full max-w-xs">
<label htmlFor="fruit-search" className="label">
<span className="label-text">Search for a fruit</span>
</label>
<AutoComplete id="fruit-search" placeholder="e.g., Apple, Banana..." />
</div>
</main>
);
}

运行 pnpm run dev,访问首页。现在,尝试在输入框中输入 “app”。您会看到:

  1. 输入框末尾的搜索图标变成了加载图标,并伴有脉冲动画。
  2. 等待 500 毫秒后,加载动画消失,下方弹出一个包含 “Apple” 和 “Pineapple” 的建议列表。
  3. 尝试输入 “error”,您会看到组件下方提示错误,并在控制台中看到 MSW 模拟的 500 错误。

img

我们的 AutoComplete 组件已经功能完备,并且正在与我们搭建的 Mock API 完美地协同工作。现在,只剩下“黄金工作流”的最后一步:将它带入 Storybook。

7.5. 流程闭环:在 Storybook 中消费 Mock API

如果我们现在直接为 AutoComplete 编写 Story,它在 Storybook 中发出的 API 请求将会失败(404 Not Found),因为 Storybook 环境中并没有一个真实的后端,也没有任何 Mock 拦截。我们为 Next.js 应用配置的 MSW,其 Service Worker 监听的是主应用的 fetch 请求。而 Storybook 是一个完全独立的开发服务器,运行在不同的端口上,它自己的 iframe 预览环境无法自动利用我们之前的 MSW 配置。我们需要一个专门的“连接器”,让 Storybook 也能加载并使用我们已经编写好的 MSW 请求处理器 (Handlers)。这个连接器就是 msw-storybook-addon

在这一节中,我们将安装并配置这个插件,将我们在 src/mocks/handlers.ts 中定义的“虚拟后端”,无缝地集成到 Storybook 的开发环境中。

7.5.1. 配置 msw-storybook-addon


解决方案:

为了让 Storybook 能够识别并使用我们在 src/mocks/handlers.ts 中定义的请求处理器,我们需要一个专门的“连接器”插件——msw-storybook-addon。它负责在 Storybook 的环境中,启动 MSW 的 Service Worker 并加载我们的 Mock 规则。

第一步:安装插件

我们将 msw-storybook-addon 添加为项目的开发依赖。

1
pnpm add -D msw-storybook-addon

第二步:在 Storybook 中注册插件

与任何其他插件一样,我们需要在 Storybook 的主配置文件中“注册”它,以便 Storybook 在启动时加载它。

文件路径: .storybook/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import type { StorybookConfig } from "@storybook/nextjs-vite";

const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@chromatic-com/storybook",
"@storybook/addon-docs",
"@storybook/addon-onboarding",
"@storybook/addon-a11y",
"@storybook/addon-vitest",
"@storybook/addon-themes",
"msw-storybook-addon", // <-- 新增:在此处注册 MSW 插件
],
framework: {
name: "@storybook/nextjs-vite",
options: {},
},
staticDirs: ['../public'], // <-- 确保 public 目录被 Storybook 服务
};
export default config;

解析:

  • 我们在 addons 数组中添加了 msw-storybook-addon
  • 同时,我们 必须 确保 staticDirs 配置中包含了 public 目录。这是因为 msw init 命令生成的 mockServiceWorker.js 文件位于 public 目录下,Storybook 需要能够访问并提供这个文件给浏览器。

第三步:在 preview.ts 中加载全局 Handlers

最后,也是最关键的一步,我们需要在 .storybook/preview.ts 文件中进行配置,告诉 msw-storybook-addon 如何初始化,以及在哪里找到我们定义的 Mock API 规则。

文件路径: .storybook/preview.ts

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
import type { Preview } from "@storybook/react";

// 1. 从插件中导入初始化工具
import { initialize, mswLoader } from 'msw-storybook-addon';

// 2. 导入我们自己编写的 Handlers
import { handlers } from '../src/mocks/handlers';

// 导入全局样式
import "../src/app/globals.css";

// 3. 初始化 MSW
initialize();

const preview: Preview = {
parameters: {
// 4. 将 handlers 全局应用到所有 stories
msw: {
handlers: handlers,
},
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
// 5. (关键) 添加 mswLoader 以确保 MSW 在 Story 渲染前启动
loaders: [mswLoader],
decorators: [
// ... 此前配置的主题切换装饰器 ...
],
};

export default preview;

代码深度解析:

  1. import { initialize, mswLoader }: 我们从插件中导入两个核心工具。
  2. initialize(): 这个函数必须在文件的顶层调用一次。它负责在浏览器中查找并注册位于 public 目录下的 Service Worker 脚本 (mockServiceWorker.js)。
  3. import { handlers }: 我们导入了在 7.3.2 小节中创建的、包含 /api/search 接口 Mock 逻辑的 handlers 数组。这完美地体现了 MSW “一次定义,处处使用”的理念。
  4. parameters.msw.handlers: 我们在全局 parameters 中配置 msw,将导入的 handlers 作为所有故事的 默认 Mock 规则。这意味着,除非在单个 Story 中特别覆盖,否则所有 Storybook 中的 fetch 请求都会被这套规则所拦截。
  5. loaders: [mswLoader]: 这是至关重要的一步。loaders 是 Storybook 7+ 的一个特性,它允许在 Story 渲染 之前 执行一些异步操作。mswLoader 会确保 Service Worker 已经成功激活,然后再开始渲染组件。这可以有效防止组件在 MSW 就绪前提早发起请求而导致真实网络穿透或失败的问题。

第四步:最终验证

所有配置都已完成。现在,让我们重新启动 Storybook。

1
pnpm storybook

启动成功后,打开浏览器的开发者工具,并切换到 控制台 (Console) 标签页。您应该能看到一条与在 Next.js 应用中看到的完全相同的日志:

[MSW] Mocking enabled.

这条日志的出现,标志着我们的 API Mocking 基础设施已经成功地、无缝地集成到了 Storybook 的开发环境中。我们的“可视化工作台”现在拥有了一个功能齐全、行为可控的“虚拟后端”。

我们终于为 AutoComplete 组件这个复杂异步组件,铺平了最后一块道路,可以在 Storybook 中为它的各种异步状态编写可视化、可交互、可测试的 Stories 了。


7.5.2. 编写异步组件的 Stories

现在,我们的 Storybook 环境已经具备了 Mock API 的能力。我们将利用这一能力,为 AutoComplete 组件编写一套完整的、能够覆盖其所有异步状态的可视化文档。

第一步:创建 Story 文件

1
touch src/components/ui/AutoComplete.stories.tsx

第二步:编写 Meta 对象和基础 Story

我们首先创建 meta 对象,并编写一个最基础的、用于展示成功状态的 Default Story。这个 Story 会直接使用我们在 preview.ts 中配置的全局 MSW Handlers

文件路径: src/components/ui/AutoComplete.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
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { AutoComplete } from './AutoComplete';

const meta: Meta<typeof AutoComplete> = {
title: 'UI/AutoComplete',
component: AutoComplete,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
// AutoComplete 自身没有太多可直接配置的 props,
// 因为其行为主要由内部的 useAutoComplete Hook 驱动。
// 我们可以为底层的 Input props 添加 argTypes。
argTypes: {
placeholder: {
control: 'text',
},
disabled: {
control: 'boolean',
},
},
};

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

// 基础的、展示成功状态的 Story
export const Default: Story = {
args: {
placeholder: '搜索水果 (例如: Apple)...',
},
};

第三步:为“加载中”状态编写 Story

这是 msw-storybook-addon 威力初显的时刻。为了稳定地展示 AutoComplete 在请求过程中的“加载中”状态,我们只需要为这个特定的 Story 覆盖 默认的 MSW Handler,让它返回一个永不结束的 Promise

文件路径: src/components/ui/AutoComplete.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
// ... 此前的 meta 和 Default Story ...
import { http, delay } from 'msw'; // 导入 MSW 的工具函数

// ...
export const Default: Story = { /* ... */ };

// 展示加载中状态的 Story
export const Loading: Story = {
args: {
...Default.args, // 复用默认的 props
// 关键:我们让 Input 框预先填充一个值,以在 Story 加载时就触发搜索
defaultValue: 'apple',
},
parameters: {
msw: {
handlers: [
// 覆盖全局 handler,让这个 Story 的 API 请求无限延迟
http.get('/api/search', async () => {
await delay('infinite');
}),
],
},
},
};

代码深度解析:

  • parameters.msw.handlers: msw-storybook-addon 允许我们通过这个参数,为 单个 Story 提供特定的 handlers。这些 handlers 会覆盖我们在 preview.ts 中设置的全局 handlers。
  • await delay('infinite'): msw 提供的这个工具函数,会创建一个永远不会 resolve 的 Promise,完美地模拟了一个长时间运行的网络请求。

第四步:为“请求失败”和“无结果”状态编写 Story

同理,我们可以轻松地创建用于展示其他边界情况的 Stories。

文件路径: src/components/ui/AutoComplete.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
// ... 此前的 meta 和 Stories ...
import { http, HttpResponse, delay } from 'msw';

// ...
export const Loading: Story = { /* ... */ };

// 展示请求失败状态的 Story
export const Error: Story = {
args: {
...Default.args,
defaultValue: 'any-query', // 触发搜索
},
parameters: {
msw: {
handlers: [
// 覆盖 handler,使其返回一个 500 错误
http.get('/api/search', () => {
return new HttpResponse(null, { status: 500 });
}),
],
},
},
};

// 展示无结果状态的 Story
export const Empty: Story = {
args: {
...Default.args,
defaultValue: 'xyz', // 一个不会匹配到任何结果的查询
},
parameters: {
msw: {
handlers: [
// 覆盖 handler,使其返回一个空数组
http.get('/api/search', () => {
return HttpResponse.json([]);
}),
],
},
},
};

现在,如果您运行 pnpm storybook,您将在 UI/AutoComplete 下看到四个独立的 Story,每一个都精确、稳定地展示了组件在一种特定异步状态下的 UI 表现。


7.6 本章小结

在本章中,我们围绕着“异步”这一主题,进行了一次从工程化基建到高级 Hooks 应用的深度实践。

  1. API Mocking 战略: 我们首先深入探讨了前端开发中 API Mocking 的必要性,横向对比了多种方案,并最终确立了 MSW 作为我们项目的最佳实践。我们学会了它的核心原理(网络层拦截)和基础配置。
  2. 高级 Hooks 组合: 我们构建了一个复杂的 useAutoComplete Hook,它完美地 组合 了我们在第六章学习的 useDebounce(性能优化)和 useReducer(状态机管理),是“胖 Hooks,瘦组件”架构思想的终极体现。
  3. 复杂组件构建: 我们通过 组合 InputPopover 原语,并为其注入 daisyUI 样式,成功地构建了一个功能与外观兼备的 AutoComplete UI 组件。
  4. 异步 Storytelling: 我们掌握了使用 msw-storybook-addon 为异步组件编写独立、确定的 Stories 的高级技巧,能够轻松地文档化 loading, error 等边界状态。

通过本章,您不仅收获了一个生产级的 AutoComplete 组件,更重要的是,掌握了一整套应对任何复杂异步组件的 通用解决方案

课程总结:从“组件开发者”到“设计系统架构师”的思维跃迁

本次课程的核心,不仅在于掌握一系列现代化的前端技术,更在于完成一次深刻的思维模式转变。我们沿着一条精心设计的路径,从关注单个组件的 “实现细节”,逐步提升到关注整个系统的 “架构、流程与生态”,最终以内化的“架构师思维”来审视和解决问题。


第一章. 架构师的开篇:从组件库到设计系统

这是我们思维跃迁的起点。我们建立的第一个认知,就是我们构建的并非一个简单的 UI 工具集,而是一个系统性的工程解决方案。

  • 思维跃迁:从“我需要一个按钮”,到“我们需要一个能定义、生产和治理所有组件的 系统”。我们精确定义了设计系统的三大支柱—— 设计语言、组件库、模式与文档,明确了其作为公司核心数字资产的战略地位。
  • 技术决策:我们不再是盲目跟风,而是基于“行为与样式分离”的核心原则,做出了 Radix + Tailwind v4 + daisyUI 的架构选型。这体现了架构师对技术背后 设计哲学 的深度思考。
  • 工程基石:我们从零搭建了 Prorise UI 项目,并立刻建立了一套包含 Huskylint-stagedcommitlint 的自动化质量保障体系。这标志着我们将 质量内建于流程,而非事后弥补,这是架构师对项目长期健康度负责的体现。

成长图谱:我们学会了像架构师一样,在编码之前先 建立认知框架、进行审慎的技术选型,并搭建稳固的工程化地基


第二章. 工作流革命:Storybook 与“文档驱动开发”

在这一章,我们通过开发最基础的 Button 组件,演练并固化了一套世界级的组件开发模式。

  • 思维跃迁:从“先写代码,后写文档”,到以 Storybook 为中心的 “文档驱动开发”。我们将组件的“文档”和“故事”视为开发过程的起点和中心,而非终点和附属品。
  • 核心产出:我们提炼出了一套可复用的 “黄金工作流”
    1. 构思与可视化 (Storybook First):先定义组件的 API 契约和视觉形态。
    2. 定义与验证行为 (Interaction Testing):用 play 函数编写可执行的交互文档。
    3. 编码实现 (Make it Pass):以通过所有“故事”和“测试”为目标进行编码。
    4. 叙事与文档 (MDX Enrichment):最后,将已有的可视化示例组装成丰富的文档。
  • 质量内化:通过 play 函数,我们将 集成测试 无缝地融入到了组件的开发和文档流程中,实现了“Story 即测试,测试即文档”。

成长图谱:我们掌握了一套 可预测、可重复、高质量 的组件生产流程。思维从关注“代码实现”转向关注 “定义标准和流程”


第三章. 模式的掌握:从单一组件到复合组件

我们挑战了 DropdownMenu 这样的复杂组件,深入到了 React 设计模式的核心。

  • 思维跃迁:从思考“一个组件如何工作”,到思考“一组组件如何协同工作”。我们深入理解了 复合组件模式 的优雅与灵活性,并掌握了其背后的技术基石——React Context
  • 架构能力:我们学会了通过 对比分析daisyUI 纯 CSS 方案 vs. Radix JS 驱动方案)来识别现有方案的局限性,并做出引入更专业底层技术的决策。这体现了架构师进行技术权衡和方案升级的能力。
  • 封装实践:我们亲手将 Radix 的“无头”行为骨架与 daisyUI 的样式“皮肤”完美结合,产出了一个生产级的 DropdownMenu 组件。

成长图谱:我们具备了 设计和实现复杂组件 的能力,并能熟练运用高级设计模式来管理复杂性,实现 高内聚、低耦合 的 API。


后续章节. 生态的治理:主题、图标与逻辑抽象

最后,我们将视野从单个组件彻底提升到了整个设计系统的生态层面,学习如何管理和扩展系统的核心资产。

  • 思维跃呈:从“使用系统”到 “治理和扩展系统”
    • 主题系统:我们剖析了项目中的双轨主题架构,并动手将其重构为 “单一事实来源” 的统一模式,最后构建了企业级的动态主题切换器。这体现了架构师对系统一致性和可维护性的追求。
    • 图标系统:我们进行了战略性的方案对比,并决策采用 SVGR + Iconify混合战略,最后通过统一的 <Icon> 组件,将复杂的底层实现封装在优雅的 API 之后。这展示了架构师进行顶层设计和抽象封装的能力。
    • 逻辑抽象与 API Mocking:我们通过构建 useAutoComplete 等高级 Hooks,践行了 “胖 Hooks,瘦组件” 的现代 React 架构思想。并通过集成 MSW,为前端开发流程建立了 独立、健壮的 Mocking 基础设施,彻底解决了前后端依赖阻塞问题。

成长图谱:我们学会了从 系统治理、战略决策、优雅抽象 的角度思考问题,掌握了维护一个设计系统长期健康、提升整个团队开发体验所需的顶层技能。

最终总结:思维的跃迁

回顾这段旅程,我们能力的成长是螺旋式上升的,而每一次提升的内核,都是一次思维模式的跃迁:

  • 组件开发者 关心的是 “How”:如何用代码实现一个具体的功能。
  • 设计系统架构师 关心的是 “What”“Why”
    • What: 我们应该遵循 什么 样的规范、流程和模式,来保证所有产出物的一致性和高质量?
    • Why: 我们 为什么 选择这个技术栈?它背后的设计哲学是什么?这个架构决策将如何影响系统的长期演进和团队的协作效率?

我们从一个简单的 Button 出发,最终收获的不仅是一个功能完备的 Prorise UI 设计系统,更是一套内化于心的、能够指导我们未来构建任何复杂前端应用的 架构师思维模型