傻子都能理解的 React Hook 闭包陷阱

为什么开发时总有一些状态没有得到更新,明明已经更新了状态?挠破头也想不明白。

这时你可能已经陷入了一个React新手朋友常入的坑点------闭包陷阱。

想要理解这一点,还是要从原理层下手。本文会将原理层的代码抽象成非常易懂的简化代码,让大家彻底明白React是怎么把你"耍"了的。

一. 先来发现Hook不能怎样使用

合法的本质:hook在每次组件更新时,必须保证相同的执行顺序。

一切不能保证相同执行顺序的逻辑都不能用。所以只能在组件顶层调用,下面的做法都是非法的:

1. 嵌套

js 复制代码
function MyComponent() {
  function handleClick() {
    const [count, setCount] = useState(0); // ❌ 错误!函数的执行不一定在时候,固然顺序不对。
    // ...
  }
  
  return <button onClick={handleClick}>Click me</button>;
}

2. 循环

js 复制代码
function MyComponent({ items }) {
  for (let i = 0; i < items.length; i++) {
    const [value, setValue] = useState(items[i]); // ❌ 错误!每次循环的次数、循环体的逻辑实现都未必保证每次组件函数执行,都完全一致。
    // ...
  }
  // ...
}

3. 条件语句

js 复制代码
function MyComponent({ shouldFetch }) {
  if (shouldFetch) {
    const [data, setData] = useState(null); // ❌ 错误!更不用说了,每次都可能执行或者不执行
    // ...
  }
  // ...
}

二. 为什么必须保证调用顺序完全一致?

结论是,React依赖Hook的调用顺序来关联渲染状态。比如:useState。

js 复制代码
function Counter({ isVisible }) {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Alice');
}

useState的本质是一个函数,每次调用useState都是创建不同的状态变量,而useState无论调用多少次始终都是同一个函数指针。这也就意味着useState不是"纯函数",每次执行都可能返回完全不同的值, 上面代码中第一次调用返回的是count, setCount,第二次调用返回的是name, setName。为了hook的"非纯函数"的特性,React通过链表来维护了每一个Hook的状态。

useState的逻辑可以被简化成下面的逻辑:

js 复制代码
let currentHook = 0; // 当前Hook的索引,每个state都有一个状态
const memoryCells = []; // 数组,用来存放每次调用useState后创建的值。在React中这里实际应该是链表

function useState(initialState) {
  const hookIndex = currentHook++;
  
  if (!memoryCells[hookIndex]) {
    memoryCells[hookIndex] = initialState;
  }
  
  function setState(state) {/.修改memoryCells[hookIndex]的值./}
  
  return [memoryCells[hookIndex], setState]
}

function renderComponent() {
  currentHook = 0; // 每次渲染重置索引
  // ...调用组件函数
  const [count, setCount] = useState(0); // 返回memoryCells[0]
  const [name, setName] = useState('Alice'); // 返回memoryCells[1]
}

看了上面的代码应该就明白为什么顺序这么重要了。因为每次执行组件函数,都会将currentHook重置为0,每遇到一个useState都会递增currentHook,从而拿取 memoryCellscurrentHook 的值。如果useState的执行顺序混乱,或者执行次数也每次不固定,那么递增的索引会使得每次useState拿到的值都可能是错的。

三. 那结合其他Hooks呢?

其实都差不多,都是同一个hook函数每次调用返回的不一样状态。在React中,这些Hook状态都是通过链表来维护的。但是,所有Hooks都共享一条链表。

比如下面这段代码:

js 复制代码
function App(){
    const [count, setCount] = useState(1)
    const [name, setName] = useState('FDirector')
    useEffect(()=>{
        // ...
    }, [])
    const text = useMemo(()=>{
        return 'ddd'
    }, [])
}

在React中会被建立出这样一条Hooks链表:

那链表上的每个节点的数据结构(每个hook的状态载体)是什么样的?

可以被简化成下面这样,实际React中的实现会非常复杂,我们只需要知道个大概即可:

ts 复制代码
type Hook = {
  memoizedState: any, // 存储当前的组件state,useState能用的上
  baseState: any, 
  baseUpdate: Update<any, any> | null, 
  queue: UpdateQueue<any, any> | null,
  next: Hook | null, // 下一个Hook节点
};

好了,现在让我们抛弃经验意识,从原理层上思考下面的这段代码。渲染完毕后触发click事件,看看结果如何?

js 复制代码
function App(){
    const [count, setCount] = useState(1);
    useEffect(()=>{
        setInterval(()=>{
            console.log(count)
        }, 1000)
    }, [])
    function click(){ setCount(2) }
}

过程如下,稍显墨迹,但很细节,若有错误感谢指点:

  1. useState创建hook节点,memoizedState储存为1,并且将1存储在 第一轮App函数作用域的count变量上。

  2. useEffect创建节点,上一个hook节点的next指向它。当前为初始化阶段,依赖数组为\[\],创建 setInterval 的回调函数,作用域链中包含第一轮App函数作用域 ,count读取的是第一轮App函数作用域中的count。

  3. setInterval回调触发,console第一轮App函数作用域中的count;

  4. 点击事件触发,setCount参数为2,使得useState hook的memoizedState = 2。App函数重新执行,进入第二轮App函数作用域

  5. useState读取hook节点的memoizedState,得到值为2,并将2存储在 第二轮App函数作用域 的count变量上。

  6. useEffect执行,前进hooks链表节点,但依赖数组为\[\],即无依赖变化,无需重新执行useEffect回调。故没有新的setInterval被创建,第一轮App函数作用域 中创建的 interval 也未得到销毁。

  7. setInterval回调触发,读取作用域链,为第一轮App函数作用域 ,得到的count故来自第一轮App函数作用域 , 即依旧为1 !

相关推荐
子兮曰4 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰4 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
前端小万4 天前
写公众号赚了 3000 块后,我做了一款叫 "一键成稿" 的软件
前端·微信小程序
爱勇宝4 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
三十而立洋4 天前
Cookie 详解:从产生到安全,一次讲透
前端·javascript
卡布鲁4 天前
把一个 Vite + Vue3 应用塞进 qiankun (React + Umi3) 主站:十个坑的复盘
前端·javascript·react.js
汉堡大王95274 天前
Jev:不是聊天机器人, 而是一个智能 if 语句
前端·人工智能·后端
梦想很大很大4 天前
从运行事实到回归证据:Workrun 的 Telemetry 与 Evaluation 实践
前端·人工智能·后端
计算机魔术师4 天前
Meta Muse agent 接入 Shopify 的 Shop Pay 实现代理式购物
前端
沙蒿同学5 天前
我用 Go 搭了一条 AI Agent 流水线:从 1 张商品图到一整套淘宝详情页
前端·javascript·后端