React Hooks从16.8版本正式发布以来,已经成为React开发的主流方式,面试中关于Hooks的问题也越来越多,越来越深入。最近整理了一些React Hooks的进阶面试题,涵盖了原理、性能优化、常见坑点、自定义Hooks等方面,今天分享给大家,看看这些你都掌握了吗。

这些题目不是简单的概念题,而是需要深入理解原理和实践经验才能答好的,适合有一定React开发经验的同学。如果你能全部答上来,说明你对Hooks的理解已经比较深入了。接下来一道道来看。

一、useState和useReducer的区别,什么时候用useReducer

这是一个很常见的问题,很多人觉得useState够用了,为什么还要useReducer。

简单来说,useState适合管理简单的状态,比如单个值或者简单的对象。useReducer适合管理复杂的状态,比如状态之间有依赖关系,或者下一个状态依赖于上一个状态,或者状态更新逻辑很复杂,需要统一管理。

具体来说,以下场景适合用useReducer:

第一,状态逻辑复杂,涉及多个子值。比如一个表单有多个字段,字段之间有联动关系,用useState管理会很分散,用useReducer可以把所有状态和更新逻辑集中管理。

第二,下一个状态依赖于上一个状态。比如计数器的增减,或者列表的增删改查,用useReducer的action来描述操作更清晰,而且能保证状态更新的正确性。

第三,需要在子组件中触发状态更新,而且更新逻辑很复杂。用useReducer可以把dispatch传下去,子组件只需要dispatch对应的action,不需要知道具体的更新逻辑,这样组件更解耦。

第四,需要做状态更新的日志记录或者时间旅行调试。useReducer的action是纯对象,很容易记录和回放,适合做调试。

举个例子,一个购物车的状态管理,有添加商品、删除商品、修改数量、清空购物车等操作,而且总价和数量需要根据商品列表计算,这种场景用useReducer就比useState合适很多,因为所有的操作都可以用action来描述,更新逻辑集中在reducer里,便于维护和测试。

当然也不是说复杂状态就一定要用useReducer,如果状态虽然复杂但是更新逻辑简单,用useState也可以。关键是看哪种方式代码更清晰,更易维护。

二、useEffect的依赖数组为什么重要,常见的坑有哪些

useEffect是Hooks里最常用也最容易出错的Hook,很多bug都是因为依赖数组处理不当导致的。

依赖数组的作用是告诉React,当依赖数组里的值发生变化时,才重新执行effect。如果依赖数组为空,effect只在组件挂载时执行一次,卸载时执行清理函数。如果不写依赖数组,每次渲染都会执行effect。

常见的坑有以下几个:

第一,遗漏依赖。这是最常见的问题,effect里用到了某个state或者props,但是依赖数组里没写,导致effect里拿到的是旧的值。比如在effect里用了count,但是依赖数组里没写count,那么effect里的count永远是初始值,不会更新。这个问题在闭包里很常见,因为effect的回调函数形成了闭包,捕获了当时的变量值,如果依赖不对,就会拿到旧值。

第二,过度依赖。有些人怕遗漏依赖,就把所有用到的变量都放进依赖数组,结果导致effect频繁执行,甚至无限循环。比如在effect里更新了某个state,又把这个state放进依赖数组,就会导致effect执行后更新state,state变化又触发effect,无限循环。

第三,对象和数组的依赖问题。因为React是用Object.is来比较依赖的值是否变化的,对于对象和数组,每次渲染都是新的引用,所以即使内容没变,也会被认为是变化了,导致effect频繁执行。比如把一个对象作为依赖,每次渲染这个对象都是重新创建的,引用不一样,就会触发effect。解决方案是用useMemo缓存对象,或者把对象的属性拆出来作为依赖,或者用useRef保存对象。

第四,函数的依赖问题。和对象类似,每次渲染函数都是新的引用,如果把函数作为依赖,也会导致effect频繁执行。解决方案是用useCallback缓存函数,或者把函数移到effect内部,或者用useRef保存函数。

第五,清理函数的问题。useEffect可以返回一个清理函数,在组件卸载或者依赖变化时执行。很多人忘记写清理函数,导致内存泄漏或者重复绑定。比如在effect里加了事件监听,但是没在清理函数里移除,组件卸载后事件监听还在,就会内存泄漏。或者定时器没清除,组件卸载后还在运行。

