跳到主要内容

设计组件库

问题

如何从零设计和架构一个组件库(类似 Ant Design、Element Plus、shadcn/ui、Radix UI)?架构分层、API 设计模式、样式方案、可访问性等方面需要做哪些关键的技术决策?

答案

组件库的架构设计是前端系统设计面试中的高频题目。不同于组件库建设侧重工程化流程(Storybook、打包、发布、测试),本文聚焦架构层面的设计决策和模式选型:如何分层、如何设计 API、如何实现 Headless 逻辑、如何搭建主题系统等。同时也可以参考 React 组件通用设计 中关于单组件设计模式的内容。


一、概述:何时自建组件库

在决定是否自建组件库前,需要做清晰的场景判断:

场景推荐方案理由
中后台项目,标准化 UI直接用 Ant Design / Element Plus开箱即用,维护成本低
C 端产品,有独立设计系统Headless UI + 自定义样式完全控制 UI,适配品牌设计
多产品线,统一设计语言自建组件库一致性、品牌化、沉淀业务能力
快速验证原型shadcn/ui(复制粘贴模式)灵活修改,无版本锁定
核心判断标准

ROI(投入产出比) 是关键。自建组件库的隐性成本很高(开发、维护、文档、迁移),只有在多个产品线需要统一设计语言,或者现有组件库严重无法满足定制需求时才考虑自建。

三种建设路径

路径开发成本维护成本灵活度代表方案
全量自建极高极高Ant Design、MUI
基于 Headless 封装shadcn/ui(基于 Radix)
Fork + 定制高(升级困难)Fork Ant Design

二、组件库架构分层

一个成熟的组件库通常采用四层架构,从底层到上层依次是:

各层职责

层级职责包含样式示例
Headless 逻辑层纯状态管理 + 交互逻辑 + ARIAuseSelectuseComboboxuseDialog
Primitive 原子层最小 UI 单元,自带基础样式ButtonInputPopoverCheckbox
Composed 组合层多个原子组件的组合DatePickerComboboxTransfer
Recipe 业务层封装特定业务逻辑LoginFormPaymentCard
分层的核心价值

分层让不同的使用者可以在不同层级接入:需要完全自定义 UI 的用 Headless 层,需要快速开发的用 Composed 层,需要业务开箱即用的用 Recipe 层。

项目目录结构

packages/ 目录结构
packages/
├── headless/ // 第一层:Headless Hooks
│ ├── use-select/
│ ├── use-combobox/
│ ├── use-dialog/
│ └── use-date-picker/
├── primitives/ // 第二层:原子组件
│ ├── button/
│ ├── input/
│ ├── popover/
│ └── checkbox/
├── components/ // 第三层:组合组件
│ ├── date-picker/
│ ├── combobox/
│ └── color-picker/
├── recipes/ // 第四层:业务组件
│ ├── login-form/
│ └── user-card/
├── tokens/ // Design Token
├── theme/ // 主题系统
└── utils/ // 通用工具

三、组件 API 设计原则

组件 API 的设计质量直接决定了组件库的开发体验。以下是五种核心 API 设计模式。

3.1 受控/非受控统一模式

所有涉及状态的组件(Input、Select、Modal、Tabs 等)都应同时支持受控和非受控模式。核心实现是 useControllableState Hook:

hooks/useControllableState.ts
import { useState, useCallback, useRef } from 'react';

function useControllableState<T>(
controlledValue: T | undefined,
defaultValue: T,
onChange?: (value: T) => void,
): [T, (next: T | ((prev: T) => T)) => void] {
const isControlled = controlledValue !== undefined;
const isControlledRef = useRef(isControlled);

// 开发环境下检测模式切换
if (process.env.NODE_ENV !== 'production') {
if (isControlledRef.current !== isControlled) {
console.warn(
'组件在受控和非受控模式之间切换,这可能导致不可预期的行为。'
);
}
}

const [internalValue, setInternalValue] = useState<T>(defaultValue);
const value = isControlled ? controlledValue : internalValue;

const setValue = useCallback(
(next: T | ((prev: T) => T)) => {
const nextValue =
typeof next === 'function'
? (next as (prev: T) => T)(value)
: next;

// 非受控模式下更新内部状态
if (!isControlled) {
setInternalValue(nextValue);
}
// 无论哪种模式都触发回调
onChange?.(nextValue);
},
[isControlled, value, onChange],
);

return [value, setValue];
}

export { useControllableState };

使用示例:

components/Switch.tsx
interface SwitchProps {
checked?: boolean;
defaultChecked?: boolean;
onChange?: (checked: boolean) => void;
disabled?: boolean;
}

function Switch({ checked, defaultChecked = false, onChange, disabled }: SwitchProps) {
const [isChecked, setIsChecked] = useControllableState(checked, defaultChecked, onChange);

return (
<button
role="switch"
aria-checked={isChecked}
disabled={disabled}
onClick={() => !disabled && setIsChecked((prev) => !prev)}
className={`switch ${isChecked ? 'switch-on' : 'switch-off'}`}
>
<span className="switch-thumb" />
</button>
);
}

// 受控模式
<Switch checked={value} onChange={setValue} />
// 非受控模式
<Switch defaultChecked={true} />
// 非受控 + 监听
<Switch defaultChecked={false} onChange={(v) => console.log(v)} />

3.2 Compound Components 模式

Compound Components 通过 Context 在父子组件间隐式共享状态,用户以声明式的方式自由组合子组件。

components/Select/Select.tsx
import React, { createContext, useContext, useState, useCallback } from 'react';

// ---- Context 定义 ----
interface SelectContextValue {
value: string | undefined;
onSelect: (value: string) => void;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
highlightedIndex: number;
setHighlightedIndex: (index: number) => void;
}

const SelectContext = createContext<SelectContextValue | null>(null);

function useSelectContext(): SelectContextValue {
const ctx = useContext(SelectContext);
if (!ctx) {
throw new Error('Select 子组件必须在 <Select> 内部使用');
}
return ctx;
}

// ---- 父组件 ----
interface SelectProps {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
children: React.ReactNode;
}

function Select({ value, defaultValue, onChange, children }: SelectProps) {
const [selectedValue, setSelectedValue] = useControllableState(value, defaultValue ?? '', onChange);
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);

const onSelect = useCallback(
(val: string) => {
setSelectedValue(val);
setIsOpen(false);
},
[setSelectedValue],
);

return (
<SelectContext.Provider
value={{ value: selectedValue, onSelect, isOpen, setIsOpen, highlightedIndex, setHighlightedIndex }}
>
<div className="select-root" role="listbox">
{children}
</div>
</SelectContext.Provider>
);
}

// ---- Trigger 子组件 ----
function SelectTrigger({ children, placeholder }: { children?: React.ReactNode; placeholder?: string }) {
const { value, isOpen, setIsOpen } = useSelectContext();
return (
<button
className="select-trigger"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
>
{children ?? value ?? placeholder ?? '请选择'}
</button>
);
}

// ---- Option 子组件 ----
function SelectOption({ value, children, disabled }: { value: string; children: React.ReactNode; disabled?: boolean }) {
const ctx = useSelectContext();
const isSelected = ctx.value === value;

return (
<div
role="option"
aria-selected={isSelected}
aria-disabled={disabled}
className={`select-option ${isSelected ? 'selected' : ''} ${disabled ? 'disabled' : ''}`}
onClick={() => !disabled && ctx.onSelect(value)}
>
{children}
{isSelected && <span className="check-icon">&#10003;</span>}
</div>
);
}

// ---- 挂载子组件到命名空间 ----
Select.Trigger = SelectTrigger;
Select.Option = SelectOption;

export { Select };

使用方式对比:

配置式 vs 组合式对比
// 配置式 — 灵活性低
<Select options={[{ label: 'React', value: 'react' }]} />

// 组合式 — 灵活性高
<Select defaultValue="react">
<Select.Trigger placeholder="选择框架" />
<Select.Option value="react">React</Select.Option>
<Select.Option value="vue">Vue</Select.Option>
<Select.Option value="angular" disabled>Angular</Select.Option>
</Select>

3.3 Polymorphic Components(多态组件)

多态组件通过 as prop 改变底层渲染的 HTML 元素或组件,在保持样式和行为的同时灵活切换语义标签。

components/Box/Box.tsx
import React from 'react';

// 多态组件的核心类型定义
type PolymorphicProps<E extends React.ElementType, P = {}> = P &
Omit<React.ComponentPropsWithoutRef<E>, keyof P | 'as'> & {
as?: E;
};

type PolymorphicRef<E extends React.ElementType> =
React.ComponentPropsWithRef<E>['ref'];

// 带 ref 转发的多态组件类型
type PolymorphicComponentWithRef<
DefaultElement extends React.ElementType,
Props = {},
> = <E extends React.ElementType = DefaultElement>(
props: PolymorphicProps<E, Props> & { ref?: PolymorphicRef<E> }
) => React.ReactNode;

// ---- 实现 ----
interface ButtonOwnProps {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
}

const Button: PolymorphicComponentWithRef<'button', ButtonOwnProps> =
React.forwardRef(function Button<E extends React.ElementType = 'button'>(
{ as, variant = 'primary', size = 'md', loading, children, ...rest }: PolymorphicProps<E, ButtonOwnProps>,
ref: PolymorphicRef<E>,
) {
const Component = as ?? 'button';
return (
<Component
ref={ref}
className={`btn btn-${variant} btn-${size}`}
disabled={loading}
{...rest}
>
{loading && <span className="spinner" />}
{children}
</Component>
);
});

使用示例:

多态组件的灵活用法
// 默认渲染为 <button>
<Button variant="primary">提交</Button>

// 渲染为 <a> 标签
<Button as="a" href="/docs" variant="ghost">查看文档</Button>

// 渲染为 React Router 的 Link
import { Link } from 'react-router-dom';
<Button as={Link} to="/dashboard" variant="secondary">进入控制台</Button>

3.4 Slot 模式(asChild)

Radix UI 首创的 asChild 模式,将组件的行为和属性"传递"到子元素上,而非包裹一层 DOM。比 as 更灵活,避免了泛型类型体操。

utils/Slot.tsx
import React from 'react';

interface SlotProps extends React.HTMLAttributes<HTMLElement> {
children: React.ReactNode;
}

/**
* Slot 组件:将自身的 props 合并到唯一子元素上
*/
function Slot({ children, ...slotProps }: SlotProps) {
if (!React.isValidElement(children)) {
return null;
}
// 合并 className
const mergedProps = {
...slotProps,
...children.props,
className: [slotProps.className, children.props.className]
.filter(Boolean)
.join(' '),
style: { ...slotProps.style, ...children.props.style },
};

return React.cloneElement(children, mergedProps);
}

// ---- Button 支持 asChild ----
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
asChild?: boolean;
}

function Button({ asChild, variant = 'primary', className, ...props }: ButtonProps) {
const combinedClassName = `btn btn-${variant} ${className ?? ''}`;

if (asChild) {
return <Slot className={combinedClassName} {...props} />;
}

return <button className={combinedClassName} {...props} />;
}

使用示例:

asChild 用法
// 普通用法
<Button variant="primary">点击</Button>

// asChild:把 Button 的样式和行为传递给子元素
<Button asChild variant="primary">
<a href="/docs">查看文档</a>
</Button>
// 渲染结果:<a href="/docs" class="btn btn-primary">查看文档</a>

3.5 API 设计模式对比

模式适用场景优点缺点代表库
受控/非受控所有有状态组件灵活,兼容两种用法需要额外 Hook所有主流库
Compound Components复杂组合组件声明式、灵活Context 开销Radix、Chakra
as 多态需要切换元素的组件类型安全泛型复杂Chakra UI、MUI
asChild Slot需要合并属性到子元素无泛型、更灵活限制单子元素Radix UI
Render Props需要完全控制渲染极致灵活嵌套较深Downshift

四、Headless UI 设计

什么是 Headless UI

Headless UI 将交互逻辑视觉样式完全分离。逻辑层以 Hook 或 renderless 组件的形式输出状态和事件处理器,用户完全掌控渲染。

