React

谈谈React Hooks之useEffect

2020-01-17  本文已影响0人  tracyXia

自从Hooks出现,函数式组件(Function Component)的功能在不断丰富,你很可能已经运用Hooks写了一些组件。

那么,你有时会不会嘀咕类似下面的问题:

在解决上述问题之前,我必须提出本文的目的是想帮你真正地领会useEffect。

在讨论useEffect之前,我们需要先讨论一下函数式组件的渲染(rendering)。

每一次渲染都有它自己的 Props and State

我们来看一个计数器组件Counter:

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

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

按照React Class Component的惯性思维,我们可能会认为count 会“监听”状态的变化并自动更新,从而引起视图的渲染。可事实,并不是这样的,上例中,count仅是一个声明的变量数字而已。它不是神奇的数据绑定, 观察者监听模式, 或者其他任何东西。它就是一个普通的数字。我们的组件第一次渲染的时候,Counter函数执行,从useState()拿到count的初始值0。当我们点击button调用setCount(1),React会再次渲染组件,函数再次执行,这一次count是1,如此等等。一言以蔽之:当我们更新状态的时候,React会重新渲染组件。每一次渲染都能拿到独立的count ,这个值是函数中的一个常量,而并没有做任何特殊的数据绑定。这里关键的点在于任意一次渲染中的count常量都不会随着时间改变。渲染输出会变是因为我们的组件被一次次调用,而每一次调用引起的渲染中,它包含的count值独立于其他渲染。

看下面的例子,来校验你是否有认真吸取上述的内容

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

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  );
}

思考我按照下面的步骤去操作:

那么,此时弹框中的count会是几呢?

接下来,我们具体解析它是如何工作的。再一次强调,我们的Function Component每次渲染都会被调用,所以实际上,每一次渲染都有一个“新版本”的handleAlertClick。每一个版本的handleAlertClick“记住” 了它自己的 count。因此,事件处理函数“属于”某一次特定的渲染,当你点击的时候,它会使用那次渲染中counter的状态值。

在任意一次渲染中,props和state是始终保持不变的。如果props和state在不同的渲染中是相互独立的,那么使用到它们的任何值也是独立的(包括事件处理函数)。它们都“属于”一次特定的渲染。即便是事件处理中的异步函数调用“看到”的也是这次渲染中的count值。

从而,正确的答案就是3。这个值是我点击时候count的值。

到这里,已经解决了前面埋的问题:为什么拿到的是前一次的state或prop?

每次渲染都有它自己的useEffect

言归正传,接下来我们正式开始useEffect的讨论。

让我们继续拓展上面的例子:

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

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

抛一个问题给你:useEffect是如何读取到最新的count 状态值的呢?

也许,是某种“data binding”或“watching”机制使得count能够在useEffect函数内更新?也或许count是一个可变的值,React会在我们组件内部修改它以使我们的useEffect函数总能拿到最新的值?

都不是。我们已经知道count是某个特定渲染中的常量。事件处理函数“看到”的是属于它那次特定渲染中的count状态值。对于useEffect也同样如此:useEffect 函数本身在每一次渲染中都不相同。每一个useEffect版本“看到”的count值都来自于它属于的那次渲染。React会记住你提供的useEffect函数,并且会在每次更改作用于DOM并让浏览器绘制屏幕后去调用它。

我们现在知道useEffect会在每次渲染后运行,此时我们就可以解答第二个问题。

Question:为什么有时出现无限重复请求的问题?

我们来看下面的例子

import React, { useState, useEffect } from 'react';
import axios from 'axios';
function App() {
  const [data, setData] = useState({ hits: [] });
  useEffect(async () => {
    const result = await axios(
      'https://hn.algolia.com/api/v1/search?query=redux',
    );
    setData(result.data);
  });
  return (
    <ul>
      {data.hits.map(item => (
        <li key={item.objectID}>
          <a href={item.url}>{item.title}</a>
        </li>
      ))}
    </ul>
  );
}
export default App;

在该例子中你会毫无意外的跳进无限重复请求的怪圈。

一切,皆因为

effect hook runs when the component mounts but also when the component updates.

首次挂载后我们进入了effect hook,并获取到数据,变更了Function Component中的state,Function Component更新,effect hook再次执行。因此,获取数据->更新state->effect hook执行,again and again。

不过话说回来,在每一次渲染后都去运行所有的useEffect可能并不高效。(并且在某些场景下,它可能会导致无限循环。)所以我们该怎么解决这个问题?

告诉React去比对你的useEffect

