名称: 原子设计-分子 描述: 用于将原子组合成分子组件,如表单字段、搜索栏和卡片头部。分子是原子的功能组。 允许工具:
- Bash
- 读取
- 写入
- 编辑
- Glob
- Grep
原子设计:分子
掌握分子组件的创建——原子的功能组,作为一个单元协同工作。分子将多个原子组合成更复杂、有目的的UI元素。
什么是分子?
分子是原子设计中的第一级组合。它们是:
- 仅由原子组成:从不包括其他分子
- 单一目的:专注于一件事
- 功能单元:原子为特定任务协同工作
- 可重用:在不同组织和上下文中使用
- 最小状态:可能具有有限的内部状态用于UI关注
常见分子类型
表单分子
- 表单字段(标签 + 输入 + 错误)
- 搜索表单(输入 + 按钮)
- 切换组(标签 + 切换)
- 日期选择器(输入 + 日历触发器)
- 文件上传器(拖放区 + 按钮)
导航分子
- 导航项(图标 + 文本 + 指示器)
- 面包屑项(链接 + 分隔符)
- 分页控件(按钮 + 页面指示器)
- 标签项(图标 + 标签)
显示分子
- 媒体对象(头像 + 文本)
- 卡片头部(标题 + 副标题 + 操作)
- 列表项(复选框 + 内容 + 操作)
- 统计显示(标签 + 值 + 趋势)
操作分子
- 按钮组(多个按钮)
- 下拉触发器(按钮 + 图标)
- 图标按钮(图标 + 工具提示)
- 操作菜单(按钮 + 菜单项)
FormField 分子示例
完整实现
// molecules/FormField/FormField.tsx
import React from 'react';
import { Label } from '@/components/atoms/Label';
import { Input, type InputProps } from '@/components/atoms/Input';
import { Text } from '@/components/atoms/Typography';
import styles from './FormField.module.css';
export interface FormFieldProps extends InputProps {
/** 字段标签 */
label: string;
/** 唯一字段标识符 */
name: string;
/** 输入下方的帮助文本 */
helpText?: string;
/** 错误消息 */
error?: string;
/** 必填字段指示器 */
required?: boolean;
}
export const FormField = React.forwardRef<HTMLInputElement, FormFieldProps>(
(
{
label,
name,
helpText,
error,
required = false,
id,
className,
...inputProps
},
ref
) => {
const fieldId = id || `field-${name}`;
const helpTextId = helpText ? `${fieldId}-help` : undefined;
const errorId = error ? `${fieldId}-error` : undefined;
const describedBy = [helpTextId, errorId].filter(Boolean).join(' ') || undefined;
return (
<div className={`${styles.field} ${className || ''}`}>
<Label htmlFor={fieldId} required={required} disabled={inputProps.disabled}>
{label}
</Label>
<Input
ref={ref}
id={fieldId}
name={name}
hasError={!!error}
aria-describedby={describedBy}
aria-required={required}
{...inputProps}
/>
{helpText && !error && (
<Text id={helpTextId} size="sm" color="muted" className={styles.helpText}>
{helpText}
</Text>
)}
{error && (
<Text id={errorId} size="sm" color="danger" className={styles.error} role="alert">
{error}
</Text>
)}
</div>
);
}
);
FormField.displayName = 'FormField';
/* molecules/FormField/FormField.module.css */
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.helpText {
margin-top: 2px;
}
.error {
margin-top: 2px;
display: flex;
align-items: center;
gap: 4px;
}
SearchForm 分子示例
// molecules/SearchForm/SearchForm.tsx
import React, { useState, useCallback } from 'react';
import { Input } from '@/components/atoms/Input';
import { Button } from '@/components/atoms/Button';
import { Icon } from '@/components/atoms/Icon';
import styles from './SearchForm.module.css';
export interface SearchFormProps {
/** 占位符文本 */
placeholder?: string;
/** 初始搜索值 */
defaultValue?: string;
/** 提交处理器 */
onSubmit: (query: string) => void;
/** 实时搜索的更改处理器 */
onChange?: (query: string) => void;
/** 加载状态 */
isLoading?: boolean;
/** 大小变体 */
size?: 'sm' | 'md' | 'lg';
/** 显示清除按钮 */
clearable?: boolean;
}
export const SearchForm: React.FC<SearchFormProps> = ({
placeholder = '搜索...',
defaultValue = '',
onSubmit,
onChange,
isLoading = false,
size = 'md',
clearable = true,
}) => {
const [query, setQuery] = useState(defaultValue);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
onChange?.(value);
},
[onChange]
);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
onSubmit(query.trim());
},
[onSubmit, query]
);
const handleClear = useCallback(() => {
setQuery('');
onChange?.('');
}, [onChange]);
return (
<form className={styles.form} onSubmit={handleSubmit} role="search">
<Input
type="search"
value={query}
onChange={handleChange}
placeholder={placeholder}
size={size}
leftAddon={<Icon name="search" size="sm" />}
rightAddon={
clearable && query ? (
<button
type="button"
onClick={handleClear}
className={styles.clearButton}
aria-label="清除搜索"
>
<Icon name="x" size="sm" />
</button>
) : undefined
}
aria-label="搜索查询"
/>
<Button type="submit" size={size} isLoading={isLoading}>
搜索
</Button>
</form>
);
};
SearchForm.displayName = 'SearchForm';
/* molecules/SearchForm/SearchForm.module.css */
.form {
display: flex;
gap: 8px;
align-items: stretch;
}
.clearButton {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
color: var(--color-neutral-500);
transition: color 150ms;
}
.clearButton:hover {
color: var(--color-neutral-700);
}
MediaObject 分子示例
// molecules/MediaObject/MediaObject.tsx
import React from 'react';
import { Avatar, type AvatarProps } from '@/components/atoms/Avatar';
import { Text, Heading } from '@/components/atoms/Typography';
import styles from './MediaObject.module.css';
export interface MediaObjectProps {
/** 头像图像源 */
avatarSrc?: string;
/** 头像替代文本 */
avatarAlt: string;
/** 头像首字母回退 */
avatarInitials?: string;
/** 头像大小 */
avatarSize?: AvatarProps['size'];
/** 主要文本/标题 */
title: React.ReactNode;
/** 次要文本/副标题 */
subtitle?: React.ReactNode;
/** 额外元数据 */
meta?: React.ReactNode;
/** 右对齐操作元素 */
action?: React.ReactNode;
/** 内容对齐 */
align?: 'top' | 'center' | 'bottom';
/** 额外类名 */
className?: string;
}
export const MediaObject: React.FC<MediaObjectProps> = ({
avatarSrc,
avatarAlt,
avatarInitials,
avatarSize = 'md',
title,
subtitle,
meta,
action,
align = 'center',
className,
}) => {
const classNames = [styles.mediaObject, styles[`align-${align}`], className]
.filter(Boolean)
.join(' ');
return (
<div className={classNames}>
<Avatar
src={avatarSrc}
alt={avatarAlt}
initials={avatarInitials}
size={avatarSize}
/>
<div className={styles.content}>
<div className={styles.title}>{title}</div>
{subtitle && (
<Text size="sm" color="muted" className={styles.subtitle}>
{subtitle}
</Text>
)}
{meta && (
<Text size="xs" color="muted" className={styles.meta}>
{meta}
</Text>
)}
</div>
{action && <div className={styles.action}>{action}</div>}
</div>
);
};
MediaObject.displayName = 'MediaObject';
NavItem 分子示例
// molecules/NavItem/NavItem.tsx
import React from 'react';
import { Icon } from '@/components/atoms/Icon';
import { Badge } from '@/components/atoms/Badge';
import styles from './NavItem.module.css';
export interface NavItemProps {
/** 导航图标 */
icon?: string;
/** 项目标签 */
label: string;
/** 链接目标 */
href: string;
/** 激活状态 */
isActive?: boolean;
/** 徽章计数 */
badge?: number;
/** 禁用状态 */
disabled?: boolean;
/** 点击处理器 */
onClick?: (e: React.MouseEvent) => void;
}
export const NavItem: React.FC<NavItemProps> = ({
icon,
label,
href,
isActive = false,
badge,
disabled = false,
onClick,
}) => {
const classNames = [
styles.navItem,
isActive && styles.active,
disabled && styles.disabled,
]
.filter(Boolean)
.join(' ');
const handleClick = (e: React.MouseEvent) => {
if (disabled) {
e.preventDefault();
return;
}
onClick?.(e);
};
return (
<a
href={href}
className={classNames}
onClick={handleClick}
aria-current={isActive ? 'page' : undefined}
aria-disabled={disabled}
>
{icon && <Icon name={icon} size="sm" className={styles.icon} />}
<span className={styles.label}>{label}</span>
{badge !== undefined && badge > 0 && (
<Badge variant="primary" size="sm" className={styles.badge}>
{badge > 99 ? '99+' : badge}
</Badge>
)}
</a>
);
};
NavItem.displayName = 'NavItem';
CardHeader 分子示例
// molecules/CardHeader/CardHeader.tsx
import React from 'react';
import { Heading, Text } from '@/components/atoms/Typography';
import { Icon } from '@/components/atoms/Icon';
import { Button } from '@/components/atoms/Button';
import styles from './CardHeader.module.css';
export interface CardHeaderProps {
/** 卡片标题 */
title: string;
/** 可选副标题 */
subtitle?: string;
/** 标题图标 */
icon?: string;
/** 操作按钮标签 */
actionLabel?: string;
/** 操作按钮点击处理器 */
onAction?: () => void;
/** 额外类名 */
className?: string;
}
export const CardHeader: React.FC<CardHeaderProps> = ({
title,
subtitle,
icon,
actionLabel,
onAction,
className,
}) => {
return (
<div className={`${styles.header} ${className || ''}`}>
<div className={styles.left}>
{icon && <Icon name={icon} size="md" className={styles.icon} />}
<div className={styles.titles}>
<Heading level={3} className={styles.title}>
{title}
</Heading>
{subtitle && (
<Text size="sm" color="muted">
{subtitle}
</Text>
)}
</div>
</div>
{actionLabel && onAction && (
<Button variant="tertiary" size="sm" onClick={onAction}>
{actionLabel}
</Button>
)}
</div>
);
};
CardHeader.displayName = 'CardHeader';
ListItem 分子示例
// molecules/ListItem/ListItem.tsx
import React from 'react';
import { Checkbox } from '@/components/atoms/Checkbox';
import { Text } from '@/components/atoms/Typography';
import { Icon } from '@/components/atoms/Icon';
import styles from './ListItem.module.css';
export interface ListItemProps {
/** 项目ID用于选择 */
id: string;
/** 主要内容 */
primary: React.ReactNode;
/** 次要内容 */
secondary?: React.ReactNode;
/** 左侧图标 */
icon?: string;
/** 项目是否可选 */
selectable?: boolean;
/** 选择状态 */
selected?: boolean;
/** 选择更改处理器 */
onSelect?: (id: string, selected: boolean) => void;
/** 右对齐操作按钮 */
actions?: React.ReactNode;
/** 点击处理器 */
onClick?: () => void;
}
export const ListItem: React.FC<ListItemProps> = ({
id,
primary,
secondary,
icon,
selectable = false,
selected = false,
onSelect,
actions,
onClick,
}) => {
const classNames = [
styles.listItem,
onClick && styles.clickable,
selected && styles.selected,
]
.filter(Boolean)
.join(' ');
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onSelect?.(id, e.target.checked);
};
return (
<div className={classNames} onClick={onClick} role={onClick ? 'button' : undefined}>
{selectable && (
<Checkbox
checked={selected}
onChange={handleCheckboxChange}
aria-label={`选择 ${primary}`}
onClick={(e) => e.stopPropagation()}
/>
)}
{icon && <Icon name={icon} size="md" className={styles.icon} />}
<div className={styles.content}>
<div className={styles.primary}>{primary}</div>
{secondary && (
<Text size="sm" color="muted" className={styles.secondary}>
{secondary}
</Text>
)}
</div>
{actions && (
<div className={styles.actions} onClick={(e) => e.stopPropagation()}>
{actions}
</div>
)}
</div>
);
};
ListItem.displayName = 'ListItem';
ButtonGroup 分子示例
// molecules/ButtonGroup/ButtonGroup.tsx
import React from 'react';
import { Button, type ButtonProps } from '@/components/atoms/Button';
import styles from './ButtonGroup.module.css';
export interface ButtonGroupItem {
id: string;
label: string;
icon?: string;
disabled?: boolean;
}
export interface ButtonGroupProps {
/** 按钮项 */
items: ButtonGroupItem[];
/** 选中项目ID(s) */
value?: string | string[];
/** 选择更改处理器 */
onChange?: (value: string | string[]) => void;
/** 允许多选 */
multiple?: boolean;
/** 大小变体 */
size?: ButtonProps['size'];
/** 禁用状态 */
disabled?: boolean;
}
export const ButtonGroup: React.FC<ButtonGroupProps> = ({
items,
value = [],
onChange,
multiple = false,
size = 'md',
disabled = false,
}) => {
const selectedIds = Array.isArray(value) ? value : [value].filter(Boolean);
const handleClick = (itemId: string) => {
if (!onChange) return;
if (multiple) {
const newValue = selectedIds.includes(itemId)
? selectedIds.filter((id) => id !== itemId)
: [...selectedIds, itemId];
onChange(newValue);
} else {
onChange(itemId);
}
};
return (
<div className={styles.group} role="group">
{items.map((item) => {
const isSelected = selectedIds.includes(item.id);
return (
<Button
key={item.id}
variant={isSelected ? 'primary' : 'secondary'}
size={size}
disabled={disabled || item.disabled}
onClick={() => handleClick(item.id)}
aria-pressed={isSelected}
className={styles.button}
>
{item.label}
</Button>
);
})}
</div>
);
};
ButtonGroup.displayName = 'ButtonGroup';
Stat 分子示例
// molecules/Stat/Stat.tsx
import React from 'react';
import { Text, Heading } from '@/components/atoms/Typography';
import { Icon } from '@/components/atoms/Icon';
import styles from './Stat.module.css';
export type TrendDirection = 'up' | 'down' | 'neutral';
export interface StatProps {
/** 统计标签 */
label: string;
/** 统计值 */
value: string | number;
/** 比较的先前值 */
previousValue?: string | number;
/** 趋势方向 */
trend?: TrendDirection;
/** 趋势百分比 */
trendValue?: string;
/** 统计图标 */
icon?: string;
/** 帮助文本 */
helpText?: string;
}
export const Stat: React.FC<StatProps> = ({
label,
value,
trend,
trendValue,
icon,
helpText,
}) => {
const getTrendColor = (direction?: TrendDirection) => {
switch (direction) {
case 'up':
return 'success';
case 'down':
return 'danger';
default:
return 'muted';
}
};
const getTrendIcon = (direction?: TrendDirection) => {
switch (direction) {
case 'up':
return 'trending-up';
case 'down':
return 'trending-down';
default:
return 'minus';
}
};
return (
<div className={styles.stat}>
<div className={styles.header}>
{icon && <Icon name={icon} size="sm" className={styles.icon} />}
<Text size="sm" color="muted">
{label}
</Text>
</div>
<div className={styles.value}>
<Heading level={2}>{value}</Heading>
</div>
{(trend || trendValue) && (
<div className={styles.trend}>
{trend && (
<Icon
name={getTrendIcon(trend)}
size="xs"
color={`var(--color-${getTrendColor(trend)}-500)`}
/>
)}
{trendValue && (
<Text size="sm" color={getTrendColor(trend)}>
{trendValue}
</Text>
)}
</div>
)}
{helpText && (
<Text size="xs" color="muted" className={styles.helpText}>
{helpText}
</Text>
)}
</div>
);
};
Stat.displayName = 'Stat';
最佳实践
1. 保持分子专注
// 好:单一、清晰的目的
const SearchForm = () => (
<form>
<Input placeholder="搜索..." />
<Button>搜索</Button>
</form>
);
// 坏:做太多
const SearchWithFiltersAndResults = () => (
<div>
<Input />
<Button>搜索</Button>
<FilterDropdown /> {/* 应该是独立的分子 */}
<ResultsList /> {/* 应该是生物 */}
<Pagination /> {/* 应该是独立的分子 */}
</div>
);
2. 仅从原子导入
// 好:仅使用原子
import { Button } from '@/components/atoms/Button';
import { Input } from '@/components/atoms/Input';
import { Icon } from '@/components/atoms/Icon';
// 坏:从其他分子导入
import { FormField } from '@/components/molecules/FormField'; // 错误级别!
import { Button } from '@/components/atoms/Button';
3. 精心组合属性
// 好:清晰的属性转发
interface SearchFormProps {
onSubmit: (query: string) => void;
inputProps?: Partial<InputProps>;
buttonProps?: Partial<ButtonProps>;
}
// 坏:令人困惑的属性命名
interface SearchFormProps {
inputPlaceholder?: string;
inputDisabled?: boolean;
inputSize?: string;
buttonVariant?: string;
buttonDisabled?: boolean;
// ... 无尽的属性转发
}
4. 最小化管理内部状态
// 好:最小的UI状态
const SearchForm = ({ onSubmit }) => {
const [query, setQuery] = useState('');
return (
<form onSubmit={() => onSubmit(query)}>
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
<Button type="submit">搜索</Button>
</form>
);
};
// 坏:太多内部状态
const SearchForm = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]); // 应该在父级
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
fetchResults(query).then(setResults); // 分子中的业务逻辑!
}, [query]);
};
要避免的反模式
1. 分子包含分子
// 坏:分子导入另一个分子
// molecules/ComplexForm/ComplexForm.tsx
import { FormField } from '../FormField'; // 错误!
import { SearchForm } from '../SearchForm'; // 错误!
// 好:保持在原子级别,或提升到生物
// organisms/ComplexForm/ComplexForm.tsx
import { FormField } from '@/components/molecules/FormField';
import { SearchForm } from '@/components/molecules/SearchForm';
2. 分子中的业务逻辑
// 坏:分子中的API调用
const SearchForm = ({ apiEndpoint }) => {
const handleSubmit = async (query) => {
const results = await fetch(`${apiEndpoint}?q=${query}`);
// 这里处理结果...
};
};
// 好:委托给父级
const SearchForm = ({ onSubmit }) => {
const handleSubmit = (query) => {
onSubmit(query); // 父级处理API逻辑
};
};
3. 过度抽象
// 坏:不必要的分子用于单个原子
const IconWrapper = ({ icon }) => <Icon name={icon} />;
// 好:直接使用原子
<Icon name="search" />
何时使用此技能
- 为特定功能组合原子
- 创建可重用的表单组件
- 构建导航元素
- 创建卡片和列表组件
- 为常见UI组合建立模式
相关技能
atomic-design-fundamentals- 核心方法论概述atomic-design-atoms- 创建原子组件atomic-design-organisms- 构建复杂生物