用 useEffect 优雅管理 React 中的异步操作

432 阅读4分钟

声明:我的环境是 react-v18.2.0

如果你使用 useEffect 来执行异步函数,同时返回的也是一个异步函数,那么这个时候你就需要注意了。

useEffect 用法

其定义为:useEffect(setup, dependencies?) 。首先 useEffect 的执行时机在组件第一次加载时和依赖更新时。

useEffect(() => {
  console.log("开始执行")
  return () => { // cleanup
    console.log("依赖更新后先执行")
  }
}, deps)

如果组件第一次加载,那么就会在页面中打印:开始执行。当 deps 发生改变时就会打印依赖更新后先执行->开始执行。其中 setup 可以返回一个函数,这个函数被称为 cleanup ,也就是当依赖更新/组件卸载时会先执行 cleanup 函数;这个函数的目的是清除 setup 的副作用,比如事件监听,那么这里 cleanup 就是清除这个事件监听。

如果存在依赖项,那么依赖项改变时先执行 cleanup ,如果 cleanup 函数中包含有依赖项,那么此时的依赖项是之前的值,而执行 setup 里面的值就是最新的值。我们以简单计数器为例。

const [count, setCount] = useState(0);

useEffect(() => {
  console.log("开始执行", count);
  return () => {
    console.log("依赖更新后先执行", count);
  };
}, [count]);

当我点击按钮让 count+1 以后,得到的日志为:依赖更新后先执行 0 -> 开始执行 1

异步需求

先说说什么情况下,我们需要使用到 cleanup 的场景;如果你的 setup 创建的状态需要在依赖更新后把之前创建的状态重置,那么你使用 cleanup 就是非常的明智,比如 setTimeout 监听。

const [count, setCount] = useState(0);

useEffect(() => {
  const time = setTimeout(() => {
    console.log("打印了", count);
  }, 1000);
  return () => {
    clearTimeout(time);
  };
}, [count]);

假如我们需要一个效果,按钮点击后,按钮不再点击 1s 后的只会生效一次 console.log("打印了", count) 这样的逻辑,而且连续点击都不算。那么就需要每次点击之后先清除之前的定时器。还有一个最常见的应用场景就是,事件监听,比如 react-navigation 中监听页面聚焦。

现在有一个需求,在组件加载的时候需要将本地的 state 存入 AsyncStorage 中,当 state 更新的时候需要把之前保存的删除掉。其中我模拟一下这个 AsyncStorage :

const AsyncStorage = {
  items: [] as any[],
  setItem(item: any) {
    return new Promise((resolve) => {
      setTimeout(() => {
        this.items.push(item);
        resolve(undefined);
      }, 500);
    });
  },
  removeItem(item: any) {
    return new Promise((resolve) => {
      setTimeout(() => {
        this.items = this.items.filter((e) => e !== item);
        resolve(undefined);
      }, 800);
    });
  },
  getItems() {
    return this.items;
  },
};

功能很简单, setItemremoveItem 是异步的,我现在实现上面的效果:

useEffect(() => {
  AsyncStorage.setItem(count).then(() => {
    console.log(AsyncStorage.items); // 方便查看日志
  });

  return () => {
    AsyncStorage.removeItem(count);
  };
}, [count]);

我希望 items 永远都是最新的值,比如当 count 为 0 ,那么这里打印 [0] ,当 count 变成 6 时 ,打印应该是 [6] 。我现在试试点击一次,看看效果:

发现居然是两个。下面我一直连续点击:

由于 cleanup 是异步的,所以 cleanup 还没执行完就会开始执行 setup ,这就是导致这样情况的原因。由于 useEffect ,没办法做到让两者按顺序执行;所以只能靠我们自己来完成。下面是我封装好的组件,能实现其对应的功能:

import { useEffect, useRef } from "react";

const useAsyncEffect = (
  setup: () => void | Promise<void | (() => Promise<void>)>
) => {
  const cleanupRef = useRef<(() => Promise<void>) | void>();
  const runsRef = useRef<(() => Promise<void>)[]>([]);
  const runningRef = useRef(false);

  useEffect(() => {
    const run = async () => {
      await cleanupRef.current?.();
      cleanupRef.current = await setup?.();
    };
    runsRef.current.push(run);
    const startRun = async () => {
      if (runningRef.current) {
        return;
      }
      runningRef.current = true;
      while (runsRef.current.length > 0) {
        const tempRun = runsRef.current.shift();
        await tempRun?.();
      }
      runningRef.current = false;
    };
    startRun();
  }, [setup]);
};

export default useAsyncEffect;

下面在组件中这样使用:

const [count, setCount] = useState(0);
  
useAsyncEffect(async () => {
  await AsyncStorage.setItem(count).then((res) => {
    console.log(AsyncStorage.items);
  });
  console.log(AsyncStorage.items);
  return async () => {
    await AsyncStorage.removeItem(count);
  };
});

这样即便连续点击也不会有问题,看看日志效果:

在使用这个的过程中,尽量使用 useCallback 包裹传递进去的函数。

useAsyncEffect(
  useCallback(async () => {
    await AsyncStorage.setItem(count).then((res) => {
      console.log(AsyncStorage.items);
    });
    console.log(AsyncStorage.items);
    return async () => {
      await AsyncStorage.removeItem(count);
    };
  }, [count])
);

因为我没办法知道你的依赖,你通过这种我就不需要关心你的依赖,只要发现你传递函数的引用变了就知道肯定是你的依赖变了。设计这个 hooks 需要考虑两点,

  1. setup 之前需要先执行完 cleanup ,添加的时候还没清理完成就会出现添加的时候还有其他值;
  2. cleanup 之前需要保存 setup 执行完,有可能还没保存你就清除了,这个时候就是无效的。

对于第一点很好弄,只需要把 cleanup 函数保存起来,下次执行的时候先执行完 cleanup 再执行 setup 就没问题了;第二点由于连续点击导致有可能上一次的 setup 还没执行完,下一次的 cleanup 就开始执行,此时的 cleanup 其实还没之前保存的,所以无效。所以我采用队列的方式来实现,这样每一次都只是先把要做的任务压入队列中,至于执行,让队列自己慢慢的做。