React进阶之使用React hook需要避免的5个错误

700 阅读6分钟

翻译: 卷帘依旧

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

react hook useEffect useState

你可能已经阅读了很多有关React hooks的文章了,但是有时候知道如何不使用知道如何使用同样重要。

在这篇文章中,我会列举一些React hooks的使用错误以及如何修复这些错误。

1. 不要改变钩子的调用顺序

在写这篇文章的前几天,我正在编写一个根据id获取游戏数据的组件,以下是FetchGame组件的简单实现:

function FetchGame({ id }) {
    if (!id) {
      return 'Please select a game to fetch';
    }
    const [game, setGame] = useState({ 
      name: '',
      description: '' 
    });
    useEffect(() => {
      const fetchGame = async () => {
        const response = await fetch(`/api/game/${id}`);
        const fetchedGame = await response.json();
        setGame(fetchedGame);
      };
      fetchGame();
    }, [id]);
    return (
      <div>
        <div>Name: {game.name}</div>
        <div>Description: {game.description}</div>
      </div>
    );
}

试一下演示示例

组件FecthGame接收一个propid-要获取的游戏iduseEffect()通过await fetch(/game/${id})获取到了游戏信息并将其保存到变量game中。

打开演示示例并加载几个游戏。组件能够正确地获取游戏,并且根据获取到的数据更新状态,但是Problemstab下有警告-Eslint对hooks的错误执行顺序给出了警告信息:

image.png

问题出现在最先的退出语句中:

function FetchGame({ id }) {
    if (!id) {
      return 'Please select a game to fetch';
    }
    
    // ...
}

如果id为空,组件就会渲染出'Please select a game to fetch'并退出。没有hooks被调用。但是如果id不为空(比如id==='1'),那么useState()useEffect()就会被调用。

条件语句执行hooks会导致意料不到的问题,而且很难调试。React hooks的内部工作原理要求组件在每次渲染的时候总是按相同的顺序执行hooks。

这正是hooks的第一条规则不要在循环、条件或嵌套函数中调用Hooks

解决hooks的错误顺序问题只需要将return语句移到hooks调用语句之后:

function FetchGame({ id }) {
    const [game, setGame] = useState({ 
      name: '',
      description: '' 
    });
    useEffect(() => {
      const fetchGame = async () => {
        const response = await fetch(`/api/game/${id}`);
        const fetchedGame = await response.json();
        setGame(fetchedGame);
      };
      if (id) { 
        fetchGame(); 
      }
    }, [id]);
    if (!id) {
      return 'Please select a game to fetch';
    }
    return (
      <div>
        <div>Name: {game.name}</div>
        <div>Description: {game.description}</div>
      </div>
    );
}

现在,无论id是否为空,useState()useEffect()hook总是会按照相同的顺序被调用。

这是一个帮助避免条件渲染hook的实用性建议:

"在组件主体的顶部执行hooks,逻辑渲染语句移到组件底部。"

eslint-plugin-react-hooks同样能够帮助你强制纠正hooks的执行顺序。

2. 不要使用陈旧的状态

下面的组件MyIncreaser在点击按钮之后增加状态变量count:

function MyIncreaser() {
    const [count, setCount] = useState(0);
    const increase = useCallback(() => {
      setCount(count + 1);
    }, [count]);
    const handleClick = () {
      increase();
      increase();
      increase();
    };
    return (
      <>
        <button onClick={handleClick}>Increase</button>
        <div>Counter: {count}</div>
      </>
    );
}

有趣的部分是handleClick调用了3次状态更新语句。

现在,在打开演示示例之前,我想问你一个问题:如果你点击一次Increase按钮,计数器count会增加3吗?

好的,打开示例并点击Increase按钮一次。

然而,尽管increase()handleClick()内部被调用了3次,count也仅仅增加了1...

问题在于setCount(count + 1)状态更新器,当按钮点击之后,React调用了setCount(count + 1)3次:

const handleClick = () => {
    increase();
    increase();
    increase();
  };
  // same as:
  const handleClick = () => {
    setCount(count + 1);
    // count variable is now stale
    setCount(count + 1);
    setCount(count + 1);
};