要避免这些坑,最好的方式是启用eslint-plugin-react-hooks的exhaustive-deps规则,它会自动检查依赖是否完整,给出警告。但是也要注意,这个规则不是万能的,有些场景它的建议可能不对,需要自己判断。

三、useCallback和useMemo的区别,什么时候用,什么时候不用

useCallback和useMemo都是用来做性能优化的,很多人分不清它们的区别,也不知道什么时候该用什么时候不该用。

简单来说,useCallback是缓存函数,useMemo是缓存计算结果。useCallback(fn, deps)等价于useMemo(() => fn, deps),只不过useCallback返回的是函数本身,useMemo返回的是函数的返回值。

具体来说:

useCallback用于缓存函数,避免每次渲染都创建新的函数引用。当把函数作为props传给子组件时,如果函数引用每次都变,子组件就会重新渲染,即使用React.memo包裹也没用。这时候用useCallback缓存函数,就能保证函数引用不变,子组件不会不必要地重新渲染。

useMemo用于缓存计算结果,避免每次渲染都重新计算。当有一个很耗时的计算,而且计算结果只依赖于某些特定的值时,用useMemo缓存结果,只有依赖变化时才重新计算,能提升性能。

但是要注意,useCallback和useMemo不是免费的,它们本身也有开销,需要额外的内存来缓存,而且每次渲染也要比较依赖是否变化。所以不是所有地方都要用,只有在确实有性能问题的时候才用。

什么时候该用:

第一,把函数作为props传给用React.memo包裹的子组件,而且子组件渲染开销大。这时候用useCallback缓存函数,避免子组件不必要的重新渲染。

第二,函数作为其他Hook的依赖,比如useEffect、useCallback、useMemo的依赖。如果函数引用每次都变,会导致这些Hook频繁执行,用useCallback缓存函数能避免这个问题。

第三,计算很耗时,而且计算结果可以复用。比如大数据量的过滤、排序、聚合,或者复杂的数学计算,用useMemo缓存结果能明显提升性能。

什么时候不该用:

第一,简单的计算,比如简单的算术运算或者字符串拼接,用useMemo的开销可能比重新计算还大,没必要用。

第二,函数很简单,而且不传子组件,也不作为其他Hook的依赖,每次创建新函数的开销很小,没必要用useCallback。

第三,依赖变化很频繁,缓存的值每次都要重新计算,缓存没有意义,反而增加了开销。

总的来说,useCallback和useMemo是性能优化的工具,应该在有明确性能问题的时候再用,而不是一开始就到处用。过早优化反而可能增加代码复杂度和运行开销。

四、自定义Hook的设计原则,如何设计一个好的自定义Hook

自定义Hook是Hooks的精髓,它能让我们把组件逻辑提取出来复用。但是设计一个好的自定义Hook并不容易,有很多需要注意的地方。

设计自定义Hook的几个原则:

第一,单一职责。一个自定义Hook只做一件事,不要把不相关的逻辑都塞进去。比如useFetch只负责数据获取,useLocalStorage只负责本地存储,不要把数据获取和本地存储混在一个Hook里。单一职责的Hook更容易复用,也更容易测试和维护。

第二,命名规范。自定义Hook的名字要以use开头,这是约定,也是eslint规则要求的。名字要能清晰表达Hook的功能,比如useFetch、useDebounce、useLocalStorage、useIntersectionObserver,一看名字就知道是做什么的。

第三,参数设计要合理。参数不要太多,一般不超过3个,如果参数太多,可以考虑用对象作为参数。参数要有默认值,提高易用性。对于可选的配置项,用对象的方式传入,便于扩展。比如useFetch(url, options),options里可以配置method、headers、body等,以后加新的配置项也不会破坏API。

第四,返回值设计要清晰。返回值可以是单个值,也可以是数组或者对象。如果返回多个值,而且顺序很重要,用数组,比如useState返回[state, setState]。如果返回多个值,而且用户可能只需要其中一部分,用对象,比如useFetch返回{ data, loading, error, refetch },用户可以只解构需要的字段。

第五,处理好依赖和闭包。自定义Hook内部用到的state、props、函数等,要正确处理依赖,避免闭包陷阱。特别是在useEffect、useCallback、useMemo里,要确保依赖数组正确。如果有内部函数需要传给外部,要用useCallback缓存,避免引用变化。

