Skip to content

Lesson 29:React 最佳实践与反模式 — 写出专业级代码 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 4(原理篇)
  • 推荐时长:120~180 分钟(首次学习)
  • 先修要求:至少完成一个 React 中型项目,具备 TS 与性能调优基础
  • 学习产出:形成组件、状态、性能与无障碍的评审清单,能结合代码解释改进理由
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 20% + 编码 60% + 复盘 20%。

🎯 本节目标:掌握 React 开发的工程最佳实践,学会识别和避免常见反模式,写出可维护、高性能、安全、无障碍的专业级代码。

📦 本节产出:一份随时可查阅的实战速查手册,涵盖组件设计、状态管理、性能优化、TypeScript、无障碍、安全和调试技巧。

本课多数代码块是独立的对照片段,不要把“改前/改后”或不同方案同时粘进一个文件。Hooks 片段放在组件或自定义 Hook 中,并补齐对应导入;省略的类型和业务组件沿用自己的项目。

一、组件设计最佳实践 ​

1.1 单一职责原则 ​

tsx
// ❌ 一个组件干了太多事
function ProductPage() {
  const [products, setProducts] = useState([])
  const [searchTerm, setSearchTerm] = useState('')
  const [cart, setCart] = useState([])
  const [isModalOpen, setIsModalOpen] = useState(false)
  // ... 200 行的巨型组件
}

// ✅ 拆分为职责清晰的小组件
function ProductPage() {
  return (
    <>
      <ProductSearch />
      <ProductGrid />
      <CartSummary />
    </>
  )
}

当一个组件同时处理多种业务职责、难以命名或测试时,考虑拆分。行数只能提示检查,不能用固定的 150 行决定架构。

1.2 组件命名与文件组织 ​

# ✅ 推荐的命名规范
components/
├── ui/                    # 通用 UI 原子组件 (Button, Input, Dialog)
├── features/              # 业务功能组件
│   ├── ProductCard.tsx     # PascalCase 命名
│   ├── ProductCard.test.tsx
│   └── useProductCard.ts   # 配套 Hook 放一起
├── layouts/               # 布局组件 (Sidebar, Header)
└── providers/             # Context Provider 包装组件

1.3 Props 设计原则 ​

tsx
// ❌ Boolean Props 地狱
<Button primary large rounded disabled loading />

// ✅ 用有语义的枚举/变体
<Button variant="primary" size="lg" disabled loading />

// 相关字段可以组成一个明确的数据对象
<UserCard user={user} />
// 也可以只传组件需要的字段,避免让组件依赖整个领域对象
<UserName name={user.name} />

1.4 按变化方式选择组合或配置 ​

tsx
// 固定结构可以用配置式 API
<Card
  title="标题"
  subtitle="副标题"
  image="/photo.jpg"
  footer={<Button>操作</Button>}
  showBorder
  variant="elevated"
/>

// 内容结构经常变化时,可以用组合式 API
<Card variant="elevated">
  <CardImage src="/photo.jpg" />
  <CardBody>
    <CardTitle>标题</CardTitle>
    <CardSubtitle>副标题</CardSubtitle>
  </CardBody>
  <CardFooter>
    <Button>操作</Button>
  </CardFooter>
</Card>

组合式方便在 CardBody 中加入评分等内容;配置式便于统一结构与约束。上面只演示组件组合,只有配合共享状态或协作协议时才进一步涉及 Compound Components 模式。


二、State 管理最佳实践 ​

2.1 状态放在哪里?决策树 ​

先分清数据来源和生命周期,再判断共享范围:

TanStack Query 管理服务端状态的请求和缓存,不是因为“变化频繁”就拿它保存任意表单状态。Context 也不是只能放低频数据;根据消费者范围、更新成本和实测结果决定是否拆分或换用 selector。

2.2 减少不必要的 State ​

tsx
// ❌ 冗余 state(可以从现有 state 派生)
const [todos, setTodos] = useState<Todo[]>([])
const [completedCount, setCompletedCount] = useState(0)  // 冗余!
const [activeCount, setActiveCount] = useState(0)         // 冗余!

// ✅ 用派生值代替
const [todos, setTodos] = useState<Todo[]>([])
const completedCount = todos.filter(t => t.completed).length  // 每次渲染自动计算
const activeCount = todos.length - completedCount

