首页
/ Dify 的 @langgenius/dify-ui:基于 Base UI 与 Tailwind CSS v4 的私有 UI 原语包

Dify 的 @langgenius/dify-ui:基于 Base UI 与 Tailwind CSS v4 的私有 UI 原语包

2026-09-06 17:03:32作者:管翌锬

@langgenius/dify-ui 是 Dify 仓库 packages/dify-ui 目录下的一个私有工作区包,为 Dify 各前端产品提供独立的 UI 原语、设计令牌、CSS-first 的 Tailwind v4 样式和 cn() 工具函数。阅读本文可以掌握该包的引入方式、全部公开子路径导出的原语清单、基于 Base UI 的包装策略与 cva/设计令牌体系,以及围绕该包建立的文档契约、样式规范与测试体系,从而在 Dify 前端代码中正确选用、组合这些 UI 原语。

1. 包定位:独立原语包与“薄包装”策略

根据包索引 README 的说明,@langgenius/dify-ui 的定位是:

  • 提供 独立的 UI 原语(independent UI primitives)设计令牌(design tokens)CSS-first 的 Tailwind 样式以及 cn() 工具,服务对象是 Dify 的全部产品线;
  • 大多数交互式原语是 Base UI 无头(headless)组件的薄且带立场(thin, opinionated)的包装器——即 Dify 只在其上叠加自己的外观、变体与细节行为,交互逻辑仍归上游所有;
  • Dify 自研的原语统一使用语义化 HTMLcva(class-variance-authority)、cnDify 设计令牌来构建;
  • 包本身对 workspace 私有private: true),但它的公开子路径(public subpaths)被视为稳定的包边界——消费方可以依赖这些子路径,而包内部实现可以自由演进。

从源码结构看,这一策略在 package.json 中得到印证:

  • 包名为 @langgenius/dify-uiversion0.0.1private: true
  • exports 字段显式声明了 ./styles.css./cn 以及 30+ 个原语子路径(如 ./button./dialog./select),每个子路径分别给出 typesimport 两个条件,均指向 src/<name>/index.tsx
  • peerDependencies 声明了 @base-ui/reactclass-variance-authorityreactreact-domtailwindcss 五个对等依赖,说明交互能力、变体系统、样式引擎都刻意外置给消费方统一提供;
  • 依赖里唯一的运行时依赖是 cn(workspace 版本),对应实现见 src/cn.ts,其中仅有一行 export { cn } from 'cn'——即 cn 复用工作区共享实现,本包只做再导出。

包边界规则集中写在 AGENTS.md 中,核心约束包括:

  • 保持独立原语包身份,不得导入应用包,不得依赖路由、i18n、应用状态、schema、数据获取或业务 API;
  • 需要无头行为时优先使用 @base-ui/react,用 cvacn 与设计令牌做外观,每个原语一个 src/<name>/ 目录,stories 与测试就地放置;
  • 优先使用 Base UI 的 data 属性与 CSS 变量表达视觉状态,不得仅为加 class 而在 React 侧镜像原语状态;
  • 上游 API 或选择器契约不明确时,先读当前官方 Base UI 文档与已安装的 @base-ui/react 类型声明再编码。

2. 使用方式:workspace 依赖 + 无根桶导出

包索引 README 给出标准接入方式:在消费方的 package.json 中声明 workspace 依赖:

{
  "dependencies": {
    "@langgenius/dify-ui": "workspace:*",
  },
}

导入必须走公开子路径。该包刻意不提供根桶(root barrel)导出,目的是让每个原语成为一条独立、可追踪的模块边界:

import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { Input } from '@langgenius/dify-ui/input'
import { cn } from '@langgenius/dify-ui/cn'
import '@langgenius/dify-ui/styles.css'

样式入口 styles.css 需要在消费方的根样式表或入口点处只导入一次(配合 Tailwind v4 的接入方式见第 6 节)。

authoring 指南 呼应的是:包内部组件之间可以相对导入,消费方只允许通过公开子路径导入;每个公开原语都要求 package.json#exports 中存在匹配的子路径。

3. 公开原语总览

包索引 README 的完整原语分类表如下(与 package.jsonexports 一一对应):

分类 公开子路径
Actions ./button./icon-button./toggle
Controls ./segmented-control
Display ./collapsible./kbd
Feedback ./meter./progress./status-dot./toast
Form ./form./field./fieldset./input./input-group./textarea./checkbox./checkbox-group./radio-group./number-field./select./slider./switch
Layout ./scroll-area
Media ./avatar
Navigation ./file-tree./pagination./tabs
Overlay and menu ./alert-dialog./context-menu./dialog./drawer./dropdown-menu./popover./preview-card./tooltip
Search and pick ./autocomplete./combobox./select

