聊一聊useEffect中的无限循环陷阱

3,725 阅读6分钟

翻译:卷帘依旧

原文地址: dmitripavlutin.com/react-useef…

react hook useEffect

React中的useEffect()钩子可用于处理副作用,比如获取网络请求、直接操作DOM,开始和结束计时器。

尽管useEffect()useState()(状态管理的钩子)一样,是最经常使用到的钩子,useEffect()需要花费一些时间去熟悉和掌握正确的使用方法。

使用useEffect()时一个可能遇到的陷阱是组件渲染的无限循环问题。这篇文章对产生无限循环的场景以及如何避免这些问题出现。

如果你对useEffect()不熟悉,建议你先阅读useEffect入门篇。良好的知识基础可以避免新手犯的错误。

1. 无限循环与副作用更新状态

定义一个包含输入框的函数式组件,现在的工作是对输入框内容改变的次数进行统计和展示。

组件<CountInputChanges />的实现方式如下:

import { useEffect, useState } from 'react';
function CountInputChanges() {
  const [value, setValue] = useState('');
  const [count, setCount] = useState(-1);
  useEffect(() => setCount(count + 1));
  const onChange = ({ target }) => setValue(target.value);
  return (
    <div>
      <input type="text" value={value} onChange={onChange} />
      <div>Number of changes: {count}</div>
    </div>
  )
}

<input type="text" value={value} onChange={onChange} />是一个可控组件,状态变量value存储输入框的值,事件句柄onChange在处理输入框内容变化时同步更新value

我决定使用useEffect()钩子改变count变量,每次由于用户在输入框输入内容导致组件重新渲染时,useEffect(() => setCount(count + 1))都会更新计数器的值。

由于useEffect(() => setCount(count + 1))没有使用依赖参数,() => setCount(count + 1)回调在组件每次渲染后都会执行。

你预料到这个组件会出现的问题了吗?试试运行这个demo看看吧。

这个例子显示了count状态变量不受控制的增加,即使没有在输入框输入任何内容。这就是一个无限循环的例子。

问题的根源在于useEffect()的使用方式:

useEffect(() => setCount(count + 1));

这导致了组件重新渲染的无限循环。

在初始渲染之后,useEffect()执行更新状态的副作用回调,状态的更新触发重新渲染,在重新渲染完成之后,useEffect()再次执行副作用回调并且再次更新状态变量,而状态的更新又回触发重新渲染...如此无限循环。

image.png

1.1 修复依赖参数

无限循环的问题的解决方法可以通过给useEffect(callback, dependencies)传入正确的依赖参数。

代码的目的是统计value变化的次数count,那么可以通过简单地将value作为副作用的依赖传入:

import { useEffect, useState } from 'react';
function CountInputChanges() {
  const [value, setValue] = useState('');
  const [count, setCount] = useState(-1);
  useEffect(() => setCount(count + 1), [value]);
  const onChange = ({ target }) => setValue(target.value);
  return (
    <div>
      <input type="text" value={value} onChange={onChange} />
      <div>Number of changes: {count}</div>
    </div>
  );
}

[value]作为useEffect(..., [value])的依赖,那么count状态变量只有在[value]变化的时候才会更新。这样就解决了无限循环问题。

image.png

在线运行修复后的demo

现在,一旦在输入框中输入内容,状态变量count就能够正确展示输入框内容变化的次数了。

1.2 使用引用

另一个解决无限循环的可选方案是通过使用引用(通过useRef()钩子创建引用)来存储输入框内容变化的次数。

核心思想是引用的更新不会触发组件的重新渲染。

以下是可能的实现:

import { useState, useRef } from 'react';
function CountInputChanges() {
  const [value, setValue] = useState('');
  const countRef = useRef(0);
  const onChange = ({ target }) => {
    setValue(target.value);
    countRef.current++;
  };
  return (
    <div>
      <input type="text" value={value} onChange={onChange} />
      <div>Number of changes: {countRef.current}</div>
    </div>
  );
}

如代码中所示,事件句柄onChange内部的countRef.current++在每次value变化的时候都会执行。引用的变化不会触发重新渲染。

在线运行demo

现在,一旦改变输入框的内容,countRef引用就会更新而且不会触发重新渲染-有效地解决了无限循环问题。

2.无限循环与新对象引用

即使你正确地设置了useEffect()的依赖,在使用对象类型作为依赖的时候你仍然必须十分小心。

