React 自定义防抖、节流 Hook

468 阅读9分钟

React Hooks 自 16.8 版本发布以来,彻底改变了函数组件的编写方式。他们不仅简化了状态管理和副作用处理,还极大地提升了代码地复用性和可维护性。也让类组件使用的越来越少了。

什么是React Hooks

React Hooks 是 React 16.8 版本引入的一组新特性,允许我们开发的时候在函数组件中使用状态和其他 React 特性,像之前的函数组件,是没有自己的状态的,并且没有生命周期一类的东西。 Hooks 的设计目的就是解决函数组件中状态管理和副作用处理的难题,同时增强代码的复用性和可读性。

为什么需要 Hooks

在 Hooks 之前,函数组件主要用于展示 UI,无法管理状态和处理副作用。这些功能只能通过类组件实现。然后,类组件存在一些缺点,如 this 绑定复杂,曾几何时,还有人记得 React 之前的绑定事件是这样的

import React, { Component } from 'react';


class MyButton extends Component {
    constructor(props) {
        super(props);
        // 绑定 this
        this.handleClick = this.handleClick.bind(this)
    }
    
    /**
     *  类组件普通绑定事件
     *  优点:性能较好,因为绑定操作只在构造函数中执行一次
     *  缺点:需要再构造函数中进行额外的绑定操作,代码略显繁琐
     */
    handleClick() {
        console.log('按钮被点击了!');
    }
    
    /**
     *  使用箭头函数作为类属性
     *  优点:语法简洁,避免了构造函数中进行绑定,更易读和维护
     *  缺点:需要配置 Babel 或其他编译器才能支持(大多数现代项目默认支持)
     */
    handleClickArrow = () => {
        console.log('按钮被点击了!');
    }
    
    /**
     *  在 render 方法中使用箭头函数
     *  优点:简单直观,无需在构造函数中绑定
     *  缺点:每次渲染时都会创建一个新的函数,可能会影响性能,特别时在需要传递参数或在大量组件中使用时
     */
    handleClickRender() {
        console.log('按钮被点击了!')
    }
    
    render() {
        return (
            <>
                <button onClick={this.handleClick}>
                    点我
                </button>
                <button onClick={this.handleClickArrow}>
                    点我箭头
                </button>
                <button onClick={() => this.handleClickRender()}>
                    点我render 中箭头
                </button>
            </>
        )
    }
}

Hooks 的优势

  1. 简化组件逻辑:函数组件可以取代类组件,减少样板代码,使用 Hooks 可以在函数组件中轻松管理状态和副作用
  2. 提升代码复用性:通过自定义 Hooks,可以将组件逻辑提取到可服用的函数中,避免重复代码。
  3. 增强可读性和可维护性:函数组件比类组件简洁, Hooks 进一步优化了代码结构

如何使用 React Hooks

React 提供了一组内置的 Hooks,涵盖了大部分常见的功能需求,使用也很简单,下面就举例最常用的

import { useState, useEffect, useRef } from 'react';

function Counter() {
    const [count, setCount] = useState(0); // 初始化 count 为 0
    const inputRef = useRef(null)
    
    useEffect(() => {
        if(inputRef.current) {
            inputRef.current.focus()
        }
    }, [])
    
    return (
        <>
            <input ref={inputRef} value={count} type="number" onChange={(e) => { setCount(e.target.value) }}/>
        </>
    )
}

使用很简单,具体可参看官网说明,简单易懂

那什么是自定义 Hooks 呢

自定义 Hooks 是以 use 开头的 Javascript 函数,它们可以调用其他的 Hooks,并封装特定的逻辑,以便在多个组件中服用。通过自定义 Hooks,咱们可以将组件逻辑抽象成可服用的函数,使代码更加模块化和易于维护。

为什么使用自定义Hooks

  1. 复用逻辑:在多个组件中共享相同的逻辑,而不需要重复编写相同的代码(其实类组件的时候我感觉也可以复用逻辑🥲)
  2. 抽象复杂性:将复杂的逻辑抽象到自定义 Hooks 中,使组件代码更简洁(理论上函数组件中只存在 hook 就行)
  3. 提高可读性:通过命名自定义 Hooks,可以更清晰地表达逻辑意图。
  4. 易于测试:自定义 Hooks 可以单独测试,提高代码质量。

如何创建自定义 Hooks

创建自定义 Hooks 的步骤如:

  1. use 开头命名:自定义 Hooks 必须use 开头,以遵循 React Hooks 的命名规范,并确保我们的 Hooks 能被 React 识别
  2. 调用内置 Hooks:在自定义 Hooks 中,可以调用其他内置的 Hooks,如useStateuseEffect等。
  3. 返回需要的值:自定义 Hooks 可以返回任何类型的数据,如状态、函数、对象等,供组件使用

