React代码审查检查清单:派生状态、Reducer、Effects与Memo
了解如何识别七种常见的 React 代码审查问题,包括状态重复、手动实现数据获取功能、错误使用 effects 和过度使用 memoization,以及应如何改写代码。
拥有数年React使用经验的开发者通常了解props的传递方式、hooks的运作原理,以及为何绝不能直接修改状态。然而在他们的拉取请求中,代码审查仍会不断发现相同的问题:组件虽然能运行,但稳定性差、难以测试、功能过度设计或扩展性不足。要突破这一阶段,并非需要学习晦涩的API,而是要更清晰地把握状态的存储位置、各种操作的作用以及何时进行优化才有效果。本清单以TypeScript中的待办事项列表作为示例,逐一分析七种常见问题,并指出每种情况下应采取的解决思路。
1. 应该通过计算得到的状态
React状态的核心原则是保持单一事实来源。一旦相同信息存在于两个地方,这些副本最终就会出现不一致。
具体表现形式
一个需要同时显示所有待办事项和已完成事项的待办列表,其状态中往往会出现两个数组:
// ❌ BAD: Storing derived state in two separate places
const [todos, setTodos] = useState<Todo[]>([]);
const [completedTodos, setCompletedTodos] = useState<Todo[]>([]);
这样一来,每次更改都需要执行两次。如果用户重命名了任务,代码更新了todos却忘了更新completedTodos,界面就会在一个列表中显示旧标题,而在另一个列表中显示新标题。
应如何改写
在状态中只保留一个数组,并在渲染时计算出已完成事项的子集:
// ✅ GOOD: Single source of truth with derived state
const [todos, setTodos] = useState<Todo[]>([]);
// Derived value recalculated on every render (inexpensive computation)
const completedTodos = todos.filter((todo) => todo.completed);
对于常规数据量而言,每次渲染时对数组进行过滤操作开销很小,而且由于该子列表会随时根据源数据重新计算,因此永远不会出现不同步的情况。如果计算成本确实很高,比如需要过滤数万条记录,可以用useMemo将其封装起来;否则直接使用普通表达式更为清晰。
2. 在useEffect中手动实现数据获取
在 useEffect 中手动调用 fetch 看起来很简单,但这类实现容易产生竞态条件,缺乏缓存功能,且每个组件都需手动管理加载状态和错误状态。
实际效果
该实现会仅加载列表一次,并通过 isMounted 标志来确保在组件卸载后不会更新状态:
// ❌ BAD: Boilerplate-heavy, error-prone, no caching or deduplication
useEffect(() => {
let isMounted = true;
fetch('/api/todos')
.then((res) => res.json())
.then((data) => {
if (isMounted) setTodos(data);
});
return () => { isMounted = false; };
}, []);
不过它仍存在诸多缺陷:没有错误处理机制,没有加载状态指示,当两个组件请求相同数据时无法去重,页面切换间也没有缓存功能,更没有重试机制。此外 isMounted 标志仅能防止延迟更新,无法像 AbortController 那样取消请求。
应如何改写
使用专为服务器状态设计的库,如 TanStack Query、SWR 或 RTK Query。对应的 TanStack Query 调用会指定缓存键和获取函数,然后返回数据以及加载状态和错误状态:
// ✅ GOOD: Declarative, automatically cached, auto-retried
const { data: todos, isLoading, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
这类库可自动处理后台数据重新验证、请求去重、重试以及缓存共享,同时还为乐观更新和错误边界集成提供了结构化的支持。这样一来,组件代码只需描述其所需功能即可。
3. 本可用 reducer 替代的多个 useState 调用
useState 非常适合处理简单的值,比如切换开关或文本字段。但一旦更新需要依赖之前的值,或者状态涉及由多种不同操作修改的嵌套数组和对象,大量 setter 会导致组件变得臃肿,并引发旧状态错误。
实际表现形式
一个组件会声明五到十个useState钩子,并通过众多回调函数来更新它们,以实现添加、编辑、切换和删除项的功能。结果就是业务规则分散在各个事件处理函数中。
应如何改写
useReducer将状态转换逻辑从组件中提取到单个函数中。下面的还原器使用区分联合类型来定义操作,这样TypeScript就能知道每种情况的载荷类型。在添加待办事项时,会创建一个带有自动生成ID的新对象;切换功能则会对列表进行遍历并改变对应项的状态。需要注意的是,代码中存在的拼写错误:...todo.completed应改为!todo.completed,即逻辑上的取反操作,否则代码无法编译。
type Action =
| { type: 'ADD_TODO'; payload: string }
| { type: 'TOGGLE_TODO'; payload: string };
function todoReducer(state: Todo[], action: Action): Todo[] {
switch (action.type) {
case 'ADD_TODO':
return [...state, { id: crypto.randomUUID(), text: action.payload, completed: false }];
case 'TOGGLE_TODO':
return state.map((todo) =>
todo.id === action.payload ? { ...todo, completed: ...todo.completed } : todo
);
default:
return state;
}
}
还原器在两个方面具有优势:
- 可测试性。 Reducer是纯函数,因此无需渲染任何内容或模拟React即可对其进行测试。
- 可预测性。 每次更新都通过命名的action实现,而非来自分散回调的任意setter,这使得变更更易于追踪和记录。
4. 因为“测试是个大工程”所以不写测试
许多团队将测试视为非此即彼的事:要么建立完整的端到端测试环境,这让人望而却步;要么根本不写测试。更好的做法是从那些编写成本最低且最有价值的测试开始。
请按以下顺序确定优先级:
- 独立的业务逻辑。 位于组件树之外的辅助函数、格式化工具、验证器及实用程序。
- Reducer和自定义hook。 纯状态转换逻辑。
@testing-library/react)对重要交互进行测试。reducer测试是很好的第一步。它无需渲染即可创建初始状态,直接通过函数发送动作并验证结果:
// Example: Testing a reducer takes 2 minutes and provides huge peace of mind
test('should toggle todo status', () => {
const initialState = [{ id: '1', text: 'Write Article', completed: false }];
const newState = todoReducer(initialState, { type: 'TOGGLE_TODO', payload: '1' });
expect(newState[0].completed).toBe(true);
});
这样的测试只需几分钟就能编写完成,且能确保整个功能所依赖的规则得到遵守。一旦解决了前文提到的reducer拼写错误,正是这种测试能够及时发现它。
5. 使用useEffect让React与自身同步
useEffect是React中最容易被误用的钩子。常见的用法是在属性发生变化时用它来复制或调整内部状态,这会导致额外的渲染以及短暂的界面不一致现象。
有两个误解反复出现:
- “当属性发生变化时,必须在效应中更新状态。”通常无需如此。应在渲染过程中计算该值;或者,如果子组件的状态需要重置,为其设置一个会随相关属性变化的
key,这样React就会重新渲染它。 - “点击行为应放在效应中。”由用户操作触发的逻辑应放在事件处理函数中,如
onClick,而非放在用于监听点击所引发的状态变化的效应中。
核心原则是:效应机制用于与 React 之外的系统进行同步,比如浏览器 API、订阅服务、分析工具或第三方 DOM 组件。对于 React 已经拥有的数据,则无需使用效应机制即可进行转换。如需更详细的讲解及前后对比示例,请参阅停止使用 useEffect 同步状态。
6. 默认对所有内容进行记忆化处理
性能优化往往走向两个极端:要么完全忽视渲染成本,要么将每个组件用 React.memo 包装,再将每个函数用 useCallback 包装。
为何全面记忆化反而适得其反
useMemo和useCallback并非免费功能。React需要存储依赖项并在每次渲染时进行比较。对于那些渲染成本较低的组件,这种额外的处理开销可能等同于试图跳过的渲染操作本身,还会使代码更难理解。此外,只有当所有属性都保持稳定时,记忆化才能发挥作用;如果传递给被记忆化的子组件的内容是内联对象或箭头函数,就会破坏记忆化效果。
更审慎的处理方式
- 先进行性能分析。React DevTools的Profiler功能可以显示哪些组件实际存在渲染过于频繁或速度过慢的问题。
- 在添加记忆化之前先优化结构。将状态下放到实际使用它的组件中,或把计算成本较高的子树作为
children传递,往往无需借助记忆化就能避免不必要的渲染。
React.memo,并使用 useCallback 和 useMemo 确保其属性保持稳定。7. 将 CSS 与无障碍性视为他人的工作
优秀的 React 工程师同样精通浏览器平台。组件的质量取决于其生成的标记和样式。
值得掌握的领域:
- 现代布局。 熟练掌握 Flexbox 和 CSS Grid,使得绝对定位不再成为普通布局的替代方案。
- 作用域样式。 使用 CSS Modules、Tailwind CSS 或 CSS-in-JS 将样式与组件绑定在一起,而非依赖会导致类名冲突的大型全局样式表。
<button> 而非 <div onClick={...}>。确保 Tab、Enter 和空格键等导航功能在所有位置都能正常使用。为可聚焦元素设置可见的 :focus-visible 样式,并明确显示交互状态,例如在可点击元素上显示指针光标。审查检查清单
在审查 React pull request 时,注意以下替换情况:
- 将派生值存储在额外的
useState钩子中:应在渲染时直接计算这些值。 - 在
useEffect中手动调用 API:建议使用 TanStack Query、SWR 或 RTK Query。 - 对于复杂的数组和对象频繁使用
useState:应将状态转换逻辑移至useReducer中,并对 reducer 进行测试。
useEffect来同步状态:在事件处理函数中计算值或处理相关逻辑。useCallback和React.memo:在缓存之前先进行性能分析。div元素以及缺失的焦点样式:使用语义化HTML,并确保键盘操作功能正常。总结
这些问题的根本原因大多相同:状态或逻辑存在于过多的地方,或者被频繁调用。保持单一的真实数据源,让专为特定任务设计的工具管理服务器数据,将状态转换限制在纯函数中处理,而将副作用留给外部世界,这些措施都能减少需要管理的组件数量。组件越少,就越容易测试,在需要优化时也更容易处理,同时也能让后续的审查者更放心。