React 自从引入 hooks,虽然解决了类组件的一些弊端,但是也引入了一些问题,比如闭包问题。
先看一个例子
import React, { useState, useEffect } from "react";
export default () => {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
console.log("当前值:", count);
}, 1000);
}, []);
return (
<>
count: {count}
<br />
<button onClick={() => setCount((val) => val + 1)}>增加 1</button>
</>
);
};
当增加按钮的时,发现当前值打印始终都是 0,没有发生变化。这就是 React 的闭包问题。
为了维护函数组件的state,React 用链表
的方式来存储函数组件里面的 hooks,并为每一个 hooks 创建了一个对象。hooks 函数执行的顺序是不变的,就可以根据这个链表拿到当前 hooks 对应的 Hook 对象,函数式组件就是这样拥有了state的能力
//hooks对象
type Hook = {
memoizedState: any,//存储上一次组件更新后的state
baseState: any,
baseUpdate: Update<any, any> | null,
queue: UpdateQueue<any, any> | null,
next: Hook | null,//指向下一个 hook 对象
};
回到上面的示例
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
console.log("当前值:", count);
}, 1000);
}, []);
代码第一次执行:执行 useState,count 为 0。执行 useEffect,执行其回调中的逻辑,启动定时器,每隔 1s 输出 当前值: 0
。
点击增加按钮:当state更新时, 链表从头开始重新渲染,,useState 将 Hook 对象 上保存的状态置为 1, 那么此时 count 也为 1 了。执行 useEffect,其依赖项为空,不执行回调函数。但是之前的回调函数还是在的,它还是会每隔 1s 打印count,但这里的 count 还是之前第一次执行时候的 count 值,该count值定时器的回调函数里面被引用了,就形成了闭包一直被保存。
闭包产生的原因:就是当前hooks中没有获取到最新的state
给 useEffect 设置依赖项,重新执行函数,设置新的定时器,拿到最新值。
import React, { useState, useEffect } from "react";
export default () => {
const [count, setCount] = useState(0);
//声明一个定时器
const timer = useRef(null);
useEffect(() => {
//定时器期间,有新操作时,清空旧定时器,重设新定时器
//定时器存在 则清空
if (timer.current) {
clearInterval(timer.current);
}
//重新设定时器,保证打印的永远是最新的值
timer.current = setInterval(() => {
console.log("当前值:", count);
}, 1000);
}, [count]);
return (
<>
count: {count}
<br />
<button onClick={() => setCount((val) => val + 1)}>增加 1</button>
</>
);
};
useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。
useRef 创建的是一个普通JS对象,而且会在每次渲染时返回同一个 ref 对象,当我们变化它的 current 属性的时候,操作的都是同一个对象,所以定时器中能够读到最新的值。
import React, { useState, useEffect } from "react";
export default () => {
const [count, setCount] = useState(0);
const latestCount = useRef(count);
useEffect(() => {
setInterval(() => {
console.log("当前值:", latestCount.current);
}, 1000);
}, []);
return (
<div>
count: {count}
<br />
<button
onClick={() => {
setCount((val) => val + 1);
latestCount.current += 1;
}}
>
增加 1
</button>
</div>
);
基于上述的第二种解决方案,useLatest 这个 hook 随之诞生。它返回当前最新值的 Hook,可以避免闭包问题。实现原理很简单,就是使用 useRef 包一层:
import { useRef } from 'react';
// 通过 useRef,保持每次获取到的都是最新的值
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value;
return ref;
}
export default useLatest;
代码实现
import React, { useState, useEffect } from "react";
import { useLatest } from 'ahooks';
export default () => {
const [count, setCount] = useState(0);
const latestCountRef = useLatest(count);
useEffect(() => {
const interval = setInterval(() => {
console.log("当前值:", latestCountRef.current);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<>
count: {count}
<br />
<button onClick={() => setCount((val) => val + 1)}>增加 1</button>
</>
);
};
React 中另一个闭包场景,是基于 useCallback 的。
const [count, setCount] = useState(0);
const callbackFn = useCallback(() => {
console.log(`Current count is ${count}`);
}, []);
以上不管,我们的 count 的值变化成多少,执行 callbackFn 打印出来的 count 的值始终都是 0。这个是因为回调函数被 useCallback 缓存,形成闭包,从而形成闭包陷阱。
那我们怎么解决这个问题呢?官方提出了 useEvent-- 相当于useCallback的升级版,但该题案没有通过,后续官方还会给出新的解决方法。useEvent保持函数引用不变与访问到最新状态。使用它之后,上面的例子就变成了。
const callbackFn = useEvent(() => {
console.log(`Current count is ${count}`);
});
在 ahooks 中已经实现了类似的功能,useMemoizedFn
是持久化 function 的 Hook,理论上,可以使用 useMemoizedFn 完全代替 useCallback。使用 useMemoizedFn,可以省略第二个参数 deps,同时保证函数地址永远不会变化。
const memoizedFn = useMemoizedFn(() => {
console.log(`Current count is ${count}`);
});
我们来看下它的源码,可以看到其还是通过 useRef 保持 function 引用地址不变,并且每次执行都可以拿到最新的 state 值。
function useMemoizedFn<T extends noop>(fn: T) {
// 通过 useRef 保持其引用地址不变,并且值能够保持值最新
const fnRef = useRef<T>(fn);
fnRef.current = useMemo(() => fn, [fn]);
// 通过 useRef 保持其引用地址不变,并且值能够保持值最新
const memoizedFn = useRef<PickFunction<T>>();
if (!memoizedFn.current) {
// 返回的持久化函数,调用该函数的时候,调用原始的函数
memoizedFn.current = function (this, ...args) {
return fnRef.current.apply(this, args);
};
}
return memoizedFn.current as T;
}
ahooks 是怎么解决 React 的闭包问题的?