【每日一面】React Hooks闭包陷阱

基础问答

问题:谈一谈你对 React Hook的闭包陷阱的理解。

产生问题的原因:JavaScript 闭包特性 + Hooks 渲染机制

闭包的本质:函数能够访问其定义时所在的词法作用域,即使函数在作用域外执行,也可以记住定义时的词法作用域的内容,后续执行时,使用这些信息。

javascript 复制代码
function callback(index) {
  let idx = index;
  let op;
  return (type) => {
    op = type;
    console.log(op);
    switch (type) {
      case 'add':
        idx++;
        break;
      case 'sub':
        idx--;
        break;
    }
    return idx;
  }
}

const fn = callback(8);
console.log(fn('add')); // 9
console.log(fn('sub')); // 8

这里的 idx 正常会在 callback 函数执行结束后释放,但是由于我们返回的是一个函数,函数中依赖这个 idx 变量,所以未能释放,此时这个变量被这个匿名函数持有,而在 fn 变量存续期间,idx 和 op 都是不会释放的,这也就形成了一个闭包。

不过经典闭包还是 for 循环

Hooks 渲染逻辑 :React 组件每次渲染都是独立的快照,可以理解为,每次重新执行相关钩子的时候,组件都会重新生成一个新的作用域。

闭包陷阱 :根据上面两点,React Hooks 的闭包陷阱产生过程应当是这样的,React 在渲染开始前创建了新的状态包(作用域),而我们写代码的时候无意中创建了一个闭包,持有了 React 的当前状态,再下次渲染开始时,React 重新创建了状态包,但是我们在一开始创建的闭包持有的依旧是前一次 React 创建的状态,是旧的,这就是产生闭包陷阱的根源。这里我们以一个具体例子来看:

javascript 复制代码
import { useEffect, useState } from "react"

const App = () => {
  const [count, setCount] = useState(1);

  useEffect(()=> {
    const timer = setInterval(() => console.log(count), 1000);
    return () => clearInterval(timer)
  }, []);

  const addOne = () => {
    setCount(pre => pre+1);
  }

  return (
    <div className="main" > 
      <p>Hello: {count}</p>
      <button onClick={addOne}>+1</button>
    </div>
  )
}

export default App

这里在组件首次渲染的时候,useEffect 帮我们设置了一个定时器,定时器执行的函数持有了外部作用域的 count 变量,产生了一个闭包。

再之后,我们在页面上点击按钮时,触发了 setCount(pre => pre+1) 状态更新,但是由于没有配置 useEffect 的更新依赖,所以定时器还是持有旧的状态包。此时打印的还是 1,没有更新。

闭包陷阱破解方式

  1. 使用 useRef :useRef 在初始化后,是一个形如 { current: xxx } 的不可变对象,不可变可以理解为,这个对象的地址不会发生变化,所以在浅层次的比较(===)中,更新后的前后对象是一个。所以取值的时候,总是能拿到最新的值。
  2. 添加 Hooks 依赖 :在 useEffect 钩子的依赖列表中增加 count,当 count 发生变化的时候,会重新执行 useEffect ,内部的 timer 会重新生成,拿到最新的作用域的值。
  3. 修改 state 为一个对象:类似于 useRef,我们在更新 state 的时候,可以直接把内容写入该对象中,避免直接替换 state 对象。

扩展知识

React 官方要求我们不能将 hooks 用 if 条件判断包裹,其原因是 React 的 Fiber 架构中收集 Hooks 信息的时候是按顺序收集的,并以链表的形式进行存储的。如下示例:

javascript 复制代码
function App() {
  const [count, setCount] = useState(0);
  const [isFirst, setIsFirst] = useState(false);

  useEffect(() => {
    console.log('hello init');
  }, []);

  useEffect(() => {
    console.log('count change: ', count);
  }, [count]);

  const a = 1;
}

示例中存在 4 个 hooks,所以 React 收集完成后形成的链表应当是这样的:

React 为链表节点设计了如下数据结构:

javascript 复制代码
type Hook = {
  memoizedState: any,
  /** 省略这里不需要的内容 */
  next: Hook | null,
};

其中 next 就是链表节点用于指向下一个节点的指针,memoizedState 则是上一次更新后的相关 state。组件更新的时候,hooks 会严格按照这个顺序进行执行,按顺序拿到对应的 Hook 对象,所以如果我们用 if else 包裹了其中一个 hook,就会出现链表执行过程中,Hooks 对象取值错误的情况。

同样的,React 官方告诉我们,如果想在更新的时候拿到当前 state 的值,建议使用回调函数的写法,即:setCount(pre => pre + 1) 这种写法,这个原因,通过 Hook 的数据结构也大致可以判断,因为 memoizedState 存储了前一次更新的数据,使用回调时,这个 memoizedState 就可以作为参数提供给我们,并且保证总是正确的。

面试追问

  1. 能手写一个闭包吗?

参考前文代码。

  1. 使用 useRef 存储值,会有什么问题?

useRef 在初始化后,是形如 { current: xxx } 的对象,这个对象地址不会变化,所以我们监听 ref 是不起作用的,同时,和 useState 不同,useRef 内容的变更不会触发组件重新渲染。

  1. 请谈谈 hooks 在 React 中的更新逻辑?

React 是以链表形式来组织管理 hooks 的,在收集过程中按照顺序组装成链表,然后每次触发状态更新时,会从链表头开始依次判断执行更新。

  1. 那 hooks 中,useState 的更新是同步还是异步?

可以理解为异步的,展开来说,则是: state 更新函数(如触发 setCount)是同步触发的,React 执行更新(即 count 被更新)是异步的。这种设计主要是出于性能考虑,避免重复渲染,减少重绘重排。

  1. useEffect 依赖数组传空数组和不传依赖,二者有什么区别?

空数组:effect 仅在组件首次渲染时执行一次,后续不会再执行,相当于组件挂载阶段。

不传依赖:effect 会在组件首次渲染时、每次重新渲染后都执行。这种形式隐含存在渲染循环的风险,即 effect 中存在修改 state 的操作,那么按照不传依赖时执行的规则,就会陷入渲染 -> 更新 -> 触发重渲染 -> 更新 -> 触发重渲染......这样的循环。

相关推荐
崔庆才丨静觅1 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60612 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了2 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅2 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅3 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅3 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment3 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅4 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊4 小时前
jwt介绍
前端
爱敲代码的小鱼4 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax