今日分享下React知识点:了解 React Hooks 闭包的应用和原理。
React Hooks 是 React 16.8 版本引入的一种新的特性,它允许我们在不编写 class 组件的情况下使用 state 以及其他的 React 功能。其中,最为常用的就是 useState 和 useEffect。在使用 React Hooks 时,由于函数组件没有实例,所以 Hooks 靠的是闭包来访问和更新 state。但是,在使用 Hooks 时,我们需要注意闭包陷阱问题。
什么是闭包陷阱?
闭包是指一个函数可以访问定义在函数外部的变量。在 React 中,Hooks 函数也是闭包,它们可以访问定义在函数外部的变量。React Hooks 的闭包陷阱与普通 JavaScript 中的闭包陷阱类似,但是由于 React Hooks 的设计,使用 Hooks 时可能会遇到一些特定的问题。
React Hooks 中的闭包陷阱主要会发生在两种情况:
-
在 useState 中使用闭包;
-
在 useEffect 中使用闭包。
useState 中的闭包陷阱
在useState中使用闭包,主要是因为useState的参数只会在组件挂载时执行一次。如果我们在useState中使用闭包,那么闭包中的变量值会被缓存,这意味着当我们在组件中更新状态时,闭包中的变量值不会随之更新。
示例
React Hooks 的闭包陷阱发生在 useState 钩子函数中的示例,如下:
function Counter() { const [count, setCount] = useState(0); const handleClick = () => { setTimeout(() => { setCount(count + 1); }, 1000); }; const handleReset = () => { setCount(0); }; return ( <div> <p>Count: {count}</p> <button onClick={handleClick}>Increment</button> <button onClick={handleReset}>Reset</button> </div> ); }
在上面的代码中,我们定义了一个handleClick函数,它使用了一个闭包来缓存count的值。然而,由于闭包中的count值被缓存了,这意味着即使我们在1秒后调用setCount方法来更新count的值,闭包中的count值仍然是旧的值。因此,如果我们点击Increment按钮,即使我们重复点击多次,计数器也只会增加1次。
避免方法
为了解决这个问题,我们需要使用React Hooks提供的更新函数的形式来更新状态。我们可以把handleClick函数改成这样:
const handleClick = () => { setTimeout(() => { setCount(count => count + 1); }, 1000); };
在这个版本的handleClick函数中,我们使用了setCount的更新函数形式。这个函数会接收count的当前值作为参数,这样我们就可以在闭包中使用这个值,而不需要担心它被缓存。
useEffect 的闭包陷阱
在useEffect中使用闭包的问题则是因为useEffect中的函数是在每次组件更新时都会执行一次。如果我们在useEffect中使用闭包,那么这个闭包中的变量值也会被缓存,这样就可能会导致一些问题。
示例
React Hooks 中的闭包陷阱通常发生在 useEffect 钩子函数中,例如:
function App() { const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => { console.log(count); }, 1000); return () => clearInterval(timer); }, []); const handleClick = () => { setCount(count + 1); }; return ( <div> <p>Count: {count}</p> <button onClick={handleClick}>Increment</button> </div> ); }
在这个例子中,我们使用了 useState 和 useEffect Hooks。在 useEffect 回调函数内部,我们使用了一个 setTimeout 函数来更新 count 状态变量。然而,由于 useEffect 只会在组件首次渲染时执行一次,因此闭包中的 count 变量始终是首次渲染时的变量,而不是最新的值。
避免方法
为了避免这种闭包陷阱,可以使用 useEffect Hook 来更新状态。例如,以下代码中,通过 useEffect Hook 来更新 count 的值,就可以避免闭包陷阱:
useEffect(() => { const timer = setInterval(() => { console.log(count); }, 1000); return () => clearInterval(timer); }, [count]);
通过闭包访问和更新 state
在 React 中,class 组件可以使用 this.state 和 this.setState 来管理组件的状态。这是因为 class 组件具有实例,可以将状态存储在实例属性中,以便在组件的生命周期方法和事件处理程序中访问和更新。
而函数组件则没有实例,无法将状态存储在实例属性中。为了解决这个问题,React 引入了 React Hooks,其中最为常用的是 useState。useState 允许我们在函数组件中使用 state,而无需编写 class 组件。
useState 是通过闭包来实现的。当我们调用 useState 时,它会返回一个数组,其中第一个元素是当前状态的值,第二个元素是更新状态的函数。例如:
import React, { useState } from 'react'; const Counter = () => { const [count, setCount] = useState(0); // ... };
在这个例子中,useState 的初始值为 0,useState 的返回值是一个数组 [count, setCount],其中 count 是当前状态的值,setCount 是更新状态的函数。
当我们在组件内部调用 setCount 函数时,React 会在内部使用闭包来访问和更新 count 变量。这是因为,useState 是在组件的顶层作用域中调用的,而 setCount 函数是在组件的事件处理程序中调用的。这意味着,setCount 函数需要访问 count 变量,但是 count 变量无法存储在实例属性中。
为了解决这个问题,React 使用了闭包,将 count 变量保存在内部函数中。当组件重新渲染时,React 会创建一个新的闭包,并将 count 变量的值更新为新的状态值。这个新的闭包会在下一次调用 setCount 函数时被使用。
下面是一个例子,展示了 useState 如何通过闭包来访问和更新 state 的:
import React, { useState } from 'react'; const Counter = () => { const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); }; return ( <> <p>You clicked {count} times</p> <button onClick={handleClick}>Click me</button> </> ); };
在这个例子中,我们调用 useState,并将初始值设置为 0。在组件内部,我们创建了一个 handleClick 函数,并调用 setCount 函数来更新 count 的值。由于 setCount 函数是在 handleClick 函数中调用的,因此需要使用闭包来访问和更新 count 变量。
需要注意的是,由于闭包的作用,如果我们在组件的事件处理程序中访问了过时的 state,可能会导致组件的状态出现错误。为了避免这种情况,我们需要使用 React Hooks 提供的其他功能,例如 useEffect 和 useCallback。这些功能可以帮助我们避免闭包陷阱,确保组件的状态更新正确地渲染到视图上。
从 React Hooks 源码看闭包陷阱
React Hooks 中闭包陷阱的问题源于 useState 等 Hooks 的实现方式。在 React 内部,每个组件都有一个对应的 Fiber 对象,表示组件的渲染状态。useState 等 Hooks 的实现都是基于这个 Fiber 对象的,并且会在 Fiber 对象中存储当前状态值和更新状态的函数。
例如,在 useState Hook 中,会通过调用 useStateImpl 函数来获取当前状态值和更新状态的函数:
function useState(initialState) { const dispatcher = resolveDispatcher(); return dispatcher.useState(initialState); } function useStateImpl(initialState) { const hook = mountState(initialState); return [hook.memoizedState, dispatchAction.bind(null, hook.queue)]; }
其中,mountState 函数是用来初始化 Hook 对象的。它会检查当前 Fiber 对象上是否已经存在对应的 Hook,如果存在的话就直接返回该 Hook,否则就创建一个新的 Hook 对象并存储到当前 Fiber 对象上:
function mountState(initialState) { const currentHook = updateQueue.next; if (currentHook !== null) { updateQueue.next = currentHook.next; return currentHook; } else { const newHook = { memoizedState: typeof initialState === 'function' ? initialState() : initialState, queue: [], next: null, }; if (updateQueue.last === null) { updateQueue.first = updateQueue.last = newHook; } else { updateQueue.last = updateQueue.last.next = newHook; } return newHook; } }
需要注意的是,每个 Hook 对象中都有一个 queue 属性,用来存储更新状态的 action。而 dispatchAction 函数则是用来触发更新的:
function dispatchAction(queue, action) { const update = { action, next: null, }; if (queue.last === null) { queue.first = queue.last = update; } else { queue.last = queue.last.next = update; } scheduleWork(); }
在组件重新渲染时,React 会重新执行函数组件的函数体,从而调用 useState 等 Hook 重新获取状态值和更新状态的函数。由于每次重新渲染都会创建一个新的 Fiber 对象,因此在新的 Fiber 对象上获取到的 Hook 对象和状态值都是新的。
然而,由于更新状态的函数是存储在 Hook 对象中的,因此会造成更新函数的闭包引用的是旧的状态值,而不是最新的状态值。例如,在以下代码中,每次点击按钮都会增加 count 的值,但是打印出来的 count 值却始终为 1,这是因为 setCount 使用的是 count 的初始值,而不是最新的值,因为 setCount 是在一个闭包中定义的:
function Counter() { let count = 0; const [visible, setVisible] = useState(false); function handleClick() { count++; console.log(count); setVisible(!visible); } return ( <> <button onClick={handleClick}>Click me</button> {visible && <div>Count: {count}</div>} </> ); }