实现一个 Headless useCombobox

Combobox(可搜索下拉选择器)是 Headless 设计的经典案例,它涉及状态管理、键盘导航和 ARIA 属性三大核心能力。

headless/useCombobox.ts
import { useState, useCallback, useRef, useMemo } from 'react';

interface ComboboxOption {
label: string;
value: string;
disabled?: boolean;
}

interface UseComboboxOptions {
options: ComboboxOption[];
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
onInputChange?: (input: string) => void;
}

function useCombobox({
options,
value,
defaultValue,
onChange,
onInputChange,
}: UseComboboxOptions) {
const [selectedValue, setSelectedValue] = useControllableState(
value, defaultValue ?? '', onChange
);
const [inputValue, setInputValue] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const listRef = useRef<HTMLUListElement>(null);
const inputRef = useRef<HTMLInputElement>(null);

// 过滤选项
const filteredOptions = useMemo(
() => options.filter((opt) =>
opt.label.toLowerCase().includes(inputValue.toLowerCase())
),
[options, inputValue],
);

// 选中某项
const selectOption = useCallback(
(val: string) => {
const option = options.find((o) => o.value === val);
if (option && !option.disabled) {
setSelectedValue(val);
setInputValue(option.label);
setIsOpen(false);
setHighlightedIndex(-1);
}
},
[options, setSelectedValue],
);

// 键盘导航
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setHighlightedIndex((prev) =>
Math.min(prev + 1, filteredOptions.length - 1)
);
}
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (isOpen && highlightedIndex >= 0) {
selectOption(filteredOptions[highlightedIndex].value);
}
break;
case 'Escape':
setIsOpen(false);
setHighlightedIndex(-1);
break;
}
},
[isOpen, highlightedIndex, filteredOptions, selectOption],
);

// ---- Props Getter 模式:返回要 spread 的 props ----
const getInputProps = useCallback(() => ({
ref: inputRef,
value: inputValue,
role: 'combobox' as const,
'aria-expanded': isOpen,
'aria-autocomplete': 'list' as const,
'aria-activedescendant':
highlightedIndex >= 0 ? `combobox-option-${highlightedIndex}` : undefined,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setInputValue(val);
setIsOpen(true);
setHighlightedIndex(-1);
onInputChange?.(val);
},
onKeyDown: handleKeyDown,
onFocus: () => setIsOpen(true),
onBlur: () => setTimeout(() => setIsOpen(false), 200),
}), [inputValue, isOpen, highlightedIndex, handleKeyDown, onInputChange]);

const getListProps = useCallback(() => ({
ref: listRef,
role: 'listbox' as const,
}), []);

const getOptionProps = useCallback(
(index: number) => ({
id: `combobox-option-${index}`,
role: 'option' as const,
'aria-selected': filteredOptions[index]?.value === selectedValue,
'aria-disabled': filteredOptions[index]?.disabled,
'data-highlighted': index === highlightedIndex,
onClick: () => selectOption(filteredOptions[index].value),
onMouseEnter: () => setHighlightedIndex(index),
}),
[filteredOptions, selectedValue, highlightedIndex, selectOption],
);

return {
// 状态
isOpen,
inputValue,
selectedValue,
highlightedIndex,
filteredOptions,
// Props getter
getInputProps,
getListProps,
getOptionProps,
// 操作
setIsOpen,
selectOption,
};
}

使用 Headless Hook 构建自定义 UI:

components/MyCombobox.tsx
function MyCombobox() {
const {
isOpen,
filteredOptions,
getInputProps,
getListProps,
getOptionProps,
} = useCombobox({
options: [
{ label: 'React', value: 'react' },
{ label: 'Vue', value: 'vue' },
{ label: 'Angular', value: 'angular' },
{ label: 'Svelte', value: 'svelte' },
],
});

return (
<div className="my-combobox">
<input {...getInputProps()} placeholder="搜索框架..." />
{isOpen && filteredOptions.length > 0 && (
<ul {...getListProps()} className="dropdown">
{filteredOptions.map((opt, i) => (
<li key={opt.value} {...getOptionProps(i)} className="dropdown-item">
{opt.label}
</li>
))}
</ul>
)}
</div>
);
}

Headless 组件库对比

特性Radix UIHeadless UIReact AriaArk UI
维护者WorkOSTailwind LabsAdobeChakra 团队
框架支持ReactReact / VueReactReact / Vue / Solid
组件数量30+10+40+30+
ARIA 完整度极高
样式方案无样式 + data-state无样式无样式无样式
动画支持data-state + CSSTransition 组件无内置状态属性
包体积按组件引入,小中等中等
适用场景通用Tailwind 项目高 a11y 要求多框架项目

五、主题系统设计

5.1 Design Token 分层

Design Token 是设计系统与代码的桥梁。Token 的分层决定了主题的灵活度和维护成本。

Token 层级职责变动频率示例
Global Token原始设计值,与品牌无关极少--color-blue-500--spacing-4
Alias Token语义化映射主题切换时变动--color-primary--color-bg-page
Component Token组件专属组件定制时变动--btn-bg--input-border

5.2 CSS Variables 实现

tokens/tokens.ts
// ---- Global Tokens ----
const globalTokens = {
// 调色板
'--color-blue-50': '#eff6ff',
'--color-blue-500': '#3b82f6',
'--color-blue-600': '#2563eb',
'--color-blue-700': '#1d4ed8',
'--color-gray-50': '#f9fafb',
'--color-gray-100': '#f3f4f6',
'--color-gray-900': '#111827',
// 间距
'--spacing-1': '4px',
'--spacing-2': '8px',
'--spacing-3': '12px',
'--spacing-4': '16px',
'--spacing-6': '24px',
// 圆角
'--radius-sm': '4px',
'--radius-md': '8px',
'--radius-lg': '12px',
'--radius-full': '9999px',
// 字号
'--font-size-xs': '12px',
'--font-size-sm': '14px',
'--font-size-base': '16px',
'--font-size-lg': '18px',
} as const;

