总结 UseEffect 使用指南

A Guide to useeffect

Posted by Toma on February 10, 2020

原文 useEffect 完整指南

摘要

如何用 useEffect 模拟 componentDidMount 生命周期?

虽然可以使用useEffect(fn, []),但它们并不完全相等。 和componentDidMount不一样,useEffect会捕获 props和state。 所以即便在回调函数里,你拿到的还是初始的props和state。

如何正确地在useEffect里请求数据?[]又是什么?

[] 表示effect没有使用任何React数据流里的值,因此该effect仅被调用一次是安全的。[] 同样也是一类常见问题的来源,也即你以为没使用数据流里的值但其实使用了。你需要学习一些策略(主要是useReducer 和 useCallback)来移除这些effect依赖,而不是错误地忽略它们。

应该把函数当做effect的依赖吗?

一般建议把不依赖props和state的函数提到你的组件外面,并且把那些仅被effect使用的函数放到effect里面。如果这样做了以后,你的effect还是需要用到组件内的函数(包括通过props传进来的函数),可以在定义它们的地方用useCallback包一层。

为什么有时候在effect里拿到的是旧的state或prop呢?

Effect拿到的总是定义它的那次渲染中的props和state。

Effect 更新

每一次渲染都有它自己的事件处理函数
每次渲染都有它自己的Effects

所以下面的两个例子是相等的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Example(props) {
  useEffect(() => {
    setTimeout(() => {
      console.log(props.counter);
    }, 1000);
  });
}

function Example(props) {
  const counter = props.counter;
  useEffect(() => {
    setTimeout(() => {
      console.log(counter);
    }, 1000);
  });
}

在组件内什么时候去读取props或者state是无关紧要的。因为它们不会改变。在单次渲染的范围内,props和state始终保持不变。(解构赋值的props使得这一点更明显。)

当然,有时候你可能想在effect的回调函数里读取最新的值而不是捕获的值。最简单的实现方法是使用refs。

需要注意的是当你想要从过去渲染中的函数里读取未来的props和state,你是在逆潮而动。虽然它并没有错(有时候可能也需要这样做),但它因为打破了默认范式会使代码显得不够“干净”。

1
2
3
4
5
6
7
8
9
10
11
12
13
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);
  });
  // ...

在React中去直接修改值看上去有点怪异。然而,在class组件中React正是这样去修改this.state的。不像捕获的props和state,你没法保证在任意一个回调函数中读取的latestCount.current是不变的。根据定义,你可以随时修改它。这就是为什么它不是默认行为,而是需要你主动选择这样做。

Effect 中的清理

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

effect的清除并不会读取“最新”的props。它只能读取到定义它的那次渲染中的props值

同步,非生命周期

我最喜欢React的一点是它统一描述了初始渲染和之后的更新。这降低了你程序的熵。

useEffect使你能够根据props和state同步React tree之外的东西。

1
2
3
4
5
6
7
8
9
10
function Greeting({ name }) {
  useEffect(() => {
    document.title = 'Hello, ' + name;
  });
  return (
    <h1 className="Greeting">
      Hello, {name}
    </h1>
  );
}

告诉React去比对你的Effects

如果想要避免effects不必要的重复调用,你可以提供给useEffect一个依赖数组参数(deps):

1
2
3
useEffect(() => {
    document.title = 'Hello, ' + name;
}, [name]); // Our deps

关于依赖项不要对React撒谎

1
2
3
4
5
6
7
8
9
10
11
function SearchResults() {
  async function fetchData() {
    // ...
  }

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

  // ...
}

有时候你是这样做了,但可能会引起一个问题。比如,你可能会遇到无限请求的问题,或者socket被频繁创建的问题。解决问题的方法不是移除依赖项。我们会很快了解具体的解决方案。

设置[]为依赖会引入一个bug。React会对比依赖,并且跳过后面的effect

两种诚实告知依赖的方法

第一种策略是在依赖中包含所有effect中用到的组件内的值。让我们在依赖中包含

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

第二种策略是修改effect内部的代码以确保它包含的值只会在需要的时候发生变更。我们不想告知错误的依赖 - 我们只是修改effect使得依赖更少。

让Effects自给自足

我们想去掉effect的count依赖。
在这个场景中,我们其实并不需要在effect中使用count。当我们想要根据前一个状态更新状态的时候,我们可以使用setState的函数形式:

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

setCount(count + 1) 传递了更少的信息,因为它不再被当前的count值“污染”。它只是表达了一种行为(“递增”)。