useEffect(() => {
    document.title = 'Hello, ' + name;
  }, [name]);

这好比你告诉React:“Hey,我知道你看不到这个函数里的东西,但我可以保证只使用了渲染中的name,别无其他。”,如果当前渲染中的这些依赖项name和上一次运行这个useEffect的时候依赖项name一样,因为没有什么需要同步React会自动跳过这次useEffect:

在前面无限循环的例子中,因为这里useEffect没有使用任何Function Component中的props和state,因此我们可以

....
useEffect(async () => {
    const result = await axios(
      'https://hn.algolia.com/api/v1/search?query=redux',
    );
    setData(result.data);
  }, []);
....

除此之外,我们会发现另一个问题。被async包裹的函数会返回一个promise对象

The async function declaration defines an asynchronous function, which returns an AsyncFunction object. An asynchronous function is a function which operates asynchronously via the event loop, using an implicit Promise to return its result.

但是,effect hook应当return nothing or a clean up function。因此我们会收到如下的警告

Warning: useEffect function must return a cleanup function or nothing. Promises and useEffect(async () => ...) are not supported, but you can call an async function inside an effect.

这就是为什么我们不能在useEffect中使用async 函数。但是我们可以转道间接使用,如下:

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

接下来我们进入之前的第三个问题

Question:如何最优的管理useEffect依赖

需要重点提出的是,像上述例子中将 [ ] 设为useEffect的依赖时,我们必须慎重!!!

举个例子,我们来写一个每秒递增的计数器。直觉上我们会设置依赖为[]。“我只想运行一次useEffect”,这样对吗?

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

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

  return <h1>{count}</h1>;
}

这个例子的效果是只会递增一次。如果你认为的是“只有当我想重新触发useEffect的时候才需要去设置依赖”,那么你就需要警惕了,这种想法是极其错误的!!!

你需要知道依赖是我们给React的暗示,告诉它useEffect中所有需要使用的渲染中的值。在这里,useEffect中使用了count但我们对React撒谎说它没有依赖。这样做迟早会出幺蛾子。

上述的例子之所以只递增一次,是因为在第一次渲染中,count是0。因此,setCount(count + 1)在第一次渲染中等价于setCount(0 + 1)。既然我们设置了[]依赖,useEffect不会再重新运行,定时器中拿到的count总是第一次渲染时的值0,后面每一秒都会调用setCount(0 + 1)

错就错在,我们对React撒谎说我们的useEffect不依赖组件内的任何值,可实际上我们的useEffect有依赖!!!这里我们的useEffect依赖count ,它是组件内的值(不过在effect外面定义)

类似这样的问题,等到写出了bug再来排查,是很难想到的。因此,比较好的做法是将useEffect的依赖如实告知React作为一条硬性规则,并且要列出所有依赖。可以在项目中按此设置lint规则校验,在团队内做硬性规定

解决上述问题,有两种思路:
第一种是依赖中包含所有useEffect中用到的组件内的值,即在依赖中包含count

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

但这并不太理想,每次count变更都会重新运行useEffect,定时器会在每一次count改变后清除和重新设定,这并不理想。

第二种策略是移除不必要的依赖,在这里我们完全可以通过setCount的函数形式来实现当前数据根据上一次渲染时的数据值进行变更。

setCount(count + 1); ----> setCount(c => c + 1); //你可以认为它是在给React“发送指令”告知如何更新状态。

因为我们在useEffect中写了setCount(count + 1)所以count是一个必需的依赖。但是,我们真正想要的是把count转换为count+1,然后返回给React。可是React其实已经知道当前的count。我们需要告知React的仅仅是去递增状态 ,不管它现在具体是什么值。此时useEffect不需要任何依赖,只在第一次渲染中运行一次,但是定时器回调函数可以完美地在每次触发的时候给React发送c => c + 1更新指令。

然而,setCount(c => c + 1) 非常受限于它能做的事。举个例子,如果我们有两个互相依赖的状态,或者我们想基于一个prop来计算下一次的state,它就并不能做到。此时,我们可以用更强大的useReducer

我们来修改上面的例子让它包含两个状态:count 和 step。我们的定时器会每次在count上增加一个step值:

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

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

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  );
}

这个例子目前的行为是修改step会重启定时器。不过,假如我们不想在step改变后重启定时器,我们该如何处理呢?

将状态更新逻辑与描述进行解藕

当你想更新一个状态,并且这个状态更新依赖于另一个状态的值时,你可能需要用useReducer去替换它们。useReducer可以让你把组件内发生了什么(actions)和状态如何响应并更新分开表述。

