项目:react-ui | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
在业务系统里,「数据契约」通常指接口报文或数据库 schema;在组件库语境下,它换了一个形态——每个组件对其消费者做出的全部承诺:props 的类型与默认值、受控/非受控的值语义、事件回调的签名、样式定制的合法通道(StylesApi 选择器与 CSS 变量白名单)、以及主题环境对组件行为的约束。本篇为 7 个核心实体(Button、TextInput、Select、Tabs、Modal、DataTable、Notifications)各立一份「契约卡片」:导入语句、全配置代码示例(受控/非受控两形态)、StylesApi 定制与 CSS 变量覆盖的真实用法、核心 props 表、行为说明。示例全部按源码中的类型定义与 JSDoc 编写,可核对出处以「见《0X-xxx》」引用(组件总体设计见《02-组件设计》,受控双模语义见《04-数据模型》,导出清单见《15-接口全量参考》——本篇不重复那三篇的系统论述,只提供「拿来即用」的完整示例与逐条契约说明)。
阅读本篇前需要知道的两条全局契约:其一,所有组件必须在 UIProvider 树内使用(未包裹时复合组件抛中文错误,主题读取回落 DEFAULT_THEME),样式需在应用入口引入一次 import '@xiaoye-react/ui/style.css'(packages/ui/package.json 的 exports 子路径);其二,全库状态组件的受控判定统一是「传了 value 即受控」,value={null} 也是受控(《04-数据模型》第 8 节)。
每张契约卡片建议按固定顺序阅读:先看 props 表右侧的「契约说明」列——它写的不是功能描述而是行为承诺(比如「受控时不排数据」是承诺,「支持排序」是功能);再对照代码示例找到该承诺的触发写法;最后看行为说明里标黑的易踩点。一个实用的自查习惯:写完一段组件代码后,逐行问自己「这个 prop 的值在受控/非受控两种形态下各是什么语义」——大多数集成问题(状态不动、回调双触发、清除后类型报错)都出在两种形态的语义混淆上,而不是 prop 记错名字。
导入:
import { Button } from '@xiaoye-react/ui';非受控全配置示例(Button 无状态值,这里的「形态」指常用配置路径):
<Button
component="a"
href="/console/list"
variant="gradient"
gradient={{ from: 'blue', to: 'cyan', deg: 45 }}
size="md"
radius="md"
color="blue"
justify="space-between"
leftSection={<IconArrowLeft />}
rightSection={<IconChevronRight />}
fullWidth
loading={submitting}
loaderProps={{ color: 'currentColor' }}
autoContrast
classNames={{ root: 'btn-root', label: 'btn-label' }}
styles={{ root: { boxShadow: '0 2px 8px rgba(0,0,0,.12)' } }}
vars={{ root: { '--button-height': '52px' } }}
attributes={{ 'data-testid': 'submit-btn' }}
onClick={handleClick}
>
保存并返回列表
</Button>StylesApi 用法与变体封装:
// withProps:固定 props 的变体组件(静态件 classes/varsResolver/extend 全部透传)
const DestructiveButton = Button.withProps({ variant: 'filled', color: 'red' });
// 按选择器取内部类名做 CSS 探测
console.log(Button.classes.root);
// 主题级:主题上下文里对 Button 做全局定制
// theme={{ components: { Button: { defaultProps: { variant: 'light' }, classNames: { label: 'global-label' } } } }}核心 props 表(完整版见《02-组件设计》第 2.1 节):
| Prop | 类型 | 默认值 | 契约说明 |
|---|---|---|---|
variant | 8 种字面量(filled/light/outline/transparent/white/subtle/default/gradient) | 'filled' | 写入 data-variant,颜色解析走 variantColorResolver |
size | UISize | compact-${UISize} | string | 'sm' | 决定 --button-height/-padding-x/-fz |
color / radius / gradient | UIColor / UIRadius / UIGradient | 主题值 | 颜色键或任意 CSS 值 |
component | ElementType | 'button' | 多态:'a'、路由组件均可,原生属性自动继承 |
loading / disabled | boolean | — | loading 时禁用并显示 Loader、aria-busy |
leftSection / rightSection | ReactNode | — | 对应 with-left/right-section mod |
classNames / styles / vars / attributes | StylesApi 四件套 | — | 选择器:root/inner/loader/section/label |
vars 白名单 | ButtonCssVariables | — | --button-justify/height/padding-x/fz/radius/bg/hover/hover-color/color/bd 共 10 个 |
行为说明:Button 是 polymorphicFactory 范本(《18-代码走读实录》第 1 节走读了它的实现)。颜色不进入 style 属性,而是经 varsResolver 解析为 --button-* 变量写进根元素,CSS Modules 只消费变量——因此 vars 覆盖 --button-height 是受支持路径(白名单内),而想改 hover 背景应覆盖 --button-hover 而不是写 CSS 选择器。loading 状态的 Loader 颜色取 var(--button-color)、尺寸取 calc(var(--button-height) / 1.8),说明「组件内部自引用变量」是刻意的契约:改一个变量,联动部件自动跟随。component="a" 时类型层继承锚点属性(href/target),同时运行时仍渲染 UnstyledButton 基座获得 reset 样式。
导入:
import { TextInput } from '@xiaoye-react/ui';受控形态全配置示例:
const [name, setName] = useState('');
<TextInput
label="项目名称"
description="2-32 个字符,创建后不可修改"
placeholder="例如:运维巡检平台"
error={nameError ?? undefined}
required
withAsterisk
size="md"
variant="default"
radius="sm"
disabled={readonly}
leftSection={<IconForms size={16} />}
rightSection={<CharCount value={name} />}
maxLength={32}
value={name}
onChange={e => setName(e.currentTarget.value)}
onBlur={validateName}
classNames={{ input: 'ti-input' }}
styles={{ label: { fontWeight: 600 } }}
vars={{ root: { '--input-height': '44px' } } as any}
data-autofocus
/>非受控形态:
const ref = useRef<HTMLInputElement>(null);
<TextInput
defaultValue="默认项目名"
key={formKey}
ref={ref}
onChange={e => draftRef.current = e.currentTarget.value}
/>核心 props 表:
| Prop | 类型 | 默认值 | 契约说明 |
|---|---|---|---|
label / description / error | ReactNode | — | InputWrapper 统一渲染;error 出现即输入框进入错误态并设 aria-invalid |
required / withAsterisk | boolean | — | 标签星号;required 有 a11y 语义,withAsterisk 纯视觉 |
value / defaultValue / onChange | 原生 input 契约 | — | 双模与原生一致(TextInput 直接透传原生受控语义) |
leftSection / rightSection | ReactNode | — | 输入框内部插槽 |
size / variant / radius | UISize 系 | 主题值 | 沿 InputBase 尺寸刻度 |
labelProps / descriptionProps / errorProps | 原生 div 属性 | — | 三个包装元素的属性透传 |
| StylesNames | root + InputBaseStylesNames | — | root/wrapper/label/description/error/... 由 InputBase 声明 |
行为说明:TextInput 展示了「输入外壳族」的契约分层——自身只声明 label/description/error/required 四个表单化 props,其余(尺寸、variant、插槽)全部继承 InputBaseProps,这保证了全库输入类组件的表单语义一致(《02-组件设计》第 3 节的统一包装约定)。error 是 ReactNode 而非 string,可以直接放带链接的 JSX;它既是渲染内容也是状态信号(驱动 aria-invalid 与边框色),一份值两种用途。非受控形态配 key={formKey} 是配合 useForm uncontrolled 模式的「子树重建」重置法(《04-数据模型》第 3 节)。
与 useForm 配合时还有一条隐性契约值得单独说明:getInputProps('name') 产出的 props 已按 FormMode 展开了 value/defaultValue 与 onChange/onBlur/onError 的组合,此时不要再手工往 TextInput 上传同名 props——后写的会覆盖 getInputProps 的产物,把校验错误回调静默顶掉。独立使用(不经 useForm)时才用上面示例的手工双模写法。error 的显示与清除时机由 clearInputErrorOnChange 契约控制(默认 true:用户再次输入即清错),若想让错误驻留到下一次提交,显式把它关掉而不是在消费方反复 setState 回写错误。
导入:
import { Select } from '@xiaoye-react/ui';受控全配置示例:
const [dept, setDept] = useState<string | null>(null);
<Select
label="所属部门"
placeholder="选择部门"
data={[
{ value: 'rd', label: '研发部', group: '技术线' },
{ value: 'qa', label: '测试部', group: '技术线' },
{ value: 'hr', label: '人事部', group: '职能线' },
'外包', // 字符串条目也合法
]}
value={dept}
onChange={setDept}
searchable
clearable
maxDropdownHeight={280}
position="bottom-start"
checkIconPosition="right"
renderOption={({ option, checked }) => (
<span style={{ fontWeight: checked ? 700 : 400 }}>{option.label}</span>
)}
nothingFoundMessage="没有匹配的部门"
classNames={{ option: 'sel-option' }}
styles={{ dropdown: { borderRadius: 12 } }}
/>非受控形态:
<Select
defaultValue="rd"
onChange={v => console.log('选中', v)} // 清除时收到 null
data={DEPT_DATA}
/>核心 props 表:
| Prop | 类型 | 默认值 | 契约说明 |
|---|---|---|---|
data | (string | SelectItem | SelectGroupData)[] | 必填 | 三形态混填;SelectItem 含 group 分组字段 |
value / defaultValue / onChange | string | null 系 | — | null 表示未选中('' 是合法选项值,故不用 '' 当空态) |
searchable / clearable | boolean | — | 搜索走 defaultOptionsFilter;清除回调 onChange(null) |
label / error 等 | ReactNode | — | 同 InputWrapper 外壳族 |
maxDropdownHeight / position | 尺寸 / FloatingPosition | — | 弹层几何由 floating-ui 承载 |
renderOption | ({ option, checked }) => ReactNode | — | 整体接管选项渲染 |
| StylesNames | 选项/下拉/输入外壳各节点 | — | dropdown/option/... 经 Combobox 内核声明 |
行为说明:Select 是「策略封装」族(《02-组件设计》第 2.4 节)的代表:弹层机制在 Combobox 内核(注册表驱动、键盘导航、floating-ui 定位),Select 只做单选策略。契约上最需要记住的是值语义——受控值类型是 string | null,把「未选中」表达为 null 而不是空串,这与 DataTable 的 selectedKeys(string[])、Tabs 的 value(string)构成一张「按形状分语义」的值类型地图:单值可空用 null、集合用数组、多态标签页用字符串。onChange 在用户清除时收到 null 而非 undefined,消费方必须按可空处理。分组数据按 group 字段聚合渲染,无需手动构造嵌套结构。
导入:
import { Tabs } from '@xiaoye-react/ui';非受控全配置示例:
<Tabs
defaultValue="posts"
variant="pills"
color="teal"
radius="md"
orientation="horizontal"
keepMounted={false}
classNames={{ tab: 'my-tab' }}
vars={{ root: { '--tabs-radius': '10px' } }}
>
<Tabs.List>
<Tabs.Tab value="posts">文章</Tabs.Tab>
<Tabs.Tab value="comments">评论</Tabs.Tab>
<Tabs.Tab value="about">关于</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="posts">文章列表…</Tabs.Panel>
<Tabs.Panel value="comments" keepMounted>评论(隐藏时保留 DOM)…</Tabs.Panel>
<Tabs.Panel value="about">关于…</Tabs.Panel>
</Tabs>受控形态:
const [active, setActive] = useState('posts');
<Tabs value={active} onChange={setActive}>
{/* 子件不变 —— 受控/非受控切换不改子件代码 */}
</Tabs>核心 props 表:
| Prop | 类型 | 默认值 | 契约说明 |
|---|---|---|---|
value / defaultValue / onChange | string 系 | — | 受控双模;Tab 的 value 与 Panel 的 value 手工对应 |
orientation | 'horizontal' | 'vertical' | 'horizontal' | 影响方向键导航与 FloatingIndicator 方向 |
variant | 'default' | 'outline' | 'pills' | 'default' | pills 复用 light 的颜色解析 |
color / radius | UIColor / UIRadius | 主题值 | 写入 --tabs-color/--tabs-radius |
keepMounted | boolean | false | Panel 级可覆盖;true 时非活动面板保留 DOM(display:none) |
| 静态子组件 | Tabs.List / Tabs.Tab / Tabs.Panel | — | 由 staticComponents 并入类型 |
| StylesNames / CSS 变量 | root/list/tab/tabSection/panel;--tabs-radius/--tabs-color/--tabs-bg/--tabs-bd | — | 白名单四个变量 |
行为说明:Tabs 的契约核心是「根管状态、子件消费」(Tabs.context.ts 的 TabsProvider 携带 activeValue/activateTab/getTabId/getPanelId——《04-数据模型》第 5 节)。对消费者意味着两件事:受控/非受控只改根组件的三个 props,子件零改动;无障碍关联(aria-controls/aria-labelledby)由根的 id 管理函数自动生成,Tab 与 Panel 的 value 只需要字符串对齐。default/outline 变体下 FloatingIndicator 滑动条是组件自动行为,pills 变体则高亮整块底色(varsResolver 的 resolverVariant 技巧,见《02-组件设计》第 2.3 节)。keepMounted 在根上设默认、Panel 上可单点覆盖,适合「切换回来要保滚动位置」的表单页签。
另外两条容易被忽略的行为契约:其一,Tab 与 Panel 的 value 只在根组件的上下文里对齐,Tabs.Tab 单独渲染在 Tabs 树外拿不到 context 会直接抛出中文错误(createSafeContext 的契约,《04-数据模型》第 5 节)——「Tab 渲染了但不切换」的第一排查点就是树结构断没断(比如中间隔了条件渲染的包裹层不算断,隔了 Portal 才算);其二,orientation="vertical" 不只是视觉旋转,它同时切换 roving tabindex 的方向键语义(上下键移动、左右键让位给页面滚动),做侧边导航型 Tabs 时应一并改 orientation 而不是用 CSS 硬转。
导入:
import { Modal, Button } from '@xiaoye-react/ui';受控全配置示例(Modal 只有受控形态——打开与否完全由消费方 state 驱动):
const [opened, { open, close }] = useDisclosure(false);
<>
<Button onClick={open}>编辑用户</Button>
<Modal
opened={opened}
onClose={close}
title="编辑用户"
size="lg"
radius="md"
centered
withOverlay
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
withCloseButton
closeOnEscape
closeOnClickOutside={false}
trapFocus
returnFocus
lockScroll
withinPortal
zIndex={200}
transitionProps={{ duration: 200 }}
classNames={{ header: 'm-header', body: 'm-body' }}
vars={{ root: { '--modal-radius': '16px' } }}
>
<Modal.Body>…表单…</Modal.Body>
</Modal>
</>核心 props 表:
| Prop | 类型 | 默认值 | 契约说明 |
|---|---|---|---|
opened / onClose | boolean / () => void | 必填 | 唯一的状态契约;关闭动作(Esc/遮罩/按钮)统一收敛到 onClose |
title / withCloseButton | ReactNode / boolean | — | title 自动生成 aria id(${id}-title) |
size / radius / centered / fullScreen | 尺寸/布尔 | — | 写入 --modal-size/--modal-radius |
overlayProps | Overlay 属性 | — | 遮罩透明度/模糊 |
closeOnEscape / closeOnClickOutside / trapFocus / returnFocus / lockScroll / withinPortal / keepMounted | boolean | 各默认开启或关闭见《02》2.5 | 关闭仲裁在 ModalBase:Esc 只关栈顶(modal-stack 语义) |
zIndex | number | --ui-z-index-modal(200) | 分层契约见 default-css-variables-resolver |
| StylesNames / CSS 变量 | root/header/title/body/content/inner/close/overlay;--modal-radius/--modal-size/--modal-y-offset/--modal-x-offset | — | 八选择器四变量 |
行为说明:Modal 是三层架构的表现层(ModalBase 内核 → Modal 组装 → ModalStack 多模态栈,《02-组件设计》第 2.5 节)。对消费者的契约要点:其一,opened/onClose 是唯一状态通道——组件内部不持有打开状态,closeOnEscape 等开关只决定「哪些用户动作会触发 onClose」,最终是否关闭仍由消费方在 onClose 里 setState;其二,Esc 的关闭仲裁是栈顶优先,嵌套 Modal 不会误关下层(modal-stack.ts 语义),消费方无需自己管理 z-index 队列;其三,焦点陷阱与归还(trapFocus/returnFocus 默认行为)意味着打开时焦点圈定在弹层内、关闭后回到触发元素——消费方不要再手动 focus。
进出场动画的契约同样值得记:过渡时长与曲线经 transitionProps 整体覆盖,关闭动画结束有 onExitTransitionEnd 回调——「关闭后等动画播完再请求数据」这类需求应挂它而不是在 onClose 里 setTimeout 猜时长。keepMounted 打开后 Modal 内容隐藏而非卸载,表单草稿因此得以保留,但代价是首次打开前的内容也会渲染(重内容建议保持默认卸载)。centered 与 fullScreen 互斥的视觉语义由 CSS 变量层仲裁(--modal-y-offset/--modal-x-offset 在全屏时失效),样式上不必再做适配。
导入:
import { DataTable } from '@xiaoye-react/ui';远端分页 + 受控排序 + 行选择全配置示例:
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [records, setRecords] = useState<User[]>([]);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<User> | null>(null);
<DataTable<User>
columns={[
{ accessor: 'name', title: '姓名', sortable: true, width: 160 },
{ accessor: 'email', title: '邮箱', ellipsis: true },
{ accessor: 'role', title: '角色', render: u => ROLE_LABELS[u.role] },
{ accessor: 'actions', title: '操作', width: 80, sticky: 'right',
render: u => <Button size="xs" variant="light">编辑</Button> },
]}
records={records}
rowKey={u => u.id}
total={total} page={page} pageSize={pageSize}
onPageChange={setPage} onPageSizeChange={setPageSize}
sortStatus={sortStatus} onSortStatusChange={setSortStatus}
selectionMode="checkbox"
selectedKeys={selectedKeys} onSelectedKeysChange={setSelectedKeys}
withColumnSettings
loading={loading}
empty={<EmptyState title="暂无数据" />}
striped highlightOnHover
minHeight={360} maxHeight={560}
verticalSpacing="sm"
onRowClick={u => openDetail(u.id)}
styles={{ td: { cursor: 'pointer' } }}
/>非受控形态(本地数据一键起跑):
<DataTable columns={columns} records={localUsers} rowKey={u => u.id} />
// 排序在本地 sortRecords 稳定排序;不传 total/page 即不渲染分页底栏核心 props 表(列模型与完整 API 见《02-组件设计》第 2.2 节):
| Prop | 类型 | 默认值 | 契约说明 |
|---|---|---|---|
columns | DataTableColumn<T>[] | 必填 | accessor 字符串才可排序;render 自定义单元格 |
records / rowKey | T[] / (record, i) => string | — / String(i) | 远端分页传当前页数据 |
page 系 / total | number 系 | — | 提供即渲染分页底栏(Pagination + 每页条数) |
sortStatus 系 | DataTableSortStatus | null 系 | — | 受控时不排数据;非受控才本地排序 |
selectedKeys 系 | string[] 系 | [] | 全选为差集实现,跨页选中保留 |
selectionMode | 'checkbox' | 'radio' | 不启用 | 首列出现选择列(宽 40px) |
virtualized + estimatedRowHeight | boolean + number | false + 42 | @tanstack/react-virtual 行虚拟化 |
withColumnSettings | boolean | false | 列设置面板控制列显隐 |
| StylesNames / CSS 变量 | 12+ 选择器;--datatable-horizontal/vertical-spacing | — | tr/th/td/footer/selectionCell/emptyCell/columnSettings* |
行为说明:DataTable 是全库状态契约密度最高的组件——五组状态(分页/排序/选择/展开/列显隐)全部是 value + defaultValue + finalValue + onChange 四件套(《04-数据模型》第 2 节)。契约上有三个容易踩的点:受控 sortStatus 时组件不排数据(排序职责在服务端,本地只展示 sortStatus 描述的状态),非受控才走本地稳定排序;rowKey 必须返回稳定唯一键,选择/展开/列设置三组状态都以其为主键,用默认的 String(index) 配合远端分页会导致跨页选中错乱;函数型 accessor 取不到原始值所以不可排序(开发态 console.warn)。性能契约方面,虚拟滚动下每帧重复解析选择器的场景已被 useStyles 的按选择器缓存兜住(《10-性能设计与优化》第 2 节),消费方无需为 tr/td 的 styles 传函数做 memo。
导入:
import { Notifications, notifications } from '@xiaoye-react/ui';宿主 + 命令式调用示例:
// 应用根:渲染一次宿主
<UIProvider>
<Notifications position="top-right" limit={5} />
<App />
</UIProvider>
// 任意模块(组件、请求拦截器、工具函数)内:
notifications.show({ title: '保存成功', message: '配置已更新', color: 'teal', autoClose: 3000 });
notifications.show({ title: '请求失败', message: err.message, color: 'red', autoClose: false });
notifications.update(id, { message: '已重试成功' });
notifications.hide(id);
notifications.clean(); // 清空全部
notifications.cleanQueue(); // 清空排队中的核心契约表:
| 实体 | 类型/方法 | 契约说明 |
|---|---|---|
notifications.show | (data: NotificationData) => void | 超出 limit 进入 queue 排队;priority 高者先展示 |
notifications.hide/update | 按 id 操作 | update 需传完整 data |
NotificationData | message 必填;title/color/position/autoClose/priority/allowClose/onOpen/onClose | autoClose: number 毫秒、false 不自动关 |
宿主 <Notifications /> | position/limit | 只渲染一次;按 position 分桶渲染 |
| 状态存放 | 模块级 store(useSyncExternalStore) | 不进 React 树,可在非组件代码调用 |
行为说明:Notifications 与前六个组件的契约形态根本不同——它的 API 不是 props 而是一组模块级函数,状态住在外部 store(notifications.store.ts,ADR-008 见《16-决策记录》)。这带来三条独特契约:调用不需要组件上下文(请求拦截器里可直接 show),但宿主必须渲染一次否则通知无处挂载;队列语义(超过 limit 排队)由宿主的 limit 声明,单条 notification 的 position 可覆盖宿主默认值(分桶渲染,getDistributedNotifications 用 Map 分桶 + 稳定排序);生命周期回调 onOpen/onClose 在状态提交完成后调用(store 注释明确「避免用户在回调中 show/hide 时被外层 setState 覆盖丢失」),所以在 onClose 里再 show 是安全契约。
测试与多实例场景另有一条纪律:模块级 store 是进程级单例,单测之间必须 notifications.clean() 防串扰(《09-测试与质量保障》第 5 节的代表性测试走读处理过这一点);同页渲染两个 <Notifications /> 宿主时两者消费同一份状态、各自按 position 分桶渲染,这不是「两个通知中心」而是「一个状态的两次视图」——想做隔离的通知域应换宿主 position 约定而不是重复渲染宿主。autoClose 传数字时毫秒计时从 show 时刻起算,进 queue 排队的时间也计入,对「排队后闪退」类问题这是第一排查点。
从粗到细的四级覆盖通道,一级一个代码层:
import { UIProvider, createTheme, Button } from '@xiaoye-react/ui';
// 第一级:主题对象(JS 侧声明,类型收窄走 createTheme 恒等函数)
const theme = createTheme({
primaryColor: 'teal',
primaryShade: 7,
defaultRadius: 'md',
fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif',
colors: {
// virtual color:亮暗两套分别取值
brand: [
'#e6f7f4', '#c2ece4', '#8fd9cb', '#5cc6b1', '#2fb397',
'#1a9c82', '#0f7d67', '#0c6453', '#094b3f', '#07332b',
],
},
// 主题级组件定制(全局 StylesApi)
components: {
Button: { defaultProps: { radius: 'md' }, classNames: { label: 'app-btn-label' } },
},
});
export default function App() {
return (
<UIProvider
theme={theme}
colorScheme="auto" // 受控传 'light'|'dark';auto 跟随系统
classNamesPrefix="ui"
localStorageKey="ui-color-scheme-value"
>
{/* 第二级:CSS 变量级联——组件消费 --ui-* 与 --brand-* */}
<Button color="brand" variant="filled">主题色按钮(随明暗切换自动取 shade)</Button>
{/* 第三级:实例级 vars 覆盖组件白名单变量 */}
<Button vars={{ root: { '--button-height': '52px' } }}>加高按钮</Button>
{/* 第四级:classNames/styles 按选择器注入 */}
<Button classNames={{ root: 'hero-btn' }} styles={{ label: { letterSpacing: 2 } }}>
定制按钮
</Button>
</UIProvider>
);
}直接覆盖全局 CSS 变量(CSS 侧,无需 JS):
:root {
--ui-radius-default: 10px;
--ui-color-body: #fafafa;
}
[data-ui-color-scheme="dark"] {
--ui-color-body: #121212;
}行为说明:四级通道的分工是——主题对象负责「语义声明」(什么是品牌色、默认圆角多大),defaultCssVariablesResolver 把它翻译为 :root 上的 --ui-color-brand-* 十阶与 --ui-primary-color-* 派生变量(《07-模块详解》第 3.4 节);CSS 侧直接改 --ui-* 变量是「不经过 JS 的主题定制」,亮暗切换只翻转 data-ui-color-scheme 属性对应的变量组,零 React 重渲染(《10-性能设计与优化》第 4 节);组件级 vars 受 XxxCssVariables 白名单类型约束,白名单外的变量名编译期报错;classNames/styles 是最细粒度的兜底。色彩键 color="brand" 之所以生效,是因为 colors 里声明了同名虚拟色——variantColorResolver 按当前色彩方案从十阶里取 filled 底色(light 取 shade 6 一带、dark 取 shade 8 一带,由 primaryShade: { light: 6, dark: 8 } 契约控制)。这四级通道叠加的优先级与生效面,速查表见《03-接口设计》第 3 节。