// ---- Alias Tokens(Light 主题)----
const lightAliasTokens = {
'--color-primary': 'var(--color-blue-500)',
'--color-primary-hover': 'var(--color-blue-600)',
'--color-primary-active': 'var(--color-blue-700)',
'--color-bg-page': '#ffffff',
'--color-bg-elevated': '#ffffff',
'--color-text-primary': 'var(--color-gray-900)',
'--color-text-secondary': '#6b7280',
'--color-border': '#e5e7eb',
} as const;

// ---- Alias Tokens(Dark 主题)----
const darkAliasTokens = {
'--color-primary': '#60a5fa',
'--color-primary-hover': '#93bbfd',
'--color-primary-active': '#3b82f6',
'--color-bg-page': '#0f172a',
'--color-bg-elevated': '#1e293b',
'--color-text-primary': '#f1f5f9',
'--color-text-secondary': '#94a3b8',
'--color-border': '#334155',
} as const;

export { globalTokens, lightAliasTokens, darkAliasTokens };

5.3 暗色模式实现

theme/ThemeProvider.tsx
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { globalTokens, lightAliasTokens, darkAliasTokens } from '../tokens/tokens';

type ThemeMode = 'light' | 'dark' | 'system';

interface ThemeContextValue {
mode: ThemeMode;
resolvedMode: 'light' | 'dark';
setMode: (mode: ThemeMode) => void;
}

const ThemeContext = createContext<ThemeContextValue>({
mode: 'system',
resolvedMode: 'light',
setMode: () => {},
});

export const useTheme = () => useContext(ThemeContext);

function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

interface ThemeProviderProps {
defaultMode?: ThemeMode;
storageKey?: string;
children: React.ReactNode;
}

export function ThemeProvider({
defaultMode = 'system',
storageKey = 'ui-theme',
children,
}: ThemeProviderProps) {
const [mode, setModeState] = useState<ThemeMode>(() => {
if (typeof window === 'undefined') return defaultMode;
return (localStorage.getItem(storageKey) as ThemeMode) ?? defaultMode;
});

const resolvedMode = mode === 'system' ? getSystemTheme() : mode;

const setMode = useCallback(
(newMode: ThemeMode) => {
setModeState(newMode);
localStorage.setItem(storageKey, newMode);
},
[storageKey],
);

// 监听系统主题变化
useEffect(() => {
if (mode !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => setModeState('system'); // 触发重新渲染
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [mode]);

// 应用 CSS Variables
useEffect(() => {
const root = document.documentElement;
const aliasTokens = resolvedMode === 'dark' ? darkAliasTokens : lightAliasTokens;

// 设置 Global + Alias tokens
const allTokens = { ...globalTokens, ...aliasTokens };
for (const [key, value] of Object.entries(allTokens)) {
root.style.setProperty(key, value);
}
root.setAttribute('data-theme', resolvedMode);
}, [resolvedMode]);

return (
<ThemeContext.Provider value={{ mode, resolvedMode, setMode }}>
{children}
</ThemeContext.Provider>
);
}

5.4 多品牌主题

同一组件库支持多品牌皮肤的核心思路是 Alias Token 层按品牌覆盖

tokens/brands.ts
// ---- 品牌 A(蓝色调)----
const brandATokens = {
'--color-primary': '#3b82f6',
'--color-primary-hover': '#2563eb',
'--radius-md': '8px',
'--font-family': "'Inter', sans-serif",
};

// ---- 品牌 B(紫色调)----
const brandBTokens = {
'--color-primary': '#8b5cf6',
'--color-primary-hover': '#7c3aed',
'--radius-md': '12px',
'--font-family': "'Poppins', sans-serif",
};

// ---- 品牌 C(绿色调)----
const brandCTokens = {
'--color-primary': '#10b981',
'--color-primary-hover': '#059669',
'--radius-md': '4px',
'--font-family': "'Noto Sans SC', sans-serif",
};

// 使用方式:
// <ThemeProvider brand="brandB">
// <App />
// </ThemeProvider>

六、样式方案选型

全方位对比

维度CSS ModulesCSS-in-JS (runtime)Tailwind CSSZero-runtime CSS-in-JS
代表方案原生支持styled-components / EmotionTailwind CSSvanilla-extract / Panda CSS
DX 开发体验高(类型安全、动态)高(原子化、快速)高(类型安全)
运行时性能极佳(零运行时)有开销(插入 style)极佳(纯 CSS)极佳(编译时生成)
SSR 兼容性天然支持需额外配置天然支持天然支持
动态主题CSS Variables完全动态CSS VariablesCSS Variables
Tree Shaking文件级需 babel 插件PurgeCSS 自动编译时按需
包体积按组件引入包含运行时 ~12KB原子类去重接近零运行时
类型安全完全支持Tailwind Intellisense完全支持
生态/社区广泛成熟但逐渐下降极火上升中
趋势判断

2024-2025 年社区的明显趋势是从运行时 CSS-in-JS(styled-components/Emotion)迁出,转向零运行时方案或 Tailwind CSS。核心原因:

  1. React 18 Streaming SSR 与运行时 CSS-in-JS 不兼容
  2. React Server Components 不支持 Context(运行时 CSS-in-JS 依赖 ThemeContext)
  3. 运行时性能开销在大型应用中明显

Ant Design 5 从 Less 迁到 cssinjs(编译时生成),MUI 也在探索零运行时方案。

组件库样式方案推荐


七、可访问性(Accessibility)

可访问性不是"加分项",而是组件库的基本要求。WAI-ARIA 规范定义了常见交互组件的标准行为模式。

WAI-ARIA 模式清单

组件ARIA Role关键属性键盘交互
Dialogrole="dialog"aria-modalaria-labelledbyEsc 关闭、Tab 陷阱
Selectrole="listbox"aria-expandedaria-selectedArrow 导航、Enter 选中
Tabsrole="tablist"aria-selectedaria-controlsArrow 切换、Home/End
Menurole="menu"aria-expandedaria-haspopupArrow 导航、Enter 激活
Accordionrole="region"aria-expandedaria-controlsEnter/Space 展开
Tooltiprole="tooltip"aria-describedby聚焦显示、Esc 隐藏
Switchrole="switch"aria-checkedSpace 切换

键盘导航:Roving Tabindex

Roving Tabindex 是复合组件(Tabs、Menu、RadioGroup)中管理焦点的标准模式:同一时刻只有一个元素 tabIndex={0}(可 Tab 聚焦),其余为 tabIndex={-1}(只能通过 Arrow 键导航到)。

hooks/useRovingTabindex.ts
import { useState, useCallback } from 'react';

function useRovingTabindex(itemCount: number, options?: { loop?: boolean; orientation?: 'horizontal' | 'vertical' }) {
const [focusedIndex, setFocusedIndex] = useState(0);
const { loop = true, orientation = 'horizontal' } = options ?? {};

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';

let nextIndex = focusedIndex;

switch (e.key) {
case nextKey:
e.preventDefault();
nextIndex = focusedIndex + 1;
if (nextIndex >= itemCount) {
nextIndex = loop ? 0 : itemCount - 1;
}
break;
case prevKey:
e.preventDefault();
nextIndex = focusedIndex - 1;
if (nextIndex < 0) {
nextIndex = loop ? itemCount - 1 : 0;
}
break;
case 'Home':
e.preventDefault();
nextIndex = 0;
break;
case 'End':
e.preventDefault();
nextIndex = itemCount - 1;
break;
}
setFocusedIndex(nextIndex);
},
[focusedIndex, itemCount, loop, orientation],
);

const getItemProps = useCallback(
(index: number) => ({
tabIndex: index === focusedIndex ? 0 : -1,
onKeyDown: handleKeyDown,
onFocus: () => setFocusedIndex(index),
}),
[focusedIndex, handleKeyDown],
);

return { focusedIndex, getItemProps };
}