能由当前 state/props 直接算出的值,通常在渲染时计算;只有确实要保存独立的历史快照或用户可编辑副本时,才考虑单独的状态。

2.3 State 更新的不可变原则 ​

tsx
// ❌ 修改已有 state 对象会破坏快照与引用比较
const handleToggle = (id: number) => {
  const todo = todos.find(t => t.id === id)
  todo!.completed = !todo!.completed  // 直接修改了对象
  setTodos(todos)                      // Object.is 相同通常会跳过这次更新,UI 可能未同步
}

// ✅ 不可变更新(Immutable Update)
const handleToggle = (id: number) => {
  setTodos(prev => prev.map(t => 
    t.id === id ? { ...t, completed: !t.completed } : t
  ))
}

// ✅ 嵌套对象的不可变更新
const updateNestedField = () => {
  setUser(prev => ({
    ...prev,
    address: {
      ...prev.address,
      city: '上海'  // 只改了 city,其他保持不变
    }
  }))
}

三、useEffect 最佳实践 ​

3.1 你可能不需要 useEffect ​

这是 React 官方文档着重强调的一点:很多场景被滥用了 useEffect。

tsx
// ❌ 用 useEffect 同步派生值
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [fullName, setFullName] = useState('')

useEffect(() => {
  setFullName(`${firstName} ${lastName}`)  // 多余的 state + effect!
}, [firstName, lastName])

// ✅ 直接在渲染中计算
const fullName = `${firstName} ${lastName}`
tsx
// ❌ 用 useEffect 响应事件
useEffect(() => {
  if (submitted) {
    sendAnalytics('form_submitted')  // 应该放在事件处理器里!
  }
}, [submitted])

// ✅ 在事件处理器中执行
const handleSubmit = () => {
  setSubmitted(true)
  sendAnalytics('form_submitted')  // 事件驱动,不是状态驱动
}

3.2 useEffect 合法用例 ​

场景说明
订阅外部系统WebSocket、EventListener、IntersectionObserver
同步到外部存储localStorage、sessionStorage
发起数据请求fetch API(但更推荐 TanStack Query 或 RSC)
操作 DOMfocus、scroll、测量尺寸
定时器setTimeout、setInterval(记得清理!)

3.3 对称地释放资源 ​

Effect 建立了连接、订阅或定时器时,应清理对应资源;不是每个 Effect 都必须返回清理函数。清理不仅在卸载时执行,也会在依赖变化后的下一次 setup 之前执行。useEffect 官方说明。

tsx
// ❌ 忘记清理可能留下连接或重复订阅
useEffect(() => {
  const ws = new WebSocket('wss://...')
  ws.onmessage = (e) => setData(JSON.parse(e.data))
  // 组件卸载时 WebSocket 还在连着!
}, [])

// ✅ 返回清理函数
useEffect(() => {
  const ws = new WebSocket('wss://...')
  ws.onmessage = (e) => setData(JSON.parse(e.data))
  
  return () => ws.close()  // 依赖变化后的重新连接前、或卸载时关闭
}, [])

四、性能优化最佳实践 ​

4.1 不要过早优化! ​

tsx
// ❌ 到处加 memo / useMemo / useCallback
const MemoizedButton = memo(Button)        // 大多数情况没必要
const value = useMemo(() => a + b, [a, b]) // 简单计算不需要 memo
const handler = useCallback(() => {}, [])  // 不是所有回调都需要缓存

// ✅ 只在出现性能问题时优化
// 先用 React DevTools Profiler 测量,找到真正的瓶颈

4.2 何时使用 memo / useMemo / useCallback? ​

情况判断方式
多个列表项反复渲染且 props 常不变Profiler 确认成本后尝试 memo;大量 DOM 还可考虑虚拟列表
回调传给已 memo 的组件只有引用变化正好阻碍跳过渲染时,useCallback 才可能有收益
耗时计算且输入常不变测量后尝试 useMemo,不要原地排序 state/props
简单拼接、加减或原生按钮回调通常无需缓存,保持实现简单

memo 仍会响应组件自己的 state 和读取的 Context 更新。useMemo/useCallback 是性能工具,不应用来保证业务正确性;缓存可能被丢弃。若已显式启用 React Compiler,先检查它生成的优化,再决定是否需要手动 memo。

4.3 key 的正确使用 ​

