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

16 KiB
Raw Blame History

状态管理 + 样式方案指南

涵盖 Zustand、TanStack Query、状态管理选型、Tailwind CSS 4、CSS Variables 主题系统和响应式设计。


一、Zustand 模式

1.1 基础 Store 设计

// 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 拆分)

// 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持久化

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

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 设计

// 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 缓存策略与数据获取

// 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乐观更新

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无限滚动

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

/* 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 实现多主题系统

/* 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 主题切换组件

'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

// 使用 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 让组件根据容器宽度(而非视口宽度)自适应布局:

// 父容器标记为 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() 实现流体排版

/* 字体大小在 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);
}
// 在 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 移动优先响应式设计

// 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+ │  大屏显示器      │
└─────────────┴──────────┴─────────────────┘