Skip to content

React中如何更优雅的使用定时器 #51

@lihongxun945

Description

@lihongxun945

问题

前端同学们依然会有很多时候会碰到需要使用定时器的场景,比如轮播动画、轮询数据等。很多时候大家都是随手写一个 setInterval 来完成需求。
比如下面的一个典型的计时器场景:

function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    setInterval(() => {
      setCount(count + 1);
    }, 1000);
  }, []);
  return (
    <div>{count}</div>
  );
}

眼见的小伙伴可能已经发现了几个问题:

  1. 没有写 clearInterval
  2. 直接在闭包中取值 count 会有问题

上面两个问题很好解决,我们稍微修改一下代码即可:

function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const interval = setInterval(() => {
      setCount((count) => count + 1);
    }, 1000);
    return () => clearInterval(interval);
  }, []);
  return (
    <div>{count}</div>
  );
}

useInterval

看似我们两行代码就把问题解决了,然而上面的代码其实依然存在一些隐患,很容易导致后续更新代码的时候出现问题:

  1. 因为闭包问题的存在(严格来说,是因为setInterval中永远执行的是第一次 render 时的匿名函数,没有更新),如果在 setInterval 里面取外面的值,比如 props,那么依然无法取到最新的值
  2. setInterval 的参数不是“响应式的”,比如把1000 改成一个变量,那么要记得在 useEffect 中加一下依赖

关于setInterval 错误用法会导致的问题,以及推荐的正确用法,Dan有一篇较早的博客详细进行了探讨 https://overreacted.io/making-setinterval-declarative-with-react-hooks/

这篇文章中写了一个 useInterval,完整实现如下:

function useInterval(callback, delay) {
  const savedCallback = useRef();

  // Remember the latest callback.
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  // Set up the interval.
  useEffect(() => {
    function tick() {
      savedCallback.current();
    }
    if (delay !== null) {
      let id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
}

useInterval 的实现非常简单,19行代码中解决了几个关键问题:

  1. 通过 useRef 保证引用最新的callback,完美解决了闭包带来的问题
  2. 自动执行 clearInterval
  3. 自动处理 delay 变化

当然我们现在不用复制这段代码,可以直接用 ahooks 提供的 useInterval 即可 https://ahooks.js.org/zh-CN/hooks/use-interval

用 useInterval改造一下我们的代码:

function App() {
  const [count, setCount] = useState(0);
  useInterval(() => {
    setCount(count+1);
  }, 1000);
  return (
    <div>{count}</div>
  );
}

代码看起来干净整洁多了。

解决性能问题

最近实现一个通过轮询更新数据的功能时发现一个问题,这个系统是一个开发工具,几乎一直打开着,但是很多时候会切换到别的页面,在页面不显示的时候,其实没有必要更新,那么用 useInterval 就造成了很大性能浪费。
按这个思路,我用 requestAnimationFrame 模拟实现了 setInterval ,在浏览器每一次执行绘制任务前触发,如果页面没有显示在前台,那么就不会执行回调函数,完美解决了后台不需要刷新的问题。
这里我打算写一个 useRafInterval,在绝大多数场景下可以直接搜索替换掉 useInterval。为了实现 useRafInterval,首先需要实现一个 setRafIntervalcancelRafInterval,用以替代 setIntervalclearInterval

实现 setRafInterval 和 cancelRafInterval

setRafInterval基本思路是在 requestAnimationFrame中判断时间进行循环调用,代码比较简单直接上代码:

interface Handle {
  id: number | NodeJS.Timer;
}

const setRafInterval = function (callback: () => void, delay: number = 0): Handle {
  if (typeof requestAnimationFrame === typeof undefined) {
    return {
      id: setInterval(callback, delay),
    };
  }
  let start = new Date().getTime();
  const handle: Handle = {
    id: 0,
  };
  const loop = () => {
    const current = new Date().getTime();
    if (current - start >= delay) {
      callback(); // 执行回调
      start = new Date().getTime();
    }
    handle.id = requestAnimationFrame(loop);
  };
  handle.id = requestAnimationFrame(loop);
  return handle;
};

需要注意两个地方:

  1. 对于没有RAF的环境,主要是SSR渲染时,需要降级到 setInterval
  2. 由于多次调用了 requestAnimationFrame,所以id是会变化的,这样就没法直接返回id,需要用一个对象包装一下,目前没有找到更优雅的方式 =。=

cancelRafInterval 代码更简单一些:

function cancelAnimationFrameIsNotDefined(t: any): t is NodeJS.Timer {
  return typeof cancelAnimationFrame === typeof undefined;
}

const clearRafInterval = function (handle: Handle) {
  if (cancelAnimationFrameIsNotDefined(handle.id)) {
    return clearInterval(handle.id);
  }
  cancelAnimationFrame(handle.id);
};

这里逻辑上很简单,需要注意的是对id类型的判断,由于typeof cancelAnimationFrame === typeof undefined不是对 handle本身的类型判断,TS无法根据这个条件推断出 handle.id的类型,需要增加一个类型守卫才可以。

React 模块外部,其实可以直接用上面的 setRafIntervalcancelRafInterval 替换掉 setIntervalclearInterval;

实现 useRafInterval

有了 setRafIntervalcancelRafInterval之后,可以着手来实现useRequestInterval了。这里我直接借鉴了ahooksuseInterval的实现,做了一个简单的替换即可:

function useRafInterval(
  fn: () => void,
  delay: number | undefined,
  options?: {
    immediate?: boolean;
  },
) {
  const immediate = options?.immediate;

  const fnRef = useLatest(fn);

  useEffect(() => {
    if (typeof delay !== 'number' || delay < 0) return;
    if (immediate) {
      fnRef.current();
    }
    const timer = setRafInterval(() => {
      fnRef.current();
    }, delay);
    return () => {
      clearRafInterval(timer);
    };
  }, [delay]);
}

想用的小伙伴不用复制上面的代码了,useRafInterval 已经在 ahooks中发布了,详细的使用文档可以参考Ahooks官方文档: https://ahooks.js.org/zh-CN/hooks/use-raf-interval
另外发现提交了这个之后,不知什么时候又多了 useRafTimeoutuseRafState,形成了一个小小的 raf家族

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions