react-use 之 useGetSet:用 getter 函数规避闭包过期值(Stale Closure)陷阱的 React 状态 Hook
【免费下载链接】react-useReact Hooks — 👍项目地址: https://gitcode.com/gh_mirrors/re/react-use
useGetSet是 react-use 提供的一个 React 状态 Hook,它不直接返回原始状态值,而是返回一个get取值函数与一个set设值函数。这一设计专为"状态在嵌套函数(如setTimeout回调、事件处理器)中被读取"的场景打造,能够从根源上规避useState因闭包捕获过期值(stale closure)而引发的计数器错乱等隐蔽 Bug。读完本文,你将掌握useGetSet的完整用法、与useState的对比差异、其源码级实现原理,以及它在"延迟累加"这类典型异步场景中的实战价值。
核心用法:get 代替状态值本身
useGetSet的调用方式与useState相似,接受一个初始值,但返回的是两个函数:
import {useGetSet} from 'react-use'; const Demo = () => { const [get, set] = useGetSet(0); const onClick = () => { setTimeout(() => { set(get() + 1) }, 1_000); }; return ( <button onClick={onClick}>Clicked: {get()}</button> ); };几个关键点:
get是一个函数,调用get()才能拿到当前最新的状态值,因此渲染时也必须写{get()}而非{cnt};set是一个设值函数,用法与useState的 setter 一致;- 在
setTimeout、setInterval、Promise 回调、事件处理器等嵌套函数中读取状态时,直接调用get(),读取到的一定是"那一刻"的最新值,而不是创建回调时闭包捕获的旧值。
该示例同样以可运行的 Storybook 故事形式存在于仓库中(stories/useGetSet.story.tsx),并配有独立测试用例验证其在嵌套函数中的行为(tests/useGetSet.test.ts)。
为什么需要它:useState的闭包过期值陷阱
如果采用useState的朴素写法实现同样的"每次点击 1 秒后自增",快速连点多次时计数器会出问题:
const DemoWrong = () => { const [cnt, set] = useState(0); const onClick = () => { setTimeout(() => { set(cnt + 1) }, 1_000); }; return ( <button onClick={onClick}>Clicked: {cnt}</button> ); };问题根源在于闭包捕获(closure capture):onClick内setTimeout的回调是在"创建的那一刻"捕获了当时的cnt快照。快速连点 3 次时,3 个定时器回调捕获到的cnt都是 0,1 秒后各自执行set(0 + 1),最终计数器只会停在 1,而不是期望的 3。
反观useGetSet版本:回调里写的是set(get() + 1),get()在定时器真正触发时才去读取最新状态,因此 3 次回调依次读到 0、1、2,最终结果是正确的 3。这一行为被仓库测试 tests/useGetSet.test.ts 严格验证:模拟 3 次点击后jest.runAllTimers()快进所有定时器,断言get()返回 3。
源码实现:useRef 存值 + useUpdate 强制渲染
useGetSet的实现非常精简(src/useGetSet.ts):
import { Dispatch, useMemo, useRef } from 'react'; import useUpdate from './useUpdate'; import { IHookStateInitAction, IHookStateSetAction, resolveHookState } from './misc/hookState'; export default function useGetSet<S>( initialState: IHookStateInitAction<S> ): [get: () => S, set: Dispatch<IHookStateSetAction<S>>] { const state = useRef(resolveHookState(initialState)); const update = useUpdate(); return useMemo( () => [ () => state.current as S, (newState: IHookStateSetAction<S>) => { state.current = resolveHookState(newState, state.current); update(); }, ], [] ); }逐行拆解其工作原理:
useRef保存最新状态:state是一个 ref,get()直接返回state.current。ref 对象在整个组件生命周期内保持稳定,且state.current是"实时可读"的——这正是它不怕闭包过期值的根本原因。useMemo保证 get/set 身份稳定:get和set被包裹在依赖为[]的useMemo中,组件每次渲染都返回同一对函数引用,非常适合传给子组件、加入 effect 依赖数组,不会因函数身份变化引发额外重渲染或重复订阅。set写值并强制渲染:set先将新值写入state.current,再调用update()触发一次重渲染,从而让 UI 与内部状态保持同步。
useUpdate:基于 useReducer 的强制更新
useGetSet依赖的update来自 src/useUpdate.ts:
import { useReducer } from 'react'; const updateReducer = (num: number): number => (num + 1) % 1_000_000; export default function useUpdate(): () => void { const [, update] = useReducer(updateReducer, 0); return update; }它通过useReducer每次把计数加 1(取模 1_000_000 防止溢出)来触发一次"无状态变更的强制渲染"。也就是说,useGetSet的状态本体其实存放在 ref 里,React 的 state 机制只负责充当"渲染信号灯"。
参数类型与函数式更新:IHookState 类型体系
从类型签名可以看出,useGetSet的初始值与更新值都支持函数形式。这些类型定义在 src/misc/hookState.ts:
export type IHookStateInitialSetter<S> = () => S; export type IHookStateInitAction<S> = S | IHookStateInitialSetter<S>; export type IHookStateSetter<S> = ((prevState: S) => S) | (() => S); export type IHookStateSetAction<S> = S | IHookStateSetter<S>;- 初始值
IHookStateInitAction<S>:可以传一个普通值,也可以传一个"懒初始化"函数() => S; - 更新值
IHookStateSetAction<S>:可以传一个新值,也可以传一个函数,函数会接收到当前的state.current作为参数。
无论传入的是值还是函数,最终都会经过resolveHookState统一解析(src/misc/hookState.ts):
export function resolveHookState<S, C extends S>( nextState: IHookStateResolvable<S>, currentState?: C ): S { if (typeof nextState === 'function') { return nextState.length ? (nextState as Function)(currentState) : (nextState as Function)(); } return nextState; }解析规则值得注意:
- 传入函数时,若函数声明了参数(
length > 0),则以当前状态为参数调用它,实现函数式更新:set(prev => prev + 1); - 若函数不声明参数(
length === 0),则被当作懒初始化/无参工厂函数直接调用; - 传入普通值时原样返回。
因此useGetSet的set完全支持set(1)、set(prev => prev + 1)两种写法,与useState的函数式更新体验一致。
完整可运行示例
综合以上要点,一个既演示嵌套函数取值、又演示函数式更新的完整组件如下:
import React from 'react'; import {useGetSet} from 'react-use'; const Counter = () => { const [get, set] = useGetSet(0); // 异步延迟累加:规避闭包过期值 const delayedIncrement = () => { setTimeout(() => { set(get() + 1); }, 1_000); }; // 函数式更新:基于最新值翻倍 const double = () => set(prev => prev * 2); return ( <div> <p>当前值:{get()}</p> <button onClick={delayedIncrement}>1 秒后 +1</button> <button onClick={double}>翻倍</button> </div> ); };测试验证:行为即规格
仓库为useGetSet提供了 4 组测试(tests/useGetSet.test.ts),可作为行为规格参考:
| 测试场景 | 断言内容 |
|---|---|
| 初始化 | get、set均为函数(L10-L16) |
| 读取当前值 | get()返回初始值'foo'(L18-L25) |
| 设置新值 | act(() => set('bar'))后get()返回'bar'(L27-L35) |
| 嵌套函数场景 | 连点 3 次、runAllTimers()快进后值为 3(L41-L64) |
最后一组测试正是文档示例的自动化版本:它用jest.useFakeTimers()模拟 1 秒延迟,连续触发 3 次onClick,再快进所有定时器,最终断言get()等于 3,从测试层面坐实了useGetSet对闭包过期值问题的解决能力。
使用注意事项
- 渲染中读取:
useGetSet的set内部通过useUpdate强制重渲染,属于"无状态变更"的渲染,若在渲染阶段直接调用set需遵守 React 渲染约束,常规用法(事件回调/异步回调中调用)不受影响; get只在需要时调用:get是函数而非值,JSX 中必须写成{get()};它不会建立响应式依赖,适合在回调中"按需取最新值";- 与
useGetSetState的区别:react-use 还提供 useGetSetState(文档见 docs/useGetSetState.md),后者针对对象状态合并更新场景,而useGetSet面向任意类型的单一状态,两者可按需选用; - 导出路径:
useGetSet已从库入口统一导出(src/index.ts),直接import {useGetSet} from 'react-use'即可,无需额外配置。
小结
useGetSet用一个极简的"ref 存值 + 强制渲染"方案,化解了 React Hooks 生态中高频出现的闭包过期值问题:凡是在异步回调、定时器、事件监听等嵌套函数里需要读写状态的场景,用get()替代直接的状态引用,就能以最小的心智成本写出行为正确、可预测的代码。其函数身份稳定(useMemo缓存)、支持函数式更新、有测试与 Storybook 故事双重佐证,是 react-use 状态类 Hooks 中兼具实用性与教学价值的典范实现。
【免费下载链接】react-useReact Hooks — 👍项目地址: https://gitcode.com/gh_mirrors/re/react-use
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考