
当“直接构建”不再奏效
小型前端不需要架构。一个 components/ 文件夹和一个 pages/ 文件夹就能带你走很远。但随着团队和代码库的增长,缺乏有意识的架构会带来累积的痛苦:循环导入、所有权模糊、组件膨胀到 2000 行、“这个逻辑属于哪个 store 模块?”
前端架构的目标不是制造官僚主义——而是提供护栏,让正确的决策变得容易。

“组件和工具函数”的问题
最常见的文件夹结构也是最难扩展的:
src/
├── components/ ← 所有 UI 相关
├── hooks/ ← 所有自定义 hooks
├── utils/ ← 辅助函数
├── store/ ← 状态管理
├── types/ ← TypeScript 类型
└── pages/ ← 页面/路由
问题所在:
- 单个
components/文件夹可能有 200+ 个文件——没有指导说明哪些组件依赖什么 - 跨组件导入导致循环:UserCard 导入 ProductCard 又导入 UserCard
- 缺乏就近原则:UserCard.tsx、useUser.ts、userTypes.ts、userStore.ts 分散在四个不同的文件夹
- “全局”和“特性特定”代码之间没有明确边界
特性切片设计(FSD)
FSD 基于代码的层和用途提供一致的结构:
src/
├── app/ # 应用级设置:providers、router、全局样式
├── pages/ # 路由级组件(组合特性)
├── widgets/ # 大型复合块(Header、Sidebar、Feed)
├── features/ # 具有业务价值的用户交互
│ ├── auth/
│ ├── user-profile/
│ └── product-cart/
├── entities/ # 业务实体(User、Product、Order)
│ ├── user/
│ ├── product/
│ └── order/
└── shared/ # 可复用:UI 工具包、工具函数、API 客户端
├── ui/
├── api/
├── lib/
└── config/
依赖规则:层只能从下面的层导入。pages 从 widgets 和 features 导入。features 从 entities 和 shared 导入。shared 不从上面导入任何内容。
// ✅ 有效导入(向下):
// pages/ProductPage.tsx 从 widgets/ProductCard 导入
// features/auth/LoginForm.tsx 从 entities/user 导入
// entities/user/api.ts 从 shared/api 导入
// ❌ 无效导入(向上)——这些是 bug:
// shared/ui/Button.tsx 从 features/auth 导入 ← 绝对不行
// entities/user 从 features/auth 导入 ← 绝对不行

特性切片结构
每个特性切片都是自包含的:
features/auth/
├── ui/ # 特定于 auth 的 UI 组件
│ ├── LoginForm.tsx
│ ├── RegisterForm.tsx
│ └── AuthProvider.tsx
├── model/ # 状态、类型、hooks
│ ├── authStore.ts # Zustand/Redux slice
│ ├── useAuth.ts # 自定义 hooks
│ └── types.ts
├── api/ # 该特性的 API 调用
│ └── authApi.ts
└── index.ts # 公共 API——只导出其他模块需要的内容
// features/auth/index.ts — 显式公共 API
export { LoginForm } from './ui/LoginForm'
export { AuthProvider } from './ui/AuthProvider'
export { useAuth } from './model/useAuth'
export type { User, AuthState } from './model/types'
// 所有未在此处导出的内容都是该特性的私有内容
// 其他特性不能直接导入 AuthStore 内部
设计令牌:一致 UI 的基础
// tokens.ts — 视觉属性的单一事实来源
export const tokens = {
color: {
// 原始值
gray: {
50: '#F9FAFB',
100: '#F3F4F6',
500: '#6B7280',
900: '#111827',
},
blue: {
500: '#3B82F6',
700: '#1D4ED8',
},
red: {
500: '#EF4444',
},
// 语义(基于用途——令牌的实际含义)
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 自定义属性(从令牌自动生成)
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]};
/* ... */
}
`
}

组件 API 设计
// ❌ 隐式 API——不清楚哪些是有效的
interface ButtonProps {
variant?: string // 有效值是什么?
size?: string // 同样的问题
color?: string // 无限组合——维护噩梦
onClick?: () => void
disabled?: boolean
className?: string // 逃生口,让你破坏任何东西
}
// ✅ 显式、受限的 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'
// 没有 className——改用 variant 系统
// 没有 style——强制使用设计系统
// 只有 children: React.ReactNode
children: React.ReactNode
}
function Button({ variant, size = 'md', disabled, loading, ...props }: ButtonProps) {
return (
<button
className={buttonVariants({ variant, size })} // cva 或 class-variance-authority
disabled={disabled || loading}
{...props}
>
{loading ? <Spinner /> : props.children}
</button>
)
}
状态架构决策框架
服务器状态(来自 API) → React Query / SWR
- 缓存、后台重新获取、乐观更新
本地 UI 状态 → useState + useReducer
- 表单状态、模态框打开/关闭、仅 UI 的切换
共享客户端状态(跨组件) → Zustand / Jotai
- 用户偏好、购物车、选中项
- 应该很少见——大多数状态是局部的
URL 状态 → URL 搜索参数
- 筛选值、分页、排序顺序
- 好处:可分享链接、返回按钮
表单状态 → React Hook Form / Formik
- 复杂表单与验证
全局服务器会话 → React Context(很少)
- 当前用户、功能标志
- 仅当涉及 SSR 时
// 示例:正确的状态放置
function ProductList() {
// 服务器状态:React Query 管理缓存、加载、错误
const { data: products, isLoading } = useQuery({
queryKey: ['products', filters],
queryFn: () => fetchProducts(filters),
})
// URL 状态:筛选条件在页面刷新后保留且可分享
const [searchParams, setSearchParams] = useSearchParams()
const filters = {
category: searchParams.get('category') ?? 'all',
sort: searchParams.get('sort') ?? 'newest',
page: Number(searchParams.get('page') ?? 1),
}
// 本地 UI 状态:面板打开/关闭不需要 URL
const [filtersOpen, setFiltersOpen] = useState(false)
// 全局状态:购物车是共享的,但来自 Zustand
const addToCart = useCartStore(state => state.addItem)
// ...
}
微前端:何时考虑
微前端将大型前端拆分为独立可部署的片段,每个片段由不同团队拥有:
// Module Federation (Webpack/Vite)
// 宿主应用(shell)在运行时加载远程应用
// webpack.config.js (host)
new ModuleFederationPlugin({
remotes: {
checkout: 'checkout@https://checkout.example.com/remoteEntry.js',
catalog: 'catalog@https://catalog.example.com/remoteEntry.js',
},
})
// 在宿主中使用:
const CheckoutFlow = React.lazy(() => import('checkout/CheckoutFlow'))
const ProductCatalog = React.lazy(() => import('catalog/ProductCatalog'))
何时使用微前端:
- 同一代码库有 10+ 名前端开发者
- 团队需要独立的部署流水线
- 不同部分使用不同框架
- 存在清晰、稳定的领域边界
何时不使用:
- 1-3 人团队(协调开销 > 收益)
- 没有清晰的领域边界
- 性能是问题(多个 bundle 加载)
- 你正在启动一个绿地项目
最重要的架构决策是在代码库增长之前建立清晰的规则。将架构改造到 100,000 行代码库中比从一开始就采用合理的约定要痛苦得多。
→ 使用 JSON Viewer 探索和导航复杂的 JSON 结构。