两个通用工具子路径:

  • ./cn:基于 clsx + tailwind-merge 组合条件类名;
  • ./styles.css:提供设计令牌、主题变量与共享工具类。

从目录结构看,src/ 下每个原语目录都遵循统一布局:index.tsx(公开 API 边界)、index.stories.tsx(Storybook 契约示例)、__tests__/index.spec.tsx(单元测试);仅 buttonicon-buttoninput-group 三个组件额外拥有本地 README,对应下文的组件级契约。此外还有跨原语共享的模块,如 src/form-control-shared.tssrc/overlay-shared.tssrc/internals/use-iso-layout-effect.ts

4. 以 Button 为例:cva 变体体系与 Dify 立场

Button 契约 是本包“薄包装 + Dify 立场”的最佳样本。其语义约定:

  • 有可见文本标签的动作使用 Button;图标按钮用 IconButton;持久按压态用 Toggle导航到 URL 一律用原生链接,不通过 Button 渲染链接,只复用其视觉变体:
<a className={buttonVariants({ variant: 'secondary' })} href="/settings">
  Settings
</a>
  • Button 默认渲染 <button type="button">,表单提交时必须显式 type="submit"
  • render 配合 nativeButton={false} 仅用于“非 button 元素刻意需要按钮语义”的场景,不是链接模式

src/button/index.tsx 中可以看到这套契约的落地:buttonVariantscva 定义了三个维度——

  • variantprimary / secondary / secondary-accent / tertiary / ghost / ghost-accent
  • sizesmall(h-6,gap 3px 级)、medium(h-8,默认)、large(h-9);
  • tonedefault / destructive,并针对每个 variant 通过 compoundVariants 给出 destructive 专属令牌;
  • defaultVariantsvariant: 'secondary'size: 'medium'tone: 'default'

所有视觉状态都通过设计令牌类名(如 bg-components-button-primary-bgdata-disabled:text-...)表达,并依赖 Base UI 的 data-disabled 等 data 属性而非 React 状态镜像——这正是 AGENTS.md 边界规则在实现层面的体现。ButtonPropsOmit<BaseButtonNS.Props, 'className'> 叠加 VariantProps 与 Dify 自有的 loadingclassName 构成,className 在包装层统一经 cn() 消费。

loading 与 disabled 的区分

Button README 用一张表明确区分了两个事实:

Prop 含义 默认焦点行为
disabled 动作不可用。 原生禁用,移出 Tab 序。
loading 动作已被触发、正在等待。 等待期间阻止激活,但按钮保留焦点。

内部映射为:

disabled={disabled || loading}
focusableWhenDisabled={focusableWhenDisabled ?? loading}

也就是说,loading 期间按钮以 aria-disabled 保留可聚焦性而不是原生 disabled,防止“触发后焦点丢失”;调用方只需把等待状态传给 loading,把独立的可用性条件留在 disabled

<Button loading={isSaving} disabled={!canSave}>
  Save
</Button>

契约还明确禁止把同一等待态重复写进 disabled,并说明 loading 与独立的 disabled 同时为 true 是合法的;只有当希望 loading 按钮退出 Tab 序时才显式传 focusableWhenDisabled={false}。另一个可访问性立场是:spinner 是装饰性的,Button 不添加 aria-busy(该属性的 WAI-ARIA 定义针对“内容可能被辅助技术延迟修改”的元素,不是待处理动作的通用替代);长任务的播报/进度由功能侧自己的状态或 live region 负责。

内容间距契约:Button 自己拥有直接子元素之间的间距,调用方不要给图标加 margin 或标准 gap-*medium/large 分别为 4px/6px,smallprimary 下 3px、其余变体 4px。

IconButton 与 Input Group 契约

另外两份组件级契约分别是 IconButtonInput Group

  • IconButton:用于“一个图标、无可见文本”的命令。必须且只能提供一个可访问名称来源(aria-labelaria-labelledby),tooltip 只是视觉增强、不构成可访问名称;glyph 作为单个装饰性 React 元素传入并 aria-hidden,glyph 自己负责光学尺寸,IconButton 负责尺寸、圆角、颜色、hover、disabled 与 focus-visible 外观。当 Toggle、Menu、Popover、Tooltip、Collapsible 拥有交互状态时,把状态原语放在外层、通过 render prop 组合 IconButton,而不是把状态镜像到图标按钮上。
  • InputGroup:当一个文本输入要与前缀/后缀/动作共享同一视觉表面时使用。结构要求恰好一个直接的 InputGroupInput 加一个或多个直接的 InputGroupAddonInputGroup 拥有边框、背景、聚焦态与非交互指针表面,InputGroupInput 拥有原生输入与值。DOM 顺序上输入必须位于所有 addon 之前,视觉位置用 align="inline-start"/"inline-end" 表达,不改变语义与焦点顺序。点击非交互表面会把焦点移给直接输入,点击交互 addon 则只作用于该控件本身;共享表面行为可通过 InputGrouponMouseDownpreventDefault() 取消。需要名称、标签、校验、描述或错误时,外层包 Field,其状态会传播到 InputGroupInput

