首页
/ Dify Button 组件详解:动作语义、loading 与 disabled 状态契约,以及基于 Base UI 的变体体系

Dify Button 组件详解:动作语义、loading 与 disabled 状态契约,以及基于 Base UI 的变体体系

2026-09-06 15:10:49作者:何举烈Damon

本文基于 Dify 仓库中 Button 组件指南 展开,系统讲解 @langgenius/dify-uiButton 组件的设计契约:它如何作为 Base UI Button 的"有主见"(opinionated)包装器,在保留上游按钮语义、焦点与组合行为的前提下,叠加 Dify 的视觉变体、尺寸间距与 loading 状态。读完后,你可以在 Dify 前端代码中正确选择按钮语义(原生按钮、表单提交、链接样式复用)、正确区分 loadingdisabled 的适用场景,并理解其 cva 变体体系与无障碍(a11y)设计背后的实现原理。

1. 组件定位:一个"有主见"的包装器

Dify UI 的 Button 位于独立 UI 包 packages/dify-ui 中。按照包级文档的说明,该包的绝大多数交互原语都是对 Base UI headless 组件的薄而"有主见"的包装:Dify 负责视觉变体、设计令牌与状态语义,交互与组合行为仍由上游 Base UI 契约主导。Button 正是这一模式的典型代表:

  • 上游行为(按钮语义、键盘交互、disabled 行为、组合渲染)由 Base UI 的 Button 提供;
  • Dify 层通过 class-variance-authoritycva)注入 variant / size / tone 三组视觉变体;
  • Dify 层额外新增 loading 状态,并接管了 Base UI 推荐的 focusableWhenDisabled 接线。

组件入口为 packages/dify-ui/src/button/index.tsx,对外导出 ButtonbuttonVariants 与类型 ButtonProps(见 index.tsx#L143-L145)。包的 package.json 通过子路径导出 ./button 提供稳定边界(见 packages/dify-ui/package.json#L24-L27),消费方按子路径引入,而非根桶文件:

import { Button } from '@langgenius/dify-ui/button'
import '@langgenius/dify-ui/styles.css'

styles.css 提供设计令牌与主题变量,消费方应在根样式表中只引入一次(见 包级 README 的 Usage 章节)。Dify Web 应用中已有大量该子路径的实际消费点,例如 web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx/webapp-signin/components/sso-auth.tsx) 等登录、重置密码页面。

2. 按钮语义:何时用 Button,何时不用

2.1 默认渲染原生按钮,提交表单需显式声明

Button 默认渲染原生 <button type="button">——这一点在源码中体现为组件签名里的 type = 'button' 默认参数(见 index.tsx#L112-L127)。当按钮承担表单提交职责时,必须显式传入 type="submit"

<form onSubmit={handleSubmit}>
  <Button type="submit">Save</Button>
</form>

单元测试直接锁定了这两个事实:默认属性为 type="button",且允许覆写为 submit(见 tests/index.spec.tsx#L10-L18)。

2.2 禁止用 Button 渲染链接

指南明确:不要把链接渲染成 Button。Base UI 会对渲染元素施加按钮语义、键盘交互和 disabled 行为,把锚点塞进 Button 会与这套语义冲突。正确做法是保留原生 <a> 或路由链接,只复用视觉变体函数 buttonVariants

<a className={buttonVariants({ variant: 'secondary' })} href="/settings">
  Settings
</a>

buttonVariants 是一个导出的 cva 实例,调用方可以传入 variantsizetoneclassName,因此链接(或任何其他元素)都能获得与按钮一致的视觉语言,而不承担按钮的交互语义。Storybook 中专门有一个 StyledLink 故事来演示与固化这一反模式边界(见 index.stories.tsx#L160-L180)。

2.3 render + nativeButton={false} 的正确用法

只有当某个非按钮元素确实需要按钮语义时,才使用 render 配合 nativeButton={false}。测试用例验证了渲染结果确实是一个 DIV 但保留了 button 的 accessibility role(见 index.spec.tsx#L20-L28)。指南特别强调:这不是"链接模式",链接场景请回到上一节的原生锚点方案。

3. loadingdisabled:两个描述不同事实的 prop

这是该文档最核心的语义契约。disabledloading 描述的是两个不同的事实,不能混用:

Prop 含义 默认焦点行为
disabled 该动作不可用(availability) 原生 disabled,移出 Tab 顺序
loading 动作已被触发,正在等待中(pending) 激活被阻断,但按钮保留焦点

3.1 源码中的状态映射

内部实现把这两个状态映射到 Base UI 的交互契约上(见 index.tsx#L125-L130):

<BaseButton
  type={type}
  className={cn(buttonVariants({ variant, size, tone, className }))}
  disabled={disabled || loading}
  focusableWhenDisabled={focusableWhenDisabled ?? loading}
  {...props}
>

两个关键点:

  1. disabled || loading:loading 期间一律阻断激活,无论 disabled 是什么;
  2. focusableWhenDisabled ?? loading:Dify 的 loading prop "拥有" 这行接线——加载中默认让按钮保持可聚焦。这样用户在触发一个异步动作后不会丢失焦点(比如回车键焦点还停在按钮上),交互连续性更好。

实现细节上,loading 按钮使用的是 aria-disabled 而非原生 disabled 属性来表达"不可用"。aria-disabled 保留可聚焦性,但要求组件自身负责抑制激活行为——这正是 Base UI 对 focusableWhenDisabled 的处理方式。Dify UI 在测试中验证了默认 disabled 走原生语义(无 aria-disabled 属性,见 index.spec.tsx#L32-L38),而 Storybook 的 Loading 故事则通过 play 函数断言:loading 按钮带 aria-disabled="true"、不带 aria-busy、可被聚焦、点击不触发 onClick(见 index.stories.tsx#L88-L115)。

还有一个防"意外提交"的行为级测试:表单内一个 type="submit" loading 的按钮,在输入框中按回车不会触发表单 onSubmit,因为 loading 态会阻断隐式提交(见 index.spec.tsx#L50-L67)。

3.2 调用方守则:pending 状态只写进 loading

  • 独立的可可用性条件放在 disabled
<Button loading={isSaving} disabled={!canSave}>
  Save
</Button>
  • 不要把同一个 pending 状态重复写进 disabled
// 错误:loading 已经阻断了激活
<Button loading={isSaving} disabled={isSaving}>Save</Button>

// 错误:disabled 中只保留独立可用性条件
<Button loading={isSaving} disabled={isSaving || !canSave}>Save</Button>

// 正确
<Button loading={isSaving} disabled={!canSave}>Save</Button>
  • loading 与独立的 disabled 条件同时为 true 是合法的:pending 期间 loading 的焦点策略生效;loading 结束后,剩余可用性条件仍决定按钮是否禁用。
  • 只有当 loading 按钮应显式退回到原生 disabled 行为、允许离开 Tab 顺序时,才传 focusableWhenDisabled={false}。测试用例覆盖了这个退出开关(见 index.spec.tsx#L40-L47)。

3.3 无障碍:为什么不加 aria-busy

加载中的旋转图标是装饰性的(源码中带 aria-hidden="true",见 index.tsx#L133-L138),不会替代按钮的可见文本。Button 故意不添加 aria-busy:按照 WAI-ARIA 规范,aria-busy 定义的是"内容正在被修改、辅助技术可以推迟获取其内容变化"的元素,它不是"待处理动作状态"的通用替代品。当长任务需要状态播报或进度更新时,由功能层(feature)自行拥有对应的 status、live region 或 progress 组件,而不是依赖按钮。

旋转图标的实现细节同样体现了克制:i-ri-loader-2-line size-3 animate-spin,并带 motion-reduce:animate-none 以尊重用户的"减少动态效果"系统偏好。

4. 内容与间距:Button 拥有子元素间距

Button 负责其直接子元素之间的间距,调用方不应再给图标加 margin 或在调用点补一个常规的 gap-*

<Button>
  <span aria-hidden="true" className="i-ri-rocket-line size-4" />
  Launch
</Button>

从源码的 cva 定义可以精确还原指南给出的间距数值(gap-* 为 Tailwind 间距刻度,1 = 4px):

size 类名 实际 gap
small gap-1index.tsx#L46 4px
small + primary compoundVariants 覆写为 gap-0.75index.tsx#L55-L60 3px
medium gap-1index.tsx#L47 4px
large gap-1.5index.tsx#L48 6px

即:常规(medium)与 large 使用 4px / 6px 间距;smallprimary 变体用 3px、其余变体用 4px——与指南描述完全一致。只有在有文档记录的布局例外时,才用 className 覆写间距。

5. 变体体系:variant / size / tone 三轴

源码中 buttonVariants 的完整定义见 index.tsx#L9-L104。三轴结构如下:

  • variant(6 种)primarysecondarysecondary-accenttertiaryghostghost-accent
  • size(3 种)small(h-6 / 12px 高、text-xs)、medium(h-8、13px 字号)、large(h-9、text-sm、font-semibold);
  • tone(2 种)defaultdestructive

默认组合为 variant: 'secondary'size: 'medium'tone: 'default'index.tsx#L98-L102)。

值得注意的实现手法:所有视觉状态(normal / hover / disabled)都挂在同一组令牌前缀上(components-button-*),并且 disabled 态使用 Tailwind 的 data-disabled: 变体而不是硬编码 disabled:——这与第 3 节中 aria-disabled 而非原生 disabled 的 loading 方案是一致的:样式层只依赖 data-disabled 这个统一事实来源,与底层究竟是原生 disabled 还是 aria-disabled 解耦。tone: 'destructive' 通过 compoundVariantsprimary / secondary / tertiary / ghost 四个变体交叉生成红色系令牌(index.tsx#L55-L97)。primary + small 还有一个复合覆写:更小的 gap-0.75 px-2,用于紧凑场景。

公共基类统一处理了布局与焦点环:inline-flex items-center justify-center overflow-hidden whitespace-nowrap focus-visible:ring-2 ... data-disabled:cursor-not-allowedindex.tsx#L10),即焦点可见性由组件保证,调用方无需重复。

6. 组合与相邻组件的分工

指南给出了三条组件选择边界:

  1. 有可见文本标签的动作 → Button
  2. 纯图标命令(无可见文本) → IconButtonIconButton 额外要求恰好一个可访问名称来源(aria-labelaria-labelledby),且装饰性字形必须由 aria-hidden 的子元素携带;tooltip 只是视觉增强,不是可访问名称。图标 + 文字的场景仍归 Button("including buttons with leading or trailing icons");
  3. 持久按压状态 → Toggle;激活即跳转 URL → 原生链接。

当 Toggle、Menu、Popover 等原语拥有交互状态时,应保持这些原语在外层、通过 render prop 组合 IconButton(见 icon-button/README.md 的 Composition 章节),而不是把状态镜像到图标按钮上。Button 的 ref 转发能力(测试见 index.spec.tsx#L78-L92)也支持这种组合场景。命名与描述的系统性契约详见 docs/accessible-names-and-descriptions.md

7. 开发验证:如何运行本组件的测试与 Story

dify-ui 包提供以下脚本(见 package.json#L165-L172):

  • pnpm --filter @langgenius/dify-ui test:以浏览器模式(vitest + playwright)运行单元测试,例如 button 的 spec 文件
  • pnpm --filter @langgenius/dify-ui storybook:在 6006 端口启动 Storybook,浏览 index.stories.tsx 中的全部 11 个故事(Default / Secondary / SecondaryAccent / Ghost / GhostAccent / Tertiary / Disabled / Loading / Destructive / WithIcon / SmallSize / LargeSize / StyledLink),其中 LoadingStyledLink 故事自带 play 交互断言,可自动验证焦点与 aria 行为。

8. 要点回顾

  • Button 默认 type="button";表单提交必须显式 type="submit";链接保持原生 <a> + buttonVariants,不要塞进 Button
  • disabled 表达"不可用",loading 表达"已触发、待完成";pending 状态只写 loading,可用性条件只写 disabled,两者正交。
  • loading 按钮默认走 aria-disabled + 保留焦点(focusableWhenDisabled ?? loading),可聚焦性可通过 focusableWhenDisabled={false} 显式退出;组件不加 aria-busy,播报职责归功能层。
  • 子元素间距由组件按 size/variant 统一管理(3–6px),调用方不补 gap-*
  • 视觉三轴 variant × size × tone 全部经由导出的 cva 实例 buttonVariants 生成,disabled 样式统一挂在 data-disabled: 变体上。
  • 行为正确性由浏览器模式单元测试(tests/index.spec.tsx)与 Storybook play 断言双重固化。
登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391