useEffect使用指南

114 阅读6分钟

注:本文是对useEffect完整指南的总结

摘要

  • useEffect执行时机:使用useEffect去调度的任务,任务执行时机是在DOM渲染完成后;这里包含了两个意思:

    • 当前渲染的副作用函数的执行是在当前的DOM渲染完毕之后;
    • 清除上一次的副作用的函数是在当前副作用函数执行之前,但在当前的DOM渲染之后;
    • 清除操作在组件卸载时也会执行;
    graph TD
    本次DOM渲染完成 --> 上一次的副作用清除函数执行 --> 本次副作用函数执行
    

    React的核心思想是DOM渲染优先,防止UI阻塞,副作用函数滞后DOM渲染;

  • useEffect捕获当前渲染的props和state:由于函数的闭包特性,副作用函数内部访问的props和state是该次渲染的props和state,每次渲染都有自己的props和state,以及对应的副作用函数;

  • useEffect的依赖数组,如果副作用函数使用了函数组件内部的数据,则要“如实”地写在依赖数组里:不要对React撒谎,副作用函数使用了函数组件内的数据,却没有写在依赖数组里,这样会造成某些意料之外的问题;

副作用的清理

以前一直以为副作用的清理是在下一次渲染DOM之前。但实际上,上一次副作用的清理函数是在DOM渲染之后; 例如以下代码

useEffect(() => {
    ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
    };
  });

假设第一次渲染的时候props{id: 10},第二次渲染的时候是{id: 20}。之前会认为发生了下面的这些事:

  • React 清除了 {id: 10}的effect。
  • React 渲染{id: 20}的UI。
  • React 运行{id: 20}的effect。

但这种想法是错误的。

React只会在浏览器绘制后运行effects。这样UI才不会被阻塞,而大多数effects并不会阻塞屏幕的更新。

Effect的清除同样会被延迟,直到DOM重新渲染完成才会执行清除操作;

那么正确的执行流程是这样的:

  • React 渲染{id: 20}的UI。
  • 浏览器绘制。我们在屏幕上看到{id: 20}的UI。
  • React 清除{id: 10}的effect。
  • React 运行{id: 20}的effect。

每次渲染都有自己的...所有(props、state、事件监听函数、副作用函数等等)

这里的每次渲染指的是react组件的渲染,而非DOM渲染;

由于函数闭包的特性,每次函数式组件的渲染,都会得到一个新的组件视图描述(虚拟DOM),包含了数据流(props和state以及其他函数内部的变量值)和函数(事件监听函数、effect、定时器、其他API调用);每个函数都会捕获当前渲染的数据流;