5. 样式体系:styles.css、主题与设计令牌

CSS 入口 src/styles/styles.css 是 Tailwind v4 意义上的“preset(CSS-first)”,文件头注释明确:它是消费方唯一的 CSS 入口(@import '@langgenius/dify-ui/styles.css'),且消费方必须在自己的根样式表中 @import 'tailwindcss' 让引擎生成工具类——该文件只贡献设计令牌、运行时 CSS 变量与项目工具类。入口的导入结构为:

@import '../themes/theme.css';
@import '../themes/light.css' layer(base);
@import '../themes/dark.css' layer(base);
@import './utilities.css';
@import './components.css';

随后用 @theme 覆盖 Tailwind v4 的默认 oklch 调色板,把 bg-gray-500text-primary-600 等标准类名解析到 Dify 品牌色板(例如 --color-gray-500: #667085--color-primary-500: #2970ff),从而让“标准 Tailwind 类名 = 设计系统取值”。主题变量分别放在 src/themes/theme.csslight.cssdark.css,工具类与组件级样式则拆在 utilities.csscomponents.css 中。

Styling 指南 补充了消费方的完整接入步骤与两条硬性规范:

  1. 消费方样式表接入@source 路径相对消费方样式表解析):
@import 'tailwindcss';
@import '@langgenius/dify-ui/styles.css';

若 workspace 消费方需要直接扫描 Dify UI 源码,追加 @source

/* Example only: resolve paths from this stylesheet. */
@source '../../../packages/dify-ui/src';
@source not '../../../packages/dify-ui/src/**/*.{spec,test}.{ts,tsx}';
@source not '../../../packages/dify-ui/src/**/*.stories.{ts,tsx}';
  1. Figma 圆角令牌映射:Figma 圆角令牌与 Tailwind v4 默认值错开一档,必须按下表转换,而不是新增自定义主题值或 radius-* 工具类:
Figma 令牌 Tailwind 类
--radius/2xs rounded-xs
--radius/xs rounded-sm
--radius/sm rounded-md
--radius/md rounded-lg
--radius/lg rounded-[10px]
--radius/xl rounded-xl
--radius/2xl rounded-2xl
--radius/3xl rounded-[20px]
--radius/6xl rounded-[28px]
--radius/full rounded-full

例如 Figma 输出的 rounded-[var(--radius/sm, 6px)] 应转换为映射表中的 Tailwind 类;只有没有标准类匹配时才允许任意值。指南同时要求:优先用语义 Dify 令牌与既有组件变体,其次才考虑硬编码;important 修饰符只用于“所有权的变体、data 属性与选择器结构都无法表达该状态”时的紧凑兼容覆盖;focus-visible 样式要挂在视觉上代表焦点的元素上(例如 SliderThumb 因内部 range input 接收焦点而使用 has-[:focus-visible])。

6. 文档体系:组件指南与跨组件契约

包索引 README 的 Guides 章节给出了文档导航原则:从这里开始,然后只打开正在改动的那个契约对应的指南;组件特定的 Dify 行为放在组件旁边,多个原语共享的契约放在 docs/,上游行为归 Base UI 官方文档所有。

组件级指南(Dify 自有契约):

指南 Dify 自有契约
Button 动作语义、submit 与链接的选择、loading 与 disabled 的区分、内容间距。
Icon Button 可访问名称、装饰性 glyph、外观所有权、原语组合。
Input Group 复合输入解剖、共享表面所有权、DOM 顺序、焦点、交互 addon。

跨组件指南(均在 packages/dify-ui/docs/ 下):

指南 范围
Accessible names and descriptions 命名来源、描述、覆盖、安全地移除 label。
Forms 原生提交边界、值所有权、字段、标签与错误。
Selection 带类型的值,以及在分段控件、选择器与单选组之间的选择。
Overlays Portal、presence 生命周期、层级、触发器组合与语义。
Styling Tailwind CSS 集成与 Figma 圆角映射。
Public API authoring 子路径导出、命名、公开类型、泛型、私有辅助。
Testing and development 包命令、测试所有权、可访问性、动画配置。

