595 lines
16 KiB
Markdown
595 lines
16 KiB
Markdown
|
|
# 状态管理 + 样式方案指南
|
|||
|
|
|
|||
|
|
> 涵盖 Zustand、TanStack Query、状态管理选型、Tailwind CSS 4、CSS Variables 主题系统和响应式设计。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 一、Zustand 模式
|
|||
|
|
|
|||
|
|
### 1.1 基础 Store 设计
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// stores/authStore.ts
|
|||
|
|
import { create } from 'zustand';
|
|||
|
|
|
|||
|
|
interface User {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
email: string;
|
|||
|
|
avatar?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface AuthState {
|
|||
|
|
user: User | null;
|
|||
|
|
token: string | null;
|
|||
|
|
isAuthenticated: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface AuthActions {
|
|||
|
|
login: (user: User, token: string) => void;
|
|||
|
|
logout: () => void;
|
|||
|
|
updateProfile: (data: Partial<User>) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 状态与操作分离,类型清晰
|
|||
|
|
export const useAuthStore = create<AuthState & AuthActions>((set) => ({
|
|||
|
|
// 状态
|
|||
|
|
user: null,
|
|||
|
|
token: null,
|
|||
|
|
isAuthenticated: false,
|
|||
|
|
|
|||
|
|
// 操作
|
|||
|
|
login: (user, token) =>
|
|||
|
|
set({ user, token, isAuthenticated: true }),
|
|||
|
|
|
|||
|
|
logout: () =>
|
|||
|
|
set({ user: null, token: null, isAuthenticated: false }),
|
|||
|
|
|
|||
|
|
updateProfile: (data) =>
|
|||
|
|
set((state) => ({
|
|||
|
|
user: state.user ? { ...state.user, ...data } : null,
|
|||
|
|
})),
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 选择器:按需订阅,避免不必要的重渲染
|
|||
|
|
export const useUser = () => useAuthStore((s) => s.user);
|
|||
|
|
export const useIsAuth = () => useAuthStore((s) => s.isAuthenticated);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.2 Slice 模式(大型 Store 拆分)
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// stores/slices/cartSlice.ts
|
|||
|
|
import type { StateCreator } from 'zustand';
|
|||
|
|
|
|||
|
|
export interface CartSlice {
|
|||
|
|
items: CartItem[];
|
|||
|
|
addItem: (product: Product, quantity: number) => void;
|
|||
|
|
removeItem: (productId: string) => void;
|
|||
|
|
clearCart: () => void;
|
|||
|
|
totalPrice: () => number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const createCartSlice: StateCreator<CartSlice> = (set, get) => ({
|
|||
|
|
items: [],
|
|||
|
|
|
|||
|
|
addItem: (product, quantity) =>
|
|||
|
|
set((state) => {
|
|||
|
|
const existing = state.items.find((i) => i.productId === product.id);
|
|||
|
|
if (existing) {
|
|||
|
|
return {
|
|||
|
|
items: state.items.map((i) =>
|
|||
|
|
i.productId === product.id
|
|||
|
|
? { ...i, quantity: i.quantity + quantity }
|
|||
|
|
: i
|
|||
|
|
),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
items: [...state.items, { productId: product.id, product, quantity }],
|
|||
|
|
};
|
|||
|
|
}),
|
|||
|
|
|
|||
|
|
removeItem: (productId) =>
|
|||
|
|
set((state) => ({
|
|||
|
|
items: state.items.filter((i) => i.productId !== productId),
|
|||
|
|
})),
|
|||
|
|
|
|||
|
|
clearCart: () => set({ items: [] }),
|
|||
|
|
|
|||
|
|
totalPrice: () =>
|
|||
|
|
get().items.reduce((sum, i) => sum + i.product.price * i.quantity, 0),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// stores/index.ts — 合并 Slices
|
|||
|
|
import { create } from 'zustand';
|
|||
|
|
import { createCartSlice, type CartSlice } from './slices/cartSlice';
|
|||
|
|
import { createUISlice, type UISlice } from './slices/uiSlice';
|
|||
|
|
|
|||
|
|
type StoreState = CartSlice & UISlice;
|
|||
|
|
|
|||
|
|
export const useStore = create<StoreState>()((...args) => ({
|
|||
|
|
...createCartSlice(...args),
|
|||
|
|
...createUISlice(...args),
|
|||
|
|
}));
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.3 Persist Middleware(持久化)
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import { create } from 'zustand';
|
|||
|
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
|||
|
|
|
|||
|
|
export const useSettingsStore = create<SettingsState>()(
|
|||
|
|
persist(
|
|||
|
|
(set) => ({
|
|||
|
|
theme: 'system',
|
|||
|
|
language: 'zh-CN',
|
|||
|
|
sidebarCollapsed: false,
|
|||
|
|
|
|||
|
|
setTheme: (theme) => set({ theme }),
|
|||
|
|
setLanguage: (lang) => set({ language: lang }),
|
|||
|
|
toggleSidebar: () =>
|
|||
|
|
set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
|||
|
|
}),
|
|||
|
|
{
|
|||
|
|
name: 'app-settings', // localStorage key
|
|||
|
|
storage: createJSONStorage(() => localStorage),
|
|||
|
|
partialize: (state) => ({
|
|||
|
|
// 只持久化需要的字段
|
|||
|
|
theme: state.theme,
|
|||
|
|
language: state.language,
|
|||
|
|
}),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.4 Devtools Middleware
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import { create } from 'zustand';
|
|||
|
|
import { devtools } from 'zustand/middleware';
|
|||
|
|
|
|||
|
|
export const useCountStore = create<CountState>()(
|
|||
|
|
devtools(
|
|||
|
|
(set) => ({
|
|||
|
|
count: 0,
|
|||
|
|
increment: () => set((s) => ({ count: s.count + 1 }), false, 'increment'),
|
|||
|
|
decrement: () => set((s) => ({ count: s.count - 1 }), false, 'decrement'),
|
|||
|
|
}),
|
|||
|
|
{ name: 'CountStore' } // Redux DevTools 中显示的名称
|
|||
|
|
)
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 二、TanStack Query
|
|||
|
|
|
|||
|
|
### 2.1 QueryKey 设计
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// lib/queryKeys.ts — 统一管理查询键
|
|||
|
|
export const queryKeys = {
|
|||
|
|
// 用户相关
|
|||
|
|
users: {
|
|||
|
|
all: ['users'] as const,
|
|||
|
|
lists: () => [...queryKeys.users.all, 'list'] as const,
|
|||
|
|
list: (filters: UserFilters) => [...queryKeys.users.lists(), filters] as const,
|
|||
|
|
details: () => [...queryKeys.users.all, 'detail'] as const,
|
|||
|
|
detail: (id: string) => [...queryKeys.users.details(), id] as const,
|
|||
|
|
},
|
|||
|
|
// 文章相关
|
|||
|
|
posts: {
|
|||
|
|
all: ['posts'] as const,
|
|||
|
|
list: (params: PostListParams) => [...queryKeys.posts.all, 'list', params] as const,
|
|||
|
|
detail: (id: string) => [...queryKeys.posts.all, 'detail', id] as const,
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.2 缓存策略与数据获取
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// hooks/useUsers.ts
|
|||
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||
|
|
import { queryKeys } from '@/lib/queryKeys';
|
|||
|
|
|
|||
|
|
// 查询 Hook
|
|||
|
|
export function useUsers(filters: UserFilters) {
|
|||
|
|
return useQuery({
|
|||
|
|
queryKey: queryKeys.users.list(filters),
|
|||
|
|
queryFn: () => userService.getList(filters),
|
|||
|
|
staleTime: 5 * 60 * 1000, // 5分钟内数据视为新鲜
|
|||
|
|
gcTime: 30 * 60 * 1000, // 30分钟后垃圾回收(原 cacheTime)
|
|||
|
|
placeholderData: keepPreviousData, // 切换页码时保留旧数据
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 用户详情
|
|||
|
|
export function useUser(id: string) {
|
|||
|
|
return useQuery({
|
|||
|
|
queryKey: queryKeys.users.detail(id),
|
|||
|
|
queryFn: () => userService.getById(id),
|
|||
|
|
enabled: !!id, // id 不存在时不发请求
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.3 Optimistic Updates(乐观更新)
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
export function useUpdateUser() {
|
|||
|
|
const queryClient = useQueryClient();
|
|||
|
|
|
|||
|
|
return useMutation({
|
|||
|
|
mutationFn: (data: UpdateUserData) => userService.update(data),
|
|||
|
|
|
|||
|
|
// 乐观更新:先更新 UI,再等服务端确认
|
|||
|
|
onMutate: async (newData) => {
|
|||
|
|
// 取消正在进行的查询,避免覆盖乐观更新
|
|||
|
|
await queryClient.cancelQueries({
|
|||
|
|
queryKey: queryKeys.users.detail(newData.id),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 保存之前的数据用于回滚
|
|||
|
|
const previousUser = queryClient.getQueryData(
|
|||
|
|
queryKeys.users.detail(newData.id)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 乐观更新缓存
|
|||
|
|
queryClient.setQueryData(
|
|||
|
|
queryKeys.users.detail(newData.id),
|
|||
|
|
(old: User) => ({ ...old, ...newData })
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return { previousUser };
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 出错时回滚
|
|||
|
|
onError: (_err, newData, context) => {
|
|||
|
|
if (context?.previousUser) {
|
|||
|
|
queryClient.setQueryData(
|
|||
|
|
queryKeys.users.detail(newData.id),
|
|||
|
|
context.previousUser
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 无论成功失败,都重新获取最新数据
|
|||
|
|
onSettled: (_data, _err, variables) => {
|
|||
|
|
queryClient.invalidateQueries({
|
|||
|
|
queryKey: queryKeys.users.detail(variables.id),
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.4 Infinite Queries(无限滚动)
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
export function useInfinitePosts() {
|
|||
|
|
return useInfiniteQuery({
|
|||
|
|
queryKey: queryKeys.posts.all,
|
|||
|
|
queryFn: ({ pageParam }) =>
|
|||
|
|
postService.getList({ cursor: pageParam, limit: 20 }),
|
|||
|
|
initialPageParam: undefined as string | undefined,
|
|||
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 组件中使用
|
|||
|
|
function PostFeed() {
|
|||
|
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
|||
|
|
useInfinitePosts();
|
|||
|
|
|
|||
|
|
// 所有页面的文章合并为一个列表
|
|||
|
|
const allPosts = data?.pages.flatMap((page) => page.items) ?? [];
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
{allPosts.map((post) => (
|
|||
|
|
<PostCard key={post.id} post={post} />
|
|||
|
|
))}
|
|||
|
|
{hasNextPage && (
|
|||
|
|
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
|
|||
|
|
{isFetchingNextPage ? '加载中...' : '加载更多'}
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 三、状态管理选型
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 状态类型 │ 推荐方案 │ 典型场景 │
|
|||
|
|
├────────────────────┼──────────────────────┼──────────────────────┤
|
|||
|
|
│ 服务端状态 │ TanStack Query │ API 数据、分页、缓存 │
|
|||
|
|
│ 客户端全局状态 │ Zustand │ 用户认证、UI设置 │
|
|||
|
|
│ 组件局部状态 │ useState/useReducer │ 表单、开关、临时状态 │
|
|||
|
|
│ 跨组件共享 │ Context │ 主题、语言(低频更新)│
|
|||
|
|
│ URL 状态 │ nuqs / searchParams │ 筛选、排序、分页 │
|
|||
|
|
│ 表单状态 │ React Hook Form │ 复杂表单、多步骤表单 │
|
|||
|
|
└─────────────────────────────────────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**选型原则**:
|
|||
|
|
- 服务端数据 **一律**用 TanStack Query,不要手动 `useEffect` + `useState` 管理
|
|||
|
|
- 全局 UI 状态(主题、侧边栏展开、通知等)用 Zustand
|
|||
|
|
- 筛选条件、分页参数放 URL,保证可分享、可后退
|
|||
|
|
- Context 仅用于低频更新的全局数据(主题、i18n),避免高频更新导致整棵树重渲染
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 四、Tailwind CSS 4
|
|||
|
|
|
|||
|
|
### 4.1 新特性
|
|||
|
|
|
|||
|
|
Tailwind CSS 4 使用 CSS-first 配置方式,不再需要 `tailwind.config.js`:
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
/* app/globals.css */
|
|||
|
|
@import 'tailwindcss';
|
|||
|
|
|
|||
|
|
/* 自定义主题 — 直接用 CSS 语法 */
|
|||
|
|
@theme {
|
|||
|
|
--color-brand-50: #eff6ff;
|
|||
|
|
--color-brand-500: #3b82f6;
|
|||
|
|
--color-brand-900: #1e3a5f;
|
|||
|
|
|
|||
|
|
--font-display: 'Inter', sans-serif;
|
|||
|
|
--breakpoint-3xl: 1920px;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.2 CSS Variables 实现多主题系统
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
/* styles/themes.css */
|
|||
|
|
@import 'tailwindcss';
|
|||
|
|
|
|||
|
|
/* 默认亮色主题 */
|
|||
|
|
:root {
|
|||
|
|
--color-bg-primary: #ffffff;
|
|||
|
|
--color-bg-secondary: #f9fafb;
|
|||
|
|
--color-text-primary: #111827;
|
|||
|
|
--color-text-secondary: #6b7280;
|
|||
|
|
--color-border: #e5e7eb;
|
|||
|
|
--color-accent: #3b82f6;
|
|||
|
|
--color-accent-hover: #2563eb;
|
|||
|
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|||
|
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
|
|||
|
|
--radius-default: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 暗色主题 */
|
|||
|
|
[data-theme='dark'] {
|
|||
|
|
--color-bg-primary: #0f172a;
|
|||
|
|
--color-bg-secondary: #1e293b;
|
|||
|
|
--color-text-primary: #f1f5f9;
|
|||
|
|
--color-text-secondary: #94a3b8;
|
|||
|
|
--color-border: #334155;
|
|||
|
|
--color-accent: #60a5fa;
|
|||
|
|
--color-accent-hover: #93bbfc;
|
|||
|
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
|||
|
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 粉色主题 */
|
|||
|
|
[data-theme='pink'] {
|
|||
|
|
--color-accent: #ec4899;
|
|||
|
|
--color-accent-hover: #db2777;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 绿色主题 */
|
|||
|
|
[data-theme='green'] {
|
|||
|
|
--color-accent: #22c55e;
|
|||
|
|
--color-accent-hover: #16a34a;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.3 主题切换组件
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
'use client';
|
|||
|
|
|
|||
|
|
import { useSettingsStore } from '@/stores/settingsStore';
|
|||
|
|
|
|||
|
|
const themes = [
|
|||
|
|
{ value: 'light', label: '亮色' },
|
|||
|
|
{ value: 'dark', label: '暗色' },
|
|||
|
|
{ value: 'pink', label: '粉色' },
|
|||
|
|
{ value: 'green', label: '绿色' },
|
|||
|
|
] as const;
|
|||
|
|
|
|||
|
|
export function ThemeSwitcher() {
|
|||
|
|
const { theme, setTheme } = useSettingsStore();
|
|||
|
|
|
|||
|
|
const handleChange = (newTheme: string) => {
|
|||
|
|
setTheme(newTheme);
|
|||
|
|
document.documentElement.setAttribute('data-theme', newTheme);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
{themes.map((t) => (
|
|||
|
|
<button
|
|||
|
|
key={t.value}
|
|||
|
|
onClick={() => handleChange(t.value)}
|
|||
|
|
className={`rounded-lg px-3 py-1.5 text-sm transition-colors
|
|||
|
|
${theme === t.value
|
|||
|
|
? 'bg-[var(--color-accent)] text-white'
|
|||
|
|
: 'bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)]'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{t.label}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.4 在 Tailwind 中使用 CSS Variables
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// 使用 arbitrary values 引用 CSS 变量
|
|||
|
|
function Card({ children }: { children: React.ReactNode }) {
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className="
|
|||
|
|
rounded-[var(--radius-default)]
|
|||
|
|
bg-[var(--color-bg-primary)]
|
|||
|
|
text-[var(--color-text-primary)]
|
|||
|
|
border border-[var(--color-border)]
|
|||
|
|
shadow-[var(--shadow-md)]
|
|||
|
|
p-6
|
|||
|
|
"
|
|||
|
|
>
|
|||
|
|
{children}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 五、响应式设计模式
|
|||
|
|
|
|||
|
|
### 5.1 Container Queries
|
|||
|
|
|
|||
|
|
Container queries 让组件根据**容器宽度**(而非视口宽度)自适应布局:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// 父容器标记为 container
|
|||
|
|
function Sidebar() {
|
|||
|
|
return (
|
|||
|
|
<aside className="@container">
|
|||
|
|
<UserCard />
|
|||
|
|
</aside>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 子组件根据容器宽度响应
|
|||
|
|
function UserCard() {
|
|||
|
|
return (
|
|||
|
|
<div className="flex flex-col @md:flex-row @lg:gap-4 items-center gap-2">
|
|||
|
|
<Avatar className="size-10 @md:size-16" />
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-sm @md:text-base font-medium">用户名</h3>
|
|||
|
|
<p className="hidden @md:block text-xs text-gray-500">用户简介</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.2 clamp() 实现流体排版
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
/* 字体大小在 16px ~ 24px 之间流畅缩放 */
|
|||
|
|
.fluid-title {
|
|||
|
|
font-size: clamp(1rem, 0.5rem + 2vw, 1.5rem);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 间距也可以流体化 */
|
|||
|
|
.fluid-section {
|
|||
|
|
padding: clamp(1rem, 3vw, 3rem);
|
|||
|
|
gap: clamp(0.5rem, 1.5vw, 1.5rem);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// 在 Tailwind 中使用 clamp
|
|||
|
|
function HeroSection() {
|
|||
|
|
return (
|
|||
|
|
<section className="px-[clamp(1rem,5vw,4rem)] py-[clamp(2rem,8vw,6rem)]">
|
|||
|
|
<h1 className="text-[clamp(1.5rem,4vw,3.5rem)] font-bold leading-tight">
|
|||
|
|
欢迎使用我们的平台
|
|||
|
|
</h1>
|
|||
|
|
<p className="mt-[clamp(0.5rem,1.5vw,1.5rem)] text-[clamp(0.875rem,1.5vw,1.25rem)]">
|
|||
|
|
描述文字
|
|||
|
|
</p>
|
|||
|
|
</section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.3 移动优先响应式设计
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// Tailwind 默认就是移动优先:无前缀 = 移动端,sm/md/lg = 向上覆盖
|
|||
|
|
function ProductGrid() {
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className="
|
|||
|
|
grid
|
|||
|
|
grid-cols-1 /* 移动端:单列 */
|
|||
|
|
sm:grid-cols-2 /* >=640px:两列 */
|
|||
|
|
md:grid-cols-3 /* >=768px:三列 */
|
|||
|
|
lg:grid-cols-4 /* >=1024px:四列 */
|
|||
|
|
gap-4
|
|||
|
|
sm:gap-6
|
|||
|
|
"
|
|||
|
|
>
|
|||
|
|
{products.map((p) => (
|
|||
|
|
<ProductCard key={p.id} product={p} />
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 响应式导航:移动端汉堡菜单 → 桌面端水平导航
|
|||
|
|
function Navbar() {
|
|||
|
|
const [isOpen, setIsOpen] = useState(false);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<nav className="relative">
|
|||
|
|
{/* 移动端:汉堡按钮 */}
|
|||
|
|
<button
|
|||
|
|
className="md:hidden p-2"
|
|||
|
|
onClick={() => setIsOpen(!isOpen)}
|
|||
|
|
>
|
|||
|
|
<MenuIcon />
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
{/* 导航链接:移动端垂直展开,桌面端水平排列 */}
|
|||
|
|
<ul
|
|||
|
|
className={`
|
|||
|
|
${isOpen ? 'flex' : 'hidden'}
|
|||
|
|
flex-col absolute top-full left-0 w-full bg-white shadow-lg
|
|||
|
|
md:static md:flex md:flex-row md:shadow-none md:w-auto
|
|||
|
|
gap-1 md:gap-6
|
|||
|
|
`}
|
|||
|
|
>
|
|||
|
|
<li><a href="/" className="block px-4 py-2">首页</a></li>
|
|||
|
|
<li><a href="/products" className="block px-4 py-2">产品</a></li>
|
|||
|
|
<li><a href="/about" className="block px-4 py-2">关于</a></li>
|
|||
|
|
</ul>
|
|||
|
|
</nav>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.4 常用响应式断点参考
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────┬──────────┬─────────────────┐
|
|||
|
|
│ 断点 │ 宽度 │ 设备类型 │
|
|||
|
|
├─────────────┼──────────┼─────────────────┤
|
|||
|
|
│ (默认) │ 0px+ │ 手机竖屏 │
|
|||
|
|
│ sm │ 640px+ │ 手机横屏 │
|
|||
|
|
│ md │ 768px+ │ 平板竖屏 │
|
|||
|
|
│ lg │ 1024px+ │ 平板横屏/小笔记本 │
|
|||
|
|
│ xl │ 1280px+ │ 桌面显示器 │
|
|||
|
|
│ 2xl │ 1536px+ │ 大屏显示器 │
|
|||
|
|
└─────────────┴──────────┴─────────────────┘
|
|||
|
|
```
|