【每日一面】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 的操作,那么按照不传依赖时执行的规则,就会陷入渲染 -> 更新 -> 触发重渲染 -> 更新 -> 触发重渲染......这样的循环。

相关推荐
To_OC7 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
To_OC7 小时前
LC 17 电话号码的字母组合:我的回溯算法,就是从这道题开窍的
javascript·算法·leetcode
vipbic7 小时前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy8 小时前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志8 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble9 小时前
c端系统,其实更像一个信息展示平台
前端
你怎么知道我是队长9 小时前
JavaScript的变量和数据类型介绍
开发语言·javascript·ecmascript
李姆斯10 小时前
管理是否可以被完全量化
前端·产品经理·团队管理
名字还没想好☜11 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司11 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能