把函数移到Effects里

如果某些函数仅在effect中调用,你可以把它们的定义移到effect中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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中确实没有再使用组件范围内的任何东西。

你可能不想把getFetchUrl 移到effects中,因为你想复用逻辑。

第一个, 如果一个函数没有使用组件内的任何值,你应该把它提到组件外面去定义,然后就可以自由地在effects中使用

或者, 你也可以把它包装成 useCallback Hook :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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

  // ...
}

useCallback 本质上是添加了一层依赖检查。它以另一种方式解决了问题 - 我们使函数本身只在需要的时候才改变,而不是去掉对函数的依赖。

如果我把query添加到useCallback 的依赖中,任何调用了getFetchUrl的effect在query改变后都会重新运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

  // ...
}

因为fetchData只有在Parent的query状态变更时才会改变,所以我们的Child只会在需要的时候才去重新请求数据。

使用 useCallback,函数完全可以参与到数据流中。我们可以说如果一个函数的输入改变了,这个函数就改变了。如果没有,函数也不会改变。感谢周到的 useCallback,属性比如props.fetchData的改变也会自动传递下去。

类似的,useMemo可以让我们对复杂对象做类似的事情。

1
2
3
4
5
6
7
function ColorPicker() {
  // Doesn't break Child's shallow equality prop check
  // unless the color actually changes.
  const [color, setColor] = useState('pink');
  const style = useMemo(() => ({ color }), [color]);
  return <Child style={style} />;
}

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

我更倾向于把fetchData放在我的effect里(它可以抽离成一个自定义Hook)或者是从顶层引入。我想让effects保持简单,而在里面调用回调会让事情变得复杂。

竞态

经典例子是下面这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Article extends Component {
  state = {
    article: null
  };
  componentDidMount() {
    this.fetchData(this.props.id);
  }
  componentDidUpdate(prevProps) {
    if (prevProps.id !== this.props.id) {
      this.fetchData(this.props.id);
    }
  }
  async fetchData(id) {
    const article = await API.fetchArticle(id);
    this.setState({ article });
  }
  // ...
}

有问题的原因是请求结果返回的顺序不能保证一致。比如我先请求 {id: 10},然后更新到{id: 20},但{id: 20}的请求更先返回。请求更早但返回更晚的情况会错误地覆盖状态值。

这被叫做 竞态,这在混合了async / await(假设在等待结果返回)和自顶向下数据流的代码中非常典型(props和state可能会在async函数调用过程中发生改变)。

Effects并没有神奇地解决这个问题,尽管它会警告你如果你直接传了一个async 函数给effect
你可以直接在清除函数中取消异步请求。
或者,最简单的权宜之计是用一个布尔值来跟踪它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Article({ id }) {
  const [article, setArticle] = useState(null);

  useEffect(() => {
    let didCancel = false;

    async function fetchData() {
      const article = await API.fetchArticle(id);
      if (!didCancel) {
        setArticle(article);
      }
    }

    fetchData();

    return () => {
      didCancel = true;
    };
  }, [id]);

  // ...
}

实践

实现一个 useTimeout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 自定义 hook
const useTimeout = (callback, delay) => {
  const savedCallback = React.useRef();

  React.useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  React.useEffect(() => {
    function tick() {
      savedCallback.current();
    }
    if (delay !== null) {
      let id = setTimeout(tick, delay);
      return () => clearTimeout(id);
    }
  }, [delay]);
};

// 使用样例
const OneSecondTimer = props => {
  const [seconds, setSeconds] = React.useState(0);
  useTimeout(() => {
    setSeconds(seconds + 1);
  }, 1000);

  return <p>{seconds}</p>;
};

ReactDOM.render(<OneSecondTimer />, document.getElementById('root'));

实现一个 useFetch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const useFetch = (url, options) => {
  const [response, setResponse] = React.useState(null);
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    const fetchData = async () => {
      try {
        const res = await fetch(url, options);
        const json = await res.json();
        setResponse(json);
      } catch (error) {
        setError(error);
      }
    };
    fetchData();
  }, []);

  return { response, error };
};

// 使用
const ImageFetch = props => {
  const res = useFetch('https://dog.ceo/api/breeds/image/random', {});
  if (!res.response) {
    return <div>Loading...</div>;
  }
  const imageUrl = res.response.message;
  return (
    <div>
      <img src={imageUrl} alt="avatar" width={400} height="auto" />
    </div>
  );
};

相关文档

各种指南