正在加载,请稍候…

Frontend Architecture Patterns: Feature Slices, Component Libraries, and Scalable Codebases

Scale your frontend codebase: Feature-Sliced Design, component library architecture, state management decisions, micro-frontends, design tokens, and patterns that prevent spaghetti code.

When "Just Build It" Stops Working

Small frontends don't need architecture. A components/ folder and a pages/ folder will carry you surprisingly far. But as teams and codebases grow, the absence of deliberate architecture creates compounding pain: import cycles, ambiguous ownership, components that grew to 2000 lines, "which store module does this logic belong in?"

The goal of frontend architecture is not to create bureaucracy — it's to provide guardrails that make the right decision the easy decision.

The Problem with "Components and Utils"

The most common folder structure is also the worst-scaling one:

src/
├── components/    ← Everything UI-related
├── hooks/         ← All custom hooks
├── utils/         ← Helper functions
├── store/         ← State management
├── types/         ← TypeScript types
└── pages/         ← Pages/routes

What's wrong:

  • A single components/ folder might have 200+ files — no guidance on which components depend on what
  • Cross-component imports create cycles: UserCard imports from ProductCard imports from UserCard
  • No co-location: UserCard.tsx, useUser.ts, userTypes.ts, userStore.ts are in four different folders
  • No clear boundaries between "global" and "feature-specific" code

Feature-Sliced Design (FSD)

FSD provides a consistent structure based on the layer and purpose of code:

src/
├── app/            # App-wide setup: providers, router, global styles
├── pages/          # Route-level components (compose features)
├── widgets/        # Large composite blocks (Header, Sidebar, Feed)
├── features/       # User interactions with business value
│   ├── auth/
│   ├── user-profile/
│   └── product-cart/
├── entities/       # Business entities (User, Product, Order)
│   ├── user/
│   ├── product/
│   └── order/
└── shared/         # Reusable: UI kit, utils, API clients
    ├── ui/
    ├── api/
    ├── lib/
    └── config/

The dependency rule: layers can only import from layers below them. pages imports from widgets and features. features imports from entities and shared. shared imports nothing from above.

// ✅ Valid imports (downward):
// pages/ProductPage.tsx imports from widgets/ProductCard
// features/auth/LoginForm.tsx imports from entities/user
// entities/user/api.ts imports from shared/api

// ❌ Invalid imports (upward) — violations these are bugs:
// shared/ui/Button.tsx imports from features/auth  ← NEVER
// entities/user imports from features/auth          ← NEVER

Feature Slice Structure

Each feature slice is self-contained:

features/auth/
├── ui/                    # UI components specific to auth
│   ├── LoginForm.tsx
│   ├── RegisterForm.tsx
│   └── AuthProvider.tsx
├── model/                 # State, types, hooks
│   ├── authStore.ts       # Zustand/Redux slice
│   ├── useAuth.ts         # Custom hooks
│   └── types.ts
├── api/                   # API calls for this feature
│   └── authApi.ts
└── index.ts               # Public API — export only what others need
// features/auth/index.ts — explicit public API
export { LoginForm } from './ui/LoginForm'
export { AuthProvider } from './ui/AuthProvider'
export { useAuth } from './model/useAuth'
export type { User, AuthState } from './model/types'

// Everything NOT exported here is private to the feature
// Other features can't import AuthStore internals directly

Design Tokens: The Foundation of Consistent UI

// tokens.ts — single source of truth for visual properties
export const tokens = {
  color: {
    // Primitives (raw values)
    gray: {
      50: '#F9FAFB',
      100: '#F3F4F6',
      500: '#6B7280',
      900: '#111827',
    },
    blue: {
      500: '#3B82F6',
      700: '#1D4ED8',
    },
    red: {
      500: '#EF4444',
    },
    
    // Semantic (purpose-based — what tokens actually mean)
    text: {
      primary: '#111827',    // gray-900
      secondary: '#6B7280',  // gray-500
      disabled: '#D1D5DB',
      inverse: '#FFFFFF',
      error: '#EF4444',
    },
    background: {
      default: '#FFFFFF',
      subtle: '#F9FAFB',
      elevated: '#FFFFFF',
    },
    border: {
      default: '#E5E7EB',
      strong: '#9CA3AF',
      focus: '#3B82F6',
    },
    interactive: {
      primary: '#3B82F6',
      primaryHover: '#2563EB',
      primaryActive: '#1D4ED8',
    },
  },
  
  spacing: {
    1: '4px',
    2: '8px',
    3: '12px',
    4: '16px',
    6: '24px',
    8: '32px',
    12: '48px',
    16: '64px',
  },
  
  typography: {
    fontFamily: {
      sans: "'Inter', system-ui, sans-serif",
      mono: "'Fira Code', 'Consolas', monospace",
    },
    fontSize: {
      xs: '12px',
      sm: '14px',
      base: '16px',
      lg: '18px',
      xl: '20px',
      '2xl': '24px',
      '3xl': '30px',
    },
    fontWeight: {
      regular: 400,
      medium: 500,
      semibold: 600,
      bold: 700,
    },
    lineHeight: {
      tight: 1.25,
      normal: 1.5,
      relaxed: 1.75,
    },
  },
  
  borderRadius: {
    sm: '4px',
    md: '8px',
    lg: '12px',
    full: '9999px',
  },
  
  shadow: {
    sm: '0 1px 2px rgba(0, 0, 0, 0.05)',
    md: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
    lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
  },
}

