广告:Codex Token 低价中转站稳定接口 · 快速接入 · 开发者备用通道
Engineering article

纯干货 | React Hooks使用规则

我用了三年React,踩过无数次坑,最大的教训是Hooks的使用规则不是随便玩玩的。在实际工程中, Hooks的滥用会导致组件结构混乱、状态管理不可预测,甚至引起致命错误。你必须知道哪些规则不能破,哪些场景下必须用某个特定方式。比如,useEffect不能在条件语句中被包裹,否则你永远无法彻底清除副作用,这事儿我亲自撞过。还有,useSt

纯干货 | React Hooks使用规则
配图来源于网络和AI生成,仅供参考。
▌ 技术引导
我用了三年React,踩过无数次坑,最大的教训是Hooks的使用规则不是随便玩玩的。在实际工程中, Hooks的滥用会导致组件结构混乱、状态管理不可预测,甚至引起致命错误。你必须知道哪些规则不能破,哪些场景下必须用某个特定方式。比如,useEffect不能在条件语句中被包裹,否则你永远无法彻底清除副作用,这事儿我亲自撞过。还有,useState不能嵌套在条件语句里,否则导致组件无法正确更新,这在复杂表单组件中特别容易出问题。另外,useContext用法要精准,千万别随便挂在顶层,否则会导致组件树的过度渲染。最关键的是,Hooks的顺序不能乱,否则你做的组件在运行时会出现难以追踪的bug。这些规则不是理论,是血泪换来的经验。

我见过用useReducer代替多个useState的场景,但如果只是简单状态,用useState反而更高效。还有一个情况是,useMemo和useCallback的使用必须谨慎,不能随便乱加。我曾经在某个项目中过度使用useMemo,结果反而让组件变得不够响应,性能倒退。另外,useEffect中如果有多个副作用,一定要拆分成多个useEffect,避免副作用之间互相干扰。还有,千万别在useEffect中写同步逻辑,否则会导致渲染阻塞,这在移动端尤其致命。最后,关于useRef,它不能替代useState,但有时候它可以用来处理副作用中的持久化数据,比如计数器或者DOM操作,但这些场景必须非常明确。

我亲身经历过useContext导致的全局状态混乱问题,特别是在大型项目中,如果context没有明确的层级结构,组件会因为订阅了错误的context值而频繁重渲染。所以,context的命名和使用方式必须清晰,否则你会发现整个应用的性能像被按下了重启键。还有一个常见的误区是,认为useEffect可以代替生命周期方法,但其实它只是partial replacement,比如componentDidMount和componentWillUnmount的行为不能完全通过useEffect替代。再比如,useCallback不能用来替代函数,除非你明确知道它会被缓存,否则你可能会在组件中频繁重建函数,影响性能。

关于Hooks的集成,我看到很多项目在用React 18的并发模式,这时候useEffect的行为和之前版本略有变化,特别是useEffect的调度机制。如果你还在用React 16或17,不要用useEffect的最佳实践去优化,否则会让你的代码出现非预期行为。还有一个经验是,不要在函数组件内部定义函数,否则useCallback会失效。另外,useLayoutEffect和useEffect的区别必须搞清楚,尤其是在处理DOM操作时,useLayoutEffect是同步执行的,而useEffect是异步的,这会影响你的渲染逻辑。最后,不要在useEffect中执行大量计算,这会拖慢应用的整体响应速度。

▌ 技术参考

一 React Hooks的规则不是随便写个函数就能搞定,而是一套必须严格遵守的使用规范。useEffect必须在组件返回的函数中执行,不能在条件语句中被包裹,否则你无法确保副作用的清除逻辑。比如,如果你这样写,`if (isLoading) useEffect(() => ...)`,你可能会在组件卸载时无法清理副作用。另外,useEffect的依赖数组不能漏掉关键变量,否则会导致无限循环。我曾在某个项目中漏掉了某个依赖项,导致组件一直处于挂载状态,最后只能通过重新构建修复。

二 在React 18的并发模式下,useEffect的调度机制发生了变化,它现在会根据优先级进行调度。这意味着你不能简单地把所有副作用都放在一个useEffect中,而应该根据业务逻辑拆分成多个useEffect。比如,对于需要立即执行的副作用,可以使用useLayoutEffect代替,但要注意它和useEffect的区别在于执行时机和阻塞渲染的能力。如果你在useEffect中执行大量计算,应该考虑用useMemo或者优化算法,否则会影响应用的整体性能。

三 useState的使用必须避免在条件语句中被包裹,因为这会导致React无法正确追踪状态变化。比如,`if (showForm) const [form, setForm] = useState({})`,这样的写法会让组件在每次渲染时重新创建状态变量,从而引发不必要的重新渲染。正确的做法是将状态定义在组件的顶层,确保它在整个组件生命周期内保持不变。如果你在处理复杂表单,推荐结合useReducer和Formik,这样可以更好地管理表单状态和验证逻辑,避免代码臃肿。

四 在React 17及以下版本中,useEffect的清除逻辑和旧版有差异,特别是在处理多个useEffect时,你需要确保每个useEffect都有独立的清除函数。比如,如果你在useEffect中订阅了一个外部事件,必须在清除函数中手动取消订阅,否则可能导致内存泄漏。一个常见的错误是,依赖数组没有正确更新,导致useEffect中的函数引用了过时的变量。解决办法是使用函数式更新方式,比如`setCount(prev => prev + 1)`,而不是直接使用变量。

