记一次 hooks 闭包陷阱问题

3,699 阅读4分钟

题图为作者拍于稻城亚丁五色海。

hooks 的闭包陷阱是个老生常谈的问题了,但依然很容易在开发时忽略。近日再次遭遇了这个问题,记录一下。

发现 bug

需求很简单:一个卡片上有多个下载按钮,点击后请求文件地址。大概实现如下:

export default function App() {
	const [loadingMap, setLoadingMap] = useState({});
	const handleClick = async (key) => {
		setLoadingMap({ ...loadingMap, [key]: true });
		await delay(3000);
		setLoadingMap({ ...loadingMap, [key]: false });
	};

	return (
		<div className="App">
			<button onClick={() => handleClick("a")}>
				{loadingMap.a ? "下载中" : "下载a"}
			</button>
			<button onClick={() => handleClick("b")}>
				{loadingMap.b ? "下载中" : "下载b"}
			</button>
		</div>
	);
}

详见:eventHandler 的闭包问题

测试时发现问题:如果连续点击 a 和 b 按钮,当 b 下载结束时,a 按钮会被重置为”下载中”。🙃

显然,这段简单的代码忽略了闭包问题,下图描述了这段逻辑里闭包问题产生的原因:

hooks eventHandler 闭包陷阱问题.png

如图描述,两次按钮点击产生了 4 次 setState,因此导致 4 次 re-render,算上初始化的 render,App 共执行了 5 次。

每次渲染都有独立的 state 上下文。

b 按钮的点击发生在第二次渲染后,对应的闭包中 loadingMap 的值为 {a: true},当前渲染中的 eventHandler 只能获取到本次渲染环境对应的 state。

因此,当在 eventHandler 中通过 setState 触发第五次渲染时,此处的 loadingMap 依然是第二次渲染时的旧值,而非最新的第四次渲染时的值。

解决方案

关键点在于:在执行 setState 时,如何能获取到最新的 state,而不是本次渲染时的 state。

方案一:useRef

解决不了的问题就上 useRef 能让你脱离掉发

useRef 是 react 开发者的老朋友,是解决 hooks 问题的万金油。它能存储一个指向不变的变量。

代码做如下更改:

const loadingMap = useRef({});
const forceUpdate = useForceUpdate();

const setLoading = (key, value) => {
	loadingMap.current[key] = value;
	forceUpdate();
};

详见:eventHandler 的闭包问题(ref处理方案)

😀 如代码所示,把 loadingMap 存在 ref. current 里,就永远能找到它了,所有的渲染里,它的指向都是唯一的。

🐸 唯一的问题是,修改 ref. current 是不会触发 re-render 的,因此需要使用 forceUpdate 强制刷新。

方案二:setState 的函数式更新

函数式更新好,获取 state 没烦恼

setState 可以接收一个函数作为参数,即函数式更新,我们把这个函数称为更新器,这种更新方式不会直接触发 re-render,而是会把更新器推到一个队列里,最后经过计算统一触发一次更新。更新器的参数是上一个计算后该状态的值,可以理解为最新的 state

代码做如下更改:

const setMap = (key, value) => {
	setLoadingMap((v) => ({
		...v,
		[key]: value
	}));
};

const handleClick = async (key) => {
	setMap(key, true);
	await delay(3000);
	setMap(key, false);
};

详见:eventHandler 的闭包问题 (函数式更新处理方案)

😀 可以说非常完美了。函数式更新有如下优点:

  • 总是可以拿到最新的 state
  • 没有依赖,如果要在 useEffect 中更新 state,但又不想因 state 更新触发 effect 时,用函数式更新很合适
  • 批量更新时只会触发一次 re-render,这可以避免不必要的 re-render

干净简洁、没有负担,函数式更新可以说是处女座之友。

🐸 不过函数式更新只能用在 setState 时,如果需要获取到最新的 state 做其他事情(比如发送一个请求),就无法使用这个方案。

方案三:useReducer

useRef 的难兄难弟,解决 hooks 的疑难杂症

reducer 的情况跟 ref 类似,它也是将 state 的状态管理转移了,得以避免 hooks 的闭包陷阱。

代码修改如下:

const reducer = (state, action) => {
	return { ...state, ...action };
};

const [loadingMap, dispatch] = useReducer(reducer, {});

const handleClick = async (key) => 
	dispatch({ [key]: true });
	await delay(3000);
	dispatch({ [key]: false });
};

代码详见:eventHandler 的闭包问题(reducer处理方案)

😀 某种程度上,reducer 被称为 hooks 的作弊模式,它可以解耦业务逻辑和更新方法的关系。和函数式更新一样,它也不需要依赖当前渲染上下文的 state,因此很适合在以下场景使用:需要在 useEffect 这类需要传入依赖的 hooks 中修改状态,但又不想因依赖改变触发 effect 的场景。

🐸 不过使用 useReducer 往往意味着更多的代码逻辑(action、reducer、dispatch... )。

总结

hooks 的闭包陷阱是非常常见的问题,当代码中有【异步逻辑+get/setState】的场景时,需要注意可能产生闭包陷阱。

本文的三种方法除了解决闭包陷阱问题,在批量更新、依赖诚实、避免不必要的 effect 等场景下也都提供了解决方案。

最后,希望 useEvent 尽快到来。

本文首发于个人博客: www.ferecord.com/react-hooks…
如若转载请附上原文地址,以便更新溯源。

本文正在参加「金石计划 . 瓜分6万现金大奖」