ui-component-patterns

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

UI Component Patterns

UI组件设计模式

When to use this skill

何时使用此技能

  • 컴포넌트 라이브러리 구축: 재사용 가능한 UI 컴포넌트 제작
  • 디자인 시스템 구현: 일관된 UI 패턴 적용
  • 복잡한 UI: 여러 변형이 필요한 컴포넌트 (Button, Modal, Dropdown)
  • 리팩토링: 중복 코드를 컴포넌트로 추출
  • 组件库构建:制作可复用的UI组件
  • 设计系统实现:应用统一的UI模式
  • 复杂UI:需要多种变体的组件(Button、Modal、Dropdown)
  • 代码重构:将重复代码提取为组件

Instructions

操作指南

Step 1: Props API 설계

步骤1:Props API设计

사용하기 쉽고 확장 가능한 Props를 설계합니다.
원칙:
  • 명확한 이름
  • 합리적인 기본값
  • TypeScript로 타입 정의
  • 선택적 Props는 optional (?)
예시 (Button):
tsx
interface ButtonProps {
  // 필수
  children: React.ReactNode;

  // 선택적 (기본값 있음)
  variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  disabled?: boolean;
  isLoading?: boolean;

  // 이벤트 핸들러
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;

  // HTML 속성 상속
  type?: 'button' | 'submit' | 'reset';
  className?: string;
}

function Button({
  children,
  variant = 'primary',
  size = 'md',
  disabled = false,
  isLoading = false,
  onClick,
  type = 'button',
  className = '',
  ...rest
}: ButtonProps) {
  const baseClasses = 'btn';
  const variantClasses = `btn-${variant}`;
  const sizeClasses = `btn-${size}`;
  const classes = `${baseClasses} ${variantClasses} ${sizeClasses} ${className}`;

  return (
    <button
      type={type}
      className={classes}
      disabled={disabled || isLoading}
      onClick={onClick}
      {...rest}
    >
      {isLoading ? <Spinner /> : children}
    </button>
  );
}

// 사용 예시
<Button variant="primary" size="lg" onClick={() => alert('Clicked!')}>
  Click Me
</Button>
设计易用且可扩展的Props。
原则:
  • 名称清晰
  • 合理的默认值
  • 使用TypeScript定义类型
  • 可选Props标记为optional(?)
示例(Button):
tsx
interface ButtonProps {
  // 必填
  children: React.ReactNode;

  // 可选(含默认值)
  variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  disabled?: boolean;
  isLoading?: boolean;

  // 事件处理函数
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;

  // 继承HTML属性
  type?: 'button' | 'submit' | 'reset';
  className?: string;
}

function Button({
  children,
  variant = 'primary',
  size = 'md',
  disabled = false,
  isLoading = false,
  onClick,
  type = 'button',
  className = '',
  ...rest
}: ButtonProps) {
  const baseClasses = 'btn';
  const variantClasses = `btn-${variant}`;
  const sizeClasses = `btn-${size}`;
  const classes = `${baseClasses} ${variantClasses} ${sizeClasses} ${className}`;

  return (
    <button
      type={type}
      className={classes}
      disabled={disabled || isLoading}
      onClick={onClick}
      {...rest}
    >
      {isLoading ? <Spinner /> : children}
    </button>
  );
}

// 使用示例
<Button variant="primary" size="lg" onClick={() => alert('Clicked!')}>
  Click Me
</Button>

Step 2: Composition Pattern (합성 패턴)

步骤2:组合模式(Composition Pattern)

작은 컴포넌트를 조합하여 복잡한 UI를 만듭니다.
예시 (Card):
tsx
// Card 컴포넌트 (Container)
interface CardProps {
  children: React.ReactNode;
  className?: string;
}

function Card({ children, className = '' }: CardProps) {
  return <div className={`card ${className}`}>{children}</div>;
}

