如何在 React hooks 中防抖

碎碎念: 今天公司新入职一位人事小姐姐,打破了我在公司的记录——「公司最小的员工」。绝对想不到她多小——零二年的,这的多聪明,上学连跳好几级了吧,佩服,不过还好我们俩算是同龄人 。

前言:有些东西就应该大胆的去尝试,尝试之后,你就会发现,哇咔咔好多坑,emmmm,摸着石头过河,才发现河底都是石头 ,就比如现在用 React hooks ,写着写着,遇见难题了,不知道去哪防抖了,函数使用 useCallback 做缓存,每次依赖一更新函数都会被重建,导致平时用的 debounce 函数毛线现在不能用了。

让我们先从简单的加法器开始

一、简单的加法器

使用 React hooksuseState 写一个简单的加法器,效果如下:

React hooks 版简单加法器

源代码:

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 就写完了,基本啥也没干,就是使用了 hooksuseRef API 来缓存 timeoutuseDebounceFn 使用模范代码为: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 immediate 分别为 true 和 false 演示示例

三、useDebounceFn 优化

如果在类组件里面我们就可以收工了,但是在 hooks 里面新出了很多用于缓存的 API,不用白不用,我们需要借助这些 API 来做下缓存优化。

  1. 缓存 => 返回的 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}

); };

观察到的现象如下:

当我更改函数组件的其他状态时,会触发 useDebounceFn 函数的重建动图演示

解决问题的办法: 使用 useCallback 来解决。

  1. 缓存需要执行的相关函数 fn 等。

接下来我们肯定会使用 useCallback 函数来做 useDebounceFn 函数的缓存,但是一旦使用 useCallback 函数,就要处理不属于 useDebounceFn 函数作用域的变量,这些变量有两条路:

  1. 借助 useCallback 函数的第二个参数,做依赖更新。
  2. 借助 useRef 永久存贮,借助 useEffect 更新永久存贮。
  3. 其实上面一项和二项是可以相互转换的

根据上面我提到的解决方法,优化的终极版源码如下,贴心的我把注释写的够清楚了,在看不懂没办法了

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 HooksuseDebounceFn 源码。捋一捋它的思路,把代码删删减减,发现和我的思路差不多,被它的变量命名糊弄住了。提出它的源代码如下:

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 的依赖更新,调用了 runrun 函数调用了 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 后变化。

频繁输入,输出结果 `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

你可能感兴趣的:(如何在 React hooks 中防抖)