第六,考虑边界情况和错误处理。好的自定义Hook要考虑各种边界情况,比如组件卸载后异步操作还没完成怎么办,参数为空怎么办,网络请求失败怎么办。要做好错误处理,把错误暴露给调用者,而不是吞掉错误。

第七,提供取消和清理机制。如果Hook里有异步操作、定时器、事件监听等,要在组件卸载时清理,避免内存泄漏。比如useFetch里要处理组件卸载后不更新state的情况,可以用AbortController取消请求,或者用一个标记变量判断组件是否卸载。

第八,要可测试。自定义Hook要容易测试,逻辑要和UI分离,最好是纯函数式的,输入确定输出就确定。可以用react-hooks-testing-library来测试自定义Hook。

举个例子,一个好的useFetch应该是这样的:参数是url和options,返回data、loading、error、refetch,内部处理了loading状态、错误处理、组件卸载清理、依赖变化重新请求,而且有合理的默认值。

设计自定义Hook的时候,要多站在使用者的角度考虑,API要简洁易用,文档要清晰,示例要完善。一个好的自定义Hook应该像内置Hook一样,用起来自然,不需要看太多文档就能上手。

五、Hooks的规则有哪些,为什么要有这些规则

React Hooks有两条核心规则,很多人知道规则但不知道为什么要有这些规则。

第一条规则是只在最顶层调用Hooks,不要在循环、条件、嵌套函数里调用Hooks。

为什么要有这条规则?因为React是靠Hooks的调用顺序来对应state和Hook的。每次渲染的时候,React按照Hooks的调用顺序依次读取对应的状态,如果在条件里调用Hook,当条件变化时,Hook的调用顺序就变了,React就会把状态对应错,导致bug。

比如有这样的代码:

if (condition) {
  const [count, setCount] = useState(0);
}

第一次渲染condition为true,调用了useState,React把count存在第一个位置。第二次渲染condition为false,没有调用useState,React就读第一个位置的状态给下一个Hook,就对应错了。

所以必须保证每次渲染Hooks的调用顺序都一样,只能在最顶层调用,不能在条件、循环、嵌套函数里调用。

第二条规则是只在React函数组件或自定义Hook里调用Hooks。

为什么要有这条规则?因为Hooks是React提供的,依赖React的渲染机制和状态管理,只有在函数组件或自定义Hook里调用,React才能正确管理Hook的状态和生命周期。在普通函数里调用,React不知道什么时候渲染,也不知道什么时候清理,就会出问题。

当然,自定义Hook里可以调用其他Hook,因为自定义Hook最终也是在函数组件里调用的,React能正确管理。

除了这两条核心规则,还有一些实践中的规则:

第一,useEffect的依赖要完整。前面讲过,遗漏依赖会导致闭包陷阱,拿到旧值。最好用eslint-plugin-react-hooks来检查。

第二,不要在useEffect里直接修改state导致无限循环。如果effect里更新了依赖里的state,就会无限循环,要避免这种情况。

第三,自定义Hook要以use开头。这是约定,也是工具识别的依据。

这些规则本质上都是为了保证Hooks的正确性和可预测性,因为Hooks是基于调用顺序和闭包的,一旦违反规则,就会出现难以排查的bug。所以一定要遵守这些规则,最好配合lint工具来自动检查。

六、useRef的常见用法,和useState的区别

useRef也是一个很常用的Hook,很多人只知道它用来获取DOM元素,其实它的用法很多。

useRef返回一个可变的ref对象,对象的current属性指向当前值。useRef和useState的区别是,修改ref的current不会触发组件重新渲染,而修改state会触发重新渲染。而且useRef的值在每次渲染之间保持不变,不会因为重新渲染而重置。

常见的用法有以下几个:

第一,获取DOM元素。这是最常见的用法,给元素加ref属性,就能通过ref.current访问到DOM元素,然后可以操作DOM,比如调用focus()、scrollIntoView(),或者测量元素尺寸。比如输入框自动聚焦,就可以用useRef获取input元素,在useEffect里调用focus()。

第二,保存可变值,不需要触发重新渲染。比如保存定时器ID、上一次的props或state、是否是第一次渲染的标记等。这些值变化的时候不需要重新渲染组件,用useRef就很合适。比如用useRef保存上一次的count值,在effect里比较count是否变化了。

