前端框架应用hook一度成为趋势。
推出hook的框架,首当其冲就是大名鼎鼎的react
。
但是很多时候hook的不正确使用,总会不自觉地掉入闭包陷阱。
首先我们了解一下hook的闭包陷阱是什么?
首先你要了解闭包,这里详细的请去看我上一篇博客,JavaScript何为闭包,浅谈闭包的形成和意义这篇博客介绍的比较详细了。
然后呢,这里就总结一下吧。
从直观上看,闭包其实就是一个对象,这个对象以key-value的形式存储了函数用到的其它函数作用域里的变量和函数,并且与函数捆绑在一起,使得函数无论在什么地方被调用,都能访问到这些变量和函数,并且在函数存在期间,闭包也会存在,这也使得闭包内的变量和函数是可以访问的,导致了这些变量和函数不会GC回收。
闭包就是将函数与函数周围的环境进行一个捆绑,这个捆绑在一起的组合就是闭包。
ok那么我们来看看react hook的闭包陷阱是什么?
首先看个正常的组件
function App() {
const [count, setCount] = useState(0);
return (
<div
onClick={() => {
setCount(count + 1);
}}
>
{count}
</div>
);
}
ok 没有问题
正常哈,但是能有个问题就是onClick
这个事件函数,每次组件重渲染都是重新生成一个函数,那么我们会想到保存这个函数的引用。其实useCallback
或useMemo
都行,这里我们用useCallback
。
function App() {
const [count, setCount] = useState(0);
const onClick = useCallback(() => {
setCount(count + 1);
}, []);
return <div onClick={onClick}>{count}</div>;
}
这里我们发现出问题了,组件只能加到1?
为什么呢?
答案很简单,就是因为闭包,首先函数引用没有变。
那么闭包什么样子?
onClick --- closure : {count : 0}
然后我们点击时候,执行了什么
setCount(0 + 1)
然后有人就说那count是不是变了?
首先我问一句,const变量有可能改变吗?
变的是什么
变的是重渲染时函数组件里的count,这里也不能说它改变,它就是重新生成了一个count。又是另外一个新的函数里的东西了,跟上一个函数的东西没有什么关系。
所以那个闭包里的还是什么?
是不是
还是上一次函数里的count,那值是不是还是{count : 0}
然后每次就是执行setCount(0 + 1)
。
自然就出现与我们想的不一样的结果。
怎么解决?
一开始不就是解决了吗
每次函数组件重渲染,重新生成一个函数。
这个新的函数就与新的count重新生成闭包,不就没问题了
function App() {
const [count, setCount] = useState(0);
const onClick = useCallback(() => {
setCount(count + 1);
}, [count]);
return <div onClick={onClick}>{count}</div>;
}
没问题了
可问题又来了,我想优化啊。不想每次都重新生成新的函数啊。这是待会的话题。我们待会讲。
先讲上一个问题,如何尽可能避免hook带来的闭包陷阱。
其实究其原因,就是effect hook 或其它 hook第二个参数带来的问题,第二个参数是什么?这里我拿effect hook来讲,其它hook自行类比。第二参数是依赖项数组,就是这个hook依赖哪些东西,重渲染时,hook重新执行了,它会去看这些依赖的东西有没有变化了,有变化了会重新运行第一个参数函数。这里进行对比的过程是浅对比。都是题外话,跟这一章没什么关系。
上面我们就是为了尽可能优化,而用useCallback
去限制函数的重新生成,也正是因为没有依赖项或者说依赖项不全才导致陷入闭包陷阱问题。
这里官方也说了,尽可能把hook的所有依赖项都写进去。
我们遵守这个规范,那么其实就可以避免很多hook闭包陷阱。
ok 解释完了hook闭包陷阱的产生和避免。
接下来就聊一个问题,如何在避免闭包陷阱的情况下还能进行一些优化?
首先这是我自己在开发过程进行的总结。都是个人见解。兄弟们就当看个意思。
还是上面那个例子。
我们用类组件来实现一下。
export default class App extends PureComponent {
state = {
count: 0,
};
onClick = () => {
this.setState({
count: this.state.count + 1,
});
};
render() {
return <div onClick={this.onClick}>{this.state.count}</div>;
}
}
我们看到一样的功能。
每次重渲染也都不会生成新的事件函数,也不会有闭包问题。
那类组件是怎么做到的呢?
类组件比起函数组件有一个天然的优势,就是this,这个天然的引用值,在整个组件的生命周期过程中并不会发生改变。
那么我们用一个在组件生命周期过程中都不会发生改变的引用值来处理不就行了吗。那肯定是用useRef
。
function App() {
const countRef = useRef(0);
const onClick = useCallback(() => {
countRef.current++;
console.log(countRef.current);
}, []);
return <div onClick={onClick}>{countRef.current}</div>;
}
可以看到没有因为闭包带来的问题
但是组件内容并不会发生变化,因为ref.current
的改变并不会引起函数组件的重渲染。
那还是要与state hook
进行结合。
function App() {
const [count, setCount] = useState(0);
const countRef = useRef(0);
const onClick = useCallback(() => {
setCount(++countRef.current);
}, []);
return <div onClick={onClick}>{count}</div>;
}
这样子就既避开了闭包陷阱也进行了优化。
如果你已经听到这里,下面其实是我搞的一个案例。
但如果是这个章节要说的,其实本质上大概就就这么多。
但我还是希望你继续看下去,也许会有收获。
最近我也是封装一个工具库yuxuannnn_utils和一个基于antd的组件库react-xs-component,有兴趣的朋友也可以去下载来用一下。
这个其实之前有讲过,但是吧,我看了一下,有错误,而且讲的不咋地,所以那篇博客,我删了,然后在这里加一下。
这里是闲聊
有人可能会说,我只要按照那个react 官方建议,把所有依赖项都写上,那就没问题了吧。
确实,但是在开发过程中,并不代表一定不会有,甚至可能避不开这个闭包。其实我之前看了一遍文章,有人说react有难啃的闭包,不好用,这里我想说一句,闭包是react的?只要你用js,几乎避不开会用到闭包的场景。
这里我想对那个发表言论的兄弟说,react很好,哪怕它不好,也肯定不是因为闭包,你因为闭包而否定react,只能说你js学的不咋地,当然我js也学的不怎么样。防止被喷。
当时我想封装一个关于count的一个hook,但是后面因为一些场景,就想着对外暴露节流的api。
但是就出问题了。
这里我把api都简化一下,主要是想说明问题。
const useCount = (max: number, initCounter?: number, duration?: number) => {
const [count, setCount] = useState(
initCounter === undefined ? 0 : initCounter
);
duration = duration !== undefined ? duration : 1000;
const handleIncrease = () => {
setCount(count + 1);
};
return {
count,
handleIncrease,
};
};
没什么问题哦
我加一下节流
const useCount = (max: number, initCounter?: number, duration?: number) => {
const [count, setCount] = useState(
initCounter === undefined ? 0 : initCounter
);
duration = duration !== undefined ? duration : 1000;
const handleIncrease = () => {
setCount(count + 1);
};
return {
count,
handleIncrease,
handleIncreaseWithDebounce: throttle(handleIncrease, duration),
};
};
function App() {
const { count, handleIncreaseWithDebounce } = useCount(100, 0);
return <div onClick={handleIncreaseWithDebounce}>{count}</div>;
}
会发现节流压根不起作用
这是因为每次hook重新执行,throttle
也是重新执行,导致lastTime
每次都是0,一直都是符合函数执行条件。
const throttle = (func, duration) => {
let lastTime = 0;
return function (...args) {
const nowTime = Date.now();
if (nowTime - lastTime >= duration) {
func.apply(this, args);
lastTime = nowTime;
}
};
};
那就要用到useCallback了
const handleIncreaseWithDebounce = useCallback(
throttle(handleIncrease, duration),
[]
);
但是吧,这样又会有闭包问题,handleIncrease
引用不变,连带着闭包里的count也是不变的。
那只能在保证handleIncrease
引用不变的情况,解决闭包带来的问题。
这里当时我用的就是ref
。
const useCount = (max: number, initCounter?: number, duration?: number) => {
const [count, setCount] = useState(
initCounter === undefined ? 0 : initCounter
);
const countRef = useRef(initCounter === undefined ? 0 : initCounter);
duration = duration !== undefined ? duration : 1000;
const handleIncrease = useCallback(() => {
setCount(++countRef.current);
}, []);
const handleIncreaseWithDebounce = useCallback(
throttle(handleIncrease, duration),
[]
);
return {
count,
handleIncrease,
handleIncreaseWithDebounce,
};
};
这样就解决了问题,也应用了节流函数。
从上面来看,hook 的闭包问题其实并不是说,在做react优化才会遇到,我们做一些前端常见的优化也会遇到。
上面的这种方法,其实就是用一个组件生命周期内一直不会改变的引用值来解决闭包陷阱。
对了这里给大家看一下,最后封装的那个hook
export const useCounterControl = (max: number, initCounter?: number, duration?: number) => {
const [count, setCount] = useState(initCounter === undefined ? 0 : initCounter);
const counterRef = useRef<number>(initCounter === undefined ? 0 : initCounter);
duration = duration !== undefined ? duration : 1000;
useEffect(() => {
counterRef.current = count; // 双向绑定 保证更新不出错
}, [count]);
const handleDecrease = useCallback(() => {
counterRef.current = (counterRef.current - 1 + max) % max;
setCount(counterRef.current);
}, [max]);
const handleIncrease = useCallback(() => {
counterRef.current = (counterRef.current + 1) % max;
setCount(counterRef.current);
}, [max]);
const handleIncreaseWithDebounce = useCallback(throttle(handleIncrease, duration), [
handleIncrease,
duration,
]);
const handleDecreaseWithDebounce = useCallback(throttle(handleDecrease, duration), [
handleDecrease,
duration,
]);
return {
count,
handleIncrease,
handleDecrease,
handleIncreaseWithDebounce,
handleDecreaseWithDebounce,
setCount,
};
};
其实针对这个特例的问题,我说的是节流在react 函数组件应用的问题,解决方法其实也不是一种。
比如ahooks
里的useThrottleFn
import { useThrottleFn } from "ahooks";
const useCount = (max: number, initCounter?: number, duration?: number) => {
const [count, setCount] = useState(
initCounter === undefined ? 0 : initCounter
);
duration = duration !== undefined ? duration : 1000;
const handleIncrease = () => {
setCount(count + 1);
};
const { run: handleIncreaseWithDebounce } = useThrottleFn(handleIncrease, {
wait: duration,
});
return {
count,
handleIncrease,
handleIncreaseWithDebounce,
};
};
当时看到ahook里面有一个节流hook,就好奇的去看一下
我们自己实现一个简单的useThrottleFn
export function useThrottleFn(fn: Function, options: { wait: number }) {
const fnRef = useRef(fn);
const { wait } = options;
fnRef.current = fn; // 函数引用更改时进行更新 这个代码可以类比useLatest
const throttled = useCallback(
throttle((...args: any[]) => { // 二次封装的函数
return fnRef.current(...args); // 对函数进行二次封装的目的是为了保证函数引用发生改变依然可以引用到最新的函数
}, wait),
[]
);
return {
run: throttled,
};
}
看上去很神奇,的确包含一些技巧。
首先在外层,我们的函数引用一直在变化,这是为了避免依赖项带来的闭包问题。
那么在useThrottleFn
内层我们使用ref
保存函数引用,并一直不断更新,目的是为了二次封装的函数与fnRef
形成闭包时,通过fnRef可以一直调用最新的函数。
那其实说到底啊,还是用了一个不变的引用值来处理问题。
本质上跟我做法其实大同小异。但是不得不说还是ahooks
的处理做法更巧妙,也更加通用。
那么看到这,大家有没有收获呢。
然后我来推荐一下自己封装的一些库。一个工具库yuxuannnn_utils和一个基于antd的组件库react-xs-component
如果有什么地方说的不好的,可以在下方留言或私聊我。