React Hooks 使用时要避免的6个错误
Published in:2023-08-25 | category: 前端 React

今天来看看在使用 React hooks 时的一些坑,以及如何避开这些坑。

问题概览:

  1. 不要改变 hooks 的调用顺序;
  2. 不要使用旧的状态;
  3. 不要创建旧的闭包;
  4. 不要忘记清理副作用;
  5. 不要在不需要重新渲染时使用 useState;
  6. 不要缺少 useEffect 依赖。

1. 不要改变 hooks 的调用顺序

下面先来看一个例子:

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
const FetchGame = ({ id }) => {
if (!id) {
return '请选择一个游戏';
}

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


这个组件接收一个参数 id,在useEffect中会使用这个 id 作为参数去请求游戏的信息。并将获取的数据保存在状态变量 game 中。

当组件执行时,会获取导数据并更新状态。但是这个组件有一个警告:

这里是告诉我们,钩子的执行是不正确的。因为当 id 为空时,组件会提示,并直接退出。如果 id 存在,就会调用useStateuseEffect这两个 hook。这样有条件的执行钩子时就可能会导致意外并且难以调试的错误。实际上,React hooks 内部的工作方式要求组件在渲染时,总是以相同的顺序来调用 hook。

这也就是 React 官方文档中所说的:不要在循环,条件或嵌套函数中调用 Hook, 确保总是在你的 React 函数的最顶层以及任何 return 之前调用他们。

解决这个问题最直接的办法就是按照官方文档所说的,确保总是在你的 React 函数的最顶层以及任何 return 之前调用他们:

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
const 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);
};
id && fetchGame();
}, [id]);

if (!id) {
return '请选择一个游戏';
}

return (
<div>
<div>Name: {game.name}</div>
<div>Description: {game.description}</div>
</div>
);
}

这样,无论传入的 id 是否为空,useState 和 useEffect 总会以相同的顺序来调用,这样就不会出错啦~

React 官方文档中的 Hook 规则:《Hook 规则》,可以使用插件eslint-plugin-react-hooks来帮助我们检查这些规则。

2. 不要使用旧的状态

先来看一个计数器的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const Increaser = () => {
const [count, setCount] = useState(0);

const increase = useCallback(() => {
setCount(count + 1);
}, [count]);

const handleClick = () => {
increase();
increase();
increase();
};

return (
<>
<button onClick={handleClick}>+</button>
<div>Counter: {count}</div>
</>
);
}


这里的handleClick方法会在点击按钮后执行三次增加状态变量 count 的操作。那么点击一次是否会增加 3 呢?事实并非如此。点击按钮之后,count 只会增加 1。问题就在于,当我们点击按钮时,相当于下面的操作:

1
2
3
4
5
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};


当第一次调用setCount(count + 1)时是没有问题的,它会将count更新为 1。接下来第 2、3 次调用setCount时,count还是使用了旧的状态(count为 0),所以也会计算出count为 1。发生这种情况的原因就是状态变量会在下一次渲染才更新。

解决这个问题的办法就是,使用函数的方式来更新状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const Increaser = () => {
const [count, setCount] = useState(0);

const increase = useCallback(() => {
setCount(count => count + 1);
}, [count]);

const handleClick = () => {
increase();
increase();
increase();
};

return (
<>
<button onClick={handleClick}>+</button>
<div>Counter: {count}</div>
</>
);
}


这样改完之后,React 就能拿到最新的值,当点击按钮时,就会每次增加 3。所以需要记住:如果要使用当前状态来计算下一个状态,就要使用函数的式方式来更新状态:

1
setValue(prevValue => prevValue + someResult)

3. 不要创建旧的闭包

众所周知,React Hooks 是依赖闭包实现的。当使用接收一个回调作为参数的钩子时,比如:

1
2
useEffect(callback, deps)
useCallback(callback, deps)


此时,我们就可能会创建一个旧的闭包,该闭包会捕获过时的状态或者 prop 变量。这么说可能有些抽象,下面来看一个例子,这个例子中,useEffect 每 2 秒会打印一次 count 的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const WatchCount = () => {
const [count, setCount] = useState(0);

useEffect(() => {
setInterval(function log() {
console.log(`Count: ${count}`);
}, 2000);
}, []);

const handleClick = () => setCount(count => count + 1);

return (
<>
<button onClick={handleClick}>+</button>
<div>Count: {count}</div>
</>
);
}


最终的输出的结果如下:

可以看到,每次打印的 count 值都是 0,和实际的 count 值并不一样。为什么会这样呢?