第一次调用setCount(count + 1)能够正确更新计数器count + 1 = 0 + 1 = 1,然而,接下来的两次调用setCount(count + 1)也将count设置为1,因为它们都使用了陈旧的状态

陈旧状态的问题可以通过使用函数的方式更新状态来解决。不使用setCount(count + 1),现在最好使用setCount(count => count + 1):

function MyIncreaser() {
    const [count, setCount] = useState(0);
    const increase = useCallback(() => {
      setCount(count => count + 1);
    }, []);
    const handleClick = () {
      increase();
      increase();
      increase();
    };
    return (
      <>
        <button onClick={handleClick}>Increase</button>
        <div>Counter: {count}</div>
      </>
    );
}

试试吧

通过使用更新器函数count => count + 1React会返回最新的真实的状态值。

打开修复好的示例。现在点击Increase按钮更新count为3,结果正如预期。

这是一条避免陈旧状态变量的规则:

如果你需要使用当前状态去计算下一个状态,总是使用函数的方式去更新状态: setValue(prevValue => prevValue + someResult)

3. 不要创建旧状态闭包

React hooks严重依赖闭包的概念,依赖闭包是因为它们丰富的表现性。

快速提醒一下,JavaScript中的闭包是从其词法范围捕获变量的函数。无论闭包在哪里执行,它总是可以从定义它的地方访问变量。

当使用接收回调作为参数的hooks(比如useEffect(callback, deps), useCallback(callback, deps))时,你可能创建了陈旧的闭包-捕获过时状态或 prop变量的闭包。

我们一起来看一个使用useEffect(callback, deps)时创建陈旧闭包的例子。

<WatchCount>组件内部,useEffect()hook每2秒打印一次count的值:

function WatchCount() {
    const [count, setCount] = useState(0);
    useEffect(function() {
      setInterval(function log() {
        console.log(`Count is: ${count}`);
      }, 2000);
    }, []);
    const handleClick = () => setCount(count => count + 1);
    return (
      <>
        <button onClick={handleClick}>Increase</button>
        <div>Counter: {count}</div>
      </>
    );
}

运行示例

打开demo点击Increase按钮,然后观察控制台-无论count的实际值是什么,控制台每隔两秒打印的是Count is : 0

为什么会这个亚子

首次渲染,闭包log捕获的count值是0.

然后,当按钮点击之后,count增加,setInterval仍然调用旧的闭包log,此时闭包捕获的是初始渲染的值0log是陈旧闭包因为它捕获了陈旧状态(过时的)变量count

解决方法是告诉useEffect()闭包log依赖count进而正确地重置计时器:

function WatchCount() {
    const [count, setCount] = useState(0);
    useEffect(function() {
      const id = setInterval(function log() {
        console.log(`Count is: ${count}`);
      }, 2000);
      return () => clearInterval(id);
    }, [count]);
    const handleClick = () => setCount(count => count + 1);
    return (
      <>
        <button onClick={handleClick}>Increase</button>
        <div>Counter: {count}</div>
      </>
    );
}

试试吧

当依赖正确设置的时候,useEffect()count发生变化之后就能更新setInterval()的闭包。

打开示例点击几次增加按钮,控制台会按照count的实际值打印内容。

为了防止闭包捕获旧值:

"确保在回调函数内部用到的state或prop都被设置为依赖"

selint-plugin-react-hooks能够帮助你记得设置正确的hook依赖。

4. 不要使用基础数据的状态

想象这样一种需求场景:我只需要在状态更新时调用副作用,但是不需要在首次渲染时调用。useEffect(callback, deps)总是会在组件挂载之后调用callback: 所以我想避免这种情况。

令我意外的是,我发现了以下解决方法:

function MyComponent() {
    const [isFirst, setIsFirst] = useState(true);
    const [count, setCount] = useState(0);
    useEffect(() => {
      if (isFirst) {
        setIsFirst(false);
        return;
      }
      console.log('The counter increased!');
    }, [count]);
    return (
      <button onClick={() => setCount(count => count + 1)}>
        Increase
      </button>
    );
}