tsx
// ❌ 用 index 做 key(列表会增删排序时)
{todos.map((todo, index) => <TodoItem key={index} todo={todo} />)}

// ✅ 用唯一且稳定的 ID
{todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}

// 🔥 进阶技巧:用 key 强制重置组件状态
<UserProfile key={userId} userId={userId} />
// userId 变了 → React 销毁旧组件、创建新组件 → 内部 state 全部重置

五、TypeScript 最佳实践 ​

5.1 组件 Props 类型 ​

interface 和 type 都能声明 props;联合类型适合 type,并不存在必须选择 interface 的 React 规则。下面只声明一次类型,继承原生按钮属性并合并调用方的 className:

tsx
import type { ButtonHTMLAttributes } from 'react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary'
}
const variants = {
  primary: 'bg-indigo-600 text-white',
  secondary: 'bg-gray-100 text-gray-900',
}
function Button({ variant = 'primary', type = 'button', className = '', children, ...rest }: ButtonProps) {
  return <button {...rest} type={type}
    className={`rounded px-4 py-2 ${variants[variant]} ${className}`}>
    {children}
  </button>
}

完整类名映射也便于 Tailwind 4 扫描。type="button" 避免在表单内意外提交;需要提交时显式传 type="submit"。

5.2 泛型组件 ​

tsx
// ✅ 通用的列表组件
interface ListProps<T> {
  items: T[]
  renderItem: (item: T) => React.ReactNode
  keyExtractor: (item: T) => string
}