// Card.Header
function CardHeader({ children }: { children: React.ReactNode }) {
  return <div className="card-header">{children}</div>;
}

// Card.Body
function CardBody({ children }: { children: React.ReactNode }) {
  return <div className="card-body">{children}</div>;
}

// Card.Footer
function CardFooter({ children }: { children: React.ReactNode }) {
  return <div className="card-footer">{children}</div>;
}

// Compound Component 패턴
Card.Header = CardHeader;
Card.Body = CardBody;
Card.Footer = CardFooter;

export default Card;

// 사용
import Card from './Card';

function ProductCard() {
  return (
    <Card>
      <Card.Header>
        <h3>Product Name</h3>
      </Card.Header>
      <Card.Body>
        <img src="..." alt="Product" />
        <p>Product description here...</p>
      </Card.Body>
      <Card.Footer>
        <button>Add to Cart</button>
      </Card.Footer>
    </Card>
  );
}
通过组合小型组件来构建复杂UI。
示例(Card):
tsx
// Card组件(容器)
interface CardProps {
  children: React.ReactNode;
  className?: string;
}

function Card({ children, className = '' }: CardProps) {
  return <div className={`card ${className}`}>{children}</div>;
}

// Card.Header
function CardHeader({ children }: { children: React.ReactNode }) {
  return <div className="card-header">{children}</div>;
}

// Card.Body
function CardBody({ children }: { children: React.ReactNode }) {
  return <div className="card-body">{children}</div>;
}

// Card.Footer
function CardFooter({ children }: { children: React.ReactNode }) {
  return <div className="card-footer">{children}</div>;
}

// 复合组件模式
Card.Header = CardHeader;
Card.Body = CardBody;
Card.Footer = CardFooter;

export default Card;

// 使用
import Card from './Card';

function ProductCard() {
  return (
    <Card>
      <Card.Header>
        <h3>Product Name</h3>
      </Card.Header>
      <Card.Body>
        <img src="..." alt="Product" />
        <p>Product description here...</p>
      </Card.Body>
      <Card.Footer>
        <button>Add to Cart</button>
      </Card.Footer>
    </Card>
  );
}

Step 3: Render Props / Children as Function

步骤3:Render Props / 子组件作为函数

유연한 커스터마이징을 위한 패턴입니다.
예시 (Dropdown):
tsx
interface DropdownProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  onSelect: (item: T) => void;
  placeholder?: string;
}