状态变量isFirst标识组件是否为首次渲染。将这样的信息保存在状态中是一个问题-一旦你更新了setIsFirst(false),就会触发重新渲染-没有原因。

是否为首次渲染的信息不应该被保存在状态中。基础设施的数据,比如渲染周期的细节(是否首次渲染,渲染次数),计时器id(setTimeout(), setInterval()),对DOM元素的直接引用,等等,应该使用引用useRef()来保存和更新。

我们将首次渲染的信息保存在引用isFirstRef中:

function MyComponent() {
    const isFirstRef = useRef(true);
    const [count, setCount] = useState(0);
    useEffect(() => {
      if (isFirstRef.current) {
        isFirstRef.current = false;
        return;
      }
      console.log('The counter increased!');
    }, [count]);
    return (
      <button onClick={() => setCounter(count => count + 1)}>
        Increase
      </button>
    );
}

isFirstRef是一个引用,用于保存组件是否为首次渲染,isFirstRef.current属性用于访问和更新引用的值。

重要的是:更新isFirstRef.current = false不会触发重新渲染。

5. 不要忘记清理副作用

很多副作用,比如发送请求或使用计时器,比如setTimeout(),都是异步的。

在组件卸载时或不需要副作用的执行结果时,不要忘记清理副作用。

比如,如果你开启了一个计时器,确保在组件卸载时停止计时器。

以下组件有个“开始增加”按钮,当按钮点击之后,计数器每秒钟加1:

function DelayedIncreaser() {
    const [count, setCount] = useState(0);
    const [increase, setShouldIncrease] = useState(false);
    useEffect(() => {
      if (increase) {
        setInterval(() => {
          setCount(count => count + 1)
        }, 1000);
      }
    }, [increase]);
    return (
      <>
        <button onClick={() => setShouldIncrease(true)}>
          Start increasing
        </button>
        <div>Count: {count}</div>
      </>
    );
}

打开示例,点击开始增加按钮,正如所料,计数器变量每秒钟增1.

然而,在计数器增加的过程中,点击卸载计数器按钮来卸载组件,React会在控制台发出关于更新已卸载组件的状态警告。

image.png

修复DelayedIncreaser组件非常简单:只需要在useEffect()的回调中使用清理函数去停止计时器就可以了:

function DelayedIncreaser() {
    // ...
    useEffect(() => {
      if (increase) {
        const id = setInterval(() => {
          setCount(count => count + 1)
        }, 1000);
        return () => clearInterval(id);
      }
    }, [increase]);
    // ...
}

打开示例。点击开始增加按钮,检查计数器是如何增加的,然后点击卸载计数器按钮:幸亏() => clearInterval(id)清理函数清除了计时器。React没有报错。

也就是说,每次对副作用编码,都要反问一下自己,是否需要清理。计时器,繁重的请求(比如上传文件),sockets-都需要被清理。

6. 总结

开始使用React hooks的最好方法是学习如何使用它们。

但是,您可能会遇到无法理解为什么hooks的行为与预期不同的情况。知道如何使用 React hooks是不够的:你还应该知道如何不使用它们。

第一个不要做的事情就是条件渲染hook或改变hooks的调用顺序。React期望的是,无论props或state是什么,组件总是能够按相同的顺序调用hooks

避免陈旧状态值的方法是使用函数的方式去更新状态。

不要忘记指明接收回调作为参数hooks用到的依赖:比如useEffect(callback, deps)useCallback(callback, deps),这能够解决陈旧闭包问题。

不要将基础数据(比如组件渲染周期信息,setTimeout()setInterval()的id)存储到状态中,经验法则是将这些数据存储到引用中。

不要忘记清理副作用,如果用到的话。

使用useEffect()可能会遇到的另一个可能的陷阱是无限循环,可以查看useEffect中的无限循环陷阱

useful links

我打破了React Hook必须按顺序、不能在条件语句中调用的枷锁

为什么顺序调用对React Hooks非常重要

欢迎评论区留言点赞

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