Focus Trap(焦点陷阱)

Dialog 和 Drawer 等模态组件必须实现焦点陷阱,防止 Tab 键跳出模态区域。

hooks/useFocusTrap.ts
import { useEffect, useRef } from 'react';

function useFocusTrap<T extends HTMLElement>(active: boolean) {
const containerRef = useRef<T>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);

useEffect(() => {
if (!active || !containerRef.current) return;

// 保存之前的焦点位置
previousFocusRef.current = document.activeElement as HTMLElement;

const container = containerRef.current;
const focusableSelector =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

const getFocusableElements = () =>
Array.from(container.querySelectorAll<HTMLElement>(focusableSelector));

// 初始聚焦
const firstFocusable = getFocusableElements()[0];
firstFocusable?.focus();

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;

const focusable = getFocusableElements();
const first = focusable[0];
const last = focusable[focusable.length - 1];

if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
};

container.addEventListener('keydown', handleKeyDown);

return () => {
container.removeEventListener('keydown', handleKeyDown);
// 关闭时恢复之前的焦点
previousFocusRef.current?.focus();
};
}, [active]);

return containerRef;
}

八、Tree Shaking 与按需加载

ESM 导出策略

Tree Shaking 的前提是使用 ESM(ES Modules),打包工具通过静态分析移除未使用的导出。

package.json 关键配置
{
"name": "@mylib/ui",
"module": "dist/esm/index.js",
"types": "dist/types/index.d.ts",
"sideEffects": [
"*.css",
"*.scss"
],
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"types": "./dist/types/index.d.ts"
},
"./button": {
"import": "./dist/esm/components/button/index.js",
"types": "./dist/types/components/button/index.d.ts"
},
"./select": {
"import": "./dist/esm/components/select/index.js",
"types": "./dist/types/components/select/index.d.ts"
},
"./styles.css": "./dist/styles.css"
}
}

Barrel Exports vs Direct Imports

方式用法Tree Shaking打包体积
Barrel Exportsimport { Button } from '@mylib/ui'依赖打包工具分析可能包含冗余
Direct Importsimport { Button } from '@mylib/ui/button'天然按需最优
Barrel Exports 的陷阱

Barrel file(index.ts 中统一 export *)可能导致 Tree Shaking 失效,因为打包工具无法确定 re-export 的模块是否有副作用。推荐:

  1. sideEffects: false 声明所有 JS 模块无副作用
  2. 每个组件提供独立的入口(exports 字段)
  3. 避免在 barrel file 中执行任何副作用代码

CSS 按需加载方案

方案原理优点缺点
CSS-in-JS样式随组件引入天然按需运行时开销
组件级 CSS 文件每个组件一个 CSS无运行时开销需手动引入或插件
全局 CSS + PurgeCSS构建时删除未使用简单需配置正确
CSS Modules作用域隔离 + Tree Shaking按需 + 隔离无动态能力

九、文档与 Playground

方案对比

方案优点缺点适用场景
Storybook生态成熟、插件丰富、交互测试独立工具,与文档站分离组件开发和测试
Docusaurus + live code文档与演示统一交互能力弱于 Storybook对外文档站
Ladle轻量 Storybook 替代生态小追求轻量的团队
HistoireVue/Svelte 优先React 支持一般Vue 组件库

API 文档自动生成

从 TypeScript 类型自动提取 Props 文档,避免手动维护:

scripts/gen-api-docs.ts
import { Project } from 'ts-morph';
import { writeFileSync } from 'fs';

const project = new Project({ tsConfigFilePath: './tsconfig.json' });

interface PropDoc {
name: string;
type: string;
required: boolean;
defaultValue?: string;
description?: string;
}

function extractProps(filePath: string, componentName: string): PropDoc[] {
const sourceFile = project.getSourceFile(filePath);
if (!sourceFile) return [];

const iface = sourceFile.getInterface(`${componentName}Props`);
if (!iface) return [];

return iface.getProperties().map((prop) => ({
name: prop.getName(),
type: prop.getType().getText(),
required: !prop.hasQuestionToken(),
description: prop
.getJsDocs()
.map((doc) => doc.getDescription().trim())
.join(''),
defaultValue: undefined, // 需从组件函数参数的解构默认值中提取
}));
}

