useEffect的几个问题

404 阅读5分钟

什么时候执行清除函数

我们知道,如果在 useEffect 函数中返回一个函数,这个函数就是清除副作用函数,它会在组件销毁的时候执行。但是其实,它会在组件每次重新渲染时执行,并且先执行清除上一个 effect 的副作用。即:

  • 首次渲染不会进行清理,会在下一次渲染,清除上一次的副作用。
  • 卸载阶段也会执行清除操作。

以下面计数器为例:

function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
        setCount(c=>c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return <h1>{count}</h1>;
}
// state count=0
 const id = setInterval(() => {setCount(0=>0 + 1);}, 1000);    // 运行第一个 effect

// state count=1
clearInterval(id); // 清除上一个 effect
const id = setInterval(() => {setCount(1=>1 + 1);}, 1000);     // 运行下一个 effect

// state count=2
clearInterval(id); // 清除上一个 effect
const id = setInterval(() => {setCount(2=>2 + 1);}, 1000);     // 运行下一个 effect

// 组件卸载
clearInterval(id); // 清除最后一个 effect

为什么有时候在useEffect里拿到的props或state是旧的?

useEffect 拿到的总是定义它的那次渲染中的 props 和 state。当useEffect的第二个参数传递 [] 数组的时候,拿到的是旧的值。无依赖项时只在组件挂载时运行一次,并非重新渲染。

1. 不要对useEffect的依赖撒谎

如果你明明使用了某个变量,却没有申明在依赖中,你等于向 React 撒了谎,后果就是,当依赖的变量改变时,useEffect 也不会再次执行:

useEffect(() => {
  document.title = "Hello, " + name;
}, []);// 这个 effect 依赖于 `name` state

看下面的例子:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // 这个 effect 依赖于 `count` state    }, 1000);
    return () => clearInterval(id);
  }, []); // 🔴 Bug: `count` 没有被指定为依赖
  return <h1>{count}</h1>;
}

setInterval 只想执行一次,所以自以为聪明的向 React 撒了谎,将依赖写成 []。 组件初始化执行一次 setInterval,销毁时执行一次 clearInterval,这样的代码以为符合预期,拿到的 count 值永远是初始化的 0

在初始渲染时,执行effect,这里会创建一个闭包,并将 count 的值被保存在该闭包当中,且初值为 0。每隔一秒,回调就会执行 setCount(0 + 1),因此,count 永远不会超过 1。

2. 诚实的代价

将count加入依赖列表就能修复这个bug。

因为useEffect的清除函数式在每次渲染后都会执行,所以每次count发生改变定时器都被重置。1. 频繁 生成/销毁 定时器带来了一定性能负担。

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, [count]);
  return <h1>{count}</h1>;
}

3. 既诚实又高效

(1)使用setState的函数式更新形式

useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

这里是demo

(2)使用ref来保存可变的变量

useRef不仅可以用于 DOM refs,ref 对象是一个 current 属性可变且可以保存任意可变值。(useRef 会在每次渲染时返回同一个 ref 对象,即在组件的整个生命周期内保持不变,变更 .current 属性不会引发组件重新渲染。)

const [count, setCount] = useState(0);
const latestCount = useRef();
useEffect(()=>{
    latestCount.current = count
},[count])
useEffect(() => {
  const id = setInterval(() => {
    setCount(latestCount.current + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

这里是demo

(3)将更新与动作解耦

在一些更加复杂的场景中比如同时依赖了两个state的情况:

const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + step);
  }, 1000);
  return () => clearInterval(id);
}, [step]);

这时可以用useReducer把 state 更新逻辑移到 useEffect 之外,React会保证dispatch在每次渲染中都是一样的。

const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {    
      return { count: count + step, step };  
  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();
  }
}
const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;

useEffect(() => {
  const id = setInterval(() => {
    dispatch({ type: 'tick' }); // 代替 setCount(c => c + step);  
  }, 1000);
  return () => clearInterval(id);
}, []);

这里是demo

为什么会出现重复请求的问题?

  • 通常是没有设置 effect 依赖参数(没有设置依赖,effect会在每次渲染后执行一次),但在effect 里做数据请求并更新状态,引起渲染再次出发effect。
  • 也可能是我们设置的依赖总在改变,如果依赖是个函数(没有用useCallback或者useMemo包一层),在组件内定义的函数每一次渲染都在变。

useEffect 不支持 async function

useEffect(async () => {
   await getPoiInfo(); // 请求数据
}, []);

上面的代码是很常见的需求,但是react并不支持这么做。

effect function 应该返回一个销毁函数(是指return返回的cleanup函数),如果 useEffect 第一个参数传入 async,返回值则变成了 Promise,会导致 react 在调用销毁函数的时候报错。

第二次渲染触发 useEffect 里的回调前,前一次渲染触发的行为都执行完成,返回的清理函数也执行完成。这样逻辑才清楚。而如果是异步的,情况会变得很复杂,可能会很容易写出有 bug 的代码。所以 React 就直接限制了不能 useEffect 回调函数中不能支持 async...await...

useEffect 怎么支持 async...await...?

1. 简单改造

创建一个异步函数(async...await 的方式),然后执行该函数。

useEffect(() => {
  const asyncFun = async () => {
    const count = await fetchData({type,});
    setCount(count);
  };
  asyncFun();
}, [type]);
复制代码

也可以使用 IIFE,如下所示:

useEffect(() => {
  (async () => {
    const count = await fetchData({type,});
    setCount(count);
  })();
}, [type]);

2. 自定义hooks

自定义 hook包裹,然后再effect中通过promise.then调用

// 自定义hook
function useAsyncEffect(effect: () => Promise<void | (() => void)>, dependencies?: any[]) {
  return useEffect(() => {
    const cleanupPromise = effect()
    return () => { cleanupPromise.then(cleanup => cleanup && cleanup()) }
  }, dependencies)
}
// 使用
useAsyncEffect(async () => {
    const count = await fetchData()
    setCount(count)
  }, [fetchData])

或者利用useCallback/useMemo

// 封装
const requestData = useCallback(async () => {
  const count = await fetchData({type,})
  setCount(count)
}, [type]); // type改变会重新生成函数

// 普通接口请求
useEffect(() => { 
  requestData();
}, [requestData]);

高阶版可以参考ahooks库 的 useAsyncEffect