首页 / 文章 / React代码审查检查清单:派生状态、Reducer、Effects与Memo

React代码审查检查清单:派生状态、Reducer、Effects与Memo

了解如何识别七种常见的 React 代码审查问题,包括状态重复、手动实现数据获取功能、错误使用 effects 和过度使用 memoization,以及应如何改写代码。

1906 词

拥有数年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。 纯状态转换逻辑。
  • 关键用户流程。通过 React Testing Library(@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 进行测试。
  • 由于设置成本较高,暂不进行测试:从纯函数、reducer和自定义hook开始。
  • 使用useEffect来同步状态:在事件处理函数中计算值或处理相关逻辑。
  • 处处使用useCallback和React.memo:在缓存之前先进行性能分析。
  • 可点击的div元素以及缺失的焦点样式:使用语义化HTML,并确保键盘操作功能正常。
  • 总结

    这些问题的根本原因大多相同:状态或逻辑存在于过多的地方,或者被频繁调用。保持单一的真实数据源,让专为特定任务设计的工具管理服务器数据,将状态转换限制在纯函数中处理,而将副作用留给外部世界,这些措施都能减少需要管理的组件数量。组件越少,就越容易测试,在需要优化时也更容易处理,同时也能让后续的审查者更放心。