// 生成 Markdown 表格
function propsToMarkdown(props: PropDoc[]): string {
const header = '| 属性 | 类型 | 必填 | 默认值 | 说明 |\n|------|------|:----:|--------|------|\n';
const rows = props
.map(
(p) =>
`| \`${p.name}\` | \`${p.type}\` | ${p.required ? '是' : '否'} | ${p.defaultValue ?? '-'} | ${p.description ?? '-'} |`,
)
.join('\n');
return header + rows;
}

十、完整案例:从零设计一个 Select 组件

综合以上所有设计原则,完整走一遍 Select 组件的设计流程。

Step 1:需求分析

Step 2:API 设计

types/select.ts
interface SelectOption<T = string> {
label: React.ReactNode;
value: T;
disabled?: boolean;
group?: string;
}

interface SelectProps<T = string> {
// ---- 核心 ----
options?: SelectOption<T>[];

// ---- 受控/非受控 ----
value?: T;
defaultValue?: T;
onChange?: (value: T) => void;

// ---- 外观 ----
placeholder?: string;
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
className?: string;
classNames?: {
root?: string;
trigger?: string;
dropdown?: string;
option?: string;
};

// ---- 搜索 ----
searchable?: boolean;
onSearch?: (keyword: string) => void;
filterOption?: (input: string, option: SelectOption<T>) => boolean;

// ---- 自定义渲染 ----
renderOption?: (option: SelectOption<T>, state: { selected: boolean; highlighted: boolean }) => React.ReactNode;
renderValue?: (option: SelectOption<T>) => React.ReactNode;

// ---- 组合模式 ----
children?: React.ReactNode;
}

Step 3:Headless Hook

headless/useSelect.ts
interface UseSelectReturn<T> {
// 状态
isOpen: boolean;
selectedOption: SelectOption<T> | undefined;
highlightedIndex: number;
filteredOptions: SelectOption<T>[];
// Props getters
getTriggerProps: () => Record<string, unknown>;
getListProps: () => Record<string, unknown>;
getOptionProps: (index: number) => Record<string, unknown>;
getSearchInputProps: () => Record<string, unknown>;
// 操作
open: () => void;
close: () => void;
select: (value: T) => void;
}

function useSelect<T = string>(options: {
items: SelectOption<T>[];
value?: T;
defaultValue?: T;
onChange?: (value: T) => void;
searchable?: boolean;
filterOption?: (input: string, option: SelectOption<T>) => boolean;
}): UseSelectReturn<T> {
// ... 状态管理、键盘导航、ARIA 属性
// 复用前面 useCombobox 的思路
// 关键:getTriggerProps / getListProps / getOptionProps
// 返回要 spread 的 props,包含所有 ARIA 和事件
}

Step 4:带样式的组件层

components/Select/Select.tsx
import { useSelect } from '../../headless/useSelect';

function Select<T = string>({
options = [],
value,
defaultValue,
onChange,
placeholder = '请选择',
size = 'md',
disabled,
loading,
searchable,
filterOption,
renderOption,
renderValue,
className,
classNames,
children,
}: SelectProps<T>) {
const {
isOpen,
selectedOption,
highlightedIndex,
filteredOptions,
getTriggerProps,
getListProps,
getOptionProps,
getSearchInputProps,
} = useSelect({
items: options,
value,
defaultValue,
onChange,
searchable,
filterOption,
});

// 如果使用组合模式(children),则渲染 Compound Components
if (children) {
return (
<SelectContext.Provider value={{ /* ... */ }}>
<div className={`select select-${size} ${className ?? ''} ${classNames?.root ?? ''}`}>
{children}
</div>
</SelectContext.Provider>
);
}

// 配置模式渲染
return (
<div className={`select select-${size} ${className ?? ''} ${classNames?.root ?? ''}`}>
<button
{...getTriggerProps()}
className={`select-trigger ${classNames?.trigger ?? ''}`}
disabled={disabled}
>
{selectedOption
? (renderValue?.(selectedOption) ?? selectedOption.label)
: <span className="select-placeholder">{placeholder}</span>
}
{loading ? <Spinner size="sm" /> : <ChevronIcon />}
</button>

{isOpen && (
<div className={`select-dropdown ${classNames?.dropdown ?? ''}`}>
{searchable && (
<input {...getSearchInputProps()} className="select-search" />
)}
<ul {...getListProps()}>
{filteredOptions.map((opt, i) => (
<li
key={String(opt.value)}
{...getOptionProps(i)}
className={`select-option ${classNames?.option ?? ''}`}
>
{renderOption
? renderOption(opt, {
selected: opt.value === selectedOption?.value,
highlighted: i === highlightedIndex,
})
: opt.label
}
</li>
))}
</ul>
</div>
)}
</div>
);
}

Step 5:可访问性测试清单

检查项ARIA / 行为状态
Trigger 有 role="combobox"aria-expandedaria-haspopup="listbox"必须
列表有 role="listbox"包含 role="option" 子项必须
选中项有 aria-selected="true"其余为 false必须
禁用项有 aria-disabled="true"键盘跳过禁用项必须
Arrow Up/Down 导航选项到达末尾循环必须
Enter 选中当前高亮项选中后关闭下拉必须
Escape 关闭下拉焦点回到 Trigger必须
Tab 关闭下拉并移动焦点焦点移到下一个元素必须
搜索时 aria-activedescendant指向当前高亮项 id搜索模式必须

常见面试问题

Q1: 什么是 Headless UI?和传统组件库有什么区别?

答案

Headless UI 是只提供交互逻辑、状态管理和可访问性,不包含任何视觉样式的组件库。通常以 Hook 或 renderless 组件的形式对外暴露。

维度传统组件库Headless 组件库
代表Ant Design、Element Plus、MUIRadix UI、React Aria、Headless UI
包含样式是,内置完整 UI否,零样式
定制成本高(覆盖样式 → 冲突)低(从零写样式)
开箱即用
包体积大(包含样式+逻辑)小(仅逻辑)
设计系统适配需要匹配库的视觉天然适配任何设计
适用场景中后台、快速交付C 端、独立设计系统

面试推荐回答:shadcn/ui 的流行代表了一种折中方案——它基于 Radix UI(Headless),预写了一层 Tailwind 样式,以"复制粘贴到你的项目"而非 npm 安装的方式分发。用户既获得了 Headless 的灵活性,又不用从零写 UI。


