碎碎念: 今天公司新入职一位人事小姐姐,打破了我在公司的记录——「公司最小的员工」。绝对想不到她多小——零二年的,这的多聪明,上学连跳好几级了吧,佩服,不过还好我们俩算是同龄人 。
前言:有些东西就应该大胆的去尝试,尝试之后,你就会发现,哇咔咔好多坑,emmmm,摸着石头过河,才发现河底都是石头 ,就比如现在用 React hooks ,写着写着,遇见难题了,不知道去哪防抖了,函数使用 useCallback
做缓存,每次依赖一更新函数都会被重建,导致平时用的 debounce
函数毛线现在不能用了。
让我们先从简单的加法器开始
一、简单的加法器
使用 React hooks
的 useState
写一个简单的加法器,效果如下:
源代码:
import React, { useState } from "react";
export default () => {
const [ count, setCount ] = useState(0);
const handleClick = () => {
setCount(count + 1);
};
return (
<>
计算结果: {count}
>
);
};
handleClick
是一个函数,一般来讲我们为了函数缓存会使用 useCallback
,即避免无关父组件 props更新和不是 count
引发的子组件更新,变更如下:
const handleClick = useCallback(() => {
console.log(count);
setCount(count + 1);
}, [ count ]);
好了,难题来了,我们先做一个快速点击按钮:
import React, { useCallback } from "react";
import debounce from "lodash.debounce";
export default () => {
const handleClick = useCallback(debounce(() => console.log("click fast!"), 1000), [ ]);
return (
<>
>
);
};
当 useCallback
无依赖项时,函数一旦被创建就不会重载了,这个地方可以放心使用 debounce
。
不信!,我们来看看我们的加法器加上 debounce
的效果,修改如下:
import React, { useState, useCallback } from "react";
import debounce from "lodash.debounce";
export default () => {
const [ count, setCount ] = useState(0);
const handleClick = useCallback(debounce(() => setCount(count + 1), 1000, { leading: false, trailing: true }), [ count ]);
return (
<>
计算结果: {count}
>
);
};
{ leading: false, trailing: true }
这个是 lodash.debounce
函数的默认值,表示防抖时,最后一次按钮触发执行函数。好了,当你狂点击 「每次加一」按钮,咦!好用哎,完全符合预期没毛病,但是当你把 lodash.debounce
函数的参数改成 { leading: true, trailing: false }
,但是防抖时第一次触发就立即执行,这时候你发现防抖失效。思考下。。。。。。
原因:问什么防抖失败呢?原因在于触发抖动立即执行了 setCount
导致 count
改变同时引发了 useCallback
依赖项改变,导致函数重建,这时的 debounce
其实是销毁 => 重建 => 销毁 => 重建······无限循环♻️了。反之,相信你也能推理出 debounce
使用默认值为啥是好的。
OK,总结下:debounce 只所以不能在 React-hooks
中放心使用的原因就是因为依赖更新的问题。如果非要使用的话,特别注意⚠️ hooks
依赖更新的时机。只有当频繁调用 handleClick
函数时,立刻执行一次相关函数,所有点击完成 1000ms 后释放防抖函数,为下次准备。只有这种情况下能放心使用 debounce。
。
既然在 React-hooks
中不能无脑使用 debounce
,那我们就自己封装一个 useDebounceFn
函数。当然你也可以去找成型的 hooks
插件,但是还是推荐研究下,因为面试的问,变态点说不定还要手写。如果要使用插件这里推荐 Umi Hooks 好用,且封装的好多 hooks
比较常用。
二、useDebounceFn 初版
之前我有一篇文章 debounce and throttle,这篇文章写的就是如何手写 debounce and throttle
,我这里直接把代码拿过来了。 先看 useDebounceFn
使用示例:
import React, { useState } from "react";
import useDebounceFn from "./useDebounceFn";
export default () => {
const [ count, setCount ] = useState(0);
// useDebounceFn(fn, wait)默认触发方式为鼠标最后一次离开触发,也即不是立即触发
const handleClick = useDebounceFn(() => {
setCount(count + 1);
}, 1000, true);
return (
<>
计算结果: {count}
>
);
};
useDebounceFn(fn, wait)
默认触发方式为:当频繁调用 handleClick
函数时,只会在所有点击完成 1000ms
后执行一次相关函数,也即不是立即触发,useDebounceFn
有三个参数,第一个参数表示要执行的相关函数 fn
,第二个参数等待执行时间 wait
,第三个参数表示防抖是立即执行还是频繁调用之后最后一次执行。灰常简单明了。接下来看 useDebounceFn
文件代码:
function useDebounceFn(func, wait, immediate = false) {
let timeout, context, result;
/* useDebounceFn 第三个参数为 true 的时候,timeout 一直为假 */
console.log("timeout", timeout);
function resDebounced(...args) {
// 这个函数里面的this就是要防抖函数要的this
//args就是事件对象event
context = this;
// 一直触发一直清除上一个打开的延时器
if (timeout) clearTimeout(timeout);
if (immediate) {
// 第一次触发,timeout===undefined恰好可以利用timeout的值
const callNow = !timeout;
timeout = setTimeout(function() {
timeout = null;
}, wait);
if (callNow) result = func.apply(context, args);
} else {
// 停止触发,只有最后一个延时器被保留
timeout = setTimeout(function() {
timeout = null;
// func绑定this和事件对象event,还差一个函数返回值
result = func.apply(context, args);
}, wait);
};
return result;
};
resDebounced.cancal = function(){
clearTimeout(timeout);
timeout = null;
};
return resDebounced;
};
export default useDebounceFn;
几乎就是全部复制粘贴过来的,现在我们知道useDebounceFn
函数唯一的问题就是,第三个参数为 true 的时候,没有防抖。原因就是因为:setCount
函数立刻执行之后,引发函数组件重新渲染,导致 useDebounceFn
被重新执行,用于标记延时器是否开启的标记变量 timeout
被清空。以至于防抖失效。
好了,找到问题那就太简单了,我们只需要解决函数无法记住 timeout
的值就 OK 了。怎么记住 timeout
的值呢?当然是使用 useRef
啦。好了我们把代码改下,改动特别的小,就是用 useRef
来缓存下变量 timeout
,逻辑都不用动,改动如下:
import { useRef } from "react";
function useDebounceFn(func, wait, immediate) {
let timeout = useRef(), context, result;
console.log(timeout, "timeout");
function resDebounced(...args) {
// 这个函数里面的this就是要防抖函数要的this
//args就是事件对象event
context = this;
// 一直触发一直清除上一个打开的延时器
if (timeout.current) clearTimeout(timeout.current);
if (immediate) {
// 第一次触发,timeout===undefined恰好可以利用timeout的值
const callNow = !timeout.current;
timeout.current = setTimeout(function() {
timeout.current = null;
}, wait);
if (callNow) result = func.apply(context, args);
} else {
// 停止触发,只有最后一个延时器被保留
timeout.current = setTimeout(function() {
timeout.current = null;
// func绑定this和事件对象event,还差一个函数返回值
result = func.apply(context, args);
}, wait);
};
return result;
};
resDebounced.cancal = function(){
clearTimeout(timeout.current);
timeout.current = null;
};
return resDebounced;
};
export default useDebounceFn;
完美,到此一个可用的 useDebounceFn
就写完了,基本啥也没干,就是使用了 hooks
的 useRef
API 来缓存 timeout
, useDebounceFn
使用模范代码为:useDebounceFn(fn, wait, immediate);
。
给大家演示一下,演示代码:
import React, { useState } from "react";
import useDebounceFn from "./useDebounceFn";
export default () => {
const [ count, setCount ] = useState(0);
const [ num, setNum ] = useState(0);
// useDebounceFn(fn, wait)默认触发方式为鼠标最后一次离开触发,也即不是立即触发
const handleClickTrue = useDebounceFn(() => {
setCount(count + 1);
}, 1000, true);
const handleClickFalse = useDebounceFn(() => {
setNum(num + 1);
}, 1000, false);
return (
<>
immediate=true
计算结果: {count}
immediate=false
计算结果: {num}
>
);
};
演示动图效果:
三、useDebounceFn 优化
如果在类组件里面我们就可以收工了,但是在 hooks
里面新出了很多用于缓存的 API,不用白不用,我们需要借助这些 API 来做下缓存优化。
- 缓存 => 返回的 resDebounced 函数
问题描述:当我更改函数组件的其他状态时,会触发 useDebounceFn
函数的重建。
我稍微把上面的例子改下:
import React, { useState } from "react";
import useDebounceFn from "./useDebounceFn";
export default () => {
const [count, setCount] = useState(0);
const [num, setNum] = useState(0);
const handleClickTrue = useDebounceFn(() => {
setCount(count + 1);
}, 1000, true);
const handleClickFalse = () => {
setNum(num + 1);
};
return (
<>
计算结果: {count}
别的状态在更新,useDebounceFn会被一直在重新创建
num 的计算结果:: {num}
>
);
};
观察到的现象如下:
解决问题的办法: 使用 useCallback
来解决。
- 缓存需要执行的相关函数
fn
等。
接下来我们肯定会使用 useCallback
函数来做 useDebounceFn
函数的缓存,但是一旦使用 useCallback
函数,就要处理不属于 useDebounceFn
函数作用域的变量,这些变量有两条路:
- 借助
useCallback
函数的第二个参数,做依赖更新。 - 借助
useRef
永久存贮,借助useEffect
更新永久存贮。 - 其实上面一项和二项是可以相互转换的
根据上面我提到的解决方法,优化的终极版源码如下,贴心的我把注释写的够清楚了,在看不懂没办法了:
import { useRef, useCallback, useEffect } from "react";
function useDebounceFn(func, wait, immediate) {
const timeout = useRef();
/* 函数组件的this其实没啥多大的意义,这里我们就把this指向func好了 */
const fnRef = useRef(func);
/* useDebounceFn 重新触发 func 可能会改变,这里做下更新 */
useEffect(() => {
fnRef.current = func;
}, [ func ]);
/*
timeout.current做了缓存,永远是最新的值
cancel 虽然看着没有依赖项了
其实它的隐形依赖项是timeout.current
*/
const cancel = useCallback(function() {
timeout.current && clearTimeout(timeout.current);
}, []);
/* 相关函数 func 可能会返回值,这里也要缓存 */
const resultRef = useRef();
function resDebounced(...args) {
//args就是事件对象event
// 一直触发一直清除上一个打开的延时器
cancel();
if (immediate) {
// 第一次触发,timeout===undefined恰好可以利用timeout的值
const callNow = !timeout.current;
timeout.current = setTimeout(function() {
timeout.current = null;
}, wait);
/* this指向func好了 */
if (callNow) resultRef.current = fnRef.current.apply(fnRef.current, args);
} else {
// 停止触发,只有最后一个延时器被保留
timeout.current = setTimeout(function() {
timeout.current = null;
// func绑定this和事件对象event,还差一个函数返回值
resultRef.current = fnRef.current.apply(fnRef.current, args);
}, wait);
};
return resultRef.current;
};
resDebounced.cancal = function(){
cancel();
timeout.current = null;
};
/* resDebounced 被 useCallback 缓存 */
/*
这里也有个难点,数组依赖项如何天蝎,因为它决定了函数何时更新
1. useDebounceFn 重新触发 wait 可能会改变,应该有 wait
2. useDebounceFn 重新触发 immediate 可能会改变,应该有 immediate
3. 当防抖时,resDebounced 不应该读取缓存,而应该实时更新执行
这时候估计你想不到用哪个变量来做依赖!被难住了吧,哈哈哈哈哈
这时候你应该想实时更新,resDebounced函数里面哪个模块一直是实时更新的。
没错就是清除延时器,这条语句。很明显依赖项就应该是它。应该怎么写呢???
提出来,看我给你秀一把。
*/
return useCallback(resDebounced, [ wait, cancel, immediate ]);
}
export default useDebounceFn;
最后再给大家演示带清除按钮的栗子:
还是上面用的加法器,只简单的增加两个「清除」按钮,代码如下:
import React, { useState } from "react";
import useDebounceFn from "./useDebounceFn";
export default () => {
const [ count, setCount ] = useState(0);
const [ num, setNum ] = useState(0);
// useDebounceFn(fn, wait)默认触发方式为鼠标最后一次离开触发,也即不是立即触发
const handleClickTrue = useDebounceFn(() => {
setCount(count + 1);
}, 3000, true);
const handleClickFalse = useDebounceFn(() => {
setNum(num + 1);
}, 1000, false);
return (
<>
immediate=true
计算结果: {count}
immediate=false
计算结果: {num}
>
);
};
演示效果:
-
immediate=true
时,频繁触发,立即执行相关函数,清除表现为可以再次立即执行相关函数不用等待。 -
immediate=false
时,频繁触发,最后一次离开等待 wait 秒执行相关函数,清除表现为无任何结果,就像没触发一样。
三、最后一点思考
第一天的白天,我遇到如何在 React hooks
中防抖,本以为不是那么难,晚上学习总结下就解决了,结果晚上创建完文章,就写了个碎碎念和前言,突然发现没有能力去写 如何在 React hooks 中防抖 了,没研究明白,导致没有任何头绪 。历经二次大创作才算完成✅,中间小修了好多次,还是挺费神的呃呃呃呃。
第二天第二次,整理结果。当前时间 Thursday, September 24, 2020 01:10:07
本来这个小标题是另一个思路实现 useDebounceFn
,但是去看了 Umi Hooks
的 useDebounceFn
源码。捋一捋它的思路,把代码删删减减,发现和我的思路差不多,被它的变量命名糊弄住了。提出它的源代码如下:
import { useCallback, useEffect, useRef } from 'react';
function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread(); }
function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance"); }
function _iterableToArray(iter) { if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter); }
function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } }
var useUpdateEffect = function useUpdateEffect(effect, deps) {
var isMounted = useRef(false);
useEffect(function () {
if (!isMounted.current) {
isMounted.current = true;
} else {
return effect();
}
}, deps);
};
function useDebounceFn(fn, deps, wait) {
var _deps = Array.isArray(deps) ? deps : [];
var _wait = typeof deps === 'number' ? deps : wait || 0;
var timer = useRef();
var fnRef = useRef(fn);
fnRef.current = fn;
var cancel = useCallback(function () {
if (timer.current) {
clearTimeout(timer.current);
}
}, []);
var run = useCallback(function () {
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
cancel();
timer.current = setTimeout(function () {
fnRef.current.apply(fnRef, args);
}, _wait);
}, [_wait, cancel]);
useUpdateEffect(function () {
run();
return cancel;
}, [].concat(_toConsumableArray(_deps), [run]));
useEffect(function () {
return cancel;
}, []);
return {
run: run,
cancel: cancel
};
}
export default useDebounceFn;
不同的是它给出了另一种用法 useDebounceFn 合理使用 deps :
使用 deps 可以实现和 run 一样的效果。如果 deps 变化,会在所有变化完成 1000ms 后执行一次相关函数。
/* TS 写法 */
const {
run,
cancel
} = useDebounceFn(
fn: (...args: any[]) => any,
deps: any[],
wait: number
);
我感觉这个实用性不是很强,为啥呢?来看看官网给出的示例:
import React, { useState } from 'react';
import { Button, Input } from 'antd';
import { useDebounceFn } from '@umijs/hooks';
export default () => {
const [value, setValue] = useState();
const [debouncedValue, setDebouncedValue] = useState();
/* 用一个变量去更新另一变量,有这个需求直接使用 useDebounce 了 */
const { cancel } = useDebounceFn(
() => {
setDebouncedValue(value);
},
[value],
1000,
);
return (
setValue(e.target.value)}
placeholder="Typed value"
style={{ width: 280 }}
/>
DebouncedValue: {debouncedValue}
);
};
用一个变量去更新另一变量,有这个需求直接使用 useDebounce 了,另外还有一个问题就是,大多数业务就类似我们的加法器,需要更新自己的 state。我们套下示例,会发现加法器点击一次就一直自动更新了。原因在于: useUpdateEffect
函数通过 useEffect
的依赖更新,调用了 run
,run
函数调用了 fn,fn 函数又调用了 setCount,导致 count 更新, count 最后去触发 useUpdateEffect 的 useEffect 钩子。从而无限循环了♻️
import React, { useState } from "react";
import { useDebounceFn } from '@umijs/hooks';
export default () => {
const [ count, setCount ] = useState(0);
// 频繁调用 run,但只会在所有点击完成 1000ms 后执行一次相关函数
const { run : handleClick } = useDebounceFn(() => {
setCount(count + 1);
}, [ count ], 1000);
return (
<>
频繁调用 run,但只会在所有点击完成 1000ms 后执行一次相关函数
计算结果: {count}
>
);
};
useUpdateEffect
这个函数也不是一点用都没有,我们可以用它的思路来实现 useDebounce
,用来防抖一个变量。
四、实现 useDebounce
例如频繁输入,输出结果 DebouncedValue
只会在输入结束 2000ms 后变化。
测试 Demo 骨架:
import React, { useState } from "react";
import useDebounce from "./useDebounce";
import { Input } from 'antd';
export default () => {
const [value, setValue] = useState("");
const debouncedValue = useDebounce(value, 2000);
return (
<>
setValue(e.target.value)} />
输入的值: {debouncedValue}
>
);
};
借用 useDebounceFn
函数,封装的 useDebounce
函数:
import { useEffect, useRef, useState } from "react";
import useDebounceFn from "./useDebounceFn";
function useDebounce(value, wait) {
const isMounted = useRef(false);
const [state, setState] = useState(value);
const effect = useDebounceFn(() => {
setState(value)
}, wait);
/*
useState 已经初始化过value,所以useEffect的componentDidMount没用了
借用useRef 让 useEffect 只负责 componentDidUpdate
*/
useEffect(function () {
if (!isMounted.current) {
isMounted.current = true;
} else {
return effect();
};
}, [value, wait]);
return state;
};
export default useDebounce;
彻底写完了,又学到不少新东西,开森 。
最后一次更新时间 Thursday, September 24, 2020 18:02:28