在第一次渲染时应该没啥问题,闭包 log 会将 count 打印出 0。从第二次开始,每次当点击按钮时,count 会增加 1,但是setInterval仍然调用的是从初次渲染中捕获的 count 为 0 的旧的 log 闭包。log 方法就是一个旧的闭包,因为它捕获的是一个过时的状态变量 count。

这里的解决方案就是,当 count 发生变化时,就重置定时器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const WatchCount = () => {
const [count, setCount] = useState(0);

useEffect(function() {
const id = setInterval(function log() {
console.log(`Count: ${count}`);
}, 2000);
return () => clearInterval(id);
}, [count]);

const handleClick = () => setCount(count => count + 1);

return (
<>
<button onClick={handleClick}>+</button>
<div>Count: {count}</div>
</>
);
}


这样,当状态变量 count 发生变化时,就会更新闭包。为了防止闭包捕获到旧值,就要确保在提供给 hook 的回调中使用的 prop 或者 state 都被指定为依赖性。

4. 不要忘记清理副作用

有很多副作用,比如fetch请求、setTimeout等都是异步的,如果不需要这些副作用或者组件在卸载时,不要忘记清理这些副作用。下面来看一个计数器的例子:

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
const 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)}>
+
</button>
<div>Count: {count}</div>
</>
);
}

const MyApp = () => {
const [show, setShow] = useState(true);

return (
<>
{show ? <DelayedIncreaser /> : null}
<button onClick={() => setShow(false)}>卸载</button>
</>
);
}


这个组件很简单,就是在点击按钮时,状态变量 count 每秒会增加 1。当我们点击+按钮时,它会和我们预期的一样。但是当我们点击“卸载”按钮时,控制台就会出现警告:

修复这个问题只需要使用 useEffect 来清理定时器即可:

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


当我们编写一些副作用时,我们需要知道这个副作用是否需要清除。

5. 不要在不需要重新渲染时使用 useState

在 React hooks 中,我们可以使用useState hook 来进行状态的管理。虽然使用起来比较简单,但是如果使用不恰当,就可能会出现意想不到的问题。来看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const Counter = () => {
const [counter, setCounter] = useState(0);

const onClickCounter = () => {
setCounter(counter => counter + 1);
};

const onClickCounterRequest = () => {
apiCall(counter);
};

return (
<div>
<button onClick={onClickCounter}>Counter</button>
<button onClick={onClickCounterRequest}>Counter Request</button>
</div>
);
}


在上面的组件中,有两个按钮,第一个按钮会触发计数器加一,第二个按钮会根据当前的计数器状态发送一个请求。可以看到,状态变量counter并没有在渲染阶段使用。所以,每次点击第一个按钮时,都会有不需要的重新渲染。

因此,当遇到这种需要在组件中使用一个变量在渲染中保持其状态,并且不会触发重新渲染时,那么useRef会是一个更好的选择,下面来对上面的例子使用 useRef 进行改编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const Counter = () => {
const counter = useRef(0);

const onClickCounter = () => {
counter.current++;
};

const onClickCounterRequest = () => {
apiCall(counter.current);
};

return (
<div>
<button onClick={onClickCounter}>Counter</button>
<button onClick={onClickCounterRequest}>Counter Request</button>
</div>
);
}

6. 不要缺少 useEffect 依赖

useEffect是 React Hooks 中最常用的 Hook 之一。默认情况下,它总是在每次重新渲染时运行。但这样就可能会导致不必要的渲染。我们可以通过给 useEffect 设置依赖数组来避免这些不必要的渲染。

来看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const Counter = () => {
const [count, setCount] = useState(0);

const showCount = (count) => {
console.log("Count", count);
};

useEffect(() => {
showCount(count);
}, []);

return (
<div>Counter: {count}</div>
);
}


这个组件可能没有什么实际的意义,只是打印了 count 的值。这时就会有一个警告:

这里是说,useEffect 缺少一个 count 依赖,这样是不安全的。我们需要包含一个依赖项或者移除依赖数组。否则 useEffect 中的代码可能会使用旧的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const Counter = () => {
const [count, setCount] = useState(0);

const showCount = (count) => {
console.log("Count", count);
};

useEffect(() => {
showCount(count);
}, [count]);

return (
<div>Counter: {count}</div>
);
}

如果 useEffect 中没有用到状态变量 count,那么依赖项为空也会是安全的:

1
2
3
useEffect(() => {
showCount(996);
}, []);
Prev:
向上管理的艺术
Next:
React 组件代码结构如何组织?