Q2: Compound Components 模式如何实现?解决什么问题?

答案

Compound Components 通过 Context 在父子组件间隐式共享状态,让用户以声明式的方式自由组合子组件,而非传递一个庞大的配置对象。

解决的问题

  1. 灵活性 — 用户可自由排列子组件、条件渲染、插入自定义元素
  2. 可读性 — JSX 层级清晰,一眼能看出结构
  3. TypeScript 友好 — 每个子组件有独立的 Props 类型
实现要点
// 1. 创建 Context
const TabsContext = createContext<TabsContextValue | null>(null);

// 2. 父组件提供状态
function Tabs({ children, defaultValue, onChange }: TabsProps) {
const [active, setActive] = useState(defaultValue);
return (
<TabsContext.Provider value={{ active, setActive: (v) => { setActive(v); onChange?.(v); } }}>
<div role="tablist">{children}</div>
</TabsContext.Provider>
);
}

// 3. 子组件消费状态
function TabPanel({ value, label, children }: TabPanelProps) {
const ctx = useContext(TabsContext)!;
return (
<>
<button role="tab" aria-selected={ctx.active === value} onClick={() => ctx.setActive(value)}>
{label}
</button>
{ctx.active === value && <div role="tabpanel">{children}</div>}
</>
);
}

// 4. 挂载到命名空间
Tabs.Panel = TabPanel;

// 使用
<Tabs defaultValue="1">
<Tabs.Panel value="1" label="概览">概览内容</Tabs.Panel>
<Tabs.Panel value="2" label="详情">详情内容</Tabs.Panel>
</Tabs>

Q3: 受控和非受控组件如何统一?useControllableState 的实现

答案

核心是一个通用 Hook,根据是否传入 value 来判断模式:

function useControllableState<T>(
controlledValue: T | undefined, // 受控值
defaultValue: T, // 非受控默认值
onChange?: (value: T) => void, // 变更回调
): [T, (next: T) => void] {
const isControlled = controlledValue !== undefined;
const [internal, setInternal] = useState(defaultValue);
const value = isControlled ? controlledValue : internal;

const setValue = useCallback((next: T) => {
if (!isControlled) setInternal(next); // 非受控才更新内部
onChange?.(next); // 两种模式都触发回调
}, [isControlled, onChange]);

return [value, setValue];
}

约定value 存在 → 受控,defaultValue → 非受控。两者不应在生命周期内切换,开发模式下应给出警告。


Q4: 组件库的 CSS 方案如何选型?

答案

选型取决于三个关键因素:SSR 需求主题定制深度团队技术栈

场景推荐方案理由
SSR + 多主题CSS Variables + Token零运行时、原生支持
Tailwind 项目Tailwind + CVA原子化、与 Tailwind 生态一致
需要类型安全vanilla-extract编译时生成、类型安全
传统 SPA 项目CSS Modules简单、作用域隔离
不推荐新项目使用styled-components / Emotion运行时开销、SSR 兼容性差

核心原则:新项目优先选择零运行时方案。CSS Variables 是主题系统的首选实现,它天然支持运行时切换,且与任何样式方案兼容。


Q5: Design Token 分层是什么?如何实现主题切换?

答案

Design Token 分为三层:

  1. Global Token — 原始设计值,如 --color-blue-500: #3b82f6,与品牌无关
  2. Alias Token — 语义化别名,如 --color-primary: var(--color-blue-500),主题切换时变化
  3. Component Token — 组件级别,如 --btn-bg: var(--color-primary)

主题切换的本质是替换 Alias Token 层的值

// 方式一:data 属性 + CSS
document.documentElement.setAttribute('data-theme', 'dark');
// CSS: [data-theme="dark"] { --color-primary: #60a5fa; --color-bg-page: #0f172a; }

// 方式二:JavaScript 动态设置
const root = document.documentElement;
for (const [key, value] of Object.entries(darkTokens)) {
root.style.setProperty(key, value);
}

// 方式三:React Context + useEffect
function ThemeProvider({ children, mode }) {
useEffect(() => {
const tokens = mode === 'dark' ? darkAliasTokens : lightAliasTokens;
Object.entries(tokens).forEach(([k, v]) => {
document.documentElement.style.setProperty(k, v);
});
}, [mode]);
return <ThemeContext.Provider value={{ mode }}>{children}</ThemeContext.Provider>;
}

Q6: 如何实现组件库的 Tree Shaking?

答案

Tree Shaking 依赖三个条件:

  1. ESM 格式输出 — 必须输出 ES Modules,打包工具才能做静态分析
  2. sideEffects 声明 — 在 package.json 中标记哪些文件有副作用
  3. 避免 barrel file 副作用index.ts 中不执行任何运行时代码
package.json
{
"module": "dist/esm/index.js",
"sideEffects": ["*.css"],
"exports": {
".": { "import": "./dist/esm/index.js" },
"./button": { "import": "./dist/esm/button/index.js" }
}
}

最佳实践:同时提供 barrel export(方便使用方)和 direct import(直接路径),配合 exports 字段让打包工具自动选择最优路径。详见构建工具 - Tree Shaking


Q7: as/asChild 多态组件如何实现?

答案

as prop(Chakra UI、MUI 方案):

通过泛型让 TypeScript 推断出目标元素的 props 类型:

// 核心类型
type PolymorphicProps<E extends React.ElementType, P = {}> = P &
Omit<React.ComponentPropsWithoutRef<E>, keyof P | 'as'> & { as?: E };

// 使用
<Button as="a" href="/docs">链接按钮</Button> // href 被正确推断
<Button as={Link} to="/home">路由链接</Button> // to 被正确推断

asChild prop(Radix UI 方案):

通过 Slot 组件将 props 合并到唯一子元素上,无需泛型:

// 核心实现:Slot 将 parent 的 props 合并到 child
function Slot({ children, ...props }) {
return React.cloneElement(children, mergeProps(props, children.props));
}