编写自定义 Hook:防抖&节流

自定义 Hooks 允许我们将可复用的逻辑提取到独立的函数中,提升代码的复用性和可维护行。防抖(Debounce)和节流(Throttle)是常见的性能优化技术,适用于处理频繁触发的事件,有输入框的实时搜索,窗口大小的调整等。下面我们将编写两个自定义 Hook:useDebounceuseThrottle

防抖(Debounce)Hook

我们实现的防抖功能参考lodash/debounce,是返回一个防抖的函数,想在哪里使用都行

import { useState } from 'react';

/**
 * 简单的防抖函数
 * @param {Function} callback - 需要防抖的函数
 * @param {number} delay - 防抖时间 
 * @returns {Function} - 防抖后的函数
 */
function useDebounce(callback, delay) {
  const [timer, setTimer] = useState(null);

  const debouncedFunction = (...args) => {
    // 如果已经有一个定时器在运行,清除它
    if (timer) {
      clearTimeout(timer);
    }
    // 创建一个新的定时器
    const newTimer = setTimeout(() => {
      callback(...args);
    }, delay);

    setTimer(newTimer);
  }

  return debouncedFunction;
}

export default useDebounce;

这应该是最基本的实现了,下面我们来看看如何优化

使用 useRef 确保组件重新渲染函数的稳定,没有清理并伴有内存泄露风险

  1. 由于我们使用useState来存储定时器 ID,每次调用 debouncedFunction 时,setTimer 会触发组件重新渲染,这不仅影响性能,还可能导致防抖功能失效

  2. 我们还需要在组件卸载时清除定时器,确保不会在卸载的组件上执行回调函数

import { useRef, useEffect, useState } from 'react';

/**
 * 简单的防抖函数
 * @param {Function} callback - 需要防抖的函数
 * @param {number} delay - 防抖时间 
 * @returns {Function} - 防抖后的函数
 */
function useDebounce(callback, delay) {
  const timer = useRef(null);
  // 返回一个防抖后的函数
  const debouncedFunction = (...args) => {
    // 清除上一次的定时器
    if (timer.current) {
      clearTimeout(timer.current);
    }
    // 创建一个新的定时器
    timer.current = setTimeout(() => {
      callback(...args);
    }, delay);
  }

  // 组件卸载时清除定时器
  useEffect(() => {
    return () => {
      if (timer.current) {
        clearTimeout(timer.current);
      }
    }
  }, []);

  return debouncedFunction;
}

export default useDebounce;

使用 useCallback 确保返回的防抖函数在依赖项不变的情况下保持稳定,避免不必要的函数重新创建

使用useCallback来记忆debouncedFunction,使其在 callbackdelay 不变时保持同一引用

import { useRef, useEffect, useCallback } from 'react';

/**
 * 简单的防抖函数
 * @param {Function} callback - 需要防抖的函数
 * @param {number} delay - 防抖时间 
 * @returns {Function} - 防抖后的函数
 */
function useDebounce(callback, delay) {
  const timer = useRef(null);
  // 返回一个防抖后的函数, 该函数会在 delay 时间后执行 callback
  const debouncedFunction = useCallback((...args) => {
    // 清除上一次的定时器
    if (timer.current) {
      clearTimeout(timer.current);
    }
    // 创建一个新的定时器
    timer.current = setTimeout(() => {
      callback(...args);
    }, delay);
  }, [callback, delay]);

  // 组件卸载时清除定时器
  useEffect(() => {
    return () => {
      if (timer.current) {
        clearTimeout(timer.current);
      }
    }
  }, []);

  return debouncedFunction;
}

export default useDebounce;

使用 useRef 保存最新的函数

为了确保防抖函数在多次快速调用时正确工作,并且依赖项(如 callback)的变化能够被正确处理,也防止有 stale closure 问题(陈旧闭包问题,是指在 JavaScript 中,尤其是在 React 中,闭包内部捕获了某个变量的旧值,导致无法正确访问最新的值)

import { useRef, useEffect, useCallback } from 'react';

/**
 * 简单的防抖函数
 * @param {Function} callback - 需要防抖的函数
 * @param {number} delay - 防抖时间 
 * @returns {Function} - 防抖后的函数
 */