上述例子,我们可以这样改写:

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();
  }
}
function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { count, step } = state;

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => {
        dispatch({
          type: 'step',
          step: Number(e.target.value)
        });
      }} />
    </>
  );
}

相比于直接在useEffect里面读取状态,此种写法在useEffect中通过dispatch了一个action来描述发生了什么,这使得我们的useEffect和step状态解耦。我们的useEffect不再关心怎么更新状态,它只负责告诉我们发生了什么。更新的逻辑全都交由reducer去统一处理。你可能会问:“这怎么就更好了?”答案是React会保证dispatch在组件的声明周期内保持不变。所以上面例子中不再需要重新订阅定时器。

何时适合将函数移到useEffect中?

举个例子:

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?

  // ...

当然,上面的代码可以正常运行。但是,在组件日渐复杂的迭代过程中我们很难确保它在各种情况下还能正常运行

function SearchResults() {
  const [query, setQuery] = useState('react');

  // Imagine this function is also long
  function getFetchUrl() {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }

  // Imagine this function is also long
  async function fetchData() {
    const result = await axios(getFetchUrl());
    setData(result.data);
  }

  useEffect(() => {
    fetchData();
  }, []);

  // ...
}

假设后期我们做了这样的迭代,添加了新的间接依赖query,如果我们忘记更新useEffect中通过其他函数调用的依赖。那么此时就会出问题

因此,如果某些函数仅在useEffect中调用,我们可以把它们的定义移到useEffect中。

function SearchResults() {
  const [query, setQuery] = useState('react');

  useEffect(() => {
    function getFetchUrl() {
      return 'https://hn.algolia.com/api/v1/search?query=' + query;
    }

    async function fetchData() {
      const result = await axios(getFetchUrl());
      setData(result.data);
    }

    fetchData();
  }, [query]); // ✅ Deps are OK

  // ...
}

这样做的好处是,如果我们后面修改 getFetchUrl增加新的状态,我们更容易察觉。useEffect的设计意图就是要强迫你关注数据流的改变,然后决定我们的useEffect该如何和它同步,而不是忽视它直到我们的用户遇到了bug

eslint-plugin-react-hooks 插件的exhaustive-depslint规则,它会在编码的时候就分析useEffect并且提供可能遗漏依赖的建议。

何时适合将函数提到组件外面去定义?

如果一个函数没有使用组件内的任何值,你应该把它提到组件外面去定义,此时,你不再需要把它设为依赖,因为它们不在渲染范围内,因此不会被数据流影响。它不可能突然意外地依赖于props或state。

考虑下面这个例子:

function SearchResults() {
  // 🔴 Re-triggers all effects on every render
  function getFetchUrl(query) {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }

  useEffect(() => {
    const url = getFetchUrl('react');
    // ... Fetch data and do something ...
  }, [getFetchUrl]); // 🚧 Deps are correct but they change too often

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... Fetch data and do something ...
  }, [getFetchUrl]); // 🚧 Deps are correct but they change too often

  // ...
}

此时getFetchUrl每次渲染都不同,所以我们的依赖数组会变得无用。在这里就适合将getFetchUrl提到组件外面去定义

// ✅ Not affected by the data flow
function 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添加一层校验?

前面我们已经介绍过了,函数式组件内的函数在每次渲染都不同,而useCallback本质上是添加了一层依赖检查。它解决了每次渲染都不同的问题,我们可以使函数本身只在需要的时候才改变。

function SearchResults() {
  const [query, setQuery] = useState('react');

  // ✅ Preserves identity until query changes
  const getFetchUrl = useCallback(() => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, [query]);  // ✅ Callback deps are OK

  useEffect(() => {
    const url = getFetchUrl();
    // ... Fetch data and do something ...
  }, [getFetchUrl]); // ✅ Effect deps are OK

  // ...
}

在这里,如果query 保持不变,getFetchUrl也会保持不变,我们的useEffect也不会重新运行。但是如果query修改了,getFetchUrl也会随之改变,因此会重新请求数据。

这里需要强调的是,到处使用useCallback是件挺笨拙的事。当我们需要将函数传递下去并且函数会在子组件的useEffect中被调用的时候,useCallback 是很好的技巧且非常有用。

类似的,useMemo可以让我们对复杂对象做类似的事情。这里就不细展开了。

useEffect中的清理

有些 useEffect 可能需要有一个清理步骤。本质上,它的目的是消除副作用(effect),比如取消订阅。

思考下面的代码:

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

假设第一次渲染的时候props是{id: 10},第二次渲染的时候是{id: 20}。那么它的执行顺序会是怎样的呢?

