倒计时
倒计时是一个非常常见的业务场景,但是在 React 中实现起来,却不算简单。
首先我们来看这段倒计时代码,它能否正常执行?
jsx
import { useEffect, useState } from 'react';
export function App() {
const [sec, setSec] = useState(10);
useEffect(() => {
const timer = setInterval(() => {
console.log('倒计时剩余秒数', sec);
setSec(sec - 1);
if (sec <= 0) {
clearInterval(timer);
}
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
return <div className='main'>{sec}</div>;
}
来看看实际表现效果

可以看到计时器在不断执行,但是 sec
的值却没有变。
这是一个很经典的问题:React 闭包陷阱 。
我们来详细分析下:useEffect
的依赖数组 []
是空的,这意味着 effect 只会在组件挂载时执行一次,而不会在 sec
状态更新时重新执行。所以setInterval
回调函数中捕获的 sec
值始终是初始值 10
。
这个问题解决起来也很简单,有两种方案,第一种就是在依赖数组中添加 sec
,这样每次 sec
更新后都会重新触发 useEffect
执行,更新 sec
的值。
jsx
export function App() {
const [sec, setSec] = useState(10);
useEffect(() => {
// ...
}, [sec]); // 添加 sec 作为依赖
return <div className='main'>{sec}</div>;
}
第二种方案。使用函数式更新, 这样每次更新都会基于最新的状态值,而不是被闭包捕获的初始值。
jsx
export function App() {
const [sec, setSec] = useState(10);
useEffect(() => {
const timer = setInterval(() => {
setSec(pre => { // 使用函数式更新
if (pre <= 0) {
clearInterval(timer);
return 0;
}
return pre - 1;
});
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
return <div className='main'>{sec}</div>;
}
什么是闭包
闭包是指函数及其引用的外部词法环境的组合。简单来说,当一个函数能够记住并访问它所在的词法作用域时,即使该函数在其原始作用域之外执行,这就形成了闭包。
这个概念估计大家都看到过很多很多次,但是真正遇到的时候还是一脸懵逼:"还能这样?" 要我说都是 JS 的错,才不是咱们的问题。
再来看一遍这个经典的题目:
js
function createFunctions() {
let funcs = [];
for (var i = 0; i < 3; i++) {
funcs.push(function() {
console.log(i);
});
}
return funcs;
}
const functions = createFunctions();
functions[0](); // 3
functions[1](); // 3
functions[2](); // 3
我们在函数中打印了 i
本意是打印 1 2 3
结果却打印了三次 3
。
这个问题的重点是:闭包捕获的是变量本身,而不是变量在某个时刻的值 。在打印的时刻,i
已经变成了 3
我们无法再获取创建函数那一刻 i
所对应的值。
而这道题的解决方式也特别简单,我们只需要把 var
改成 let
就好了,因为前者是函数作用域,而后者是块级作用域,当我们使用 let i
时,每一次循环都会创建一个新的 i
自然不再会有问题。
深入理解
重温了闭包,再回归我们的问题,为什么在前面的代码,我们没有获取到最新的 sec
?
首先从 useEffect
的实现来看
在 React 的 Fiber 架构中,每个组件对应一个 Fiber 节点,而 useEffect
会创建一个 effect 对象,被添加到 Fiber 节点的 updateQueue
中:
javascript
// React 内部简化实现示意
function mountEffect(create, deps) {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
hook.memoizedState = pushEffect(
HookHasEffect | HookPassive,
create, // 你传入的函数
undefined, // 清理函数占位
nextDeps // 依赖数组
);
}
当组件重新渲染时,React 会比较新旧依赖数组,如果依赖改变就重新创建 effect 对象,否则不更新。
javascript
// React 内部简化实现示意
function updateEffect(create, deps) {
const hook = updateWorkInProgressHook();
if (areHookInputsEqual(nextDeps, prevDeps)) {
// 依赖没变,跳过执行
pushEffect(HookPassive, create, destroy, nextDeps);
return;
}
// 依赖变了,标记这个 effect 需要在提交阶段执行
currentlyRenderingFiber.flags |= PassiveEffect;
hook.memoizedState = pushEffect(
HookHasEffect | HookPassive,
create,
destroy,
nextDeps
);
}
function areHookInputsEqual(nextDeps, prevDeps) {
// 使用 Object.is 算法比较每一项
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (!Object.is(nextDeps[i], prevDeps[i])) {
return false;
}
}
return true;
}
我们的依赖为空数组,则 effect
回调只在组件挂载时执行一次,之后即使 sec
状态变化,也不会重新创建定时器和闭包,它所引用的 sec
永远是开始的那个。
接下来我们再看 setState
让我们看看 useState
在 React 内部是如何工作的,下面是一个简化的实现:
js
// 简化的 useState 实现
function useState(initialState) {
// 获取当前正在渲染的 Fiber 节点
const currentFiber = getCurrentFiber();
// 获取或创建当前的 Hook
const hook = updateWorkInProgressHook();
if (hook.memoizedState === null) {
// 首次渲染,初始化状态
hook.memoizedState = initialState;
hook.baseState = initialState;
}
// 处理更新队列
const newState = processUpdateQueue(hook);
hook.memoizedState = newState;
// 创建 setState 函数
const setState = createSetStateFunction(currentFiber, hook);
return [hook.memoizedState, setState];
}
// 创建 setState 函数
function createSetStateFunction(fiber, hook) {
// 返回一个新的 setState 函数
return function setState(action) {
// 创建更新对象
const update = {
action: action, // 新状态值或更新函数
next: null, // 指向下一个更新
priority: getCurrentPriority(),
};
// 将更新添加到队列
enqueueUpdate(fiber, hook, update);
// 调度重新渲染
scheduleUpdateOnFiber(fiber);
};
}
// 在更新队列处理时
function processUpdateQueue(hook) {
let newState = hook.baseState;
let update = hook.queue;
while (update !== null) {
const action = update.action;
if (typeof action === 'function') {
// 函数式更新:将当前最新状态传给函数
newState = action(newState);
} else {
// 直接更新:使用 action 作为新状态
newState = action;
}
update = update.next;
}
return newState;
}
可以观察到,每一次组件的更新渲染,都会重新执行 setState
此时 state
会被更新引用为 setState()
传的那个值(不传函数的情况下)。
也就是说,我们组件此时的 sec
已经不是初始的 sec
了,useEffect
仍然引用闭包中最初的 sec
所以它的值没有被更新。
所以我们很好理解为什么在修改了依赖数组,就可以拿到最新的值。
那为什么修改为函数式更新也会生效呢?
可以看到 processUpdateQueue
的实现中,如果我们使用函数式更新,传入的值来自 fiber 节点中的 hook
,而不依赖 useEffect
中闭包的值。
所以我们可以通过函数式更新解决这个问题。