第三,在闭包里获取最新的值。因为闭包会捕获当时的变量值,如果在异步回调里需要最新的state,可以用useRef保存state的最新值,每次渲染更新ref.current,这样异步回调里通过ref.current就能拿到最新值。比如在setTimeout的回调里需要最新的count,就可以用useRef保存count,每次渲染更新ref.current = count,回调里读ref.current。

第四,跨渲染保持状态。useRef的值在组件的整个生命周期里都保持,不会因为重新渲染而重置,也不会因为父组件重新渲染而变化。比如需要保存一个不需要触发渲染的计数器,或者保存一个实例对象,都可以用useRef。

useRef和useState的选择:如果值变化需要触发重新渲染,用useState;如果值变化不需要触发重新渲染,只是想在组件的整个生命周期里保存一个值,用useRef。

还有一个要注意的点,不要在渲染过程中修改ref.current,应该在useEffect或者事件处理函数里修改。因为渲染过程应该是纯函数,不应该有副作用。

七、如何优化Hooks组件的性能

Hooks组件的性能优化是面试中经常问的问题,也是实际开发中很重要的一点。

常见的优化手段有以下几个:

第一,用React.memo包裹子组件,避免不必要的重新渲染。React.memo是高阶组件,会对props做浅比较,如果props没变,就跳过子组件的重新渲染。对于渲染开销大的子组件,用React.memo能明显提升性能。但是要注意,如果props里有对象或函数,每次渲染引用都变,React.memo就失效了,这时候需要配合useCallback和useMemo使用。

第二,用useCallback缓存函数。当把函数作为props传给子组件时,用useCallback缓存函数,保证函数引用不变,这样子组件用React.memo包裹就能生效。前面讲过useCallback的使用场景,这里就不重复了。

第三,用useMemo缓存计算结果和对象。对于耗时的计算,用useMemo缓存结果。对于作为props传给子组件的对象,用useMemo缓存对象,保证引用不变,让React.memo生效。

第四,合理拆分组件,避免大组件。大组件的渲染开销大,而且一个小的状态变化会导致整个组件重新渲染。把大组件拆成小组件,每个组件只负责一部分功能,状态变化只会影响相关的组件,能提升性能。

第五,状态下放,把状态放在需要它的最底层组件。不要把所有状态都放在顶层组件,这样一个状态变化会导致所有子组件重新渲染。把状态放在真正需要它的组件里,状态变化只会影响这个组件和它的子组件,影响范围小。

第六,用useReducer代替多个useState。当有多个相关的state,而且更新逻辑复杂时,用useReducer集中管理,能减少不必要的渲染,而且更新逻辑更清晰。

第七,避免在渲染过程中做耗时操作。渲染过程应该是纯函数,快速返回JSX。耗时的操作比如大数据量的计算、复杂的循环,应该放在useMemo里缓存,或者放在useEffect里异步执行。

第八,虚拟列表。对于长列表,不要一次性渲染所有元素,用虚拟列表只渲染可视区域的元素,能大幅提升性能。可以用react-window或react-virtualized这样的库。

第九,懒加载。对于不需要首屏显示的组件,用React.lazy和Suspense做懒加载,减少首屏的代码量和渲染开销。

第十,合理使用key。列表渲染时key要稳定且唯一,不要用index作为key,尤其是列表会变化的时候,不然会导致不必要的DOM操作和状态错乱。

性能优化要注意的是,不要过早优化,先保证功能正确,再在有性能问题的时候针对性优化。而且优化是有成本的,会增加代码复杂度,要权衡收益和成本。

八、总结

以上就是整理的React Hooks进阶面试题,涵盖了常用的Hooks的原理、区别、使用场景、常见坑点、自定义Hook设计、性能优化等方面。

这些题目没有标准答案,关键是看理解的深度和实践经验。如果能把这些问题都答清楚,说明对Hooks的理解已经比较深入了,面试中关于Hooks的问题应该都能应对。

当然Hooks的内容远不止这些,还有很多高级的用法和细节,比如useImperativeHandle、useLayoutEffect、useDebugValue等,以及Hooks的源码实现原理,有兴趣的同学可以继续深入学习。

Hooks是React的未来,也是前端开发的必备技能,深入理解Hooks的原理和最佳实践,能让我们写出更高质量的React代码。希望这篇文章能给大家带来一些帮助,也欢迎大家在评论区交流讨论,分享自己的经验和见解。