这个钩子真的物有所值吗?关于 React 代码的十个评测问题
学习十道实用的审查问题,用于识别 React Hook 代码中的冗余状态、过时的效果、渲染循环、不必要的记忆化以及体积过大的组件。
来源: Hooks
大多数 React Hook 相关的错误在拉取请求中并不明显。缺失的依赖、将属性复制到状态中的效应函数,或是没有使用记忆化的处理函数包裹的 useCallback,这些在代码审查时都可能被忽略,随后会表现为页面数据过时、不必要的重新渲染或监听器堆积等问题。下面列出了十种常见的 Hook 错误,它们被转化成了可用于审查任何组件的问题,同时说明了每种错误背后的机制以及代码应有的简化形式。
关于状态的问题
这个值可以在渲染时计算出来吗?
一种常见的模式是将fullName保存在状态中,并使用效应函数在任一名称发生变化时调用setFullName(`${firstName} ${lastName}`)。组件会先使用旧值渲染一次,效应函数在渲染完成后执行,随后设置器会安排第二次渲染。这样会导致某一帧的界面显示不一致,同时还需要额外维护这个变动部分。
更好的做法是直接计算:const fullName = `${firstName} ${lastName}`;。由于React会在每次渲染时重新运行组件,因此该值始终与其输入保持同步。如果某个值可以从属性或状态中推导出来,那就不应将其视为状态。另请参阅为何将状态与useEffect同步会适得其反。
组件的状态是否过多?
同样的错误只是规模更大:有一个表单包含三个useState调用,分别用于处理firstName、lastName和fullName。现在每次修改姓名都必须在fullName中体现出来,任何忽略这一点的写法都会导致错误。应保留两个独立的输入字段,再通过它们推导出第三个字段。对于每个useState,都要判断其值是否可以通过其他值重新生成;如果可以,则删除该useState。
修改这个值是否需要更新界面?
状态的存在是为了驱动渲染。定时器标识、DOM节点、之前的属性值或第三方实例(如地图、图表、套接字客户端)通常不会影响渲染内容,因此对它们使用useState(null)意味着每次赋值都会触发无意义的渲染。应改用const timerRef = useRef(null);:通过编写timerRef.current,可以在不触发重新渲染的情况下保留其值。但需要注意的是,绝不能通过读取ref来决定要显示什么内容。如果界面必须反映某个值,那它终究还是状态。
关于效应的问题
依赖数组是否列出了效应所读取的所有内容?
将 [] 作为参数传入,使效应“仅运行一次”,如 useEffect(() => { fetchUser(userId); }, []); 所示,这样会从首次渲染时就固定 userId 的值。当属性发生变化时,不会重新获取数据,从而导致界面显示错误的用户信息。使用 [userId] 即可解决此问题。
该数组并非用于控制执行频率的参数,而是用来指定效应需要读取哪些值。如果效应运行过于频繁,应修改其读取的内容(将函数移入效应内部,或依赖原始值而非对象),而不是隐藏依赖关系。react-hooks/exhaustive-deps 这一代码检查规则几乎能捕捉到所有这类情况,因此应将其警告视为错误处理。当 userId 变化迅速时,数据获取相关的效应也应在清理函数中忽略或中止过期的请求。
该效应会触发自身吗?
useEffect(() => { setCount(count + 1); }, [count]);其实是一个循环:每次更新都会改变count的值,进而触发该效应再次执行,如此无限循环。而函数式的更新方式setCount(prev => prev + 1)无需在闭包中引用count,可以直接读取最新值,因此可以移除该依赖项,使得效应仅在组件首次渲染时执行。需要注意的是,即便如此,组件在每次渲染时仍会递增一次;而且在开发模式下严格模式会导致组件两次渲染,因此你可能会看到计数值在本地直接跳过2。
更重要的问题是:这种更新是否真的应该放在效应中处理?用户操作的响应应通过事件处理器处理,派生值则应在渲染过程中计算。效应的作用则是与React之外的系统进行同步。
该效应会撤销它所设置的值吗?
那种仅调用 window.addEventListener('resize', handleResize) 的效应会在每次组件挂载时添加新的监听器,且永远不会移除它。应返回一个清理函数,使用相同的处理函数调用 window.removeEventListener。React会在效应重新运行之前以及组件卸载时执行该清理函数。若不这样做,就会出现内存泄漏、处理函数被多次触发,以及已消失的组件仍收到更新的情况。
一个简单的检查方法:效应中的每一个 add、connect 或 setInterval 都需要在其返回值中对应有一个 remove、disconnect 或 clearInterval。useEffect 十大常见错误排查 文章介绍了更多效应失效的模式。
关于记忆化的疑问
这个计算真的那么耗资源吗?
将字符串拼接操作用 useMemo 包装,并以 [user] 作为依赖项,其带来的成本远高于节省的成本。React 仍会在每次渲染时分配回调函数和数组并比较依赖项,这比简单拼接两个字符串要耗费更多精力。此外,这样的代码更难理解;而且如果每次渲染 user 都是新的对象,缓存机制仍会重新计算结果。
直接使用普通表达式即可。只有在执行明显较为耗时的操作时,比如对大型列表进行排序或过滤,或者当某个被缓存的子组件或其他 Hook 需要一个稳定的对象引用时,才考虑使用 useMemo。在此之前,请先通过 React DevTools Profiler 进行性能测试。
是否有任何内容依赖于该函数的唯一性?
useCallback 能保持函数引用的稳定性,而这仅在需要比较引用时才有意义:比如被 React.memo 包装的子组件或其它 Hook 的依赖列表。对于绑定在普通 button 上、仅用于日志记录的点击处理函数,使用它并无好处,因为 DOM 并不会关心该函数是否为新的版本。应先编写普通函数,等到确定了需要使用它的组件后再添加 useCallback。React 编译器可以自动应用此类记忆化处理;请查阅最新文档以了解在您的配置中该功能的启用状态。
关于结构的问题
每个 Hook 都会无条件地、以相同的顺序被调用吗?
仅在if (isLoggedIn)内部声明useState违反了Hooks规则。React是根据组件内的调用顺序来匹配Hooks的,而非名称。如果跳过一次调用,后续的所有Hook都会读取相邻位置的值,从而导致错误的结果或出现Hooks数量变化的运行时错误。
应在顶层调用所有Hook,然后再进行分支处理:首先声明user状态,如果用户未登录则返回null。循环和提前返回的情况也是如此。关于其内部原理,可参阅Hooks规则存在的原因。
这个组件是否承担了过多功能?
将数百行用于数据获取、状态管理、业务逻辑的代码与标记语混在一起,会导致代码难以阅读、测试和修改。应将这类逻辑提取到自定义 Hook 中。一个 useUserData(userId) Hook 负责管理 user 状态,当 userId 发生变化时,它会调用 fetchUser(userId).then(setUser),之后再返回 user 对象。UserProfile 组件则简化为 const user = useUserData(userId); 以及 user?.name 的渲染代码。
这样做的好处包括:
- 无需在组件间复制代码即可重复使用逻辑。
- 可以独立于标记语对逻辑进行测试。
- 组件结构更简洁,便于查看。
- 数据获取或缓存相关的修改只需在一处进行。
请记住,每个调用自定义 Hook 的组件都会拥有自己的状态:它们共享的是逻辑,而非数据。
核心要点
- 派生值是表达式,既非状态也非效应。
- 尽量减少状态;那些永远不会显示在屏幕上的值应存入引用中。
- 依赖数组用于描述某个效应读取了什么,因此要如实检查它们。
- 每一个负责初始化某事物的效应都必须负责将其清理掉。
- 只有当测量操作或对引用敏感的组件需要时才进行缓存。
- 无条件地调用钩子函数,并将较大的逻辑块移入自定义钩子中。
这些问题的大多源于习惯性使用钩子函数。在代码审查时问一问“这里真的需要这个钩子吗?”,成本很低,却能避免漫长的调试过程。