news 2026/9/18 19:28:32

react-use 之 useGetSet:用 getter 函数规避闭包过期值(Stale Closure)陷阱的 React 状态 Hook

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
react-use 之 useGetSet:用 getter 函数规避闭包过期值(Stale Closure)陷阱的 React 状态 Hook

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 一致;
  • setTimeoutsetInterval、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)onClicksetTimeout的回调是在"创建的那一刻"捕获了当时的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(); }, ], [] ); }

逐行拆解其工作原理:

  1. useRef保存最新状态state是一个 ref,get()直接返回state.current。ref 对象在整个组件生命周期内保持稳定,且state.current是"实时可读"的——这正是它不怕闭包过期值的根本原因。
  2. useMemo保证 get/set 身份稳定getset被包裹在依赖为[]useMemo中,组件每次渲染都返回同一对函数引用,非常适合传给子组件、加入 effect 依赖数组,不会因函数身份变化引发额外重渲染或重复订阅。
  3. 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),则被当作懒初始化/无参工厂函数直接调用;
  • 传入普通值时原样返回。

因此useGetSetset完全支持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),可作为行为规格参考:

测试场景断言内容
初始化getset均为函数(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对闭包过期值问题的解决能力。

使用注意事项

  • 渲染中读取useGetSetset内部通过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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 19:28:16

汽车电子底层软件实战:Autosar+CAN+Vector工具链闭环开发

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 19:27:51

OpenClaw 手机远程下发高级指令,Base URL 填 TaoToken

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 19:26:27

从P5到Core 2:X86微架构演进中的流水线与缓存之变

简介&#xff1a;一份从 286 时代讲到奔腾 4 NetBurst 架构的 Intel CPU 全系列发展史资料&#xff0c;面向计算机爱好者、硬件初学者及对处理器架构演进感兴趣的技术人员&#xff0c;以时间为主线梳理英特尔与 AMD、Cyrix 的竞争过程&#xff0c;并穿插 P5、P6、NetBurst 等关…

作者头像 李华
网站建设 2026/9/18 19:23:19

axios 投毒排查,把 Codex 通道改到 TaoToken 再查 lockfile

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华