一道有挑战性的 React Hook 场景题,考考你的功底

本文分享一个短小而又深刻的 React Hook 场景题,这个例子涉及到:

  • hook 闭包问题

  • state 更新机制

希望看完以后你会对 React 函数组件有更深入的了解。

场景复现

整个 Demo 非常简单,大家可以自己在电脑上尝试一下。

首先,有一个 button 和一个 list:

<div className="App">
  <button onClick={add}>Add</button>
  {list.map(val => val)}
</div>

list 是使用 useState 管理的状态。button 绑定了事件 onClick={add}

点击按钮,会执行 add 方法向 list 中加入一些内容。

export default function App() {
  const [list, setList] = useState([]);

  const add = () => {
    // ...
  };

  return (
    <div className="App">
      <button onClick={add}>Add</button>
      {list.map(val => val)}
    </div>
  );
}

现在页面看起来像这样:

78ab265105b0da3cac5e23bcec88e575.png

我们继续,先在 App 外部定义变量 i

let i = 0;

export default function App() {
  // ...
}

接着重点来看看 add 方法。

调用 add,会向 list 中添加新的 button,新 button 也绑定了 onClick={add}

const add = () => {
  setList(
    list.concat(
      <button 
        key={i} 
        onClick={add}>
        {i++}
      </button>
    )
  );
};

当我们点击「Add 按钮」7 次,会展示:

b8ca6ed9b4a9e159dcb8dbe73c83295d.png

在线示例:https://codesandbox.io/s/awesome-edison-hrfcku?file=/src/App.js

问题

现在问题来了:现在我们点击这些「数字按钮」,页面会怎么展示呢

  • 比如点击 0,页面会如何展示,list 最终结果是什么

  • 点击 6,又会如何展示

你可以先停下来思考一下,再继续往下读。

解答

有的同学可能会认为,点击「数字按钮」后,会有新的 button 被添加到 list 中。

先说结论,这个答案并不正确。

真正的现象是,点击数字按钮后:

  • 列表的长度将会变成 点击的数字 + 1

  • 并且列表最后一个数字会变成 点击之前最大的数字 + 1

文字不太容易理解,举一个🌰。

假设当前列表为:

0dca0afa8585d57a176d09f51ca395dd.png

我们点击 0

  • 列表的长度会变成 0 + 1 = 1

  • 列表最后一个数字会变成 6 + 1 = 7

0929f05dc47b88f041c9d1f93aa484e8.png

如果点击 2

  • 列表长度会变成 2 + 1 = 3

  • 列表最后一个数字会变次 6 + 1 = 7

0616129969656b5fc5e2dbe7694f0474.png为什么会这样呢?

原理剖析

造成这种反直觉现象的原因有两个:

  1. hook 闭包问题

  2. state 更新机制

再来看看点击按钮会调用的 add 函数:

const add = () => {
  setList(
    list.concat(
      <button 
        key={i} 
        onClick={add}>
        {i++}
      </button>
    )
  );
};

当执行 add 函数时,由于访问了外层函数 App 内的变量,所以会根据 App 函数上下文形成闭包,闭包内包括:

  • add 函数

  • list 变量

  • setList 方法

b9697338c297bd5b45f73c216e95945c.pnglist 和 setList 是调用 useState() 返回的。

这里通常有一个误解:多次调用 useState,返回的 list 都是同一个对象。

实际上,useState 返回的 list 都是基于 base state 计算出来的:

current state = base state + update1 + update2 + …

每次会将上一次的 prev state 与 update 进行合并得到新的 current state。

因此,每次调用 useState 返回的 list 都不是同一个对象,它们的内存地址不同。

这会导致每个「数字按钮」的 add 函数处于不同的闭包中,每个闭包当中的 list 都不同。

eead844bf8ade1d37e7ec1ffdb630581.png

而变量 i 是声明在 App 外层的模块级变量,在每个闭包中 i 都是相同的。

let i = 0;

export default function App() {
  // ...
}

所以,在点击 0 时:

  • i 是模块级变量,值为 6

  • list 是闭包中的变量,值为 []

add 函数实际上执行的是:

setList(
  [].concat(
    <button key={7} onClick={add}>{7}</button>
  )
);

所以 list 最终变成了 [7]。

当点击 2 时:

  • i 是模块级变量,值为 6

  • list 是闭包中的变量,值为 [0,1]

add 函数实际上执行的是:

setList(
  [0, 1].concat(
    <button key={7} onClick={add}>{7}</button>
  )
);

所以 list 最终变成了 [0, 1, 7]。

为了方便理解,这里的 [0, 1, 7] 省略了外层的 <button> 标签

如何解决

那么如何解决这个闭包问题,在 list 后面正常拼接 button 呢?

很简单,只要将 list 从闭包中清理出去就可以了,将 setList 参数改为函数形式

之前是:

setList(
  list.concat(
    <button 
      key={i} 
      onClick={add}>
      {i++}
    </button>
  )
);

修改为:

setList(list => 
  list.concat(
    <button 
      key={i} 
      onClick={add}>
      {i++}
    </button>
  )
);

这样,我们点击「Add 按钮」或任意「数字按钮」都会正常在 list 后面拼接新按钮。

16871b5fc5cd21fb41f408ba7f018800.png大家可以通过在线示例来加深理解。

在线示例:https://codesandbox.io/s/awesome-edison-hrfcku?file=/src/App.js

总结

由于 state 的更新机制是:

current state = base state + update1 + update2 + …

所以每次调用 useState,返回值 list 都是不同的对象。

并且由于闭包的存在,每个「数字按钮」add 函数中的 list 都不同。

两者共同作用,造成了这种不符合直觉的现象。

如何解决这种闭包问题呢?我们可以将 setState 改为函数形式,将变量从闭包中清理出去。

参考

  • https://betterprogramming.pub/a-react-hooks-challenge-for-senior-react-developers-f1190e1939ec

  • https://github.com/facebook/react/blob/a8c9cb18b7/packages/react-reconciler/src/ReactFiberClassUpdateQueue.new.js

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值