我们需要明白React只会在浏览器绘制后运行useEffect。这使得你的应用更流畅因为大多数useEffect并不会阻塞屏幕的更新。useEffect的清除同样被延迟了。上一次的useEffect会在重新渲染后被清除

因此,它的执行顺序是

目前为止,useEffect主要用于数据请求。那么接下来我们进入最后一个问题

Question:如何正确地在useEffect里请求数据?

function App() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('redux');
  const [url, setUrl] = useState(
    'https://hn.algolia.com/api/v1/search?query=redux',
  );
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);
  useEffect(() => {
    const fetchData = async () => {
      setIsError(false);
      setIsLoading(true);
      try {
        const result = await axios(url);
        setData(result.data);
      } catch (error) {
        setIsError(true);
      }
      setIsLoading(false);
    };
    fetchData();
  }, [url]);
  return (
    <Fragment>
      <input
        type="text"
        value={query}
        onChange={event => setQuery(event.target.value)}
      />
      <button
        type="button"
        onClick={() =>
          setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
        }
      >
        Search
      </button>
      {isError && <div>Something went wrong ...</div>}
      {isLoading ? (
        <div>Loading ...</div>
      ) : (
        <ul>
          {data.hits.map(item => (
            <li key={item.objectID}>
              <a href={item.url}>{item.title}</a>
            </li>
          ))}
        </ul>
      )}
    </Fragment>
  );
}

在上例的实践中,我添加了异常提醒以及加载中动效。这里useEffect将在组件初始挂载后和URL state变更时触发,从而获取数据,并实时变更isLoading和isError状态

更进一步思考,我们如何封装我们自己的hook用于数据获取,来提高复用呢?
看下面的例子:

const useDataApi = (initialUrl, initialData) => {
  const [data, setData] = useState(initialData);
  const [url, setUrl] = useState(initialUrl);
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);
  useEffect(() => {
    const fetchData = async () => {
      setIsError(false);
      setIsLoading(true);
      try {
        const result = await axios(url);
        setData(result.data);
      } catch (error) {
        setIsError(true);
      }
      setIsLoading(false);
    };
    fetchData();
  }, [url]);
  return [{ data, isLoading, isError }, setUrl];
};
function App() {
  const [query, setQuery] = useState('redux');
  const [{ data, isLoading, isError }, doFetch] = useDataApi(
    'https://hn.algolia.com/api/v1/search?query=redux',
    { hits: [] },
  );
  return (
    <Fragment>
      <form
        onSubmit={event => {
          doFetch(
            `http://hn.algolia.com/api/v1/search?query=${query}`,
          );
          event.preventDefault();
        }}
      >
        <input
          type="text"
          value={query}
          onChange={event => setQuery(event.target.value)}
        />
        <button type="submit">Search</button>
      </form>
      {isError && <div>Something went wrong ...</div>}
      {isLoading ? (
        <div>Loading ...</div>
      ) : (
        <ul>
          {data.hits.map(item => (
            <li key={item.objectID}>
              <a href={item.url}>{item.title}</a>
            </li>
          ))}
        </ul>
      )}
    </Fragment>
  );
}

在组件中,我们调用事先封装好的hook并传入所需的初始值。该hook输出部分必要的状态如data、isLoading、isError 以及更新状态的方法setUrl。

更进一步,在我们的自定义Hook useDataApi中,我们用于管理数据请求的状态就有data、isError、isLoading,随着业务的推进,我们可能需要添加更多的状态。目前这些状态分别调用useState钩子独立管理。随着状态的增多,我们可以考虑使用useReducer进行统一的管理。上例可以改写成:

const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl);
  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoading: false,
    isError: false,
    data: initialData,
  });
  useEffect(() => {
    let didCancel = false;
    const fetchData = async () => {
      dispatch({ type: 'FETCH_INIT' });
      try {
        const result = await axios(url);
        if (!didCancel) {
          dispatch({ type: 'FETCH_SUCCESS', payload: result.data });
        }
      } catch (error) {
        if (!didCancel) {
          dispatch({ type: 'FETCH_FAILURE' });
        }
      }
    };
    fetchData();
    return () => {
      didCancel = true;
    };
  }, [url]);
  return [state, setUrl];
};

在这里,我们还增加了一个参数didCancel来知道我们组件的mounted/unmounted状态。从而可以终止在unmounted状态时将异步获取的数据更新到状态

通过上面的学习,相信你对useEffect有了更深的理解。

上一篇下一篇

猜你喜欢

热点阅读