function Dropdown<T>({ items, renderItem, onSelect, placeholder }: DropdownProps<T>) {
  const [isOpen, setIsOpen] = useState(false);
  const [selected, setSelected] = useState<T | null>(null);

  const handleSelect = (item: T) => {
    setSelected(item);
    onSelect(item);
    setIsOpen(false);
  };

  return (
    <div className="dropdown">
      <button onClick={() => setIsOpen(!isOpen)}>
        {selected ? renderItem(selected, -1) : placeholder || 'Select...'}
      </button>

      {isOpen && (
        <ul className="dropdown-menu">
          {items.map((item, index) => (
            <li key={index} onClick={() => handleSelect(item)}>
              {renderItem(item, index)}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

// 사용
interface User {
  id: string;
  name: string;
  avatar: string;
}

function UserDropdown() {
  const users: User[] = [...];

  return (
    <Dropdown
      items={users}
      placeholder="Select a user"
      renderItem={(user) => (
        <div className="user-item">
          <img src={user.avatar} alt={user.name} />
          <span>{user.name}</span>
        </div>
      )}
      onSelect={(user) => console.log('Selected:', user)}
    />
  );
}
用于灵活定制的模式。
示例(Dropdown):
tsx
interface DropdownProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  onSelect: (item: T) => void;
  placeholder?: string;
}

function Dropdown<T>({ items, renderItem, onSelect, placeholder }: DropdownProps<T>) {
  const [isOpen, setIsOpen] = useState(false);
  const [selected, setSelected] = useState<T | null>(null);

  const handleSelect = (item: T) => {
    setSelected(item);
    onSelect(item);
    setIsOpen(false);
  };

  return (
    <div className="dropdown">
      <button onClick={() => setIsOpen(!isOpen)}>
        {selected ? renderItem(selected, -1) : placeholder || 'Select...'}
      </button>

      {isOpen && (
        <ul className="dropdown-menu">
          {items.map((item, index) => (
            <li key={index} onClick={() => handleSelect(item)}>
              {renderItem(item, index)}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

// 使用
interface User {
  id: string;
  name: string;
  avatar: string;
}

function UserDropdown() {
  const users: User[] = [...];

  return (
    <Dropdown
      items={users}
      placeholder="Select a user"
      renderItem={(user) => (
        <div className="user-item">
          <img src={user.avatar} alt={user.name} />
          <span>{user.name}</span>
        </div>
      )}
      onSelect={(user) => console.log('Selected:', user)}
    />
  );
}

Step 4: Custom Hooks로 로직 분리

步骤4:使用自定义Hook分离逻辑

UI와 비즈니스 로직을 분리합니다.
예시 (Modal):
tsx
// hooks/useModal.ts
function useModal(initialOpen = false) {
  const [isOpen, setIsOpen] = useState(initialOpen);

  const open = useCallback(() => setIsOpen(true), []);
  const close = useCallback(() => setIsOpen(false), []);
  const toggle = useCallback(() => setIsOpen(prev => !prev), []);

  return { isOpen, open, close, toggle };
}

// components/Modal.tsx
interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

function Modal({ isOpen, onClose, title, children }: ModalProps) {
  if (!isOpen) return null;

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        <div className="modal-header">
          <h2>{title}</h2>
          <button onClick={onClose} aria-label="Close">×</button>
        </div>
        <div className="modal-body">{children}</div>
      </div>
    </div>
  );
}

// 사용
function App() {
  const { isOpen, open, close } = useModal();

  return (
    <>
      <button onClick={open}>Open Modal</button>
      <Modal isOpen={isOpen} onClose={close} title="My Modal">
        <p>Modal content here...</p>
      </Modal>
    </>
  );
}
分离UI与业务逻辑。
示例(Modal):
tsx
// hooks/useModal.ts
function useModal(initialOpen = false) {
  const [isOpen, setIsOpen] = useState(initialOpen);

  const open = useCallback(() => setIsOpen(true), []);
  const close = useCallback(() => setIsOpen(false), []);
  const toggle = useCallback(() => setIsOpen(prev => !prev), []);

  return { isOpen, open, close, toggle };
}

// components/Modal.tsx
interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

function Modal({ isOpen, onClose, title, children }: ModalProps) {
  if (!isOpen) return null;

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        <div className="modal-header">
          <h2>{title}</h2>
          <button onClick={onClose} aria-label="Close">×</button>
        </div>
        <div className="modal-body">{children}</div>
      </div>
    </div>
  );
}

// 使用
function App() {
  const { isOpen, open, close } = useModal();

  return (
    <>
      <button onClick={open}>Open Modal</button>
      <Modal isOpen={isOpen} onClose={close} title="My Modal">
        <p>Modal content here...</p>
      </Modal>
    </>
  );
}

Step 5: 성능 최적화

步骤5:性能优化

불필요한 리렌더링을 방지합니다.
React.memo:
tsx
// ❌ 나쁜 예: 부모가 리렌더링될 때마다 자식도 리렌더링
function ExpensiveComponent({ data }) {
  console.log('Rendering...');
  return <div>{/* 복잡한 UI */}</div>;
}

// ✅ 좋은 예: props가 변경될 때만 리렌더링
const ExpensiveComponent = React.memo(({ data }) => {
  console.log('Rendering...');
  return <div>{/* 복잡한 UI */}</div>;
});
useMemo & useCallback:
tsx
function ProductList({ products, category }: { products: Product[]; category: string }) {
  // ✅ 필터링 결과 메모이제이션
  const filteredProducts = useMemo(() => {
    return products.filter(p => p.category === category);
  }, [products, category]);

  // ✅ 콜백 메모이제이션
  const handleAddToCart = useCallback((productId: string) => {
    // 장바구니에 추가
    console.log('Adding:', productId);
  }, []);

  return (
    <div>
      {filteredProducts.map(product => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={handleAddToCart}
        />
      ))}
    </div>
  );
}

const ProductCard = React.memo(({ product, onAddToCart }) => {
  return (
    <div>
      <h3>{product.name}</h3>
      <button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
    </div>
  );
});
避免不必要的重渲染。
React.memo:
tsx
// ❌ 不良示例:父组件重渲染时子组件也会重渲染
function ExpensiveComponent({ data }) {
  console.log('Rendering...');
  return <div>{/* 复杂UI */}</div>;
}

// ✅ 良好示例:仅在Props变化时重渲染
const ExpensiveComponent = React.memo(({ data }) => {
  console.log('Rendering...');
  return <div>{/* 复杂UI */}</div>;
});
useMemo & useCallback:
tsx
function ProductList({ products, category }: { products: Product[]; category: string }) {
  // ✅ 对过滤结果进行记忆化
  const filteredProducts = useMemo(() => {
    return products.filter(p => p.category === category);
  }, [products, category]);

  // ✅ 对回调函数进行记忆化
  const handleAddToCart = useCallback((productId: string) => {
    // 添加到购物车
    console.log('Adding:', productId);
  }, []);

  return (
    <div>
      {filteredProducts.map(product => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={handleAddToCart}
        />
      ))}
    </div>
  );
}

const ProductCard = React.memo(({ product, onAddToCart }) => {
  return (
    <div>
      <h3>{product.name}</h3>
      <button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
    </div>
  );
});

Output format

输出格式

컴포넌트 파일 구조

组件文件结构

components/
├── Button/
│   ├── Button.tsx           # 메인 컴포넌트
│   ├── Button.test.tsx      # 테스트
│   ├── Button.stories.tsx   # Storybook
│   ├── Button.module.css    # 스타일
│   └── index.ts             # Export
├── Card/
│   ├── Card.tsx
│   ├── CardHeader.tsx
│   ├── CardBody.tsx
│   ├── CardFooter.tsx
│   └── index.ts
└── Modal/
    ├── Modal.tsx
    ├── useModal.ts          # Custom hook
    └── index.ts
components/
├── Button/
│   ├── Button.tsx           # 主组件
│   ├── Button.test.tsx      # 测试文件
│   ├── Button.stories.tsx   # Storybook文档
│   ├── Button.module.css    # 样式文件
│   └── index.ts             # 导出文件
├── Card/
│   ├── Card.tsx
│   ├── CardHeader.tsx
│   ├── CardBody.tsx
│   ├── CardFooter.tsx
│   └── index.ts
└── Modal/
    ├── Modal.tsx
    ├── useModal.ts          # 自定义Hook
    └── index.ts

컴포넌트 템플릿

组件模板

tsx
import React from 'react';

export interface ComponentProps {
  // Props 정의
  children: React.ReactNode;
  className?: string;
}

/**
 * Component description
 *
 * @example
 * ```tsx
 * <Component>Hello</Component>
 * ```
 */
export const Component = React.forwardRef<HTMLDivElement, ComponentProps>(
  ({ children, className = '', ...rest }, ref) => {
    return (
      <div ref={ref} className={`component ${className}`} {...rest}>
        {children}
      </div>
    );
  }
);

Component.displayName = 'Component';

export default Component;
tsx
import React from 'react';

export interface ComponentProps {
  // Props定义
  children: React.ReactNode;
  className?: string;
}

/**
 * 组件描述
 *
 * @example
 * ```tsx
 * <Component>Hello</Component>
 * ```
 */
export const Component = React.forwardRef<HTMLDivElement, ComponentProps>(
  ({ children, className = '', ...rest }, ref) => {
    return (
      <div ref={ref} className={`component ${className}`} {...rest}>
        {children}
      </div>
    );
  }
);

Component.displayName = 'Component';

export default Component;

Constraints

约束条件

필수 규칙 (MUST)

必须遵守的规则(MUST)

  1. 단일 책임 원칙: 한 컴포넌트는 하나의 역할만
    • Button은 버튼만, Form은 폼만
  2. Props 타입 정의: TypeScript interface 필수
    • 자동완성 지원
    • 타입 안정성
  3. 접근성 고려: aria-*, role, tabindex 등
  1. 单一职责原则:一个组件仅承担一项职责
    • Button只做按钮,Form只做表单
  2. Props类型定义:必须使用TypeScript interface
    • 支持自动补全
    • 保证类型安全
  3. 考虑可访问性:使用aria-*、role、tabindex等属性

금지 사항 (MUST NOT)

禁止事项(MUST NOT)

  1. 과도한 props drilling: 5단계 이상 금지
    • Context 또는 Composition 사용
  2. 비즈니스 로직 포함: UI 컴포넌트에 API 호출, 복잡한 계산 금지
    • Custom hooks로 분리
  3. inline 객체/함수: 성능 저하
    tsx
    // ❌ 나쁜 예
    <Component style={{ color: 'red' }} onClick={() => handleClick()} />
    
    // ✅ 좋은 예
    const style = { color: 'red' };
    const handleClick = useCallback(() => {...}, []);
    <Component style={style} onClick={handleClick} />
  1. 过度Props透传:禁止超过5层的Props透传
    • 使用Context或组合模式替代
  2. 包含业务逻辑:UI组件中禁止包含API调用、复杂计算
    • 使用自定义Hook分离
  3. 内联对象/函数:会导致性能下降
    tsx
    // ❌ 不良示例
    <Component style={{ color: 'red' }} onClick={() => handleClick()} />
    
    // ✅ 良好示例
    const style = { color: 'red' };
    const handleClick = useCallback(() => {...}, []);
    <Component style={style} onClick={handleClick} />

Examples

示例

예시 1: Accordion (Compound Component)

示例1:手风琴组件(复合组件)

tsx
import React, { createContext, useContext, useState } from 'react';

// Context로 상태 공유
const AccordionContext = createContext<{
  activeIndex: number | null;
  setActiveIndex: (index: number | null) => void;
} | null>(null);

function Accordion({ children }: { children: React.ReactNode }) {
  const [activeIndex, setActiveIndex] = useState<number | null>(null);

  return (
    <AccordionContext.Provider value={{ activeIndex, setActiveIndex }}>
      <div className="accordion">{children}</div>
    </AccordionContext.Provider>
  );
}

function AccordionItem({ index, title, children }: {
  index: number;
  title: string;
  children: React.ReactNode;
}) {
  const context = useContext(AccordionContext);
  if (!context) throw new Error('AccordionItem must be used within Accordion');

  const { activeIndex, setActiveIndex } = context;
  const isActive = activeIndex === index;

  return (
    <div className="accordion-item">
      <button
        className="accordion-header"
        onClick={() => setActiveIndex(isActive ? null : index)}
        aria-expanded={isActive}
      >
        {title}
      </button>
      {isActive && <div className="accordion-body">{children}</div>}
    </div>
  );
}

Accordion.Item = AccordionItem;
export default Accordion;

// 사용
<Accordion>
  <Accordion.Item index={0} title="Section 1">
    Content for section 1
  </Accordion.Item>
  <Accordion.Item index={1} title="Section 2">
    Content for section 2
  </Accordion.Item>
</Accordion>
tsx
import React, { createContext, useContext, useState } from 'react';

// 通过Context共享状态
const AccordionContext = createContext<{
  activeIndex: number | null;
  setActiveIndex: (index: number | null) => void;
} | null>(null);

function Accordion({ children }: { children: React.ReactNode }) {
  const [activeIndex, setActiveIndex] = useState<number | null>(null);

  return (
    <AccordionContext.Provider value={{ activeIndex, setActiveIndex }}>
      <div className="accordion">{children}</div>
    </AccordionContext.Provider>
  );
}

function AccordionItem({ index, title, children }: {
  index: number;
  title: string;
  children: React.ReactNode;
}) {
  const context = useContext(AccordionContext);
  if (!context) throw new Error('AccordionItem must be used within Accordion');

  const { activeIndex, setActiveIndex } = context;
  const isActive = activeIndex === index;

  return (
    <div className="accordion-item">
      <button
        className="accordion-header"
        onClick={() => setActiveIndex(isActive ? null : index)}
        aria-expanded={isActive}
      >
        {title}
      </button>
      {isActive && <div className="accordion-body">{children}</div>}
    </div>
  );
}

Accordion.Item = AccordionItem;
export default Accordion;

// 使用
<Accordion>
  <Accordion.Item index={0} title="Section 1">
    Content for section 1
  </Accordion.Item>
  <Accordion.Item index={1} title="Section 2">
    Content for section 2
  </Accordion.Item>
</Accordion>

예시 2: Polymorphic Component (as prop)

示例2:多态组件(as prop)

tsx
type PolymorphicComponentProps<C extends React.ElementType> = {
  as?: C;
  children: React.ReactNode;
} & React.ComponentPropsWithoutRef<C>;

function Text<C extends React.ElementType = 'span'>({
  as,
  children,
  ...rest
}: PolymorphicComponentProps<C>) {
  const Component = as || 'span';
  return <Component {...rest}>{children}</Component>;
}

// 사용
<Text>Default span</Text>
<Text as="h1">Heading 1</Text>
<Text as="p" style={{ color: 'blue' }}>Paragraph</Text>
<Text as={Link} href="/about">Link</Text>
tsx
type PolymorphicComponentProps<C extends React.ElementType> = {
  as?: C;
  children: React.ReactNode;
} & React.ComponentPropsWithoutRef<C>;

function Text<C extends React.ElementType = 'span'>({
  as,
  children,
  ...rest
}: PolymorphicComponentProps<C>) {
  const Component = as || 'span';
  return <Component {...rest}>{children}</Component>;
}

// 使用
<Text>Default span</Text>
<Text as="h1">Heading 1</Text>
<Text as="p" style={{ color: 'blue' }}>Paragraph</Text>
<Text as={Link} href="/about">Link</Text>

Best practices

最佳实践

  1. Composition over Props: 많은 props 대신 children 활용
  2. Controlled vs Uncontrolled: 상황에 맞게 선택
  3. Default Props: 합리적인 기본값 제공
  4. Storybook: 컴포넌트 문서화 및 개발
  1. 优先组合而非Props:多使用children而非大量Props
  2. 受控与非受控组件:根据场景选择合适的类型
  3. 默认Props:提供合理的默认值
  4. Storybook:用于组件文档化与开发

References

参考资料

Metadata

元数据

버전

版本

  • 현재 버전: 1.0.0
  • 최종 업데이트: 2025-01-01
  • 호환 플랫폼: Claude, ChatGPT, Gemini
  • 当前版本:1.0.0
  • 最后更新:2025-01-01
  • 兼容平台:Claude、ChatGPT、Gemini

관련 스킬

相关技能

  • web-accessibility: 접근 가능한 컴포넌트
  • state-management: 컴포넌트 상태 관리
  • web-accessibility:可访问组件
  • state-management:组件状态管理

태그

标签

#UI-components
#React
#design-patterns
#composition
#TypeScript
#frontend
#UI-components
#React
#design-patterns
#composition
#TypeScript
#frontend