Files
2025-11-29 18:48:55 +08:00

938 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Component Library Architecture
Complete guide to building, organizing, and maintaining a scalable component library.
## Component Architecture Principles
### 1. Atomic Design
Organize components hierarchically from smallest to largest.
**Hierarchy:**
```
Atoms → Molecules → Organisms → Templates → Pages
```
**Example Structure:**
```
components/
├── atoms/
│ ├── Button/
│ ├── Input/
│ ├── Icon/
│ └── Label/
├── molecules/
│ ├── FormField/
│ ├── SearchBar/
│ └── Card/
├── organisms/
│ ├── Header/
│ ├── LoginForm/
│ └── ProductCard/
├── templates/
│ ├── DashboardLayout/
│ └── AuthLayout/
└── pages/
├── HomePage/
└── ProfilePage/
```
### 2. Component Types
**Presentational (Dumb) Components:**
- Focus on how things look
- No state management
- Receive data via props
- Highly reusable
```tsx
// Presentational Button
interface ButtonProps {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
onClick?: () => void;
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
children,
onClick,
}) => {
return (
<button
className={`btn btn--${variant} btn--${size}`}
onClick={onClick}
>
{children}
</button>
);
};
```
**Container (Smart) Components:**
- Focus on how things work
- Manage state
- Connect to data sources
- Use presentational components
```tsx
// Container Component
export const UserProfileContainer: React.FC = () => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser().then(data => {
setUser(data);
setLoading(false);
});
}, []);
if (loading) return <LoadingSpinner />;
if (!user) return <ErrorMessage />;
return <UserProfile user={user} />;
};
```
### 3. Component Composition
**Build complex components from simple ones.**
```tsx
// Atoms
const Avatar: React.FC<AvatarProps> = ({ src, alt }) => (
<img className="avatar" src={src} alt={alt} />
);
const Badge: React.FC<BadgeProps> = ({ children }) => (
<span className="badge">{children}</span>
);
// Molecule (composed of atoms)
const UserBadge: React.FC<UserBadgeProps> = ({ user }) => (
<div className="user-badge">
<Avatar src={user.avatar} alt={user.name} />
<span className="user-badge__name">{user.name}</span>
<Badge>{user.role}</Badge>
</div>
);
// Organism (composed of molecules)
const UserList: React.FC<UserListProps> = ({ users }) => (
<ul className="user-list">
{users.map(user => (
<li key={user.id}>
<UserBadge user={user} />
</li>
))}
</ul>
);
```
## File Structure
### Standard Component Structure
```
Button/
├── Button.tsx # Main component
├── Button.test.tsx # Tests
├── Button.stories.tsx # Storybook stories
├── Button.module.css # Styles (CSS Modules)
├── Button.types.ts # TypeScript types
└── index.ts # Barrel export
```
### Example Files
**Button/Button.tsx**
```tsx
import React from 'react';
import styles from './Button.module.css';
import { ButtonProps } from './Button.types';
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
children,
onClick,
...props
}) => {
const className = [
styles.button,
styles[`button--${variant}`],
styles[`button--${size}`],
disabled && styles['button--disabled'],
loading && styles['button--loading'],
].filter(Boolean).join(' ');
return (
<button
className={className}
disabled={disabled || loading}
onClick={onClick}
aria-busy={loading}
{...props}
>
{loading && <span className={styles.spinner} />}
{children}
</button>
);
};
```
**Button/Button.types.ts**
```tsx
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual style variant */
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Disabled state */
disabled?: boolean;
/** Loading state */
loading?: boolean;
/** Button content */
children: React.ReactNode;
/** Click handler */
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
}
```
**Button/Button.test.tsx**
```tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('renders children correctly', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('handles click events', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('applies variant classes', () => {
render(<Button variant="secondary">Click me</Button>);
const button = screen.getByText('Click me');
expect(button).toHaveClass('button--secondary');
});
it('disables interaction when loading', () => {
const handleClick = jest.fn();
render(<Button loading onClick={handleClick}>Click me</Button>);
const button = screen.getByText('Click me');
expect(button).toBeDisabled();
expect(button).toHaveAttribute('aria-busy', 'true');
});
});
```
**Button/Button.stories.tsx**
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'ghost', 'danger'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Primary Button',
},
};
export const Secondary: Story = {
args: {
variant: 'secondary',
children: 'Secondary Button',
},
};
export const Loading: Story = {
args: {
loading: true,
children: 'Loading...',
},
};
export const Disabled: Story = {
args: {
disabled: true,
children: 'Disabled Button',
},
};
```
**Button/index.ts**
```tsx
export { Button } from './Button';
export type { ButtonProps } from './Button.types';
```
## Common Components
### 1. Button Component
```tsx
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
icon?: React.ReactNode;
iconPosition?: 'left' | 'right';
children: React.ReactNode;
onClick?: () => void;
}
```
### 2. Input Component
```tsx
interface InputProps {
type?: 'text' | 'email' | 'password' | 'number' | 'tel';
label?: string;
placeholder?: string;
value?: string;
error?: string;
helpText?: string;
required?: boolean;
disabled?: boolean;
onChange?: (value: string) => void;
}
export const Input: React.FC<InputProps> = ({
type = 'text',
label,
placeholder,
value,
error,
helpText,
required,
disabled,
onChange,
}) => {
const id = useId();
const errorId = `${id}-error`;
const helpId = `${id}-help`;
return (
<div className="input-wrapper">
{label && (
<label htmlFor={id} className="input-label">
{label}
{required && <span aria-label="required">*</span>}
</label>
)}
<input
id={id}
type={type}
className={`input ${error ? 'input--error' : ''}`}
placeholder={placeholder}
value={value}
disabled={disabled}
required={required}
aria-invalid={!!error}
aria-describedby={error ? errorId : helpText ? helpId : undefined}
onChange={(e) => onChange?.(e.target.value)}
/>
{error && (
<span id={errorId} className="input-error" role="alert">
{error}
</span>
)}
{helpText && !error && (
<span id={helpId} className="input-help">
{helpText}
</span>
)}
</div>
);
};
```
### 3. Card Component
```tsx
interface CardProps {
variant?: 'default' | 'outlined' | 'elevated';
padding?: 'none' | 'sm' | 'md' | 'lg';
clickable?: boolean;
children: React.ReactNode;
onClick?: () => void;
}
export const Card: React.FC<CardProps> = ({
variant = 'default',
padding = 'md',
clickable = false,
children,
onClick,
}) => {
const Component = clickable ? 'button' : 'div';
return (
<Component
className={`card card--${variant} card--padding-${padding}`}
onClick={onClick}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
>
{children}
</Component>
);
};
```
### 4. Modal Component
```tsx
interface ModalProps {
open: boolean;
title?: string;
size?: 'sm' | 'md' | 'lg' | 'full';
children: React.ReactNode;
onClose: () => void;
}
export const Modal: React.FC<ModalProps> = ({
open,
title,
size = 'md',
children,
onClose,
}) => {
useEffect(() => {
if (open) {
// Trap focus in modal
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}
}, [open]);
if (!open) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
className={`modal modal--${size}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
>
{title && (
<div className="modal__header">
<h2 id="modal-title">{title}</h2>
<button
className="modal__close"
onClick={onClose}
aria-label="Close dialog"
>
×
</button>
</div>
)}
<div className="modal__body">{children}</div>
</div>
</div>,
document.body
);
};
```
### 5. Dropdown Component
```tsx
interface DropdownProps {
trigger: React.ReactNode;
children: React.ReactNode;
align?: 'left' | 'right';
}
export const Dropdown: React.FC<DropdownProps> = ({
trigger,
children,
align = 'left',
}) => {
const [open, setOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="dropdown" ref={dropdownRef}>
<button
className="dropdown__trigger"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-haspopup="true"
>
{trigger}
</button>
{open && (
<div className={`dropdown__menu dropdown__menu--${align}`} role="menu">
{children}
</div>
)}
</div>
);
};
```
## Component Patterns
### 1. Compound Components
**Allow components to work together while sharing implicit state.**
```tsx
// Context for shared state
const AccordionContext = createContext<{
activeIndex: number | null;
setActiveIndex: (index: number | null) => void;
} | null>(null);
// Parent component
export const Accordion: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
return (
<AccordionContext.Provider value={{ activeIndex, setActiveIndex }}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
);
};
// Child component
export const AccordionItem: React.FC<{
index: number;
title: string;
children: React.ReactNode;
}> = ({ index, title, children }) => {
const context = useContext(AccordionContext);
if (!context) throw new Error('AccordionItem must be used within Accordion');
const { activeIndex, setActiveIndex } = context;
const isOpen = activeIndex === index;
return (
<div className="accordion-item">
<button
className="accordion-item__header"
onClick={() => setActiveIndex(isOpen ? null : index)}
aria-expanded={isOpen}
>
{title}
</button>
{isOpen && <div className="accordion-item__content">{children}</div>}
</div>
);
};
// Usage
<Accordion>
<AccordionItem index={0} title="Section 1">Content 1</AccordionItem>
<AccordionItem index={1} title="Section 2">Content 2</AccordionItem>
</Accordion>
```
### 2. Render Props
**Pass rendering logic as a prop.**
```tsx
interface DataFetcherProps<T> {
url: string;
children: (data: {
data: T | null;
loading: boolean;
error: Error | null;
}) => React.ReactNode;
}
export function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return <>{children({ data, loading, error })}</>;
}
// Usage
<DataFetcher<User> url="/api/user">
{({ data, loading, error }) => {
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
if (!data) return null;
return <UserProfile user={data} />;
}}
</DataFetcher>
```
### 3. Custom Hooks Pattern
**Extract reusable logic into custom hooks.**
```tsx
// useToggle hook
export function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
// Usage in component
export const Modal: React.FC<ModalProps> = ({ children }) => {
const { value: isOpen, setTrue: open, setFalse: close } = useToggle();
return (
<>
<button onClick={open}>Open Modal</button>
{isOpen && (
<div className="modal">
{children}
<button onClick={close}>Close</button>
</div>
)}
</>
);
};
```
## Styling Strategies
### 1. CSS Modules
```css
/* Button.module.css */
.button {
display: inline-flex;
align-items: center;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
}
.button--primary {
background: var(--color-primary);
color: var(--color-white);
}
.button--secondary {
background: transparent;
color: var(--color-primary);
border: 1px solid var(--color-primary);
}
```
```tsx
import styles from './Button.module.css';
export const Button = ({ variant }) => (
<button className={`${styles.button} ${styles[`button--${variant}`]}`}>
Click me
</button>
);
```
### 2. CSS-in-JS (Styled Components)
```tsx
import styled from 'styled-components';
const StyledButton = styled.button<{ variant: string }>`
display: inline-flex;
align-items: center;
padding: ${({ theme }) => theme.space[3]} ${({ theme }) => theme.space[4]};
background: ${({ theme, variant }) =>
variant === 'primary' ? theme.colors.primary : 'transparent'};
color: ${({ theme, variant }) =>
variant === 'primary' ? theme.colors.white : theme.colors.primary};
`;
export const Button = ({ variant, children }) => (
<StyledButton variant={variant}>{children}</StyledButton>
);
```
### 3. Tailwind CSS
```tsx
import clsx from 'clsx';
export const Button: React.FC<ButtonProps> = ({ variant, size, children }) => {
return (
<button
className={clsx(
'inline-flex items-center rounded-md font-medium',
{
'bg-blue-600 text-white hover:bg-blue-700': variant === 'primary',
'bg-transparent text-blue-600 border border-blue-600': variant === 'secondary',
},
{
'px-3 py-2 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg',
}
)}
>
{children}
</button>
);
};
```
## Documentation
### Component Documentation Template
```tsx
/**
* Button component for triggering actions and navigation.
*
* @example
* ```tsx
* <Button variant="primary" onClick={handleClick}>
* Click me
* </Button>
* ```
*
* @see {@link https://design-system.example.com/button | Design System Docs}
*/
export const Button: React.FC<ButtonProps> = ({ ... }) => {
// Implementation
};
```
### Storybook Documentation
```tsx
import type { Meta } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
parameters: {
docs: {
description: {
component: `
Button component for triggering actions.
## Usage
\`\`\`tsx
import { Button } from '@/components/Button';
<Button variant="primary">Click me</Button>
\`\`\`
## Accessibility
- Keyboard accessible
- Screen reader friendly
- WCAG 2.1 AA compliant
`,
},
},
},
};
```
## Testing Strategies
### Unit Tests
```tsx
describe('Button', () => {
it('renders with correct variant', () => {
render(<Button variant="primary">Test</Button>);
expect(screen.getByRole('button')).toHaveClass('button--primary');
});
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Test</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
```
### Accessibility Tests
```tsx
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<Button>Test</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
```
### Visual Regression Tests
```tsx
// Using Chromatic or Percy
it('matches snapshot', () => {
const { container } = render(<Button variant="primary">Test</Button>);
expect(container).toMatchSnapshot();
});
```
## Performance Optimization
### Code Splitting
```tsx
// Lazy load heavy components
const HeavyComponent = lazy(() => import('./HeavyComponent'));
export const App = () => (
<Suspense fallback={<LoadingSpinner />}>
<HeavyComponent />
</Suspense>
);
```
### Memoization
```tsx
// Memoize expensive components
export const ExpensiveComponent = memo(({ data }) => {
// Expensive rendering logic
return <div>{processData(data)}</div>;
});
// Memoize callbacks
const handleClick = useCallback(() => {
// Handle click
}, [dependencies]);
// Memoize values
const expensiveValue = useMemo(() => {
return computeExpensiveValue(data);
}, [data]);
```
## Version Control
### Semantic Versioning
```
MAJOR.MINOR.PATCH
1.0.0 → Initial release
1.1.0 → New feature (backwards compatible)
1.1.1 → Bug fix (backwards compatible)
2.0.0 → Breaking change
```
### Changelog
```markdown
# Changelog
## [2.0.0] - 2024-01-15
### Breaking Changes
- Removed `type` prop from Button (use `variant` instead)
### Added
- New `loading` state for Button
- Icon support in Button component
### Fixed
- Button focus indicator contrast ratio
## [1.1.0] - 2024-01-01
### Added
- New Input component
- Card component variants
```
## Resources
- [React Component Patterns](https://kentcdodds.com/blog/compound-components-with-react-hooks)
- [Design Systems Handbook](https://www.designbetter.co/design-systems-handbook)
- [Atomic Design](https://bradfrost.com/blog/post/atomic-web-design/)
- [Storybook Best Practices](https://storybook.js.org/docs/react/writing-docs/introduction)
---
**"Good components are reusable, accessible, and well-documented."**