function useDebounce(callback, delay) {
  const timer = useRef(null);
  const callbackRef = useRef(callback);
  // 返回一个防抖后的函数, 该函数会在 delay 时间后执行 callback
  const debouncedFunction = useCallback((...args) => {
    // 清除上一次的定时器
    if (timer.current) {
      clearTimeout(timer.current);
    }
    // 创建一个新的定时器
    timer.current = setTimeout(() => {
      callbackRef.current(...args);
      timer.current = null;
    }, delay);
  }, [delay]);

  // 每次渲染时更新 callback
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  // 组件卸载时清除定时器
  useEffect(() => {
    return () => {
      if (timer.current) {
        clearTimeout(timer.current);
      }
    }
  }, []);

  return debouncedFunction;
}

export default useDebounce;

现在已经近乎完美了,但是在真正的开发或者使用当中,我们可能还需要额外的功能,必须需要立即执行一次,而且可能会需要手动清除

自认为完美的 防抖(Debounce)Hook

结合 leading 参数和 cancel 方法,以下是最终优化后的 useDebounce Hook:

import { useRef, useEffect, useCallback } from 'react';

/**
 * 完美的防抖函数
 * @param {Function} callback - 需要防抖的函数
 * @param {number} delay - 防抖延迟时间(毫秒)
 * @param {Object} options - 可选参数
 * @param {boolean} options.leading - 是否在防抖周期开始时立即调用
 * @returns {Object} - 返回防抖函数及取消方法
 */
function useDebounce(callback, delay, options = {}) {
  const { leading = false } = options; // leading 参数决定是否在延迟开始时立即调用
  const timer = useRef(null); // 保存定时器 ID
  const callbackRef = useRef(callback); // 保存回调函数的引用

  // 防抖函数
  const debouncedFunction = useCallback((...args) => {
    if (leading && !timer.current) {
      callbackRef.current(...args); // 如果 leading 为 true 且没有定时器,则立即调用回调函数
    }

    if (timer.current) {
      clearTimeout(timer.current); // 清除之前的定时器
    }

    timer.current = setTimeout(() => {
      callbackRef.current(...args); // 延迟结束后调用回调函数
      timer.current = null; // 重置定时器
    }, delay)
  }, [delay, leading]);

  // 取消防抖
  const cancel = useCallback(() => {
    if (timer.current) {
      clearTimeout(timer.current); // 清除定时器
      timer.current = null; // 重置定时器
    }
  }, [])

  // 更新回调函数引用
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  // 组件卸载时清除定时器
  useEffect(() => {
    return () => {
      cancel()
    }
  }, [cancel]);

  return { debouncedFunction, cancel}; // 返回防抖函数和取消函数
}

export default useDebounce

节流(Throttle)Hook

同理,相同的逻辑不多说,直接上最后的自认为完美的实现

import { useRef, useEffect, useCallback } from 'react';

/**
 * 完美的节流
 * @param {Function} callback - 需要节流的函数
 * @param {number} delay - 节流延迟时间(毫秒)
 * @param {Object} options - 可选参数
 * @param {boolean} options.leading - 是否在节流周期开始时立即调用
 * @param {boolean} options.trailing - 是否在节流周期结束时调用
 * @returns {Object} - 返回节流函数及取消方法
 */
function useThrottle(callback, delay, options = {}) {
  const { leading = false, trailing = false } = options; // leading 和 trailing 参数决定调用时机
  const timer = useRef(null); // 保存定时器 ID
  const lastArgs = useRef(null); // 保存最后一次调用的参数
  const canRun = useRef(leading); // 标记是否可以运行
  const callbackRef = useRef(callback); // 保存回调函数的引用

  // 更新回调函数引用
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  // 节流函数
  const throttledFunction = useCallback((...args) => {
    if (canRun.current) {
      callbackRef.current(...args);
      canRun.current = false;
      timer.current = setTimeout(() => {
        canRun.current = true;
        if (trailing && lastArgs.current) {
          callbackRef.current(...lastArgs.current);
          lastArgs.current = null;
        }
      }, delay);
    } else if (trailing) {
      lastArgs.current = args;
    }
  }, [delay, trailing]);

  // 取消节流
  const cancel = useCallback(() => {
    if (timer.current) {
      clearTimeout(timer.current); // 清除定时器
      timer.current = null; // 重置定时器
    }
    canRun.current = leading; // 根据 leading 重置
    lastArgs.current = null;
  }, [leading]);

  // 组件卸载时清除定时器
  useEffect(() => {
    return () => {
      cancel();
    };
  }, [cancel]);

  return { throttledFunction, cancel }; // 返回节流函数和取消函数
}

export default useThrottle;

自定义 Hook 想明白了还是可以写的很快很方便的,使用起来可以节省很多时间,让代码看起来很整洁,逻辑很清晰


如果代码中有什么问题欢迎指正