自定义IntersectionObserver Hook的exhaustive-deps警告修复方案
背景
参考dev.to平台相关教程,实现了可在React中使用IntersectionObserver的自定义Hook,用于监听元素是否进入视口。
原始实现代码
import { useEffect, useRef, useState } from "react" export const useIntersectionObserver = (options) => { const containerRef = useRef(null) const [isVisible, setIsVisible] = useState(false) const callback = (entries) => { const [entry] = entries setIsVisible(entry.isIntersecting) } useEffect(() => { let currentEl; const observer = new IntersectionObserver(callback, options); if (containerRef && containerRef.current) { currentEl = containerRef.current; observer.observe(currentEl); } return () => { if (containerRef && containerRef.current) { currentEl = containerRef.current; observer.unobserve(currentEl); } } }, [containerRef, options]) return [containerRef, isVisible] }
触发的ESLint警告
运行代码时控制台抛出react-hooks/exhaustive-deps规则警告:
The ref value 'containerRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'containerRef.current' to a variable inside the effect, and use that variable in the cleanup function
中文释义:effect清理函数执行时,containerRef.current的值大概率已经发生变更。如果该ref指向React渲染的DOM节点,需要在effect作用域内部将containerRef.current赋值给局部变量,清理函数统一使用该局部变量。
警告触发的深层原理
代码虽然声明了currentEl局部变量,但依然触发警告,核心问题出在清理函数的逻辑:
useRef返回的ref对象本身引用是稳定的,组件全生命周期内不会变化,但它的.current属性是完全可变的,React不会在.current变化时触发重渲染,也不会触发effect重新执行。- effect的执行时机是依赖项变更后、DOM提交到页面完成时;清理函数的执行时机是下一次effect运行前,或是组件卸载时。两次执行之间可能间隔多次重渲染,此时
containerRef.current可能已经指向其他DOM节点,甚至变为null。 - 原始代码中,虽然在绑定observer时把
containerRef.current赋值给了currentEl,但在清理函数里又重新读取containerRef.current覆盖了currentEl的值,此时拿到的元素根本不是当初调用observe绑定的目标元素:一方面会导致旧绑定元素没有被正确解绑,造成内存泄漏;另一方面可能对从未绑定过observer的新元素错误调用unobserve。ESLint规则正是检测到了清理函数中对ref可变属性的不安全读取,才抛出警告。 - 另外
containerRef本身是稳定引用,放进依赖数组没有实际意义,不会触发effect重执行。
符合React Hooks规范的修复方案
修复核心逻辑:只在effect执行、绑定observer的时机读取一次containerRef.current,存为effect作用域内的局部常量,整个effect生命周期(包括清理阶段)全程使用这个常量,不在清理函数中重新读取containerRef.current。
修复后代码:
import { useEffect, useRef, useState } from "react" export const useIntersectionObserver = (options) => { const containerRef = useRef(null) const [isVisible, setIsVisible] = useState(false) const callback = (entries) => { const [entry] = entries // 用函数式更新避免callback依赖isVisible状态 setIsVisible(() => entry.isIntersecting) } useEffect(() => { // 仅在effect执行时读取一次ref指向的元素,存为作用域常量 const currentEl = containerRef.current const observer = new IntersectionObserver(callback, options) if (currentEl) { observer.observe(currentEl) } return () => { // 清理时直接使用作用域内存储的元素引用,不再读取ref.current if (currentEl) { observer.unobserve(currentEl) } } }, [options, callback]) return [containerRef, isVisible] }
额外优化点
如果需要进一步减少不必要的observer重建,可以用useCallback包裹callback函数,确保其引用稳定;上述代码中已经使用setIsVisible的函数式更新写法,让callback不依赖任何外部状态,本身引用就是稳定的,effect只会在options变更时重新创建observer实例,性能更优。
内容的提问来源于stack exchange,提问作者Ben_Sven_Ten