五 useContext的正确使用方式是,将其定义在组件树的最顶层,并通过Provider传递值。如果context被错误地挂在某个子组件上,会导致整个组件树频繁重渲染,特别是当context的值发生变化时。所以,我建议将context封装成一个独立的模块,确保它的结构清晰,避免污染全局状态。另外,不要在useContext中直接使用useState,这样会导致 context 的值和状态之间产生耦合,增加维护难度。如果需要处理复杂的状态逻辑,推荐使用Redux或者MobX。

六 用useCallback包裹函数时,要确保依赖数组的准确性。如果依赖数组中的变量没有被正确更新,useCallback会缓存旧的函数引用,导致组件无法正确响应状态变化。例如,在处理点击事件时,如果你直接传入了一个函数,而该函数依赖了某个状态变量,必须确保该变量在依赖数组中出现。一个常见的错误是,没有将函数的依赖项放入数组,导致组件一直处于未更新状态。正确的做法是,将依赖项明确写入useCallback的依赖数组中,或者使用函数式更新方式避免变量冲突。

七 useReducer的使用场景大多集中在复杂状态管理上,比如表单、导航、过滤器等。它的好处在于能够集中管理状态,避免组件内部状态过多。但在实际应用中,很多人会把useReducer和useState混合使用,导致状态管理混乱。比如,如果你有一个简单的状态变量,用useState更高效,而useReducer更适合处理状态的转换逻辑。我见过很多项目因为错误地使用useReducer,导致组件变得臃肿,维护成本陡增,最终只能拆分成多个小组件。

八 useLayoutEffect和useEffect的区别在于,前者在DOM更新前执行,后者在更新后执行。在处理DOM操作时,比如测量元素尺寸或者设置样式,useLayoutEffect更合适,但要小心它会影响渲染性能。我有次在useLayoutEffect中执行了复杂的计算,导致应用卡顿,最终只能通过将部分逻辑移到useEffect中解决。如果你需要在useEffect中执行同步操作,控制好使用频率是关键,否则会让你的组件变得又慢又不稳定。

九 useImperativeHandle用于暴露子组件的引用,但必须谨慎使用。如果过度依赖它,会导致组件之间的耦合增加,降低可维护性。比如,在某个项目中,我用useImperativeHandle暴露了表单的验证方法,结果导致父组件频繁调用该方法,最终导致性能下降。正确的做法是,通过props传递验证方法,或者使用自定义Hook封装逻辑,避免直接暴露组件实例。同时,要注意useImperativeHandle的依赖数组是否准确,否则会导致引用更新失败。

十 在使用useMemo时,要确保它只在依赖项变化时才会重新计算。如果依赖项频繁变化,useMemo反而会拖慢应用的性能。我曾经在一个项目中用useMemo优化了一个数据计算过程,结果因为依赖项更新过于频繁,导致useMemo的计算反而成为性能瓶颈。解决办法是,分析依赖项的变化频率,如果变化太多,就不要用useMemo,而是直接计算。记住,useMemo的初始值和返回值是关键,要确保它们不会因为不必要的更新而被触发。

十一 useReducer的 reducer 函数必须是纯函数,不能有副作用。如果你在 reducer 中执行了异步操作,或者修改了外部变量,会导致状态更新不可预测。比如,我之前在 reducer 中使用了 fetch 请求,结果因为 reducer 不是纯函数,导致状态未正确更新,最终引发组件重新渲染失败。正确的做法是,将异步逻辑移到 useEffect 或者单独的函数中,确保 reducer 只处理状态转换逻辑。同时,避免在 reducer 中处理复杂的计算,这样会降低可读性和可维护性。

十二 useReducer 与 useState 的结合使用需要特别小心。比如,在某个项目中,我同时使用了 useReducer 和 useState 来管理表单数据,结果因为状态更新顺序混乱,导致表单字段的值无法正确同步。解决办法是,将所有的表单数据统一管理在 useReducer 中,避免使用多个 useState。或者,如果确实需要使用 useState,确保它只用于处理简单的状态,而 useReducer 用于处理复杂的转换逻辑。这样可以减少状态混乱的可能性,提高代码的可预测性。

十三 useEffect 中的依赖数组必须准确反映函数内部使用的变量。如果依赖数组中缺少某个变量,会导致函数引用旧值,进而引发错误。我曾在一个项目中,useEffect 中的函数引用了一个 state 变量,但该变量没有被放入依赖数组,导致函数执行了错误的逻辑。解决方案是,确保所有的变量都出现在依赖数组中,或者使用函数式更新方式避免变量引用问题。记住,依赖数组的准确性是 useEffect 能正常工作的前提。

十四 useReducer 的初始化数据可以通过函数或者对象传入,但要用对了。比如,在某些情况下,使用函数初始化可以避免依赖项冲突,而使用对象初始化更直观。我在一个项目中误用了对象初始化,结果导致 reducer 没有正确应用初始值,最终引发组件状态错误。正确的做法是,根据项目复杂度选择初始化方式,同时确保 reducer 函数能够正确处理初始值和后续更新。此外,避免在 reducer 中修改外部状态,否则会导致状态更新不可预测。

十五 React 18 的并发模式下,useEffect 的调度行为与之前版本有所不同。它会根据优先级进行调度,而不是简单的按顺序执行。这意味着你不能假设 useEffect 会立即执行,或者在某个时刻一定会被触发。我曾在一个项目中,因为没有考虑到调度优先级,导致某些关键的副作用没有及时执行,影响了用户体验。为了解决这个问题,我建议在 useLayoutEffect 中处理同步逻辑,而在 useEffect 中处理异步逻辑,确保它们在合适的时机被调度。