function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map(item => (
        <li key={keyExtractor(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  )
}

// 使用时 TypeScript 自动推断 T 的类型!
<List 
  items={products}                           // T 被推断为 Product
  renderItem={(p) => <span>{p.name}</span>}  // p 自动有 Product 类型
  keyExtractor={(p) => p.id}
/>

5.3 类型收窄(Discriminated Unions) ​

tsx
// ✅ 用联合类型让 TypeScript 帮你检查所有分支
type NotificationProps = 
  | { type: 'success'; message: string }
  | { type: 'error'; message: string; retry: () => void }   // error 必须有 retry
  | { type: 'loading' }                                       // loading 不需要 message

function Notification(props: NotificationProps) {
  switch (props.type) {
    case 'success': return <div className="text-green-600">{props.message}</div>
    case 'error':   return <div className="text-red-600">{props.message} <button onClick={props.retry}>重试</button></div>
    case 'loading': return <div role="status">正在加载…</div>
    default: {
      const exhaustive: never = props
      return exhaustive
    }
  }
}

六、无障碍 (Accessibility / a11y) ​

键盘用户、屏幕阅读器用户和临时无法使用鼠标的用户,都需要能够完成同一条业务流程。

6.1 基本规则 ​

tsx
// ❌ 用 div 做按钮
<div onClick={handleClick} className="cursor-pointer">提交</div>

// ✅ 用语义化 HTML
<button onClick={handleClick}>提交</button>

// ❌ 图片没有 alt
<img src="/hero.jpg" />

// ✅ 加描述
<img src="/hero.jpg" alt="首页横幅:春季促销活动" />
// 纯装饰图片用空 alt
<img src="/divider.svg" alt="" />

// ❌ 表单没有 label
<input type="email" placeholder="请输入邮箱" />

// ✅ label 关联 input(使用 useId!)
const id = useId()
<label htmlFor={id}>邮箱</label>
<input id={id} type="email" />

6.2 键盘导航 ​

普通可点击操作优先使用原生按钮,它自带键盘激活语义:

tsx
function ActionItem({ onSelect, children }: { onSelect: () => void; children: React.ReactNode }) {
  return <button type="button" onClick={onSelect}>{children}</button>
}

不能给一个独立的 div 加 role="option" 和 tabIndex={0} 就认为完成了下拉选择器。完整 listbox 还要有所属容器、选择状态、焦点管理和方向键操作;简单选择优先使用 <select>,自定义组件按 WAI-ARIA Listbox 模式 实现并测试。

6.3 ARIA 属性速查 ​

属性用途示例
aria-label给没有可见文字的元素命名<button aria-label="关闭">✕</button>
aria-hidden对屏幕阅读器隐藏装饰性元素<span aria-hidden="true">🎉</span>
aria-live动态内容变化时通知用户<div aria-live="polite">{count} 项结果</div>
aria-expanded折叠/展开状态<button aria-expanded={isOpen}>菜单</button>
aria-disabled只声明禁用语义;不改变默认焦点或阻止点击aria-disabled={true} 仍需自行阻止操作;普通按钮优先用 disabled

TIP

L13 使用的 Radix 版本组件提供了部分键盘和 ARIA 行为,但你仍需补齐可读名称、说明、对比度与业务焦点流程,并实际测试。使用组件库不等于整个页面已通过可访问性验收。


七、安全最佳实践 ​

7.1 XSS 防护 ​

tsx
// ✅ JSX 中的字符串会按文本渲染,不当作 HTML 解析
const userInput = '<script>alert("xss")</script>'
return <div>{userInput}</div>  // 渲染为文本,不会执行脚本

// ❌ dangerouslySetInnerHTML 会绕过转义(危险!)
return <div dangerouslySetInnerHTML={{ __html: userInput }} />  // 若输入含可执行内容,可能造成 XSS

// ✅ 浏览器端必须渲染 HTML 时,先用维护中的净化库处理
import DOMPurify from 'dompurify'
return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} />

DOMPurify 示例面向已有浏览器环境,先安装 dompurify;不能仅加 use client 就假设 Next.js 首次渲染不在服务端执行。SSR 中使用它需要兼容的 DOM 实现或经过配置的同构方案,并保持依赖更新。净化后不要再拼接不可信 HTML。JSX 转义也不能替代 URL、业务输入和权限校验。DOMPurify 官方说明。

7.2 Server Actions 安全 ​

Server Action 是可被直接请求的服务端入口。沿用 L20–21 的 requireAdmin(),从数据库读取当前角色;不能只信任可能过期的 JWT 角色,也不能依赖“按钮隐藏了”。下面展示 L20 删除 Action 的关键边界,实际项目保留其错误提示和缓存刷新:

ts
'use server'
import { prisma } from '@/lib/prisma'
import { requireAdmin } from '@/lib/authorization'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'

export async function deleteProduct(productId: unknown) {
  await requireAdmin()
  const parsed = z.string().min(1).max(100).safeParse(productId)
  if (!parsed.success) return { error: '商品 ID 无效' }
  try {
    await prisma.product.delete({ where: { id: parsed.data } })
  } catch {
    return { error: '删除失败,商品可能已被订单引用或不存在' }
  }
  revalidatePath('/admin/products')
  revalidatePath('/products')
  return { success: true }
}

若已采用 L27 的公开商品数据缓存,还要在成功后失效对应的 products tag。订单读取和更新另外需要检查当前用户是否拥有该订单。客户端 TypeScript 类型不能替代服务端运行时验证。

7.3 环境变量安全 ​

bash
# ✅ 服务端密钥(不加 NEXT_PUBLIC_ 前缀)
DATABASE_URL=postgresql://...
STRIPE_SECRET_KEY=sk_live_...

# ✅ 客户端公开信息(加 NEXT_PUBLIC_ 前缀)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
NEXT_PUBLIC_APP_URL=https://myapp.com

# ❌ 绝对不要这么做!
NEXT_PUBLIC_DATABASE_URL=...       # 数据库密码暴露给浏览器!
NEXT_PUBLIC_STRIPE_SECRET_KEY=...  # 支付密钥暴露给浏览器!

这里演示公开变量与秘密的区别,不要求新增这些公开变量。L24 的托管 Checkout 不需要 publishable key,支付跳转仍使用服务端的 APP_URL。


八、调试技巧 ​

8.1 React DevTools ​

功能用途
Components 面板查看组件树、Props、State、Hooks 的实时值
Profiler 面板录制渲染过程,找出渲染耗时最长的组件
"Highlight updates"开启后,每次重新渲染的组件会闪烁高亮

8.2 常用调试手段 ​

tsx
// 1. 用 console.log 追踪渲染
function MyComponent({ value }: { value: string }) {
  console.log('MyComponent rendered with:', value)
  // ...
}

// 2. 用 useEffect 追踪 state 变化
useEffect(() => {
  console.log('todos changed:', todos)
}, [todos])

// 3. 用 React.StrictMode 提前发现问题
// 在开发模式下会故意:
// - 额外调用组件函数等应当纯粹的逻辑,帮助发现非纯渲染
// - 根部 StrictMode 首次挂载时额外执行 Effect 的 setup → cleanup → setup
// - 额外检查 ref callback 的清理;这些检查不发生在生产模式
// - 检查废弃的 API 使用

九、常见反模式速查 ​

反模式问题正确做法
大量中间组件被迫透传无关 props依赖难追踪先尝试组件组合,必要时 Context / Zustand
用 useEffect 同步可直接计算的派生 state导致多余的渲染周期直接在渲染中计算派生值
useEffect 中 fetch 无清理竞态条件(旧请求覆盖新结果)AbortController / TanStack Query
在循环/条件中调用 useState/useEffect 等 Hooks调用顺序改变导致状态对应错误放在组件或自定义 Hook 顶层;use API 是例外
可增删、排序的列表用 index 做 key状态可能附着到错误项用同级唯一且稳定的 ID
手写请求但遗漏竞态、加载与错误状态结果可能过期或难维护简单请求补齐处理;复杂缓存需求用 Query / SWR
不相关数据共用一个经常变化的 Context value读取该 Context 的组件都接收更新按需求拆 Context,或评估 selector 订阅
客户端组件在渲染函数中直接发起 fetch每次渲染都可能产生请求和竞态使用路由/Query 加载机制;Server Component 的 async 取数另论

React 19 的 use(resource) 可在条件和循环中调用,但仍只能在组件或 Hook 内使用,且不能用 try/catch 包住 use(promise)。普通 Hooks 继续遵守顶层规则。use 官方说明。

十、熟练掌握 React 的补强训练清单 ​

如果你的目标是"能独立高质量交付 React 项目",建议在学完前三阶段(前 28 课)后继续完成下面 6 个专项训练:

专项训练内容达标标准
渲染模型手写 2 个案例解释 render/commit、状态快照、批处理、Effect 时机能准确解释"为什么会重新渲染"和"为什么出现旧值闭包"
状态架构对同一需求分别用 local state / Context / Zustand / TanStack Query 建模能说清每种方案的边界和迁移成本
性能分析用 React DevTools Profiler 分析 3 个真实性能问题并给出前后对比优化结果有量化指标(渲染次数、耗时、包体积)
TypeScript写 3 个泛型组件、2 个复杂 Hook 类型(含返回值推导)不使用 any 仍能保持良好可读性
可访问性为表单、弹窗、菜单补齐键盘导航和 ARIA 语义可仅靠键盘完整操作关键流程
工程质量补齐测试金字塔(单测/集成/E2E)和错误监控链路关键路径(登录/下单)有自动化回归保护

推荐按下面顺序执行(每周一个主题):

  1. 第 1-2 周:渲染模型 + 状态架构
  2. 第 3-4 周:性能分析 + TypeScript
  3. 第 5-6 周:可访问性 + 工程质量
  4. 第 7-8 周:脱稿重做一个中型项目(不看教程,从需求到部署)

十一、📌 前三阶段(28 节课)后的精通路线图 ​

课程覆盖了常见的开发路径;熟练程度要通过独立实现、排错和维护来检验。进一步可以选择:

选择与你当前项目有关的一项深入,完成实现、测试和复盘,再决定下一个主题。


十二、练习 ​

  1. 反模式排查:从你自己的一个 React 项目中找出 3 个反模式(如 Effect 同步 state、index 作为 key、过度 Context),并给出改造前后对比。
  2. 状态选型决策:针对“商品详情 + 购物车 + 用户偏好 + 远程列表”写出一份状态归属文档(local/Context/Zustand/Query)。
  3. a11y 改造:为一个弹窗组件补齐焦点管理、键盘关闭、aria-labelledby / aria-describedby,并自测键盘可达性。

十三、📌 本节小结 ​

你做了什么你学到了什么
系统梳理 React 最佳实践与反模式把“会写”升级到“写得稳、写得久”
建立状态管理决策思路根据数据来源与生命周期做技术选型
强化性能、TS、a11y、安全视角形成生产级前端的质量基线
制定补强训练路线为源码课与面试进阶建立能力过渡

进入 Lesson 30 前检查清单:

  • [ ] 能说明至少 5 个常见反模式及替代方案
  • [ ] 能基于场景完成状态管理选型
  • [ ] 具备基础可访问性与安全防护意识

项目驱动 · 边写边学 · React 19