Lesson 29:React 最佳实践与反模式 — 写出专业级代码
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 4(原理篇)
- 推荐时长:120~180 分钟(首次学习)
- 先修要求:至少完成一个 React 中型项目,具备 TS 与性能调优基础
- 学习产出:形成组件、状态、性能与无障碍的评审清单,能结合代码解释改进理由
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:掌握 React 开发的工程最佳实践,学会识别和避免常见反模式,写出可维护、高性能、安全、无障碍的专业级代码。
📦 本节产出:一份随时可查阅的实战速查手册,涵盖组件设计、状态管理、性能优化、TypeScript、无障碍、安全和调试技巧。
本课多数代码块是独立的对照片段,不要把“改前/改后”或不同方案同时粘进一个文件。Hooks 片段放在组件或自定义 Hook 中,并补齐对应导入;省略的类型和业务组件沿用自己的项目。
一、组件设计最佳实践
1.1 单一职责原则
// ❌ 一个组件干了太多事
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 设计原则
// ❌ Boolean Props 地狱
<Button primary large rounded disabled loading />
// ✅ 用有语义的枚举/变体
<Button variant="primary" size="lg" disabled loading />
// 相关字段可以组成一个明确的数据对象
<UserCard user={user} />
// 也可以只传组件需要的字段,避免让组件依赖整个领域对象
<UserName name={user.name} />1.4 按变化方式选择组合或配置
// 固定结构可以用配置式 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
// ❌ 冗余 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 更新的不可变原则
// ❌ 修改已有 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。
// ❌ 用 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}`// ❌ 用 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) |
| 操作 DOM | focus、scroll、测量尺寸 |
| 定时器 | setTimeout、setInterval(记得清理!) |
3.3 对称地释放资源
Effect 建立了连接、订阅或定时器时,应清理对应资源;不是每个 Effect 都必须返回清理函数。清理不仅在卸载时执行,也会在依赖变化后的下一次 setup 之前执行。useEffect 官方说明。
// ❌ 忘记清理可能留下连接或重复订阅
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 不要过早优化!
// ❌ 到处加 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 的正确使用
// ❌ 用 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:
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 泛型组件
// ✅ 通用的列表组件
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)
// ✅ 用联合类型让 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 基本规则
// ❌ 用 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 键盘导航
普通可点击操作优先使用原生按钮,它自带键盘激活语义:
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 防护
// ✅ 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 的关键边界,实际项目保留其错误提示和缓存刷新:
'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 环境变量安全
# ✅ 服务端密钥(不加 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 常用调试手段
// 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-2 周:渲染模型 + 状态架构
- 第 3-4 周:性能分析 + TypeScript
- 第 5-6 周:可访问性 + 工程质量
- 第 7-8 周:脱稿重做一个中型项目(不看教程,从需求到部署)
十一、📌 前三阶段(28 节课)后的精通路线图
课程覆盖了常见的开发路径;熟练程度要通过独立实现、排错和维护来检验。进一步可以选择:
选择与你当前项目有关的一项深入,完成实现、测试和复盘,再决定下一个主题。
十二、练习
- 反模式排查:从你自己的一个 React 项目中找出 3 个反模式(如 Effect 同步 state、
index作为 key、过度 Context),并给出改造前后对比。 - 状态选型决策:针对“商品详情 + 购物车 + 用户偏好 + 远程列表”写出一份状态归属文档(local/Context/Zustand/Query)。
- a11y 改造:为一个弹窗组件补齐焦点管理、键盘关闭、
aria-labelledby/aria-describedby,并自测键盘可达性。
十三、📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 系统梳理 React 最佳实践与反模式 | 把“会写”升级到“写得稳、写得久” |
| 建立状态管理决策思路 | 根据数据来源与生命周期做技术选型 |
| 强化性能、TS、a11y、安全视角 | 形成生产级前端的质量基线 |
| 制定补强训练路线 | 为源码课与面试进阶建立能力过渡 |
进入 Lesson 30 前检查清单:
- [ ] 能说明至少 5 个常见反模式及替代方案
- [ ] 能基于场景完成状态管理选型
- [ ] 具备基础可访问性与安全防护意识