原子设计:分子组件Skill atomic-design-molecules

该技能专注于原子设计方法论中的分子组件创建,用于将原子组件组合成功能性的UI元素,如表单字段、搜索栏和卡片头部。关键词包括原子设计、分子组件、UI组件、前端开发、React、TypeScript。适用于提升UI组件的可重用性、维护性和一致性,支持高效的前端界面构建。

前端开发 0 次安装 0 次浏览 更新于 3/25/2026

名称: 原子设计-分子 描述: 用于将原子组合成分子组件,如表单字段、搜索栏和卡片头部。分子是原子的功能组。 允许工具:

  • 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 - 构建复杂生物