如何在 useCallback 钩子内移除事件监听器

huangapple go评论56阅读模式
英文:

How to remove event listener inside useCallback hook

问题

我想创建一个通用的React钩子,它将向元素添加滚动事件并返回一个布尔值,指示用户是否已经滚动到元素的顶部。现在,问题是这个元素可能不会立即可见。因此,我不能使用useEffect。据我了解,在这种情况下,建议使用useCallback

所以我这样做,它可以工作:

function useHasScrolled() {
  const [hasScrolled, setHasScrolled] = useState(false);
  const ref = useRef(null);
  const setRef = useCallback((element) => {
    const handleScroll = (e) => {
      setHasScrolled(e.target.scrollTop !== 0);
    };
    if (element) {
      element.addEventListener("scroll", handleScroll);
    }

    ref.current = element;
  }, []);
  return {
    hasScrolled,
    scrollingElementRef: setRef
  };
}

我可以像这样使用我的钩子:

  const { hasScrolled, scrollingElementRef } = useHasScrolled();
  ....
  return <div ref={scrollingElementRef}>....

然而,问题是,我不知道如何移除事件侦听器。使用useEffect钩子很简单 - 只需返回清除函数。

如果你想查看实现,这是Codesandbox链接:https://codesandbox.io/s/pedantic-dhawan-83fdw3

英文:

I want to create a generic react hook that will add a scroll event to the element and return a boolean indicating that the user has scrolled to the top of the element.
Now, the problem is this element might not be visible right away. Hence I'm not able to use useEffect. As I understand in that situation it is advised to use useCallback

So I did, and it works:

function useHasScrolled() {
  const [hasScrolled, setHasScrolled] = useState(false);
  const ref = useRef(null);
  const setRef = useCallback((element) =&gt; {
    const handleScroll = (e) =&gt; {
      setHasScrolled(e.target.scrollTop !== 0);
    };
    if (element) {
      element.addEventListener(&quot;scroll&quot;, handleScroll);
    }

    ref.current = element;
  }, []);
  return {
    hasScrolled,
    scrollingElementRef: setRef
  };
}

I can use my hook like this:

  const { hasScrolled, scrollingElementRef } = useHasScrolled();
  ....
  return &lt;div ref={scrollingElementRef}&gt;....

However, the problem is, I don't know how to remove the event listener. With the useEffect hook, it's pretty straightforward - you just return the cleanup function.

Here's the codesandbox, if you want to check the implementation: https://codesandbox.io/s/pedantic-dhawan-83fdw3

答案1

得分: 1

预期行为 - 当从DOM中移除节点时,事件监听器也将被移除并由GC收集。

但是,Codesandbox示例有点棘手,React将

<div>加载中...</div>

<div className="scrollingDiv" ref={scrollingElementRef}>
  <h1>你好,我终于加载成功了!</h1>
  <Lorem />
</div>

视为同一个div,同一个对象,只是具有不同的props(className和children),因此当div.scrollingDiv被条件渲染为div(loading)时,事件监听器仍然存在并积累。

这个行为可以通过使用key来修复。

{loading ? (
  <div key="div1">加载中...</div>
) : (
  <div key="div2" className="scrollingDiv" ref={scrollingElementRef}>
    <h1>你好我终于加载成功了</h1>
    <Lorem />
  </div>
)}

这样事件监听器将如预期般被移除。

另一种解决方法是在自定义钩子中添加1个额外的useRef和useEffect以存储和执行实际的取消订阅函数:

function useHasScrolled() {
  const [hasScrolled, setHasScrolled] = useState(false);
  const ref = useRef(null);
  const unsubscribeRef = useRef(null);

  const setRef = useCallback((element) => {
    const eventName = "scroll";
    const handleScroll = (e) => {
      setHasScrolled(e.target.scrollTop !== 0);
    };

    if (unsubscribeRef.current) {
      unsubscribeRef.current();
      unsubscribeRef.current = null;
    }

    if (element) {
      element.addEventListener(eventName, handleScroll);

      unsubscribeRef.current = () => {
        console.log("removeEventListener called on: ", element);
        element.removeEventListener(eventName, handleScroll);
      };
      ref.current = element;
    } else {
      unsubscribeRef.current = null;
      ref.current = null;
    }
  }, []);

  useEffect(() => {
    return () => {
      if (unsubscribeRef.current) {
        unsubscribeRef.current();
        unsubscribeRef.current = null;
      }
    };
  }, []);

  return {
    hasScrolled,
    scrollingElementRef: setRef
  };
}

这段代码可以在不添加key的情况下工作。

用于Chrome开发工具控制台的实用代码,用于计算滚动监听器的数量:

Array.from(document.querySelectorAll('*'))
  .reduce(function(pre, dom){
    var clks = getEventListeners(dom).scroll;
    pre += clks ? clks.length || 0 : 0;
    return pre
  }, 0)

更新的Codesandbox链接:https://codesandbox.io/s/angry-einstein-6fb1u4?file=/src/App.js

英文:

Expected behavior - when node is removed from DOM - event listeners will be also removed and collected by GC.

But

Codesandbox example is a bit tricky, React treats

&lt;div&gt;Loading...&lt;/div&gt;

and

&lt;div className=&quot;scrollingDiv&quot; ref={scrollingElementRef}&gt;
  &lt;h1&gt;Hello, I&#39;ve finally loaded!&lt;/h1&gt;
  &lt;Lorem /&gt;
&lt;/div&gt;

as a same div, same object, just with different props (className and children), so when div.scrollingDiv is replaced by conditional rendering to div(loading) - event listeners are still there and accumulating.

This behavior can be fixed as is by using keys.

{loading ? (
  &lt;div key=&quot;div1&quot;&gt;Loading...&lt;/div&gt;
) : (
  &lt;div key=&quot;div2&quot; className=&quot;scrollingDiv&quot; ref={scrollingElementRef}&gt;
    &lt;h1&gt;Hello, I&#39;ve finally loaded!&lt;/h1&gt;
    &lt;Lorem /&gt;
  &lt;/div&gt;
)}

In that way event listeners will be removed as expected.

Another solution is to add 1 more useRef and useEffect to the custom hook to store and execute actual unsubscribe function:

function useHasScrolled() {
  const [hasScrolled, setHasScrolled] = useState(false);
  const ref = useRef(null);
  const unsubscribeRef = useRef(null);

  const setRef = useCallback((element) =&gt; {
    const eventName = &quot;scroll&quot;;
    const handleScroll = (e) =&gt; {
      setHasScrolled(e.target.scrollTop !== 0);
    };

    if (unsubscribeRef.current) {
      unsubscribeRef.current();
      unsubscribeRef.current = null;
    }

    if (element) {
      element.addEventListener(eventName, handleScroll);

      unsubscribeRef.current = () =&gt; {
        console.log(&quot;removeEventListener called on: &quot;, element);
        element.removeEventListener(eventName, handleScroll);
      };
      ref.current = element;
    } else {
      unsubscribeRef.current = null;
      ref.current = null;
    }
  }, []);

  useEffect(() =&gt; {
    return () =&gt; {
      if (unsubscribeRef.current) {
        unsubscribeRef.current();
        unsubscribeRef.current = null;
      }
    };
  }, []);

  return {
    hasScrolled,
    scrollingElementRef: setRef
  };
}

That code will work without adding key.

Utility code for Chrome dev console to count scroll listeners:

Array.from(document.querySelectorAll(&#39;*&#39;))
  .reduce(function(pre, dom){
    var clks = getEventListeners(dom).scroll;
    pre += clks ? clks.length || 0 : 0;
    return pre
  }, 0)

Updated codesandbox: https://codesandbox.io/s/angry-einstein-6fb1u4?file=/src/App.js

huangapple
  • 本文由 发表于 2023年2月9日 00:17:45
  • 转载请务必保留本文链接:https://go.coder-hub.com/75388688.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定