公开 API 编写规范要点

Public API authoring 是新增原语前必读的契约,关键规则:

  • 每个 src/<primitive>/index.tsx 就是显式公开 API 边界,实现细节保持模块私有,在文件底部用独立的 export { ... }export type { ... } 清单统一发布,禁止散落的内联导出和通配符导出
  • 命名上以原语名作为规范边界与 props 类型名(Select/SelectProps),仅当一个子路径同时导出低层解剖与高层便捷组件时才保留 Root(如 CheckboxRootCheckbox);
  • 每个运行时组件必须有精确、可导入的同名 props 类型;Dify 自研的复合 props 定义在 Dify UI 边界上,而不是复制上游形状;受控/非受控、单选/多选等“一个 prop 改变其余 props 合法形状”的场景用可辨识联合;
  • 泛型契约要端到端保持(选择器 Value/Multiple、表单值、radio 与 slider 值、overlay 负载与句柄),不得用 any 或硬编码 string 抹掉调用方类型;Tabs 因上游值类型是 any | null 而刻意保持非泛型根——“不要宣传完整解剖无法强制的类型关系”;
  • 保持公开面最小:类型不因“Base UI 有这个名字”或“实现曾经导出过”而自动公开;状态、事件细节、actions、受控态辅助、context 值、渲染/样式辅助与上游透传别名默认私有;包装器若已通过 cn() 消费 className,就省略上游的状态回调形式,公开 className?: string

测试与开发

Testing and development 定义了可复制的命令面。从仓库根目录运行 vp check packages/dify-ui 做格式化、lint 与 TypeScript 诊断;其余命令在 packages/dify-ui/ 下运行(对应 package.jsonscripts):

  • vp test --project unit:运行原语单元测试;
  • vp run storybook:启动 Storybook;
  • vp test --project storybook --run:以浏览器模式运行 Storybook 组件测试;
  • vp test:运行两个测试项目。

测试边界的设计值得借鉴:包内有两个 Vitest 项目(unit 与 storybook),都在 Playwright Chromium 的 Browser Mode 中运行,项目名标识行为所有者而非不同运行时。Storybook 用于“有文档的组件示例”,每个 story 都是一条渲染契约并跑配置好的可访问性检查;当示例还承担可见状态变化、用户交互、键盘路径、overlay 流程、表单行为、loading 行为或受控态协同时才添加 play 测试。更底层的包装契约(类变体、Base UI 透传 props、隐藏输入序列化、data 属性钩子、store、无需文档示例的边缘情况)用普通 Vitest 测试覆盖。

Storybook 可访问性测试使用 a11y.test = 'error',违规即失败;颜色对比是唯一全局禁用的规则(已知的设计令牌缺口),并明确禁止再添加全局例外——临时例外要局部限制在受影响的 story 内,且不能用 play 测试替代可访问性修复。

动画配置上,Base UI 可以在卸载过渡组件前等待 element.getAnimations();当测试断言最终 DOM 状态而非动画行为时,在 Vitest setup 中置位:

;(
  globalThis as typeof globalThis & {
    BASE_UI_ANIMATIONS_DISABLED: boolean
  }
).BASE_UI_ANIMATIONS_DISABLED = true

vitest.setup.ts 已为原语测试应用该标志;Storybook 使用自己的 preview 配置并保留真实动画生命周期。刻意断言动画行为的单元测试可以临时恢复为 false,但必须在清理时还原旧值。

7. 修改边界:贡献前必读

包索引 README 的 Contributing 章节只有一条指令:修改包之前先读 component authoring rules,然后只打开对应的契约所有者指南——这份索引刻意不重复那些契约。结合 AGENTS.md 的 Contract owners 章节,改动路由关系为:

另一个明确规则:组件只有在“拥有一份类型、stories 与上游文档都无法表达的、相当体量的 Dify 特有契约”时才配拥有本地 README,不为完整性而创建。

8. 小结

packages/dify-ui 的价值不在单个组件的丰富度,而在一套自洽的工程约束:Base UI 拥有交互、Dify 拥有外观与契约;公开面只通过 package.json#exports 子路径暴露;样式走 Tailwind v4 CSS-first 的设计令牌与 Figma 映射;文档按“契约所有者”拆分到组件旁与 docs/;测试则用 unit 与 storybook 两个浏览器模式项目分别守住“底层契约”与“文档示例契约”。阅读 包索引 READMEAGENTS.mddocs/ 目录下的各指南,即可完整还原这套体系并安全地扩展新的原语。

登录后查看全文
热门项目推荐
相关项目推荐