// CSS custom properties (automatically generated from tokens)
export function generateCSSVariables(): string {
  return `
    :root {
      --color-text-primary: ${tokens.color.text.primary};
      --color-text-secondary: ${tokens.color.text.secondary};
      --color-interactive-primary: ${tokens.color.interactive.primary};
      --spacing-4: ${tokens.spacing[4]};
      /* ... */
    }
  `
}

Component API Design

// ❌ Implicit API — unclear what's valid
interface ButtonProps {
  variant?: string      // What are the valid values?
  size?: string         // Same problem
  color?: string        // Unlimited combinations — maintenance nightmare
  onClick?: () => void
  disabled?: boolean
  className?: string    // Escape hatch that lets you break anything
}

// ✅ Explicit, constrained API
interface ButtonProps {
  variant: 'primary' | 'secondary' | 'ghost' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
  disabled?: boolean
  loading?: boolean
  type?: 'button' | 'submit' | 'reset'
  // No className — use the variant system instead
  // No style — forces use of the design system
  // Children only: React.ReactNode
  children: React.ReactNode
}

function Button({ variant, size = 'md', disabled, loading, ...props }: ButtonProps) {
  return (
    <button
      className={buttonVariants({ variant, size })} // cva or class-variance-authority
      disabled={disabled || loading}
      {...props}
    >
      {loading ? <Spinner /> : props.children}
    </button>
  )
}

State Architecture Decision Framework

Server state (from API)                → React Query / SWR
  - Caching, background refetching,
    optimistic updates

Local UI state                         → useState + useReducer
  - Form state, modal open/close,
    UI-only toggles

Shared client state (cross-component)  → Zustand / Jotai
  - User preferences, cart, selected items
  - Should be rare — most state is local

URL state                              → URL search params
  - Filter values, pagination, sort order
  - Benefits: shareable links, back button

Form state                             → React Hook Form / Formik
  - Complex forms with validation

Global server session                  → React Context (rarely)
  - Current user, feature flags
  - Only when SSR is involved
// Example: Correct state placement
function ProductList() {
  // Server state: React Query manages caching, loading, error
  const { data: products, isLoading } = useQuery({
    queryKey: ['products', filters],
    queryFn: () => fetchProducts(filters),
  })
  
  // URL state: filters survive page refresh and are shareable
  const [searchParams, setSearchParams] = useSearchParams()
  const filters = {
    category: searchParams.get('category') ?? 'all',
    sort: searchParams.get('sort') ?? 'newest',
    page: Number(searchParams.get('page') ?? 1),
  }
  
  // Local UI state: panel open/close doesn't need URL
  const [filtersOpen, setFiltersOpen] = useState(false)
  
  // Global state: cart is shared, but from Zustand
  const addToCart = useCartStore(state => state.addItem)
  
  // ...
}

Micro-Frontends: When to Consider Them

Micro-frontends split a large frontend into independently deployable pieces, each owned by a different team:

// Module Federation (Webpack/Vite)
// Host app (shell) that loads remote apps at runtime

// webpack.config.js (host)
new ModuleFederationPlugin({
  remotes: {
    checkout: 'checkout@https://checkout.example.com/remoteEntry.js',
    catalog: 'catalog@https://catalog.example.com/remoteEntry.js',
  },
})

// Use in host:
const CheckoutFlow = React.lazy(() => import('checkout/CheckoutFlow'))
const ProductCatalog = React.lazy(() => import('catalog/ProductCatalog'))

When to use micro-frontends:

  • 10+ frontend developers on the same codebase
  • Teams need independent deployment pipelines
  • Different sections use different frameworks
  • Clear, stable domain boundaries exist

When NOT to use them:

  • 1-3 developer teams (coordination overhead > benefit)
  • No clear domain boundaries
  • Performance is a concern (multiple bundle loads)
  • You're starting a greenfield project

The most important architecture decision is establishing clear rules before the codebase grows. Retrofitting architecture into a 100,000-line codebase is far more painful than starting with sensible conventions.

→ Explore and navigate complex JSON structures with the JSON Viewer.