如果effect中想要访问最新的props或者state(而非捕获的props和state),最简单的方法是使用refs;refs在函数式组件调用前后会一直保持不变,不会生成新的对象,在refs中保持对数据流的引用,从而可以得到最新的props和state;

  • 通常访问最新的props和state的场景,是在effect函数中执行异步任务,异步任务中需要访问最新的state和props,如下代码
    function Example() {
      const [count, setCount] = useState(0);
      const latestCount = useRef(count);
      useEffect(() => {
        // Set the mutable latest value    
        latestCount.current = count;    
        setTimeout(() => {
          // Read the mutable latest value      
          console.log(`You clicked ${latestCount.current} times`);    
        }, 3000);
      });
    

effect同步props和state给React Tree之外的东西

effect根据当前函数组件中的数据流(props和state)会同步更新React Tree以外的事物,也就是说,除了React Tree之外的事物都属于effect范畴,都由effect去同步;

effect的依赖数组要准确,不要撒谎

每次数据的更新都会运行effect,但很多场景下,可能effect内部并没有使用这些数据,因此没必要每次更新都去运行effect,所以我们给effect指定了依赖数组,告诉effect如果依赖没变,则不需要执行effect函数;

如果设置了依赖项,effect中用到的所有组件内的值都要包含在依赖中。 这包括props,state,函数 — 组件内的任何东西。

只想在挂载的时候运行effect

这可能是我们对依赖数组撒谎最常见的场景了。 这种思想来自于类组件的生命周期,因此即使effect中使用了props或者state,依赖数组里也只是提供了一个空数组,用来表示只在挂载时运行一次effect。 虽然在很多场景下,这样做不会引起问题,但是在某些场景下,可能会有潜在的问题;

例如计数器场景下 按照生命周期和定时器直觉逻辑,在挂载时启动定时器,卸载时清除定时器,可能会写如下的代码:

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

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

实际上,先渲染count=0,然后执行副作用,定时器执行一次,count=1渲染,执行副作用清除(取消定时器),副作用函数没有再次执行,count只会停留在1;

两种添加依赖的方式

在依赖数组中包含所有effect要用到的变量

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

修改effect代码,让依赖的变量更少

修改的思想在于,要在effect中使用最少的状态信息,即让effect尽量与状态解耦。

这样可以做到代码逻辑正确且避免effect多次调用。

例如使用回调函数的方式,而不是直接引用count

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

但最佳的方案是使用useReducer,通过组件发生了什么(actions)与状态的响应和更新分开表述,做到解耦;原因在于:

React会保证dispatch在组件的生命周期内保持不变,类似于refs机制,因此dispatch可以从依赖数组中去除也没问题,React会保证在组件生命周期里dispatch是静态的。

例如step是count递增的变量,step为2,则每次增加2;

const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;

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

对应的reducer:

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();
  }
}

如何将函数作为依赖

在effect中可能会用到组件内定义的函数;

function SearchResults() {
  const [data, setData] = useState({ hits: [] });

  async function fetchData() {
    const result = await axios(
      'https://hn.algolia.com/api/v1/search?query=react',
    );
    setData(result.data);
  }

  useEffect(() => {
    fetchData();
  }, []); // Is this okay?
  // ...

对于函数型依赖,由于函数在每次渲染都是新的,因此即使是将函数作为依赖,并不会改变effect多余的调用行为;

两个原则
如果函数只有effect调用,则将该函数在effect内部定义
function SearchResults() {
  // ...
  useEffect(() => {
    // We moved these functions inside!    
    function getFetchUrl() {      
      return 'https://hn.algolia.com/api/v1/search?query=react';    
    }    
    async function fetchData() {      
      const result = await axios(getFetchUrl());      
      setData(result.data);    
    }
    fetchData();
  }, []); // ✅ Deps are OK
  // ...
}
如果函数除了effect外,还有其他地方使用

有两个原则:

  • 如果函数没有访问组件的数据,将函数定义在组件之外,这样函数就会是静态的;

    // ✅ Not affected by the data flowfunction getFetchUrl(query) {  return 'https://hn.algolia.com/api/v1/search?query=' + query;}
    function SearchResults() {
      useEffect(() => {
        const url = getFetchUrl('react');
        // ... Fetch data and do something ...
      }, []); // ✅ Deps are OK
    
      useEffect(() => {
        const url = getFetchUrl('redux');
        // ... Fetch data and do something ...
      }, []); // ✅ Deps are OK
    
      // ...
    }
    
  • 或者使用useCallback来包装函数;

    function SearchResults() {
      // ✅ Preserves identity when its own deps are the same  const getFetchUrl = useCallback((query) => {    return 'https://hn.algolia.com/api/v1/search?query=' + query;  }, []);  // ✅ Callback deps are OK
      useEffect(() => {
        const url = getFetchUrl('react');
        // ... Fetch data and do something ...
      }, [getFetchUrl]); // ✅ Effect deps are OK
    
      useEffect(() => {
        const url = getFetchUrl('redux');
        // ... Fetch data and do something ...
      }, [getFetchUrl]); // ✅ Effect deps are OK
    
      // ...
    }