知识广场
按学科筛选:计算机科学 / 前端开发 / React
«计算机科学 / 前端开发 / React» 分类下共 14 篇帖子
## 起因 第一次看到 React Server Components(RSC)的介绍很懵: "在服务端渲染的组件"——这不就是 SSR 吗?为什么搞个新东西? 直到我真的写了一个 RSC 项目(Next.js 14 app router)才明白它解的 是另一个问题:**让组件能直接访问后端资源(DB / 文件 / API), 零 JS bundle 开销**。 ## 传统 SSR 的局限 传统 SSR(getServerSideProps / loader): ```jsx // pages/posts/[id].js (传统 Next.js pages router) export async function getServerSideProps({ params }) { const post = await db.posts.findById(params.id) const author = await db.users.findById(post.author_id) const comments = await db.comments.findByPost(params.id) return { props: { post, author, comments } } } export default function Page({ post, author, comments }) { return ( <article> <h1>{post.title}</h1> <AuthorBlock user={author} /> <CommentList items={comments} /> </article> ) } ``` 问题: - 数据获取集中在一个函数里,组件需要数据时要 prop drill 传 - 整个组件树都 hydrate(即使大部分不需要交互) - 所有数据 JSON serialize 进 HTML,包大 - `<AuthorBlock>` 和 `<CommentList>` 各自需要的数据要 page 这层先取 好再传 ## RSC 的做法 ```tsx // app/posts/[id]/page.tsx (Next.js 14 app router) // 默认就是 Server Component! import { db } from '@/db' import AuthorBlock from './AuthorBlock' import CommentList from './CommentList' export default async function Page({ params }: { params: { id: string }}) { const post = await db.posts.findById(params.id) return ( <article> <h1>{post.title}</h1> <AuthorBlock userId={post.author_id} /> <CommentList postId={params.id} /> </article> ) } ``` ```tsx // app/posts/[id]/AuthorBlock.tsx // 也是 Server Component,自己取数据 import { db } from '@/db' export default async function AuthorBlock({ userId }: { userId: string }) { const user = await db.users.findById(userId) return ( <div> <img src={user.avatar} alt="" /> <strong>{user.name}</strong> </div> ) } ``` ```tsx // app/posts/[id]/CommentList.tsx import { db } from '@/db' export default async function CommentList({ postId }: { postId: string }) { const comments = await db.comments.findByPost(postId) return ( <ul> {comments.map(c => <li key={c.id}>{c.body}</li>)} </ul> ) } ``` 变化: - 组件**就是 async 函数**,直接 await DB / API - 每个组件管自己的数据获取 - 没有 prop drilling - 这些组件的 JS 完全不进客户端 bundle(因为它们只在服务端跑) ## 客户端组件交互("use client") 需要 useState / 事件 / 浏览器 API 的组件加 `"use client"`: ```tsx // app/posts/[id]/LikeButton.tsx "use client" import { useState } from 'react' export default function LikeButton({ postId, initialCount }: ...) { const [count, setCount] = useState(initialCount) return ( <button onClick={() => { setCount(c => c + 1) fetch(`/api/posts/${postId}/like`, { method: 'POST' }) }}> ❤️ {count} </button> ) } ``` ```tsx // app/posts/[id]/page.tsx (server component) import LikeButton from './LikeButton' export default async function Page({ params }: ...) { const post = await db.posts.findById(params.id) return ( <> <h1>{post.title}</h1> <LikeButton postId={post.id} initialCount={post.likes} /> </> ) } ``` `LikeButton` 是 client component,JS 进 bundle、可以 useState。 其它 server component 完全不进 bundle。 ## 实际收益 我们的项目(中等规模博客): | | 传统 SSR | RSC | |---|---|---| | 首页 JS bundle | 280 KB | 60 KB | | First Contentful Paint | 1.8s | 0.9s | | LCP | 2.4s | 1.4s | | 代码可读性 | 数据 / UI 分离明显 | 数据在用它的组件里 | bundle 显著小(因为大部分组件不进客户端),首屏快得多。 ## 什么时候用 client component `"use client"` 的场景: - `useState` / `useEffect` 等 React hooks - onClick / onChange 等事件 - 浏览器 API(localStorage / window / etc) - 用了 client-only 库(如 framer-motion / chart.js) - Context provider(虽然 server component 也能 consume) 其它一律 server component。原则:"静态展示 → server;交互 → client"。 ## 数据获取最佳实践 ### 1. 直接 DB / ORM ```tsx import { prisma } from '@/lib/db' export default async function Page() { const posts = await prisma.post.findMany({ take: 20 }) return <PostList posts={posts} /> } ``` 不再需要 REST / GraphQL 层。component → ORM → DB。 ### 2. 并行获取 ```tsx export default async function Page({ params }: ...) { const [post, author] = await Promise.all([ db.posts.findById(params.id), db.users.findById(params.userId), ]) return ... } ``` 避免 await 串行让请求慢。 ### 3. Streaming + Suspense ```tsx import { Suspense } from 'react' export default function Page({ params }: ...) { return ( <> <h1>Article</h1> <Suspense fallback={<div>loading post...</div>}> <PostBody id={params.id} /> </Suspense> <Suspense fallback={<div>loading comments...</div>}> <CommentList postId={params.id} /> </Suspense> </> ) } ``` server 先 stream 出 `<h1>` + fallback,post / comments 各自异步加载完 就 stream 出真实 DOM 替换 fallback。用户看到 "渐进显示" 而不是 "全部等好再显示"。 ## Server Actions:从 client 调 server 函数 ```tsx // app/posts/[id]/page.tsx async function deletePost(postId: string) { 'use server' await db.posts.delete(postId) revalidatePath('/posts') } export default function Page({ params }: ...) { return ( <form action={async () => { 'use server'; deletePost(params.id) }}> <button>删除</button> </form> ) } ``` 不需要写 `/api/posts/:id/delete` endpoint。直接调函数,Next.js 自动 hook 成 RPC。 ## 与传统 SSR / SPA 共存 不是非黑即白。常见混合: - 营销页 / 文章 / 列表 → server component - 仪表盘 / 复杂表单 / 实时聊天 → client component - Next.js app router 默认 server,按需 `"use client"` ## 效果 - 首屏体验大幅改善(bundle 减少) - 数据 / UI 不再分离,组件代码更聚合 - 不需要专门维护 REST API 给前端用(直接调 ORM) - 但要熟悉"哪些代码跑服务端 / 哪些跑客户端" 心智模型 ## 踩过的坑 1. **在 server component 里用 useState** → 编译报错。新人最常见的 错。要么加 "use client",要么把状态下移到 client 子组件。 2. **import 服务端库进 client component**:bundle 暴涨 + 可能泄漏 secret。`import { db } from '@/db'` 在 client component 里就是 重大失误。lint 规则强制检查。 3. **server / client 边界传 props 必须 serializable**:函数 / Map / class 实例传不过去。只能传 plain object / 数组 / 基础类型。 4. **revalidate / cache 复杂**:Next.js 默认激进 cache,dev / prod 行为差异大。明确用 `cache: 'no-store'` / `revalidate: 60` / `cookies()`。 5. **Vercel / 自托管差异**:Server Actions 等功能在自托管 Next.js 还要配 standalone build。Vercel 上很丝滑,自部署要折腾。
Redux Toolkit 适合大型项目,对小项目却有大量样板。Zustand 是 Pmndrs(与 Three.js / Jotai 同作者)的极简方案,2024 后越来越流行。 ## 安装 ```bash npm i zustand ``` ## 最小 store ```ts // stores/counter.ts import { create } from 'zustand' interface State { count: number inc: () => void reset: () => void } export const useCounter = create<State>((set) => ({ count: 0, inc: () => set(s => ({ count: s.count + 1 })), reset: () => set({ count: 0 }), })) ``` ```tsx function Counter() { const count = useCounter(s => s.count) const inc = useCounter(s => s.inc) return <button onClick={inc}>{count}</button> } ``` 无 Provider、无 reducer、无 action type。selector 写法天然做了 memoization (只有 selector 返回值变了才重渲染)。 ## persist middleware ```ts import { persist, createJSONStorage } from 'zustand/middleware' export const useSettings = create( persist<Settings>( (set) => ({ theme: 'light', setTheme: (t) => set({ theme: t }), }), { name: 'settings', storage: createJSONStorage(() => localStorage), version: 1, } ) ) ``` 刷新页面状态自动还原。 ## immer middleware(写嵌套结构方便) ```ts import { immer } from 'zustand/middleware/immer' const useTodos = create<TodoState>()( immer((set) => ({ todos: [], addTodo: (text) => set(s => { s.todos.push({ id: Date.now(), text }) }), toggle: (id) => set(s => { const t = s.todos.find(t => t.id === id) if (t) t.done = !t.done }), })) ) ``` 直接 mutate(看起来),immer 内部生成 immutable 更新。 ## 多 store vs 单 store Zustand 鼓励"多个小 store",每个独立: ```ts useAuth() // 登录状态 useTheme() // 主题 useTodos() // 待办列表 ``` 而不是 Redux 那种"一个 root reducer"。 小 store 更容易代码分割 / 测试 / 删除。 ## 异步 action ```ts const useUser = create<UserState>((set) => ({ user: null, loading: false, async fetchUser(id: string) { set({ loading: true }) try { const r = await fetch(`/api/users/${id}`) set({ user: await r.json(), loading: false }) } catch (e) { set({ loading: false }) } }, })) ``` 异步逻辑就是普通 async 函数,无需 thunk / saga。 ## 选择多个字段时避免 re-render ```tsx // ❌ 每次新对象 → 总是重渲 const { name, email } = useUser(s => ({ name: s.name, email: s.email })) // ✅ 用 shallow 比较 import { shallow } from 'zustand/shallow' const { name, email } = useUser(s => ({ name: s.name, email: s.email }), shallow) // 或者分别 select const name = useUser(s => s.name) const email = useUser(s => s.email) ``` ## getState / setState(store 之外用) ```ts useAuth.getState().logout() useAuth.setState({ user: null }) // 订阅外部 const unsub = useAuth.subscribe( (state) => state.user, (user) => console.log('user changed', user) ) ``` 适合在路由 hook、worker 等非 React 上下文里用。 ## Devtools ```ts import { devtools } from 'zustand/middleware' const useStore = create(devtools<State>( (set) => ({ ... }), { name: 'MyStore' } )) ``` Redux DevTools 扩展能看到时间线 + state diff。 ## 与 React Server Components Zustand 是 client-side 状态。RSC 里直接用 fetch;只在交互组件("use client") 里用 store。 ## 测试 ```ts import { act } from 'react' it('inc works', () => { const { inc, count } = useCounter.getState() expect(count).toBe(0) act(() => inc()) expect(useCounter.getState().count).toBe(1) }) // 测后重置: beforeEach(() => useCounter.setState({ count: 0 })) ``` ## vs Jotai / Recoil - **Zustand**:单值存储 + selector,类似 mini Redux - **Jotai**:atomic state,类似 SolidJS signal - **Recoil**:Facebook 出的 atom 风格,已停止维护 中型项目用 Zustand 最实用;大量原子化派生状态用 Jotai。 ## 踩过的坑 - selector 返回新对象 → 总是重渲。要么 shallow 要么拆字段 select。 - store 闭包了旧值:set 用函数形式 `set(s => ...)` 而不是 `set({})`。 - store 不要循环依赖(A store 里 select B store)。把跨 store 派生 搬到组件层。 - persist 跨大版本:手动写 migrate 函数;不写则旧 state 直接覆盖 默认值有可能丢字段。
## 起因 React UI 库选择困难: - **Material UI**:很全 + 重 + 偏 google look - **Chakra UI**:现代 + 灵活但 bundle 大 - **Ant Design**:业务向,复杂表单强 - **Radix UI**:unstyled headless - **Tailwind UI**:付费 component 每个都是"npm install 进项目,按 props 用"。 痛点: - 改 design 难(要覆盖默认 style) - 升级 lib 版本可能 break style - bundle 总是吃满(你只用 button 但 import 全 lib) **shadcn/ui** 提出不同模式:**copy component code 进项目**。 不是 library,是 component template + Radix 底层。 ## 加 component ```bash npx shadcn@latest init # 配置 tailwind / 颜色主题 npx shadcn@latest add button # 把 Button 源码 copy 到 components/ui/button.tsx ``` ```tsx import { Button } from '@/components/ui/button'; <Button variant="default">Click</Button> <Button variant="destructive">Delete</Button> <Button variant="outline" size="sm">Cancel</Button> ``` UI 跟 Tailwind UI 类似(基于 Tailwind class)。 ## 改 component 是 yours ```bash npx shadcn@latest add card ``` 文件 `components/ui/card.tsx` 现在是你的。 要改 padding / 圆角 / 加 prop → 直接改源码。 没 lib 升级问题(没 lib)。 ## 模式核心 ``` Radix UI (headless, accessible) + Tailwind CSS (styling) + 你 own 的 code = shadcn/ui ``` - Radix 提供 a11y / behavior(focus trap / aria 等) - Tailwind 提供 styling - 你拥有源码 + 修改自由 ## 优势 - **零 lock-in**:lib 没了你 code 不挂 - 改 design 直接改 file - bundle 只含你用的 component(tree-shake 友好) - TS first-class - 跟现代 stack(Next.js / Vite / Astro)天然 fit ## 劣势 - 不是 install 即用(每 component 要 add + 看 code) - 升级要手动(lib 出新版要 copy 新 source) - 设计语言比 Material 单调(neutral 风格,要自己丰富) ## 完整 setup ```bash # Next.js + Tailwind 项目 npx shadcn@latest init # 装常用 npx shadcn@latest add button card input label dialog dropdown-menu sheet ``` 生成: ``` components/ui/ button.tsx card.tsx dialog.tsx ... lib/utils.ts # cn() helper ``` `cn()` 是 clsx + tailwind-merge 包装,让 className 智能合并: ```tsx <Button className={cn('w-full', isLoading && 'opacity-50', className)}> ``` ## form 模式 shadcn + react-hook-form + zod: ```tsx import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Button } from '@/components/ui/button'; import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; const schema = z.object({ email: z.string().email(), }); function MyForm() { const form = useForm({ resolver: zodResolver(schema) }); return ( <Form {...form}> <form onSubmit={form.handleSubmit(console.log)}> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl> <Input {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <Button type="submit">Submit</Button> </form> </Form> ); } ``` template 嗦但极灵活 + a11y 完整 + validation 自动。 ## theme `globals.css`: ```css @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; /* ... */ } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; /* ... */ } } ``` CSS variable 驱动主题。dark mode 加 `class="dark"` on root。 改 brand color:改 `--primary`。 ## sonner / vaul 等 shadcn 推荐组合的几个: - **sonner**:toast notification - **vaul**:bottom sheet (mobile-style drawer) - **cmdk**:command palette - **react-day-picker**:date picker 都是高质量 headless lib,shadcn add 帮你 scaffold 集成。 ## v0 / 自动生成 UI `v0.dev`(Vercel)用 LLM 生成 shadcn/ui-based React component: ``` prompt: "user profile card with avatar, name, bio, and follow button" → 生成 component code 用 shadcn pieces ``` 新 component prototype 1 分钟出。 之后人 review + 调整。 ## 与 Material / Chakra 对比 | | shadcn | Material UI | Chakra | |---|---|---|---| | 装 | copy code | npm install | npm install | | 改 design | 改 code | override theme / sx | override theme | | 学习曲线 | 中(要懂 Tailwind) | 高(API 大) | 中 | | bundle | tree-shake 极好 | 大 | 中 | | 设计语言 | minimal neutral | google material | 现代 | | 适合 | custom design / 中型项目 | 企业产品 | 通用 | 我 2024+ 项目 100% shadcn。 老 Material 项目维持。 ## 跟 Tailwind UI 对比 Tailwind UI(付费): - 设计精良,付费许可 - 复制 HTML 进项目(不分 component) shadcn: - 免费 - React component(不只是 HTML) - 跟 Radix 集成(a11y) 如果钱不是问题且要顶级设计 → Tailwind UI 块 + shadcn 组件 混用。 ## 何时不用 - 不用 React → shadcn 是 React-only(但 Svelte/Vue 有 community port) - 不用 Tailwind → 不适合(核心是 Tailwind class) - 极简项目(landing page)→ Tailwind 直接写 HTML 够 ## 真实 case 新项目 admin dashboard: - 1 周 setup shadcn + 10+ component - 0 设计稿,直接 v0 生成 + 微调 - bundle 200 KB(vs Material 类似项目 600 KB+) - 想改 button radius 全局 → 改 `--radius` CSS var 迭代速度 + 灵活性大幅提升。 ## 踩过的坑 1. **不识别相对路径**:shadcn 默认用 `@/components/ui/...` alias。 要 tsconfig + vite config 配 alias。 2. **冲突 className**:`<Button className="w-full bg-red-500">` 跟内 建 variant 冲突。`cn()` + tailwind-merge 解决。 3. **暗色模式切换闪烁**:SSR 时 server 不知道用户 prefer。Next.js 用 next-themes 处理 hydration。 4. **lib 更新没拉新**:shadcn 出新 version Button → 你的没自动升。 手动 `npx shadcn add button --overwrite` 或者 diff merge。 5. **设计简陋**:default 风格中性 → 看起来朴素。要加品牌色 / 图标 / 插图才"鲜活"。
## 起因 复杂表单 / 多级嵌套组件共享 state 时,Zustand 的"一个 store 多字段" 还是会让"用 fieldA 的组件" 因为 fieldB 改而 re-render(除非每字段 单独 selector)。 Jotai 思路完全不同:**每个状态是一个独立的 atom**。组件只订阅它真用到 的 atom,atom 变才重渲。天然细粒度。 ## 解决方案 ### 装 ```bash npm i jotai ``` ### 第一个 atom ```tsx import { atom, useAtom } from 'jotai' const countAtom = atom(0) function Counter() { const [count, setCount] = useAtom(countAtom) return <button onClick={() => setCount(c => c + 1)}>{count}</button> } function Display() { const [count] = useAtom(countAtom) return <span>{count}</span> } ``` `useAtom` 跟 `useState` 几乎一样的 API。 **多个组件共享同一 atom** 时自动同步。 不需要 Provider(默认全局): ```tsx function App() { return ( <> <Counter /> <Display /> </> ) } ``` 两个组件用同一 atom,互相同步。 ## Derived atom(计算属性) ```tsx const countAtom = atom(0) const doubledAtom = atom((get) => get(countAtom) * 2) function Doubled() { const [doubled] = useAtom(doubledAtom) return <span>{doubled}</span> } ``` `doubledAtom` 自动跟 `countAtom` 关联:count 变 → doubled 重算 → 订阅 doubled 的组件重渲。 ## Async atom ```tsx const userIdAtom = atom('alice') const userAtom = atom(async (get) => { const id = get(userIdAtom) const r = await fetch(`/api/users/${id}`) return r.json() }) function UserCard() { const [user] = useAtom(userAtom) return <div>{user.name}</div> } function App() { return ( <Suspense fallback={<Spinner />}> <UserCard /> </Suspense> ) } ``` 异步 atom 自动用 React Suspense。改 userIdAtom 触发 refetch。 ## Atom family(动态创建 atoms) 每个 user id 一个 atom: ```tsx import { atomFamily } from 'jotai/utils' const userAtomFamily = atomFamily((id: string) => atom(async () => fetch(`/api/users/${id}`).then(r => r.json())) ) function UserCard({ id }: { id: string }) { const [user] = useAtom(userAtomFamily(id)) return <div>{user.name}</div> } ``` 每个 `id` 独立 atom;不同 UserCard 不会互相影响。 ## 写组合:写一个 atom 触发多个修改 ```tsx const firstNameAtom = atom('') const lastNameAtom = atom('') const updateNameAtom = atom( null, // read function(这里没读) (get, set, fullName: string) => { const [f, l] = fullName.split(' ') set(firstNameAtom, f) set(lastNameAtom, l) } ) function Form() { const [, setName] = useAtom(updateNameAtom) return <input onChange={e => setName(e.target.value)} /> } ``` action / mutation 风格。 ## 持久化 ```tsx import { atomWithStorage } from 'jotai/utils' const themeAtom = atomWithStorage('theme', 'light') function ThemeToggle() { const [theme, setTheme] = useAtom(themeAtom) return <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}> {theme} </button> } ``` 自动 localStorage。 ## 跟 Zustand 比 | | Jotai | Zustand | |---|---|---| | 粒度 | atom(极细) | store(粗,需 selector) | | API 风格 | 函数式 hook | mutate / function | | 学习曲线 | 中(atom 思维转换) | 低 | | 大组件树 | 优秀(自动只更新订阅者) | 需精确 selector | | 异步 | 一等公民 + Suspense | 手写 | | 复杂派生 | 自然 | 需 useMemo 等 | | Devtools | 有 | 有 | 适合 Jotai 的场景: - 复杂表单(每字段独立 atom) - 大量派生状态 - 需要 Suspense 异步 适合 Zustand: - 简单全局 store(用户 auth / theme) - 倾向 imperative API ## 实战:表单 with field-level state ```tsx import { atom, useAtom } from 'jotai' const emailAtom = atom('') const emailErrorAtom = atom((get) => { const e = get(emailAtom) if (!e) return null if (!/@/.test(e)) return '邮箱格式错' return null }) const passwordAtom = atom('') const passwordErrorAtom = atom((get) => { const p = get(passwordAtom) if (p.length < 8) return '密码至少 8 位' return null }) const canSubmitAtom = atom((get) => get(emailErrorAtom) === null && get(passwordErrorAtom) === null && get(emailAtom) && get(passwordAtom) ) function EmailField() { const [email, setEmail] = useAtom(emailAtom) const [error] = useAtom(emailErrorAtom) return ( <div> <input value={email} onChange={e => setEmail(e.target.value)} /> {error && <p>{error}</p>} </div> ) } // PasswordField 类似 function SubmitButton() { const [canSubmit] = useAtom(canSubmitAtom) return <button disabled={!canSubmit}>提交</button> } ``` 每个字段独立 atom + 独立 error atom + 派生 canSubmit。 只有"我用到的" 重渲。 对比 react-hook-form: - RHF 更成熟 + 更多功能(registration / validation chain) - Jotai 概念上更轻 + 不限于表单 复杂表单仍推荐 RHF;中等表单 + 跨组件共享 Jotai 更灵活。 ## 与 React Query 集成 ```tsx import { atomWithQuery } from 'jotai-tanstack-query' const userAtom = atomWithQuery((get) => ({ queryKey: ['user', get(userIdAtom)], queryFn: async ({ queryKey: [, id] }) => fetch(`/api/users/${id}`).then(r => r.json()), })) function UserCard() { const [{ data, isPending }] = useAtom(userAtom) if (isPending) return <Spinner /> return <div>{data.name}</div> } ``` React Query 的 cache + Jotai 的 atom 组合 = 服务端状态 + 客户端 state 一致管理。 ## 调试:Jotai Devtools ```tsx import { useAtomDevtools } from 'jotai-devtools' function DebugAll() { useAtomDevtools(countAtom, { name: 'count' }) useAtomDevtools(userAtom, { name: 'user' }) return null } ``` Redux DevTools 显示所有 atom 变化时间线 + 当前值。 ## 性能注意 Jotai 极细粒度 = 每个 atom 一份 React subscription。 **几千个 atom + 大量更新** 时 React scheduler 压力大。 > 千级 atom 场景考虑: - 用 `atomFamily` 而非手写百份 atom - 把不需要响应的数据放普通对象 / Map - 必要时用 Jotai 内部 store 做 batched update ## 何时不用 Jotai - 全局只有几个简单 state(auth + theme)→ Zustand 足够 - 已经深度用 Redux Toolkit → 切换成本大 - 团队不熟函数式 → 学习曲线一道槛 ## 踩过的坑 1. **atom 在组件外创建**:放函数体内每次 render 新 atom → state 重置。 atom 一定在 module top-level 创建。 2. **派生 atom 依赖循环**:A 依赖 B,B 依赖 A → 无限循环 stack overflow。 设计时检查依赖图无环。 3. **async atom + Suspense**:默认 fetch 失败抛 ErrorBoundary。 不想用 Suspense 写 `loadable(asyncAtom)` 退化到 loading state pattern。 4. **atom 大对象**:整个对象一变所有订阅者重渲。改为多 atom 细分 + selector 派生。 5. **SSR / Next.js**:Jotai SSR 需要 `Provider` 隔离 request-level state,不能全局。注意配 hydration。
## 起因 React 里每次写"组件 mount 时 fetch 数据,loading 状态显示 spinner, error 显示 toast,组件 unmount 时 abort"——这一套 boilerplate 在 useState + useEffect 里写起来 30 行: ```tsx function UserCard({ id }) { const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { let alive = true setLoading(true) fetch(`/api/users/${id}`) .then(r => r.json()) .then(data => { if (alive) { setUser(data); setLoading(false) } }) .catch(e => { if (alive) { setError(e); setLoading(false) } }) return () => { alive = false } }, [id]) if (loading) return <Spinner /> if (error) return <ErrorBlock /> return <div>{user.name}</div> } ``` 而且这里还没处理:去重(同时多个组件查同一 id)、refetch 策略、 后台静默更新、optimistic update、stale-while-revalidate…… `TanStack Query`(前身 React Query)把这套抽象成 hook,代码减少 80%。 ## 解决方案 ### 装 ```bash npm i @tanstack/react-query ``` ### 顶层 Provider ```tsx // main.tsx import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 1 分钟内复用 cache,不 refetch gcTime: 5 * 60 * 1000, // 5 分钟没人订阅就 gc retry: 2, refetchOnWindowFocus: true, }, }, }) <QueryClientProvider client={queryClient}> <App /> <ReactQueryDevtools /> {/* dev 用 */} </QueryClientProvider> ``` ### useQuery:上面那 30 行变成 ```tsx import { useQuery } from '@tanstack/react-query' function UserCard({ id }) { const { data: user, isPending, error } = useQuery({ queryKey: ['user', id], queryFn: () => fetch(`/api/users/${id}`).then(r => r.json()), }) if (isPending) return <Spinner /> if (error) return <ErrorBlock message={error.message} /> return <div>{user.name}</div> } ``` 5 行。自动: - mount 时 fetch - unmount abort(如果 queryFn 支持 signal) - 同 queryKey 多组件去重(只一次请求) - staleTime 内复用 cache,不重 fetch - focus tab / 网络恢复时 background refetch - error 时自动重试 ### queryKey 是核心 ```tsx useQuery({ queryKey: ['user', id] }) // GET /api/users/:id useQuery({ queryKey: ['users', { tag: 'admin', page: 2 }] }) useQuery({ queryKey: ['posts', userId, 'comments'] }) ``` queryKey 像 cache 字典的 key。同 key 共享一份 cache。 对象 / 数组按值比较(deep equality)。 ### 失效 cache ```tsx import { useQueryClient } from '@tanstack/react-query' function CreatePostForm() { const qc = useQueryClient() const mutate = async (data) => { await fetch('/api/posts', { method: 'POST', body: JSON.stringify(data) }) // 让所有 posts 相关查询失效,触发自动 refetch qc.invalidateQueries({ queryKey: ['posts'] }) } // ... } ``` `invalidateQueries({ queryKey: ['posts'] })` 让所有以 `['posts', ...]` 开头的 cache 标记 stale,触发 refetch。 ### useMutation:POST / PUT / DELETE ```tsx import { useMutation, useQueryClient } from '@tanstack/react-query' function DeleteButton({ id }) { const qc = useQueryClient() const mutation = useMutation({ mutationFn: (postId) => fetch(`/api/posts/${postId}`, { method: 'DELETE' }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['posts'] }) }, }) return ( <button onClick={() => mutation.mutate(id)} disabled={mutation.isPending} > {mutation.isPending ? '删除中...' : '删除'} </button> ) } ``` ### Optimistic update(界面立刻反映,再发请求) ```tsx const mutation = useMutation({ mutationFn: (newPost) => fetch('/api/posts', ...).then(r => r.json()), onMutate: async (newPost) => { // 取消正在跑的 query await qc.cancelQueries({ queryKey: ['posts'] }) // 保存 snapshot const prev = qc.getQueryData(['posts']) // 乐观更新 cache qc.setQueryData(['posts'], (old) => [...old, newPost]) return { prev } }, onError: (err, newPost, ctx) => { // 回滚 qc.setQueryData(['posts'], ctx.prev) }, onSettled: () => { qc.invalidateQueries({ queryKey: ['posts'] }) }, }) ``` UI 立刻看到新 post,服务端失败自动回滚。用户体验大幅改善。 ### 分页 / 无限滚动 ```tsx import { useInfiniteQuery } from '@tanstack/react-query' const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam = 0 }) => fetch(`/api/posts?page=${pageParam}`).then(r => r.json()), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined, }) return ( <> {data?.pages.map(page => page.items.map(p => <PostCard key={p.id} {...p} />))} {hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}> 加载更多 </button> )} </> ) ``` ### prefetch 页面将进入前先预加载下个页面: ```tsx function PostLink({ id }) { const qc = useQueryClient() return ( <a href={`/posts/${id}`} onMouseEnter={() => qc.prefetchQuery({ queryKey: ['post', id], queryFn: () => fetch(`/api/posts/${id}`).then(r => r.json()), })} > 详情 </a> ) } ``` 鼠标 hover 时预 fetch,用户点进去秒开。 ### Devtools 极爽 `<ReactQueryDevtools />` 在 dev 模式右下角浮窗: - 所有 query 状态(fresh / fetching / stale / inactive) - cache 内容 - 手动 invalidate / refetch 测试 调试缓存问题神器。 ### SSR / Next.js 集成 ```tsx // app/posts/page.tsx import { HydrationBoundary, dehydrate, QueryClient } from '@tanstack/react-query' export default async function PostsPage() { const qc = new QueryClient() await qc.prefetchQuery({ queryKey: ['posts'], queryFn: () => fetchPosts(), }) return ( <HydrationBoundary state={dehydrate(qc)}> <PostList /> </HydrationBoundary> ) } ``` 服务端预先取数据塞进 client cache,hydrate 后客户端立刻有数据。 ## 效果 我们的 SPA 改造后: - 网络请求量降 40%(多组件共享 cache + staleTime 复用) - 用户感知到的"loading"次数下降 60%(cache 命中秒显示) - bug 数下降明显(边界情况 framework 处理而非自己写) - 代码量净减少 30% ## 替代品 - **SWR**(Vercel 出品):API 更简洁,能力少一些 - **Apollo Client**(GraphQL):GraphQL 项目优秀 - **RTK Query**(Redux Toolkit):已经用 Redux 的话 - **TanStack Query**:通用、最强大、生态最大 新项目无 GraphQL → TanStack Query 默认选。 ## 踩过的坑 1. **queryKey 不稳定**:每次 render 新建对象 / 数组 → query 永远 stale。 `useMemo(() => ['user', id], [id])` 或者扁平 `['user', id]`。 2. **mutation 后忘 invalidate**:UI 不刷新。永远在 `onSuccess` invalidate 相关 query。 3. **staleTime: 0 + refetchOnWindowFocus**:tab 切回来都 refetch, API 调用爆炸。生产 staleTime 至少 30s-1min。 4. **`useQuery` 在条件渲染分支里**:违反 hooks 规则。用 `enabled: !!id`: ```tsx useQuery({ queryKey: ['user', id], queryFn: ..., enabled: !!id }) ``` 5. **`useQuery` 直接 throw error**:导致 React error boundary 接管。 用 `error` 字段做条件渲染,或 `useErrorBoundary: true` 显式启用。
React 表单的痛点: - 每次输入触发整页 re-render(useState 模式) - 校验逻辑分散 - 类型推导 / 错误提示不一致 - 异步校验 / 提交状态难管 `react-hook-form` (RHF) + `zod` 组合是 2024 业界共识: RHF 用 ref 而非 state 避免重渲,zod 提供 schema-first 校验 + 类型推导。 ## 安装 ```bash npm i react-hook-form zod @hookform/resolvers ``` ## 5 行版 ```tsx import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' const schema = z.object({ email: z.string().email('邮箱格式不对'), password: z.string().min(8, '密码至少 8 位'), age: z.coerce.number().int().min(0).max(150), }) type FormData = z.infer<typeof schema> function LoginForm() { const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm<FormData>({ resolver: zodResolver(schema), defaultValues: { email: '', password: '', age: 18 }, }) const onSubmit = async (data: FormData) => { await fetch('/api/login', { method: 'POST', body: JSON.stringify(data) }) } return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('email')} placeholder="邮箱" /> {errors.email && <p>{errors.email.message}</p>} <input type="password" {...register('password')} /> {errors.password && <p>{errors.password.message}</p>} <input type="number" {...register('age')} /> {errors.age && <p>{errors.age.message}</p>} <button disabled={isSubmitting}>登录</button> </form> ) } ``` 注意: - `z.infer<typeof schema>` 自动推 FormData 类型,schema 改字段自动同步 - `register('email')` 把 input 注册到表单系统,不用 useState - 提交时 zod 校验,错的字段进 `errors` ## 性能:只渲染必要部分 ```tsx import { useFormState, useWatch } from 'react-hook-form' function EmailPreview({ control }) { // 只在 email 字段变了时重渲,整个表单不动 const email = useWatch({ control, name: 'email' }) return <div>预览: {email}</div> } ``` ## 嵌套 / 数组字段 ```ts const schema = z.object({ name: z.string(), emails: z.array(z.object({ address: z.string().email(), primary: z.boolean(), })).min(1), }) ``` ```tsx import { useFieldArray } from 'react-hook-form' const { fields, append, remove } = useFieldArray({ control, name: 'emails' }) return ( <> {fields.map((f, i) => ( <div key={f.id}> <input {...register(`emails.${i}.address`)} /> <input type="checkbox" {...register(`emails.${i}.primary`)} /> <button onClick={() => remove(i)}>×</button> </div> ))} <button onClick={() => append({ address: '', primary: false })}>加</button> </> ) ``` `useFieldArray` 优化动态列表场景,比手动 `useState<Email[]>` 性能好。 ## 复杂校验 ### 跨字段 ```ts const schema = z.object({ password: z.string().min(8), confirm: z.string(), }).refine(d => d.password === d.confirm, { message: '两次密码不一致', path: ['confirm'], }) ``` ### 异步(如检查用户名是否已存在) ```ts const schema = z.object({ username: z.string().min(3).refine(async (val) => { const r = await fetch(`/api/check-username?u=${val}`) return (await r.json()).available }, { message: '用户名已被占用' }), }) ``` zod 支持 async refine,RHF 自动 await。 ### transform / coerce ```ts const schema = z.object({ // <input type="number" /> 实际是字符串,z 转 number age: z.coerce.number().int(), // 字符串前后去空格 name: z.string().trim().min(1), // 把 "yes"/"no" 字符串变 boolean consent: z.string().transform(v => v === 'yes'), }) ``` ## 异步提交状态 ```tsx const { handleSubmit, formState: { isSubmitting, isSubmitSuccessful } } = useForm(...) return ( <form onSubmit={handleSubmit(onSubmit)}> ... <button disabled={isSubmitting}> {isSubmitting ? '提交中...' : '提交'} </button> {isSubmitSuccessful && <p>成功!</p>} </form> ) ``` ## 服务端错误回填 ```tsx const { setError } = useForm(...) const onSubmit = async (data) => { try { await api.create(data) } catch (e) { if (e.code === 'EMAIL_TAKEN') { setError('email', { type: 'manual', message: '邮箱已注册' }) } } } ``` ## 与 UI 组件库(Radix UI / shadcn / Mantine)配合 ```tsx import { Controller } from 'react-hook-form' <Controller control={control} name="country" render={({ field }) => <Select {...field} options={countries} />} /> ``` `Controller` 把不支持 ref 的受控组件包起来。 ## 共用 schema 到后端 zod schema 是纯 TypeScript,可以在 FastAPI / Express / Hono / tRPC 后端 共用。前后端用同一份校验,类型一致: ```ts // shared/schema.ts export const userSchema = z.object({ ... }) // frontend useForm({ resolver: zodResolver(userSchema) }) // backend (hono) import { zValidator } from '@hono/zod-validator' app.post('/api/user', zValidator('json', userSchema), (c) => { ... }) ``` ## 何时不用 RHF 简单 1-2 字段的表单直接 `useState` 就够,引入 RHF 反而麻烦。 3+ 字段 + 校验 + 异步提交时 RHF 收益最大。 ## 踩过的坑 - 忘了 `defaultValues`:第一次渲染 input value 是 undefined,React 报 "uncontrolled to controlled" 警告。永远显式给 defaultValues。 - `mode: 'onChange'` 让每次输入都校验 → 慢且打扰用户。默认 'onSubmit' 最稳;onBlur 是折中。 - 跨大版本升 RHF:v6 → v7 API 变化大。lockfile + 一次性升级。 - zod refine 异步:会让 onChange 校验变慢;尽量 onBlur 异步校验。
React 新手最常踩的"过度优化"就是给所有函数 / 计算包 `useMemo` `useCallback`。 官方文档 2024 之后已经明确:**默认不要用,先 profile**。 但是有几个场景必须用。下面分清楚。 ## 默认不要用的理由 `useMemo` 本身要执行: 1. 调用 `useMemo` 创建 hook entry 2. 比较依赖数组(浅比较) 3. 决定是否复用 这些开销 > 大多数同步函数的执行成本。所以对一个"加两个数字"包 useMemo, 反而更慢。 ## 真正需要 useMemo 的场景 ### 场景 1:计算确实贵(毫秒级以上) ```tsx function ChartView({ rawData }: { rawData: Row[] }) { // 大量数据点排序 + 重计算 const processed = useMemo(() => { return rawData .map(row => ({ ...row, score: complexCalc(row) })) .sort((a, b) => b.score - a.score) .slice(0, 100) }, [rawData]) return <Chart data={processed} /> } ``` 判断标准:profile 看到这块 > 16ms(一帧)就有必要。 ### 场景 2:作为 `useEffect` / `useMemo` / `useCallback` 的依赖 ```tsx function Form({ schema }: { schema: Schema }) { // 不 memo 的话每次渲染 validator 都是新对象,下面 useEffect 每次都跑 const validator = useMemo(() => createValidator(schema), [schema]) useEffect(() => { validator.subscribe(handleValid) return () => validator.unsubscribe(handleValid) }, [validator]) } ``` 这是 useMemo 最常见的合理用途——稳定依赖。 ### 场景 3:传给 memoized 子组件 ```tsx const SearchResults = React.memo(function SearchResults({ items }: ...) { // ... }) function Page() { const [query, setQuery] = useState('') // items 每次都是新数组 → React.memo 浅比较失败 → 子组件每次都重渲染 // 加 useMemo 之后只有 query 变了才重新生成 items const items = useMemo(() => filterItems(allItems, query), [query, allItems]) return <SearchResults items={items} /> } ``` 如果子组件没 `React.memo` 包裹,这个 useMemo 完全无效——白干。 ## useCallback 类似 ```tsx function Parent() { const [count, setCount] = useState(0) // ❌ 没必要:onClick 每次都是新引用但 Button 没 memo const onClick = useCallback(() => setCount(c => c + 1), []) return <Button onClick={onClick} /> } ``` `useCallback` 仅在以下两种情况有意义: 1. 函数作为 `useEffect` / `useMemo` 的依赖 2. 函数传给 `React.memo` 的子组件 ## 反模式集合 ### ❌ 给基础类型包 useMemo ```tsx const sum = useMemo(() => a + b, [a, b]) // 直接: const sum = a + b // 一样快 ``` ### ❌ 包内联对象 / 数组(除非传给 memo 子) ```tsx const style = useMemo(() => ({ color: 'red' }), []) // 直接: const style = { color: 'red' } // 多创建一个对象,但 GC 几乎免费 ``` ### ❌ "为了未来扩展" 提前 useMemo 写代码时不要"也许将来子组件会 React.memo 所以现在先包"。等真有需要再包。 ## 测量工具 React DevTools → Profiler → Record。看哪个组件渲染 > 5ms 或者 "unnecessarily" 标红。 Chrome DevTools → Performance → Record。看主线程任务,> 50ms 就是 long task,需要优化。 ## React 19 的 React Compiler React 19+ 带的 React Compiler 会在编译期自动决定哪些值需要 memo, **届时手写 useMemo / useCallback 几乎可以全删**。 提前为这个未来准备: 1. 不要为了 memo 而引入"复杂的依赖追踪"。代码清晰更重要,性能用编译器 2. 现有的 useMemo / useCallback 可以保留,编译器会忽略它们 ## 一个完整反例 ```tsx // 全是反优化 function UserCard({ user }: { user: User }) { const name = useMemo(() => user.name, [user]) // ❌ const onClick = useCallback(() => alert(user.id), [user]) // ❌(没 memo 子) const style = useMemo(() => ({ padding: 12 }), []) // ❌ return ( <div style={style} onClick={onClick}> <strong>{name}</strong> </div> ) } ``` 清版: ```tsx function UserCard({ user }: { user: User }) { return ( <div style={{ padding: 12 }} onClick={() => alert(user.id)}> <strong>{user.name}</strong> </div> ) } ``` 后者更短、更快、更易读。 ## 踩过的坑 - 给 useMemo 的依赖数组写错(漏依赖 / 多依赖)是 React 最常见 bug, 装 `eslint-plugin-react-hooks` 让 `exhaustive-deps` 规则帮你查。 - 复杂表单的 onChange 用 useCallback 包并传给 memoized Input 子组件—— 看起来对,但 input 上的 onChange 引用是否稳定通常不是性能瓶颈。 先 profile 再优化。 - 把 `useState` 的 setter 写进 useCallback 的依赖数组:setter 永远稳定, 写了不影响但说明你没理解 setter 的行为。可以省略。
## 起因 一个生产 bug:某个组件渲染时 throw 了未捕获错误 → 整页白屏 → 用户刷新无效 → 截图发给客服。 React 默认行为:子组件 throw 没 catch → 整个组件树卸载 → 白屏。 正确做法:用 ErrorBoundary 局部捕获 + 显示"出错了"卡片 + 上报错误到 Sentry。 ## 解决方案 ### 1. 基础 ErrorBoundary ```tsx // components/ErrorBoundary.tsx import { Component, ReactNode } from 'react' interface Props { children: ReactNode fallback?: ReactNode onError?: (error: Error, info: { componentStack: string }) => void } interface State { hasError: boolean error: Error | null } export class ErrorBoundary extends Component<Props, State> { state: State = { hasError: false, error: null } static getDerivedStateFromError(error: Error): State { return { hasError: true, error } } componentDidCatch(error: Error, info: { componentStack: string }) { console.error('ErrorBoundary caught:', error, info) this.props.onError?.(error, info) } reset = () => { this.setState({ hasError: false, error: null }) } render() { if (this.state.hasError) { if (this.props.fallback) { return this.props.fallback } return ( <div role="alert" style={{ padding: 16, background: '#fee', borderRadius: 8 }}> <h3>出错了 😞</h3> <p>{this.state.error?.message}</p> <button onClick={this.reset}>重试</button> </div> ) } return this.props.children } } ``` ErrorBoundary 必须 class component(Hooks 不支持 componentDidCatch)。 ### 2. 用法:包裹"应该独立失败"的区域 ```tsx function Dashboard() { return ( <Layout> <ErrorBoundary> <PostList /> {/* 出错只这里降级 */} </ErrorBoundary> <ErrorBoundary> <RecommendedSidebar /> </ErrorBoundary> <ErrorBoundary> <CommentFeed /> </ErrorBoundary> </Layout> ) } ``` 任意一个组件挂了不影响其它,用户至少能用其它部分。 ### 3. 顶层兜底 ```tsx function App() { return ( <ErrorBoundary fallback={ <div style={{ padding: 40, textAlign: 'center' }}> <h1>应用出错</h1> <p>请刷新页面,或联系支持</p> <button onClick={() => location.reload()}>刷新</button> </div> } > <Router> <Routes>...</Routes> </Router> </ErrorBoundary> ) } ``` ### 4. Sentry 集成(生产必备) ```bash npm i @sentry/react ``` ```ts // main.tsx import * as Sentry from '@sentry/react' Sentry.init({ dsn: 'https://[email protected]/123', environment: import.meta.env.MODE, tracesSampleRate: 0.1, // 10% 性能采样 replaysSessionSampleRate: 0.1, // 10% 会话录制 replaysOnErrorSampleRate: 1.0, // 出错的 100% 录制 integrations: [ Sentry.browserTracingIntegration(), Sentry.replayIntegration({ maskAllText: false, blockAllMedia: true }), ], }) ``` 用 Sentry 的 ErrorBoundary 替代自己写的: ```tsx import * as Sentry from '@sentry/react' <Sentry.ErrorBoundary fallback={({ error, resetError }) => ( <ErrorCard error={error} onReset={resetError} /> )} showDialog // 弹用户反馈表单 > <PostList /> </Sentry.ErrorBoundary> ``` 任何 error throw → 自动上报 Sentry → 包含 stack trace + 浏览器版本 + URL + 用户 ID + session replay 视频回放。 ### 5. 异步错误:try/catch + Sentry.captureException ErrorBoundary 只抓 render / lifecycle 错误。**异步错误(fetch / setTimeout / Promise)抓不到**: ```tsx async function loadData() { try { const r = await fetch('/api/users') if (!r.ok) throw new Error(`status ${r.status}`) return await r.json() } catch (e) { Sentry.captureException(e) throw e // 让上层 React Query / useState 知道失败 } } ``` 或者 React Query 自动: ```tsx const { data, error } = useQuery({ queryKey: ['users'], queryFn: loadData, throwOnError: true, // throw 给最近的 ErrorBoundary }) ``` `throwOnError: true` 让 TanStack Query 把 error 抛给 ErrorBoundary, 两套机制串起来。 ### 6. 区分预期错误 vs 真 bug ```tsx // 预期错误:业务规则 async function transfer(amount) { const r = await fetch('/api/transfer', ...) if (r.status === 403) { throw new InsufficientBalanceError() // 不上报 Sentry } if (!r.ok) { throw new Error('transfer failed') // 上报 } } class InsufficientBalanceError extends Error { isExpected = true } // 包装 function captureIfUnexpected(e: Error) { if ((e as any).isExpected) return Sentry.captureException(e) } ``` Sentry 项目里大量"用户输错密码 401"的告警没价值,要区分。 ### 7. 全局未捕获错误 ```ts window.addEventListener('error', (event) => { Sentry.captureException(event.error) }) window.addEventListener('unhandledrejection', (event) => { Sentry.captureException(event.reason) }) ``` Sentry SDK 默认已经 hook 了这些,不需要自己写。 ### 8. Source maps 上传 生产 bundle 是 minified;Sentry 看 stack trace 全是 `a.b.c at xyz.js:1:1234`, 没法定位。Vite 配 sourcemap + 上传到 Sentry: ```ts // vite.config.ts import { sentryVitePlugin } from '@sentry/vite-plugin' export default defineConfig({ build: { sourcemap: true }, plugins: [ react(), sentryVitePlugin({ org: 'my-org', project: 'my-app', authToken: process.env.SENTRY_AUTH_TOKEN, }), ], }) ``` build 时自动上传 sourcemap 到 Sentry,error 在 dashboard 显示原始 TS 代码 + 行号。 ### 9. 用户反馈 ```tsx <Sentry.ErrorBoundary fallback={({ error, eventId }) => ( <div> <h3>出错了</h3> <p>已自动上报。错误 ID: <code>{eventId}</code></p> <button onClick={() => Sentry.showReportDialog({ eventId })}> 提供反馈 </button> </div> )} > ``` 用户能描述"我点什么按钮触发的",Sentry 把这个反馈跟 error 绑在一起。 ## 效果 - 白屏 bug 数从每月 10+ → 0(局部降级) - Sentry 收集到 95% bug,开发能主动修而不是等用户报 - session replay 让"为什么会出现这个 state" 一目了然 - 半年内 frontend error rate 从 2% → 0.3% ## 注意事项 ### 1. 不要包太细 每个 div 一个 ErrorBoundary:渲染开销 + 视觉细碎。 原则是"独立可用的功能块"为单位(侧栏 / 主内容 / 评论区)。 ### 2. PII 信息 Sentry 默认抓表单数据。可能包含密码、邮箱、信用卡。 ```ts Sentry.init({ beforeSend(event) { // 删 password 字段 if (event.request?.data) { delete event.request.data.password } return event }, }) ``` 或者 mask all replay:`replayIntegration({ maskAllText: true })`。 ### 3. quota Sentry 免费层每月 5k events。`tracesSampleRate: 0.1` 限性能采样。 真 error 全部上报;性能 / replay 采样。 ## 踩过的坑 1. **ErrorBoundary 必须是 class**:function component 用 React 19+ 的 `use()` 可以一定程度替代,但官方 ErrorBoundary 仍是 class。 2. **`getDerivedStateFromError` 不能调 setState**:用 return 新 state。 `componentDidCatch` 才能调副作用 / 上报。 3. **dev 模式 React 显示完整错误覆盖层**:不代表 ErrorBoundary 没工作。 生产 build 才看到 fallback。 4. **forgotten reset**:fallback 里写 "重试" 按钮要调 `resetError()`, 而不是 `setState({ hasError: false })`(state 在 boundary 里, 按钮在 fallback 里)。Sentry boundary 的 `resetError` prop 帮你 处理。 5. **SSR 时 ErrorBoundary**:getDerivedStateFromError 在 SSR 跑 → 服务端如果 catch 到错误就 render fallback HTML。客户端 hydrate 时如果不同会 mismatch warning。
## 起因 要给 React 项目快速搭出"看着像样"的 UI。选择: - Material UI / Chakra:大、深度定制难、bundle 大 - Ant Design:大且企业风、设计语言强 - Headless UI / Radix:无样式、要自己写 CSS shadcn/ui 是 2023 后流行的新范式:**不是 npm 包**,而是 "复制粘贴 组件源码进你的项目"。底层用 Radix Primitives 做 a11y / 行为, Tailwind 做样式。你拥有源码,随便改。 ## 解决方案 ### 装 前提:React + Tailwind CSS 项目。 ```bash npx shadcn-ui@latest init # 交互式问几个问题: # Style: default / new-york # Base color: slate / gray / zinc / ... # CSS variables: yes ``` 生成 `components.json` 配置。 ### 加组件 ```bash npx shadcn-ui@latest add button npx shadcn-ui@latest add dialog npx shadcn-ui@latest add toast ``` 每个组件作为 `.tsx` 文件被复制到 `src/components/ui/`: ``` src/components/ui/ ├── button.tsx ├── dialog.tsx ├── toast.tsx └── ... ``` 打开 `button.tsx`: ```tsx import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( "inline-flex items-center justify-center rounded-md text-sm font-medium ...", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: "border border-input bg-background hover:bg-accent", ghost: "hover:bg-accent", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-10 px-4 py-2", sm: "h-9 rounded-md px-3", lg: "h-11 rounded-md px-8", icon: "h-10 w-10", }, }, defaultVariants: { variant: "default", size: "default" }, } ) export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> { asChild?: boolean } const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button" return ( <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} /> ) } ) Button.displayName = "Button" export { Button, buttonVariants } ``` 这是**你**的代码。要加一个 `success` variant 直接改就行: ```tsx variant: { default: "...", success: "bg-green-600 text-white hover:bg-green-700", ... }, ``` 不像传统组件库要 fork 整个 repo 或者写 wrap。 ### 用 ```tsx import { Button } from '@/components/ui/button' <Button>Click me</Button> <Button variant="outline" size="lg">Outline</Button> <Button variant="success">Saved</Button> ``` ### Dialog 例 ```tsx import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' <Dialog> <DialogTrigger asChild> <Button variant="destructive">删除</Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>确认删除</DialogTitle> </DialogHeader> <p>真的要删除这条记录吗?</p> <DialogFooter> <Button variant="outline">取消</Button> <Button variant="destructive">确认</Button> </DialogFooter> </DialogContent> </Dialog> ``` 底层 Radix Dialog 已经处理好:focus trap、ESC 关闭、aria-modal、 overlay 点击关闭、滚动锁等所有 a11y 细节。 ### 配色:CSS variables `globals.css`: ```css @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; /* ... */ } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; /* ... */ } } ``` 改这些变量 → 整个组件库换色。dark mode `<html class="dark">` 自动切。 ### theming:从 [ui.shadcn.com/themes](https://ui.shadcn.com/themes) 抄 shadcn 官网有 themes 页面,挑色调 + 复制 CSS 变量 → 替换你的 globals.css。 3 秒换主题。 ### 组件清单 shadcn 现有 50+ 组件: - Button / Input / Label / Textarea / Select / Switch / Checkbox / Radio - Dialog / Sheet / Popover / Tooltip / HoverCard / ContextMenu / DropdownMenu / Menubar / NavigationMenu / Command (cmd-k palette) - Card / Badge / Avatar / Separator / Skeleton / Progress - Toast / Alert / AlertDialog - Table / DataTable - Tabs / Accordion / Collapsible - Calendar / DatePicker - Form (react-hook-form 集成) - Carousel / Resizable / Drawer 涵盖 95% web app 需求。 ### 数据表 + sorting + filter(DataTable) ```tsx import { useReactTable } from '@tanstack/react-table' // shadcn 用 TanStack Table 做底层 const table = useReactTable({ data: users, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), }) // 渲染部分 shadcn 提供的 Table 组件包 ``` 复杂表格 < 100 行 + 完全可定制。 ## 与传统组件库对比 | | shadcn/ui | MUI v5 | Ant Design | |---|---|---|---| | 引入 | 复制源码 | npm i | npm i | | 改样式 | 直接改 .tsx | sx prop / theme override | 复杂 token | | Bundle | 仅用到的组件 | 大(tree-shaking 不彻底) | 极大 | | Tailwind | ✅ 原生 | 需适配 | 需适配 | | 升级 | 自己控制 | 升级有 breaking risk | 升级有 breaking risk | | 设计风格 | 默认 minimal | Material | 企业风 | shadcn 的"复制源码"哲学是关键差异: - **优点**:完全掌控、零依赖、bundle 最小、修改简单 - **缺点**:组件库更新不会自动应用(要重新 `add` 覆盖) ## 效果 我们一个内部工具用 shadcn 重做,对比之前的 Material UI 版: - bundle 从 850 KB → 280 KB - 设计师对样式调整一句话就能改(不需要"懂 MUI theme") - 暗色模式切换 0 配置 - 新人 onboarding 时间减半(组件就在自己代码里,IDE 跳定义直接看) ## 与同生态组合 - **next-themes**:dark mode 切换 - **react-hook-form** + **zod**:表单(shadcn Form 组件直接集成) - **TanStack Query**:数据 - **TanStack Table**:表格 - **lucide-react**:icon - **sonner**:toast 替代品(更现代) 一套组合下来基本 cover 现代 React app 全部需求。 ## 踩过的坑 1. **第一次 init 必须有 Tailwind**:shadcn 强依赖 Tailwind utility classes。 非 Tailwind 项目要么先装 Tailwind,要么用 [tremor](https://tremor.so/) 等替代。 2. **`npx shadcn-ui add` 覆盖修改**:再次 add 同名组件会覆盖你改过的 版本。要么不再 add(自己维护),要么 git 改动检查。 3. **`asChild` 模式坑**:`<Button asChild><a href="/">link</a></Button>` 把样式应用到 child 上。新人不知道这个 pattern 时疑惑。 Radix Slot 的设计,多看几次就熟。 4. **CSS 变量值是 HSL 三段值不是颜色字符串**:`--primary: 220 90% 56%` 不是 `--primary: hsl(220, 90%, 56%)`。tailwind config 用 `hsl(var(--primary) / <alpha-value>)` 拼。 5. **monorepo 共享 components/ui**:复制到每个 app 都改 = 重复劳动。 抽到 `packages/ui` 共享,但 shadcn 自动 add 路径不知道。 手动维护 import 路径或写自己的 CLI wrapper。
## 起因 新人 React 开发普遍熟"useState / useEffect / useMemo / useCallback" 名字 但用法常踩坑。下面是我帮 review code 时反复指出的 6 个点。 ## 1. Hooks 只能在 React 函数 top-level 调 ```tsx // ❌ function MyComp({ user }) { if (user) { const [name, setName] = useState(user.name) // 错!hook 在 if 里 } return ... } // ✅ function MyComp({ user }) { const [name, setName] = useState(user?.name ?? '') // hook 在 top level,逻辑在 hook 之后 ... } ``` 为什么:React 用 hook 调用**顺序** 来匹配 state。条件调用 → 顺序变 → state 错位。 eslint-plugin-react-hooks 的 `rules-of-hooks` 规则自动检查。 **必须开**。 ## 2. useEffect 不是 "componentDidMount" 新人常用法: ```tsx useEffect(() => { fetchData().then(setData) }, []) // 类似 componentDidMount ``` 问题: - React Strict Mode 在 dev 双调用 → effect 跑两次 - 切 prop 不重 fetch - cleanup 经常漏写 更深的问题:**useEffect 是同步外部系统**,不是 lifecycle hook。 ```tsx // useEffect 真正的用途 useEffect(() => { // 同步外部:subscribe DOM event / WebSocket / 第三方库 const sub = thirdPartyLib.subscribe(handler) return () => sub.unsubscribe() // cleanup 必须 }, [handler]) ``` 数据获取应该用 React Query / SWR / RSC,不是 useEffect。 事件订阅 / 第三方库初始化才用 useEffect。 ## 3. dependency array 不能撒谎 ```tsx function MyComp({ userId }) { useEffect(() => { fetchUser(userId).then(setUser) }, []) // ❌ 用到 userId 但没写依赖 → 切 userId 不 refetch } ``` eslint `exhaustive-deps` 规则强制: ```tsx useEffect(() => { fetchUser(userId).then(setUser) }, [userId]) // ✅ ``` 不要为了"骗" linter 写 `// eslint-disable-next-line`。 真要省 refetch 改其它策略(debounce / 单独 ref)。 ## 4. 不要在 useEffect 里 setState 然后依赖那个 state ```tsx function MyComp() { const [count, setCount] = useState(0) useEffect(() => { setCount(c => c + 1) // ❌ 触发 effect 重跑 → setState → 无限循环 }, [count]) } ``` 修正取决于意图: ```tsx // 只初始化一次 useEffect(() => { setCount(initialCount) }, []) // 但更好的是用 useState 初值 // 派生 state 用 useMemo 或直接计算 const doubled = count * 2 // 不需要 state ``` 90% "我有个 state 依赖另一个 state" 都该用 useMemo / 直接计算。 ## 5. useMemo / useCallback:默认不要用 ```tsx function MyComp({ items }) { const total = useMemo(() => items.reduce((s, i) => s + i.price, 0), [items]) return <span>{total}</span> } ``` `reduce` 100 个 item 极快(毫秒级)。 `useMemo` 本身的 hook 开销 + 依赖比较 > 计算成本。 **实际更慢**。 useMemo 真的有用的时候: - 计算确实贵(profile 显示 > 16ms) - 结果作为 React.memo 子组件的 prop - 结果作为另一个 useEffect 的依赖(保持稳定) ```tsx // ✅ 派生值传给 memoized 子组件 const filtered = useMemo(() => items.filter(complex), [items, filter]) return <MemoedList items={filtered} /> // ✅ 给 useEffect 稳定依赖 const config = useMemo(() => ({ url, timeout }), [url, timeout]) useEffect(() => { subscribe(config) }, [config]) ``` 不传 React.memo / 不进 useEffect deps,useMemo 多余。 React 19 + React Compiler 会自动决定哪些 memo,写代码时不用关心。 ## 6. setState 是异步的,但批量也异步 ```tsx const [count, setCount] = useState(0) function handleClick() { setCount(count + 1) setCount(count + 1) setCount(count + 1) // count 只 +1,不是 +3 console.log(count) // 仍是 0(这次 render 的 count) } ``` 原因: 1. React 同一 event 内 batch setState(多次合并成一次 render) 2. `count` 是闭包捕获的旧值 修正:函数式 setState ```tsx function handleClick() { setCount(c => c + 1) setCount(c => c + 1) setCount(c => c + 1) // count +3 } ``` `c => c + 1` 每次拿到最新 state。 读最新值(罕见用例): ```tsx function handleClick() { setCount(c => c + 1) // 这一行后 count 仍是旧值(要等下次 render) // 要立刻拿新值用 ref } ``` 要立刻执行更新后效果用 `flushSync`(React 18+): ```tsx import { flushSync } from 'react-dom' flushSync(() => { setCount(c => c + 1) }) // 这里 DOM 已经更新到新 count ``` 性能差,少用。 ## 7. ref vs state ```tsx // state:用作 UI 输出 const [count, setCount] = useState(0) // ref:用作"persistence 但不触发 render" const timerRef = useRef<NodeJS.Timer | null>(null) const renderCountRef = useRef(0) ``` ```tsx useEffect(() => { timerRef.current = setInterval(...) return () => clearInterval(timerRef.current) }, []) ``` 何时用 ref: - DOM ref(input focus) - 长生命周期 mutable(timer / WebSocket / observer) - 计数器 / debounce token(不需要 re-render) 何时用 state: - 视图反映的数据 经验:**只要 UI 不看这个值就用 ref**。 ## 8. custom hook:纯函数 + 命名 use 开头 ```tsx function useDebounce<T>(value: T, delay: number): T { const [debounced, setDebounced] = useState(value) useEffect(() => { const t = setTimeout(() => setDebounced(value), delay) return () => clearTimeout(t) }, [value, delay]) return debounced } // 用 function Search() { const [input, setInput] = useState('') const debouncedInput = useDebounce(input, 500) useEffect(() => { search(debouncedInput) }, [debouncedInput]) } ``` custom hook 内部调其它 hook → 受 hook rules 约束。 命名必须 `use` 开头 linter 才识别 + 启用规则检查。 ## 9. 控制 vs 非控制 input ```tsx // 控制(推荐):state 是真相 const [val, setVal] = useState('') <input value={val} onChange={e => setVal(e.target.value)} /> // 非控制:DOM 是真相 const ref = useRef<HTMLInputElement>(null) <input defaultValue="hello" ref={ref} /> const value = ref.current?.value ``` 控制:每次 input 触发 re-render(小开销,大表单累积)。 非控制:性能好但 React 不知道 value,复杂表单要混用。 react-hook-form 用非控制 + ref 拿值,避开 re-render,是高性能表单 首选。 ## 10. 经典反模式:把 hook 当 class state ```tsx // ❌ 一堆 useState const [name, setName] = useState('') const [email, setEmail] = useState('') const [phone, setPhone] = useState('') const [age, setAge] = useState(0) ``` 或者: ```tsx // ❌ 一个 object state(更新很麻烦) const [form, setForm] = useState({ name: '', email: '', phone: '', age: 0 }) setForm({ ...form, name: 'Alice' }) // 容易漏字段 ``` 用 useReducer 或外部 state(Zustand / Jotai / react-hook-form): ```tsx const [form, dispatch] = useReducer(formReducer, initialForm) ``` 复杂状态用专门工具。 ## 调试 hooks React DevTools → Components → 选组件 → Hooks 一栏。 能看到每个 hook 当前值。注意 useReducer / useMemo 显示有限。 ## 总结 | 反模式 | 该做的 | |---|---| | useEffect 做 fetch | React Query / SWR / RSC | | useEffect 依赖 state 触发更新 | 直接派生 / useMemo | | 无脑 useMemo / useCallback | 默认不用,profile 后按需 | | dependency array 写 [] 撒谎 | 实事求是 + lint | | 闭包 setState 用旧值 | `setX(prev => ...)` | | 一堆 useState 平铺 | useReducer / 第三方 store | | 在 if / loop 调 hook | 顶层调用,逻辑在内部分支 | 熟悉这些后写 React 体感顺很多 + bug 少很多。 ## 踩过的坑 1. **strict mode 双调 effect**:dev 模式 effect 跑两次。cleanup 必须 做对,否则 subscribe / setTimeout 等"两个跑半个" 状态。 2. **useEffect 跑两次拉两次 API**:dev 干扰开发体验。 `<StrictMode>` 内层包不要去 / 或者用 React Query 等自动 dedup。 3. **dep 数组的 object / function 每次新引用**: ```tsx useEffect(..., [{ x: 1 }]) // 每次都"新对象" ``` 触发 effect 每次都跑。用 stable ref 或 useMemo。 4. **custom hook 名没 use 开头**:lint 不识别 → 规则不生效 → 隐藏 bug。 5. **React 19 / Compiler 期望写法**:未来 useMemo / useCallback 大多 不需要。养成"先简洁后优化" 的习惯。
CSS transition / keyframes 能解决简单动画,复杂的(列表重排、组件进入/离开、 gesture、SVG morph)就力不从心。Framer Motion 是 React 生态的事实标准动画库, API 简洁,自动 GPU 加速。 ## 安装 ```bash npm i framer-motion ``` ## 1. 最简单的进入动画 ```tsx import { motion } from 'framer-motion' <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }} > Hello </motion.div> ``` 任何 HTML 元素加 `motion.` 前缀即可。 ## 2. 进入 + 离开(AnimatePresence) ```tsx import { motion, AnimatePresence } from 'framer-motion' <AnimatePresence> {visible && ( <motion.div key="modal" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} > Modal </motion.div> )} </AnimatePresence> ``` `AnimatePresence` 在子元素被 React 卸载时延迟卸载,让 `exit` 动画完成。 必须给每个直接子元素显式 `key`。 ## 3. 列表动画(重排 / 添加 / 删除) ```tsx <AnimatePresence> {items.map(item => ( <motion.li key={item.id} layout // 自动 FLIP 动画 initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 20 }} > {item.text} </motion.li> ))} </AnimatePresence> ``` `layout` 让元素位置变化时自动 morph(FLIP 算法)。 增删 / 排序列表时无需手动算位移。 ## 4. variants:复用动画状态 ```tsx const variants = { hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, y: 0 }, } <motion.div variants={variants} initial="hidden" animate="visible"> ... </motion.div> // 父子联动 const parent = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.1 } }, } const child = { hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, y: 0 }, } <motion.ul variants={parent} initial="hidden" animate="visible"> {items.map(i => <motion.li key={i.id} variants={child}>{i.text}</motion.li>)} </motion.ul> ``` `staggerChildren: 0.1` 让子元素一个接一个延迟 0.1s 进入,列表波浪效果。 ## 5. 手势:hover / tap / drag ```tsx <motion.button whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} > Click </motion.button> <motion.div drag dragConstraints={{ left: -100, right: 100, top: 0, bottom: 0 }} dragElastic={0.5} whileDrag={{ scale: 1.1 }} > 拖我 </motion.div> ``` `drag` 自动处理触摸 / 鼠标拖动。`dragConstraints` 限制范围。 ## 6. transition 选项 ```tsx <motion.div animate={{ x: 100 }} transition={{ duration: 0.5, ease: 'easeOut', // 'linear' | 'easeIn' | 'easeInOut' | 'circIn' | ... // 或弹簧物理 type: 'spring', stiffness: 100, damping: 10, }} /> ``` 弹簧(spring)参数比 duration 更自然,是 Framer Motion 默认。 ## 7. scroll-triggered 动画 ```tsx import { motion, useScroll, useTransform } from 'framer-motion' function Hero() { const { scrollYProgress } = useScroll() const opacity = useTransform(scrollYProgress, [0, 0.5], [1, 0]) const y = useTransform(scrollYProgress, [0, 1], [0, -200]) return <motion.h1 style={{ opacity, y }}>Title</motion.h1> } ``` `useTransform` 把 0-1 的 scrollYProgress 映射到任意值范围,做视差效果。 ## 8. layout 高级:跨组件共享 ```tsx import { motion } from 'framer-motion' // 小卡片 <motion.div layoutId="card-1" onClick={open}> <img src="thumb.jpg" /> </motion.div> // 弹出全屏 {open && ( <motion.div layoutId="card-1" className="fullscreen"> <img src="full.jpg" /> </motion.div> )} ``` 两个不同位置的元素 `layoutId` 相同 → Framer 自动 morph 从一个位置到另一个。 "卡片展开"效果一行写完。 ## 9. SVG morph ```tsx <motion.path d={pathData} initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 2 }} /> ``` `pathLength: 0 → 1` 让 SVG 路径"画出来"效果。 ## 10. 性能注意 Framer Motion 默认用 transform / opacity(GPU 友好)。不要动 width / height / top / left(CPU 重排)。 ```tsx // ❌ width 变化触发 layout <motion.div animate={{ width: 200 }} /> // ✅ transform scale <motion.div animate={{ scaleX: 2 }} /> ``` ## 11. 与 Tailwind / CSS-in-JS `motion.div` 接受所有 HTML props,class / style 正常写: ```tsx <motion.div className="bg-blue-500 rounded p-4" whileHover={{ scale: 1.05 }} /> ``` ## 12. 替代方案 - **react-spring**:物理为主,API 不同 - **gsap**:传统 JS 动画,强大但非 React-first - **Motion One**:framer-motion 作者的"无 React 依赖"版 React 项目就用 Framer Motion,最省事。 ## 踩过的坑 - AnimatePresence 子元素必须有 `key`:忘了 key 看不到 exit 动画。 - 直接给 `<div>` 加 motion 属性不工作:必须用 `motion.div`。 - 大量元素同时 layout 动画 → 卡。`layout` 只给真正需要 morph 的元素加。 - prefers-reduced-motion:尊重用户系统设置,给敏感动画加: ```tsx const reduce = useReducedMotion() <motion.div animate={{ x: reduce ? 0 : 100 }} /> ```
## 起因 要做一个 a11y 合规的 dropdown menu:键盘 navigation / focus trap / ARIA roles / Escape 关闭 / 点外面关闭 / 上下方向键循环 / Home/End 键 跳第一项末项 / 输入字符直接跳到对应项 / ... 每个细节单独写都 30-50 行 JS。完全无 bug 实施一个 dropdown 要 1 天。 不抽象怎么办——5 个组件就要 1 周。 Radix UI Primitives 把这些"a11y 行为正确" 的组件做成了 React component, **不带样式**,你用 Tailwind / CSS / 任何 styling 方案套外观。 ## 装 ```bash npm i @radix-ui/react-dropdown-menu npm i @radix-ui/react-dialog npm i @radix-ui/react-tooltip npm i @radix-ui/react-popover # 每个组件独立 package,按需装 ``` 或者一次性装常用的: ```bash npm i @radix-ui/themes # 带默认主题的全套 ``` ## Dropdown Menu 例子 ```tsx import * as DropdownMenu from '@radix-ui/react-dropdown-menu' function UserMenu() { return ( <DropdownMenu.Root> <DropdownMenu.Trigger asChild> <button>菜单 ↓</button> </DropdownMenu.Trigger> <DropdownMenu.Portal> <DropdownMenu.Content className="bg-white shadow-lg rounded-md p-1 min-w-[180px]" sideOffset={4} > <DropdownMenu.Item className="px-3 py-2 rounded hover:bg-gray-100 outline-none cursor-pointer" onSelect={() => navigate('/profile')} > 个人主页 </DropdownMenu.Item> <DropdownMenu.Item className="px-3 py-2 rounded hover:bg-gray-100" onSelect={() => navigate('/settings')} > 设置 </DropdownMenu.Item> <DropdownMenu.Separator className="h-px bg-gray-200 my-1" /> <DropdownMenu.Item className="px-3 py-2 rounded hover:bg-red-50 text-red-600" onSelect={logout} > 退出登录 </DropdownMenu.Item> </DropdownMenu.Content> </DropdownMenu.Portal> </DropdownMenu.Root> ) } ``` **没写任何 keyboard handler / focus trap / ARIA**,但 Radix 内部 全做好了: - ↑↓ 切换项目 - Home / End 跳首末 - Enter 选中 - Escape 关闭 - 点外面关闭 - 输入字符跳到对应项 - aria-expanded / aria-haspopup / aria-orientation 等 ARIA 全配 - focus 进入 / 离开管理 - screen reader 报"opened menu, 3 items, item 1 of 3" 业务代码只关心"我有哪些菜单项 + 点击做什么"。 ## Dialog 例子(合规 modal) ```tsx import * as Dialog from '@radix-ui/react-dialog' function DeleteConfirm({ onDelete }) { return ( <Dialog.Root> <Dialog.Trigger asChild> <button className="btn-danger">删除</button> </Dialog.Trigger> <Dialog.Portal> <Dialog.Overlay className="fixed inset-0 bg-black/50" /> <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg max-w-md"> <Dialog.Title className="text-lg font-bold">确认删除</Dialog.Title> <Dialog.Description className="text-sm text-gray-500 mt-1"> 此操作不可撤销。 </Dialog.Description> <div className="mt-4 flex gap-2 justify-end"> <Dialog.Close asChild> <button>取消</button> </Dialog.Close> <button onClick={onDelete} className="btn-danger">删除</button> </div> </Dialog.Content> </Dialog.Portal> </Dialog.Root> ) } ``` 内置: - focus trap(Tab 不出对话框) - ESC 关闭 - 点 overlay 关闭 - 自动 focus 第一可聚焦元素 - 关闭后焦点回到 trigger - 背景 inert / aria-hidden 屏蔽 - title / description 自动关联到对话框 aria-labelledby / aria-describedby 合规模态 5 行配置。 ## Tooltip / Popover ```tsx import * as Tooltip from '@radix-ui/react-tooltip' <Tooltip.Provider delayDuration={200}> <Tooltip.Root> <Tooltip.Trigger asChild> <button>?</button> </Tooltip.Trigger> <Tooltip.Portal> <Tooltip.Content className="bg-black text-white p-2 rounded text-sm" sideOffset={4}> 这是一段提示 <Tooltip.Arrow className="fill-black" /> </Tooltip.Content> </Tooltip.Portal> </Tooltip.Root> </Tooltip.Provider> ``` Tooltip 之类需要全局 Provider 控制 hover delay 一致。 ## Accordion ```tsx import * as Accordion from '@radix-ui/react-accordion' <Accordion.Root type="single" collapsible className="w-[400px]"> <Accordion.Item value="item-1"> <Accordion.Header> <Accordion.Trigger className="w-full text-left"> 什么是 React Hooks? </Accordion.Trigger> </Accordion.Header> <Accordion.Content> Hooks 是 React 16.8 引入的特性... </Accordion.Content> </Accordion.Item> <Accordion.Item value="item-2"> ... </Accordion.Item> </Accordion.Root> ``` `type='single'` 同时只展开一个;`type='multiple'` 多个。 键盘 Tab / Space / Enter 展开收起,箭头切项。 ## Switch / Checkbox / RadioGroup ```tsx import * as Switch from '@radix-ui/react-switch' <Switch.Root className="w-11 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-blue-500" checked={isDark} onCheckedChange={setIsDark} > <Switch.Thumb className="block w-5 h-5 bg-white rounded-full shadow translate-x-0.5 data-[state=checked]:translate-x-5 transition" /> </Switch.Root> ``` 完整 keyboard support + ARIA + 适合 screen reader 的 label 关联。 ## asChild 模式 很多 Radix 组件支持 `asChild`: ```tsx <DropdownMenu.Trigger asChild> <button>Click</button> {/* 把 trigger 的行为应用到这个 button */} </DropdownMenu.Trigger> // vs 默认(包一层 button) <DropdownMenu.Trigger>Click</DropdownMenu.Trigger> ``` `asChild` 让你用自己的元素(甚至自定义 styled component)作为 trigger, 不强制额外 DOM。React Aria / Headless UI 也类似。 ## 跟 shadcn/ui 关系 shadcn/ui = "Radix Primitives + Tailwind 样式 + 复制粘贴" 的预设。 直接: ```bash npx shadcn-ui@latest add dropdown-menu dialog tooltip ``` 代码进你的 `components/ui/`,底层就是 Radix。 省了"自己包样式" 这一步。 ## 跟 Headless UI / React Aria 对比 | | Radix UI | Headless UI | React Aria (Adobe) | |---|---|---|---| | 风格 | 较细粒度 primitive | 简洁 | 极完整(也最重) | | 组件数 | ~30 | 10+ | ~50 | | Tailwind 友好 | ✅ | ✅(同公司) | 中性 | | 学习曲线 | 中 | 低 | 中-高 | | a11y 严谨度 | 高 | 高 | 极高 | | bundle | 中(按需) | 小 | 中-大 | shadcn 默认用 Radix;个人偏好用 Headless UI(Tailwind 同公司)也很 好。 ## 实际效果 我们一个 React app 把所有 dialog / dropdown / popover / tooltip 改 Radix: - 之前自己实现 + 半成品 → bug 多 + 不一致 - 改 Radix 后 a11y 自动达标(axe-core lint 0 violation) - bundle 增加 ~30KB(5-6 个 primitive) - 团队 review focus 从"键盘怎么操作" → 只看业务逻辑 ## 几个建议 1. **不要从 Radix 自己包 100 个 wrapper**:用 shadcn 把 Radix + Tailwind 集成代码丢进自己 repo 直接改更灵活 2. **状态管理交给 Radix**:除非需要受控 (controlled mode),让 Radix 内部管 `open` state 3. **portal 默认开**:菜单 / dialog 进 portal 避免 overflow / z-index 父元素影响 4. **className 接受**:所有 primitive 都接 className + style, 尽情用 Tailwind / CSS Modules ## 踩过的坑 1. **DropdownMenu 不能 nested**:嵌套菜单要用 `DropdownMenu.Sub`, 不是再嵌一个 Root。 2. **Dialog 内嵌 Tooltip 不显示**:z-index / portal 父子关系问题。 Tooltip 也 portal 出去。 3. **`asChild` + Link**:React Router `<Link>` 已经是 `<a>`, 配 `asChild` 时不要重复包 `<a>`。 4. **server side rendering**:Radix 用 useId / 假 portal,SSR 时 小心 hydration mismatch。包 dynamic import / suppress hydration warning。 5. **bundle 一个 component 拉一堆 dep**:每个 Radix component 拉 `@radix-ui/react-primitive` 等 shared util。多组件用同样 base tree-shake 后实际不重复,但单独看 size 比想象大。
## 起因 要把一个英文站点国际化到中文 + 日文。 "naive"做法:`if (lang === 'zh') return '你有 ' + n + ' 条消息'`。 但碰到: - 复数:英文 "1 message" vs "5 messages";俄文 4 种复数形式 - 日期:1/2/2024 vs 2024-01-02 vs 2024年1月2日 - 货币:$1,234.56 vs ¥12,345.6 - 嵌入组件:`'<b>张三</b> 发了 <a href=...>3 条评论</a>'` 怎么拆? ICU MessageFormat 是 Unicode 联盟标准,处理这些场景。`react-intl` (FormatJS)是 React 的标准 ICU 实现。 ## 解决方案 ### 装 ```bash npm i react-intl ``` ### 顶层 Provider ```tsx import { IntlProvider } from 'react-intl' const messages = { en: { 'msg.greeting': 'Hello, {name}!' }, zh: { 'msg.greeting': '你好,{name}!' }, ja: { 'msg.greeting': '{name}さん、こんにちは!' }, } function App() { const [locale, setLocale] = useState('en') return ( <IntlProvider locale={locale} messages={messages[locale]}> <MyApp /> </IntlProvider> ) } ``` ### 用 `<FormattedMessage>` 或 `intl.formatMessage` ```tsx import { FormattedMessage, useIntl } from 'react-intl' function Greeting({ user }) { return ( <h1> <FormattedMessage id="msg.greeting" values={{ name: user.name }} /> </h1> ) } // 或 hook function Title({ name }) { const intl = useIntl() const text = intl.formatMessage({ id: 'msg.greeting' }, { name }) return <title>{text}</title> } ``` ### ICU MessageFormat 复数 ```ts const messages = { en: { 'comments.count': '{count, plural, =0 {No comments} one {1 comment} other {# comments}}' }, zh: { 'comments.count': '{count, plural, =0 {暂无评论} other {# 条评论}}' }, ru: { 'comments.count': '{count, plural, =0 {Нет комментариев} one {# комментарий} few {# комментария} many {# комментариев} other {# комментариев}}' }, } <FormattedMessage id="comments.count" values={{ count: 5 }} /> ``` `#` 自动替换成数字。 不同语言的复数规则: - 英文:one (1) / other (其它) - 中文:other - 俄文:one / few / many / other - 阿拉伯:zero / one / two / few / many / other 库里有 CLDR 数据自动应用。 ### Select(按值选不同文案) ```ts 'user.role': '{role, select, admin {管理员} editor {编辑} viewer {访客} other {未知}}' ``` ### 嵌入 React 组件 ```tsx const messages = { zh: { 'notif.commented': '<b>{user}</b> 评论了你的 <a>帖子</a>', }, } <FormattedMessage id="notif.commented" values={{ user: 'Alice', b: chunks => <strong>{chunks}</strong>, a: chunks => <a href={`/posts/${id}`}>{chunks}</a>, }} /> // 输出:<strong>Alice</strong> 评论了你的 <a href="/posts/1">帖子</a> ``` 翻译里的标签会调对应 function。完全保留 React 组件 + 事件 + 链接。 ### 日期 / 时间 / 货币 ```tsx import { FormattedDate, FormattedTime, FormattedNumber } from 'react-intl' <FormattedDate value={new Date()} year="numeric" month="long" day="numeric" /> // en: January 15, 2024 // zh: 2024年1月15日 // ja: 2024年1月15日 <FormattedTime value={new Date()} hour="numeric" minute="numeric" /> <FormattedNumber value={1234.5} style="currency" currency="USD" /> // $1,234.50 <FormattedNumber value={1234.5} style="currency" currency="CNY" /> // ¥1,234.50 <FormattedNumber value={0.85} style="percent" /> // 85% ``` 底层 `Intl.DateTimeFormat` / `Intl.NumberFormat`,浏览器原生。 ### 相对时间 ```tsx import { FormattedRelativeTime } from 'react-intl' <FormattedRelativeTime value={-2} unit="hour" /> // en: 2 hours ago // zh: 2 小时前 ``` ### 翻译文件管理 每个 locale 一个 JSON: ``` src/locales/ ├── en.json ├── zh.json └── ja.json ``` ```json // en.json { "msg.greeting": "Hello, {name}!", "comments.count": "{count, plural, =0 {No comments} one {1 comment} other {# comments}}" } ``` 按需加载: ```tsx async function loadMessages(locale: string) { return (await import(`./locales/${locale}.json`)).default } function App() { const [locale, setLocale] = useState('en') const [messages, setMessages] = useState({}) useEffect(() => { loadMessages(locale).then(setMessages) }, [locale]) return <IntlProvider locale={locale} messages={messages}>...</IntlProvider> } ``` 不同语言不打进 main bundle,按需 lazy load。 ### 提取翻译 key ```bash npx @formatjs/cli extract 'src/**/*.{ts,tsx}' --out-file lang/en.json --format simple ``` 扫描代码里所有 `FormattedMessage` / `formatMessage` 调用,提取 key + defaultMessage。翻译团队基于这个文件翻译。 ### 用 Crowdin / Lokalise / POEditor 把 en.json 上传 → 译员翻译 → 下载 zh.json / ja.json。 专业翻译工具有翻译记忆 / 协作 / 校对功能。 ### TypeScript 类型安全 ```bash npm i -D @formatjs/cli ``` ```ts // auto-generated.ts type Messages = | 'msg.greeting' | 'comments.count' // ... declare module 'react-intl' { interface FormattedMessageProps { id: Messages } } ``` 写错 id 编译报错。 ## 实战 tip ### 1. defaultMessage ```tsx <FormattedMessage id="msg.greeting" defaultMessage="Hello, {name}!" values={{ name }} /> ``` `defaultMessage` 在 dev 时显示(避免 key 缺失看到 `[msg.greeting]`), 在 extract 时被收进 source 文件。 ### 2. 不要 concat 字符串 ```tsx // ❌ 永远不要 <>{intl.formatMessage({ id: 'a' })} {intl.formatMessage({ id: 'b' })}</> // ✅ 一条 message 一句话 'msg.fullName': '{first} {last}' // 在 message 里组合 ``` 某些语言(日文)语序不一样,concat 永远不能正确翻译。 ### 3. 上下文 (context) 同样英文 "Open" 可能是按钮 / 状态 / 动词,翻译不同: ```json "button.open": "Open", "status.open": "Open", ``` 加注释让译员理解: ```tsx <FormattedMessage id="button.open" description="Button to open a file picker" defaultMessage="Open" /> ``` extract 出来 description 给译员看。 ## 效果 - 3 种语言(en/zh/ja)全覆盖 + 复数 + 日期 + 货币正确 - 翻译团队用 Crowdin 协作,开发不参与翻译细节 - bundle 按 locale 分割,每个 locale 增加 ~20KB - 加新语言只需添 JSON + locale 列表 + 设置 default 即可 ## 替代品 - **i18next**(react-i18next):更老牌、生态大、社区资源多 - **lingui**:Babel macro 转译,运行时几乎无开销 - **next-intl**:Next.js App Router 原生集成 i18next 与 react-intl 哪个好争论不断。我选 react-intl 因为 ICU 标准 + FormatJS 团队(Intl 提案推动者)维护。 ## 踩过的坑 1. **复数规则用错语法**:英文写 `{count} comments` 而不是 ICU `{count, plural, ...}`。后者才能让翻译灵活。 2. **直接 hard-code 日期格式**:`new Date().toLocaleDateString('en')` 只能英文。永远 `FormattedDate` 才跟随当前 locale。 3. **RTL 语言(阿拉伯 / 希伯来)布局**:仅翻字符串不够,CSS 也要 适配。`dir="rtl"` + `logical properties`(margin-inline-start 而非 margin-left)。 4. **lazy load 时第一次渲染缺译文**:fallback 显示 `[msg.greeting]` 是糟糕体验。加载完成前用 default locale 兜底,或显示骨架。 5. **翻译里有 HTML 注入风险**:译员误打了 `<script>` 进去会被解析。 ICU `<tag>` 必须在代码里映射;不在映射里的会被原样输出(HTML 实体 escape)。
## 起因 传统 SSR(getServerSideProps): 1. 服务端拉所有数据 2. render 整个 HTML 3. 发给浏览器 慢的部分(如调 5 个 API)阻塞整个 response。用户看白屏几秒。 Streaming SSR 让服务端**边 render 边 flush**:先把 layout / shell 推 出去,慢部分用 `<Suspense fallback>` 标记 → 浏览器先显示 fallback → 慢部分 ready 后追加 HTML 替换 fallback。 React 18 引入,19 完善。Next.js App Router 默认就是 streaming。 ## 解决方案 ### Next.js 14+ App Router ```tsx // app/posts/[id]/page.tsx import { Suspense } from 'react' async function fetchPost(id) { await new Promise(r => setTimeout(r, 200)) // 200ms return { title: '...', body: '...' } } async function fetchRelated(id) { await new Promise(r => setTimeout(r, 2000)) // 2s(慢) return [{ ... }] } async function fetchComments(id) { await new Promise(r => setTimeout(r, 1500)) // 1.5s return [{ ... }] } export default async function Page({ params }: ...) { const post = await fetchPost(params.id) // 必须先等这个 return ( <article> <h1>{post.title}</h1> <p>{post.body}</p> <Suspense fallback={<div>loading related...</div>}> <Related id={params.id} /> </Suspense> <Suspense fallback={<div>loading comments...</div>}> <Comments id={params.id} /> </Suspense> </article> ) } async function Related({ id }) { const related = await fetchRelated(id) return <RelatedList items={related} /> } async function Comments({ id }) { const comments = await fetchComments(id) return <CommentList items={comments} /> } ``` 效果: - 200ms 后浏览器看到 article 标题 + body + 两个 "loading..." fallback - 1.5s 后 comments 替换 fallback - 2s 后 related 替换 fallback vs 传统 SSR 必须等 2s 才有任何内容。**首屏感知速度大幅提升**。 ## React 19 标准 API 不用 Next.js 也能 streaming SSR: ```tsx import { renderToReadableStream } from 'react-dom/server' import { Suspense } from 'react' async function handler(req) { const stream = await renderToReadableStream( <html> <body> <App /> </body> </html>, { bootstrapScripts: ['/main.js'], } ) return new Response(stream, { headers: { 'Content-Type': 'text/html' }, }) } ``` App 内 `<Suspense>` 包慢组件 → 自动 stream。 ## use() Hook(React 19) ```tsx import { use } from 'react' function Comments({ id }: { id: string }) { const comments = use(fetchComments(id)) // 直接调 promise! return <CommentList items={comments} /> } function Page({ params }) { return ( <Suspense fallback={<div>loading...</div>}> <Comments id={params.id} /> </Suspense> ) } ``` `use(promise)` 在 Suspense boundary 内 throw promise → React 等 resolve 后重渲。比 async 函数组件更简洁。 也能 use(context): ```tsx function Foo() { const theme = use(ThemeContext) // 替代 useContext return ... } ``` ## Partial pre-rendering(Next.js 14+ 实验性) 把"完全静态" + "完全动态" 之外加第三种:"骨架静态 + slot 动态": ```tsx export default function Page() { return ( <> <StaticHeader /> {/* build 时渲染,CDN cache */} <Suspense fallback={<Skeleton />}> <DynamicCart /> {/* 每请求 render */} </Suspense> </> ) } ``` CDN 立刻返回 static shell + skeleton,CDN 后端 stream 动态部分。 最优 LCP。 ## 与 client-side fetching 对比 ```tsx // 客户端 fetch(旧 SPA) function Comments({ id }) { const { data, isPending } = useQuery({ queryKey: ['comments', id], queryFn: () => fetchComments(id), }) if (isPending) return <Skeleton /> return <CommentList items={data} /> } ``` vs streaming SSR: | | client fetch | streaming SSR | |---|---|---| | TTFB | 极快(CDN) | 略慢(服务端等数据) | | FCP | 慢(要 JS hydrate + fetch) | 快(HTML 直接来) | | SEO | 差(爬虫不跑 JS) | 好 | | 客户端 bundle | 大 | 小(server component 不进 bundle) | | 复杂度 | 中 | 低(async function 直接写) | SEO / 首屏感知重 → streaming SSR。 交互重 / 后台 app → client fetch 仍合适。 ## 多服务串行 → 并行 ```tsx // ❌ 串行 async function Page() { const a = await fetchA() const b = await fetchB() const c = await fetchC() return <Component data={{ a, b, c }} /> } // 三个 fetch 加起来时间 // ✅ 并行 async function Page() { const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]) return <Component data={{ a, b, c }} /> } ``` 或者每个独立 Suspense 各自 stream: ```tsx function Page() { return ( <> <Suspense fallback={<...>}><A /></Suspense> <Suspense fallback={<...>}><B /></Suspense> <Suspense fallback={<...>}><C /></Suspense> </> ) } ``` 各自独立"快的先到"。 ## error boundary 跟 Suspense 配套 ```tsx <ErrorBoundary fallback={<Error />}> <Suspense fallback={<Loading />}> <SlowComponent /> </Suspense> </ErrorBoundary> ``` Suspense 处理 loading;ErrorBoundary 处理 throw。两者分工。 Next.js 14:每个文件夹放 `error.tsx`(自动 boundary)+ `loading.tsx` (自动 Suspense)。 ## 性能数据 我们一个商品详情页: | | TTFB | FCP | LCP | |---|---|---|---| | 传统 SSR | 2.4s | 2.4s | 2.4s | | Streaming + 3 Suspense | 200ms | 350ms | 1.8s | | + Partial pre-render | 50ms | 100ms | 1.6s | 用户感知差距巨大。Web Vitals 全绿。 ## 注意 / 限制 ### 1. async component 仅 server side ```tsx async function ServerComp() { // ✅ 只能在 server component 用 const data = await fetch(...) return <div>{data}</div> } ``` client component 要异步必须用 `use()`: ```tsx 'use client' function ClientComp({ promise }) { const data = use(promise) // ✅ return <div>{data}</div> } ``` ### 2. 静态 export 与 streaming 不兼容 `next export` 模式只有静态 HTML,没 server runtime,streaming 无意义。 ### 3. CDN cache streaming response 是 chunked transfer。Cloudflare 等 CDN 默认 **不缓存 chunked response**。要 cache 必须改回 fixed-length。 特性 trade-off。 ### 4. browser 历史问题 Safari 14 之前对 streaming HTML 表现不一致,第一屏可能闪。 现代浏览器都没事。 ## 调试 Network → response headers 看 `transfer-encoding: chunked` 确认 streaming 模式。 Chrome DevTools Performance → record load → 看 HTML chunks 到达 时间。 ## 与 Astro / Qwik 对比 Astro 默认是 server-render-with-islands(client 只 hydrate 交互 组件),Qwik 是 resumable(client 0 JS 启动),两者都侧重首屏速度。 React 19 streaming + RSC 是同方向但仍有 React runtime 开销。 极致 perf 试试 Qwik;React 生态成熟度 + RSC 仍是主流。 ## 踩过的坑 1. **顶层 await 阻塞 stream**:page.tsx 顶部 `await fetch(...)` 阻塞 所有 stream。把它移到子组件 + Suspense 才能 stream。 2. **Suspense 套了但没 fallback**:fallback 不会显示。永远写 `fallback={<X />}` 而非 `{...}`。 3. **client component import server lib**:bundle 暴大或运行时 error。 严格分 "use client" / server-only 边界。 4. **error boundary 在 Suspense 内** vs 外:内只接 stream 那段错; 外接 Suspense 自己 + 内部错。看意图分。 5. **dev 跟 prod 行为不同**:dev 没 cache + 较多 console,prod 体感 差异大。永远以 prod build 量真实体验。