比如,以下组件CountSecrets监听用户输入的内容,一旦用户输入关键字'secret'secrets的计数器就会增加并且显示出来。

以下是组件的可能实现:

import { useEffect, useState } from "react";
function CountSecrets() {
  const [secret, setSecret] = useState({ value: "", countSecrets: 0 });
  useEffect(() => {
    if (secret.value === 'secret') {
      setSecret(s => ({...s, countSecrets: s.countSecrets + 1}));
    }
  }, [secret]);
  const onChange = ({ target }) => {
    setSecret(s => ({ ...s, value: target.value }));
  };
  return (
    <div>
      <input type="text" value={secret.value} onChange={onChange} />
      <div>Number of secrets: {secret.countSecrets}</div>
    </div>
  );
}

在线运行demo,在输入框中输入内容,尝试输入'secret'。一旦输入'secret',状态变量secret.countSecrets就会开始不可控地增加。

这也导致了无限循环问题。

为什么会这样?

secret对象作为useEffect(..., [secret])的依赖,在副作用回调函数内部,一旦输入框的值为'secret',状态更新函数就会被调用:

setState(s => ({...s, countSecrets: s.countSecrets + 1}));

状态更新函数增加了计数器countSecrets,但是同时也创建了一个新对象

secret是一个新对象,因而依赖发生了变化。所以useEffect, (..., [secret])又一次被调用,副作用更新了状态变量,进而又会导致一个新的secret对象被创建出来,如此反复。

JavaScript2个对象相同的判断条件是它们同时引用同一个对象(引用地址相同)。

2.1 避免将对象作为依赖

解决创建新对象导致的无限循环问题的最佳方案是避免在useEffect()中使用对象作为依赖:

let count = 0;
useEffect(() => {
  // some logic
}, [count]); // Good!
let myObject = {
  prop: 'Value'
};
useEffect(() => {
  // some logic
}, [myObject]); // Not good!
useEffect(() => {
  // some logic
}, [myObject.prop]); // Good!

修复<CountSecrets>组件中的无限循环问题需要改变依赖,将useEffect(..., [secret])改为useEffect(..., [secret.value])

仅仅在secret.value变化的时候调用副作用回调函数就足够了,以下是组件修复后的代码:

import { useEffect, useState } from "react";
function CountSecrets() {
  const [secret, setSecret] = useState({ value: "", countSecrets: 0 });
  useEffect(() => {
    if (secret.value === 'secret') {
      setSecret(s => ({...s, countSecrets: s.countSecrets + 1}));
    }
  }, [secret.value]);
  const onChange = ({ target }) => {
    setSecret(s => ({ ...s, value: target.value }));
  };
  return (
    <div>
      <input type="text" value={secret.value} onChange={onChange} />
      <div>Number of secrets: {secret.countSecrets}</div>
    </div>
  );
}

运行修复后的版本。在输入框中输入一些内容...一旦输入'secret',计数器就会增加,此时也不会导致无限循环问题了。

3. 总结

useEffect(callback, deps)是一个在组件渲染之后执行回调(副作用)的钩子,如果你不留心注意副作用做了什么,你就可能会触发组件渲染的无限循环问题。

产生无限循环的一个常见的例子是在副作用中更新状态时根本不添加任何依赖参数:

useEffect(() => {
    // Infinite loop!
    setState(count + 1);
});

避免无限循环的一个有效方式是合理设置依赖参数-去控制副作用应该何时运行。

useEffect(() => {
    // No infinite loop
    setState(count + 1);
}, [whenToUpdateValue]);

另外一个可选择的方式是可以使用引用。更新引用不会触发重新渲染:

countRef.current++;

无限循环的另一种常见方式是使用一个对象作为useEffect()的依赖项,并且在副作用中更新该对象(创建一个新对象):

useEffect(() => {
    // Infinite loop!
    setObject({
      ...object,
      prop: 'newValue'
    })
}, [object]);

避免使用对象作为依赖项,而是坚持使用特定的属性(其最终结果应该是一个原始值):

useEffect(() => {
    // No infinite loop
    setObject({
      ...object,
      prop: 'newValue'
    })
}, [object.whenToUpdateProp]);

使用React hooks的其他常见问题是什么?在我之前文章中,我列举了使用React钩子需要避免的5个错误

你还知道使用useEffect()过程中其他引发无限循环陷阱的方式吗?

欢迎评论区留言点赞

小小的,大大的动力❤️❤️❤️