今天重新认识了一下useEffect这个钩子函数,以前的时候只会傻乎乎的用,没想到还跟组件的生命周期有关。
先说说 useEffect 最直观的感受吧。在 class 组件里,我们要处理一些副作用操作,比如数据请求、订阅事件、手动修改 DOM 之类的,通常会放在 componentDidMount、componentDidUpdate 这些生命周期函数里。但用了 useEffect 之后,这些操作好像都能放在一个地方处理了,不用再拆分到不同的生命周期里,这点感觉还挺方便的。
就拿我写的那个计数器例子来说吧。我定义了 count 和 num 两个状态,然后在 useEffect 里写了个 console.log (' 数据变化了 '),并且把 count 和 num 放在了依赖项数组里。
js
const [count, setCount] = useState(0);
const [num, setNum] = useState(0);
useEffect(() => {
console.log('数据变化了');
}, [count, num]);
return (
<div>
{count}
<button onClick={() => setCount(count + 1)}>点击count</button>
<br />
{num}
<button onClick={() => setNum(num + 1)}>点击num</button>
</div>
);
这样一来,不管是点击 count 按钮让 count 变了,还是点击 num 按钮让 num 变了,这个 effect 都会执行,控制台都会打印出 "数据变化了"。这其实就对应着 class 组件中的 componentDidUpdate 生命周期,当组件更新时触发。要是依赖项数组里只放 count,那只有 count 变化时才会执行 effect,num 变化就不会管,这种精确控制还挺实用的。
再试试把依赖项数组空着,也就是 [],这时候 effect 只会在组件挂载的时候执行一次。我用这个特性做了个请求 GitHub 仓库列表的功能,在 effect 里写了个异步函数 fetchRepos,调用 GitHub 的 API 获取数据,然后更新 repos 状态。
js
const [repos, setRepos] = useState([]);
useEffect(() => {
const fetchRepos = async () => {
const response = await fetch('xxx');
const data = await response.json();
setRepos(data);
};
fetchRepos();
}, []);
return (
<ul>
{repos.map(item => (
<li key={item.id}>{item.full_name}</li>
))}
</ul>
);
这就相当于 class 组件里的 componentDidMount 生命周期,只在组件第一次渲染完成后执行一次,不会因为其他状态变化而重复请求,避免了不必要的网络请求,还挺高效的。要是忘了加这个空数组,那每次组件更新的时候都会发起请求,不仅浪费资源,还可能导致一些奇怪的问题。
不过最让我觉得巧妙的是 useEffect 的清理机制,这和组件卸载的生命周期有关。我写了个 Timer 组件,用 setInterval 实现了一个秒数计时器。刚开始的时候,我发现当组件卸载再重新挂载的时候,计时器会变得混乱,后来才知道是因为之前的计时器没有被正确清理,导致了内存泄漏。
js
const Timer = () => {
const [time, setTime] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setTime(prevTime => prevTime + 1);
}, 1000);
return () => {
clearInterval(timer);
console.log('计时器被清理了');
};
}, []);
return <div>已经运行了{time}秒</div>;
};
这时候 useEffect 的返回值就派上用场了,我在 effect 里返回了一个函数,在这个函数里用 clearInterval 清除了计时器。这个返回的函数就对应着 class 组件中的 componentWillUnmount 生命周期,在组件卸载前执行,用于清理资源。然后在父组件里用一个状态控制 Timer 组件的显示和隐藏:
js
const [isTimerOn, setIsTimerOn] = useState(true);
return (
<div>
{isTimerOn && <Timer />}
<button onClick={() => setIsTimerOn(!isTimerOn)}>切换计时器</button>
</div>
);
这样一来,每当点击按钮让 Timer 组件卸载的时候,那个清理函数就会执行,控制台会打印 "计时器被清理了",保证了资源的正确释放,再重新挂载时计时器也能正常工作。
其实,React Hooks 的生命周期可以更细致地理解。组件挂载时,会先初始化状态,然后执行渲染,接着运行所有不带依赖项或依赖项数组为空的 useEffect。就像上面请求 GitHub 仓库列表的例子,组件一挂载,就会执行这个 useEffect 来获取数据。
当组件更新时,也就是状态或 props 发生变化时,会先重新计算状态,然后重新渲染,之后执行依赖项发生变化的 useEffect。比如计数器例子中,count 或 num 变化后,对应的 useEffect 就会执行。而且在执行新的 effect 之前,会先执行上一次 effect 返回的清理函数(如果有的话),这样能避免一些冲突。
组件卸载时,会执行所有 effect 返回的清理函数,确保资源被妥善处理,就像 Timer 组件卸载时,会清除计时器一样。
另外,关于 useEffect 还有个小细节,就是它不能直接用 async/await。刚开始我还纳闷为什么,后来才明白,因为 useEffect 要么不返回值,要么就得返回一个清理函数,而 async 函数返回的是一个 Promise,这就不符合要求了。就像这样写是不行的:
js
// 错误写法
useEffect(async () => {
const response = await fetch('xxx');
const data = await response.json();
setRepos(data);
}, []);
所以正确的做法是在 effect 内部定义一个异步函数,然后再调用它,就像我在请求 GitHub 数据时做的那样,就是前面那段获取仓库列表的代码,那样写才不会有问题。
总的来说,useEffect 把 class 组件中的 componentDidMount、componentDidUpdate 和 componentWillUnmount 这几个生命周期函数的功能整合到了一起,让函数组件拥有了处理副作用的能力。
现在再看 Hooks,发现它确实让 React 组件的编写变得更加灵活和简洁了。不用再写 class,不用再考虑 this 指向的问题,只需要用这些以 use 开头的钩子函数,就能实现各种复杂的功能。虽然刚开始接触的时候会有点不习惯,但一旦上手了,就会发现它的魅力所在。