简体   繁体   English

移动设备上的 React useEffect 钩子未清除 setTimeout

[英]setTimeout not clearing with React useEffect hook on mobile devices

Problem Summary : setTimeout 's are not clearing on mobile devices when using React's useEffect hook.问题摘要:当使用 React 的useEffect钩子时, setTimeout在移动设备上没有被清除。 They are, however, clearing on desktop.但是,它们正在桌面上清除。

Problem Reproduction : https://codepen.io/amliving/pen/QzmPYE .问题复现https : //codepen.io/amliving/pen/QzmPYE

NB: run on a mobile device to reproduce the problem.注意:在移动设备上运行以重现问题。

My Question : Why does my solution (explained below) work?我的问题:为什么我的解决方案(如下所述)有效?

Details : I'm creating a custom hook to detect idleness.详细信息:我正在创建一个自定义钩子来检测空闲。 Let's call it useDetectIdle .我们称之为useDetectIdle It dynamically adds and removes event listeners to window from a set of events, which when triggered call a provided callback after a period of time, via setTimeout .它从一组事件中动态地向window添加和删​​除事件侦听器,这些事件在一段时间后通过setTimeout触发时调用提供的回调。

Here is the list of events that will be dynamically added to and then removed from window :以下是将动态添加到window然后从window删除的事件列表:

const EVENTS = [
  "scroll",
  "keydown",
  "keypress",
  "touchstart",
  "touchmove",
  "mousedown", /* removing 'mousedown' for mobile devices solves the problem */
];

Here's the useDetectIdle hook.这是useDetectIdle钩子。 The import piece here is that this hook, when its calling component unmounts, should clear any existing timeout (and remove all event listeners):这里的重要部分是这个钩子,当它的调用组件卸载时,应该清除任何现有的超时(并删除所有事件侦听器):

const useDetectIdle = (inactivityTimeout, onIdle) => {
  const timeoutRef = useRef(null);
  const callbackRef = useRef(onIdle);

  useEffect(() => {
    callbackRef.current = onIdle;
  });

  const reset = () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    const id = setTimeout(callbackRef.current, inactivityTimeout);
    timeoutRef.current = id;
  };

  useEffect(() => {
    reset();

    const handleEvent = _.throttle(() => {
      reset();
    }, 1000);

    EVENTS.forEach(event => window.addEventListener(event, handleEvent));

    return () => {
      EVENTS.forEach(event => window.removeEventListener(event, handleEvent));
      timeoutRef.current && clearTimeout(timeoutRef.current);
    };
  }, []);
};

useDetectIdle is called inside components like this: useDetectIdle在组件内部被调用,如下所示:

const Example = () => {
  useDetectIdle(5000, () => alert("first"));
  return <div className="first">FIRST</div>;
};

On non-touchscreen devices, useDetectIdle works perfectly.在非触摸屏设备上, useDetectIdle工作得很好。 But on mobile devices (both iOS and Android), any existing timeout is not cleared when its calling component unmounts.但是在移动设备(iOS 和 Android)上,当调用组件卸载时,不会清除任何现有的超时。 Ie the callback passed to setTimemout still fires.即传递给setTimemout的回调仍然会触发。

My Solution : Through some trial and error, I discovered that removing mousedown from the list of events solves the problem.我的解决方案:通过反复试验,我发现从事件列表中删除mousedown可以解决问题。 Does anyone know what's happening under the hood?有谁知道引擎盖下发生了什么?

Note: this doesn't answer "why your solution works", resp.注意:这不能回答“为什么您的解决方案有效”,resp。 why it seemed to help, but it points out 2 bugs in your code that I think are the real cause of the behavior.为什么它似乎有帮助,但它指出了您的代码中的 2 个错误,我认为这些错误是导致该行为的真正原因。 (Ie your solution does not really work.) (即您的解决方案实际上不起作用。)

You are handling _.throttle insufficiently - imagine the following scenario:您对_.throttle的处理不够充分 - 想象一下以下场景:

  1. Your component with hook is mounted.您的带挂钩组件已安装。
  2. User triggers one of the events - throttled function is called, ie it just internally sets the timeout for 1000ms (throttled callback will be called at the end of 1000ms).用户触发事件之一 - 调用节流函数,即它只是在内部将超时设置为 1000 毫秒(节流回调将在 1000 毫秒结束时调用)。
  3. Before the timeout gets hit, you unmount the component.在超时之前,您卸载组件。 Listeners get removed just fine, but the internal timeout remained and will eventually fire your reset() , even though your component is already unmounted (and from there it will fire the idle callback after another inactivityTimeout ms).侦听器被删除得很好,但内部超时仍然存在并且最终会触发您的reset() ,即使您的组件已经卸载(并且从那里它会在另一个inactivityTimeout ms 之后触发空闲回调)。

Why the bug was prevalent on mobile was probably tied with what the user had to do to unmount the component on mobile vs desktop, the timing, and what events fired while doing it.该错误在移动设备上普遍存在的原因可能与用户在移动设备与桌面设备上卸载组件时必须执行的操作、时间以及执行此操作时触发的事件有关。

There is also the very tiny possibility that your component's DOM gets unmounted, and because React >= 17.x runs effect cleanup methods asynchronously , a timeout could fire just before your effect cleanup method.您的组件的 DOM 被卸载的可能性也很小,并且因为 React >= 17.x 异步运行效果清理方法,超时可能会在您的效果清理方法之前触发。 I doubt this would be consistently simulated but can be fixed too.我怀疑这是否会持续模拟,但也可以修复。

You can fix both issues by switching both effects to useLayoutEffect and introducing local variable unmounted :您可以通过切换这两种效应来解决这两个问题useLayoutEffect并引入局部变量unmounted

const useDetectIdle = (inactivityTimeout, onIdle) => {
  const timeoutRef = useRef(null);
  const callbackRef = useRef();

  useLayoutEffect(() => {
    callbackRef.current = onIdle;
  });

  const reset = () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    const id = setTimeout(callbackRef.current, inactivityTimeout);
    timeoutRef.current = id;
  };

  useLayoutEffect(() => {
    reset();

    let unmounted = false;
    const handleEvent = _.throttle(() => {
      if (!unmounted) reset();
    }, 1000);

    EVENTS.forEach(event => window.addEventListener(event, handleEvent));

    return () => {
      unmounted = true;
      EVENTS.forEach(event => window.removeEventListener(event, handleEvent));
      timeoutRef.current && clearTimeout(timeoutRef.current);
    };
  }, []);
};

PS: Idle callback after mount fires after inactivityTimeout ms, whereas subsequent callbacks after inactivityTimeout + 1000 ms. PS:挂载后空闲回调在inactivityTimeout毫秒后触发,而后续回调在inactivityTimeout + 1000毫秒后触发。

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM