bookworm-smart-assistant/skills/frontend-expert/references/state-style-guide.md

595 lines
16 KiB
Markdown
Raw 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.

# 状态管理 + 样式方案指南
> 涵盖 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+ │ 大屏显示器 │
└─────────────┴──────────┴─────────────────┘
```