// 使用
<Button asChild><a href="/docs">链接按钮</a></Button>
// 渲染结果:<a href="/docs" class="btn btn-primary">链接按钮</a>
对比asasChild
类型安全需要复杂泛型无泛型,子元素自身类型
灵活度更高(子元素可以是任何组件)
实现复杂度TypeScript 类型体操React.cloneElement + props 合并
限制只能有一个子元素

Q8: 如何设计组件库的可访问性方案?

答案

组件库的可访问性需要系统性设计,不能事后补救。核心步骤:

  1. 参照 WAI-ARIA 模式 — 每个交互组件对照 WAI-ARIA Authoring Practices 实现

  2. 语义化优先 — 用原生 HTML 元素而非 div + onClick

    // 正确
    <button onClick={handleClick}>提交</button>
    // 错误
    <div onClick={handleClick} role="button" tabIndex={0}>提交</div>
  3. ARIA 属性完整rolearia-expandedaria-selectedaria-labelledby

  4. 键盘导航 — Tab 聚焦、Arrow 导航(Roving Tabindex)、Enter/Space 激活、Escape 关闭

  5. 焦点管理 — Modal 焦点陷阱、关闭后恢复焦点、aria-activedescendant

  6. 自动化测试axe-core + Storybook a11y 插件

  7. 屏幕阅读器测试 — VoiceOver (Mac) / NVDA (Windows) 实际验证


Q9: Ant Design vs shadcn/ui vs Radix UI 的设计理念对比

答案

维度Ant Designshadcn/uiRadix UI
定位全功能组件库可复制的 UI 代码集Headless 原语库
分发方式npm 安装CLI 复制到项目npm 安装
样式CSS-in-JS + TokenTailwind CSS无样式
定制方式Token 覆盖直接修改源码完全自写
版本锁定有(npm 版本)无(代码在你项目中)有(npm 版本)
组件数量60+40+30+
a11y内置基于 Radix,完善极其完善
学习成本
适用场景中后台快速开发有设计系统的项目需要极致定制
包体积较大按需复制按组件安装,小

选择建议

  • 中后台标准化 → Ant Design
  • 有设计系统 + Tailwind 技术栈 → shadcn/ui
  • 纯 Headless 需求 → Radix UI
  • Vue 项目 → Element Plus / Naive UI

Q10: 从零设计一个 Select 组件,你会怎么做?

答案

面试中建议按以下步骤结构化回答

第 1 步:需求分析 — 明确核心功能(单选/多选、搜索、异步、分组、键盘导航、a11y)

第 2 步:API 设计 — 遵循受控/非受控双模式,Props 分层设计

<Select value={v} onChange={setV} searchable placeholder="选择">
<Select.Option value="react">React</Select.Option>
<Select.Option value="vue">Vue</Select.Option>
</Select>

第 3 步:分层实现

  • Headless 层useSelect Hook)— 状态管理、键盘导航、ARIA
  • Props Getter 模式getTriggerProps()getListProps()getOptionProps()
  • UI 层 — 消费 Hook 返回值,渲染 DOM + 样式

第 4 步:可访问性role="listbox" + role="option" + aria-selected + aria-expanded + 键盘导航

第 5 步:样式方案 — CSS Variables 支持主题定制,classNames 多 slot 支持样式覆盖

第 6 步:测试 — 单元测试(状态切换、回调)+ a11y 测试(axe-core)+ 键盘导航测试


Q11: 组件库的版本管理和 Breaking Change 策略

答案

  1. 遵循 SemVerMajor.Minor.Patch

    • Major — Breaking Change(移除 API、改变行为)
    • Minor — 新增功能,向下兼容
    • Patch — Bug 修复
  2. Breaking Change 的处理策略

Deprecation 策略
// Step 1: 标记废弃(Minor 版本)
interface ButtonProps {
/** @deprecated 请使用 variant 代替 */
type?: 'primary' | 'default'; // 旧 API
variant?: 'primary' | 'default'; // 新 API
}

function Button({ type, variant, ...rest }: ButtonProps) {
if (process.env.NODE_ENV !== 'production' && type !== undefined) {
console.warn('[MyUI] Button: "type" prop is deprecated, use "variant" instead.');
}
const resolvedVariant = variant ?? type ?? 'default';
// ...
}

// Step 2: 提供 codemod 自动迁移
// npx @mylib/codemod button-type-to-variant

// Step 3: 下个 Major 版本移除旧 API
  1. 版本发布工具:推荐 Changesets,支持 Monorepo 多包版本管理。详见组件库建设中的版本管理章节。

Q12: 如何设计组件库的国际化方案?

答案

组件库的国际化(i18n)需要考虑两个层面:

层面一:组件内置文本

locale/zh-CN.ts
const zhCN = {
Select: {
placeholder: '请选择',
noData: '暂无数据',
loading: '加载中...',
},
Pagination: {
total: (total: number) => `${total}`,
prev: '上一页',
next: '下一页',
},
DatePicker: {
placeholder: '请选择日期',
today: '今天',
// ...
},
};

export type Locale = typeof zhCN;
export default zhCN;
locale/en-US.ts
const enUS: Locale = {
Select: {
placeholder: 'Please select',
noData: 'No data',
loading: 'Loading...',
},
// ...
};

层面二:通过 Provider 注入

ConfigProvider 方案
interface ConfigProviderProps {
locale?: Locale;
children: React.ReactNode;
}

const ConfigContext = createContext<{ locale: Locale }>({ locale: zhCN });
export const useConfig = () => useContext(ConfigContext);

function ConfigProvider({ locale = zhCN, children }: ConfigProviderProps) {
return (
<ConfigContext.Provider value={{ locale }}>
{children}
</ConfigContext.Provider>
);
}

// 组件内消费
function Select(props: SelectProps) {
const { locale } = useConfig();
return <div>{/* ... */}<span>{locale.Select.placeholder}</span></div>;
}

设计原则

  • 所有用户可见的文案都不应硬编码,通过 locale 对象管理
  • 提供 ConfigProvider 全局注入 + 组件级 locale prop 覆盖
  • 支持 RTL(从右到左)布局(阿拉伯语、希伯来语)
  • 日期、数字格式化使用 Intl API

相关链接