Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JavaScript 之函数防抖、节流 #248

Open
toFrankie opened this issue Feb 26, 2023 · 0 comments
Open

JavaScript 之函数防抖、节流 #248

toFrankie opened this issue Feb 26, 2023 · 0 comments
Labels
2021 2021 年撰写 JS 与 JavaScript、ECMAScript 相关的文章 代码片段 记录一些代码片段

Comments

@toFrankie
Copy link
Owner

toFrankie commented Feb 26, 2023

配图源自 Freepik

写在前面,实际项目应使用 Lodash 等主流工具库,它们经过社区的反复验证,肯定比自己写的要完善很多。

前言

相信无论在实际应用场景、亦或是面试,都会经常遇得到函数防抖、函数节流等,下面我们来聊一聊吧。

先放出一个示例:

import React, { useEffect, useRef } from 'react'
import debounce from '../../utils/debounce'
import throttle from '../../utils/throttle'
import style from './index.scss'

export default function Demo(props) {
  const inputElem1 = useRef()
  const inputElem2 = useRef()
  const inputElem3 = useRef()

  useEffect(() => {
    inputElem1.current.addEventListener('keyup', request)
    inputElem2.current.addEventListener('keyup', debounce(request, 1000))
    inputElem3.current.addEventListener('keyup', throttle(request, 3000))
  }, [])

  function request(event) {
    const { value } = event.target
    console.log(`Http request: ${value}.`)
  }

  return (
    <div className={style.container}>
      <div className={style.list}>
        <label htmlFor="input1">普通输入框:</label>
        <input name="input1" ref={inputElem1} defaultValue="" />
      </div>

      <div className={style.list}>
        <label htmlFor="input2">防抖输入框:</label>
        <input name="input2" ref={inputElem2} defaultValue="" />
      </div>

      <div className={style.list}>
        <label htmlFor="input3">节流输入框:</label>
        <input name="input3" ref={inputElem3} defaultValue="" />
      </div>
    </div>
  )
}

以上 Demo 只有三个输入框,很简单。我给每个输入框绑定了一个 keyup 键盘事件,该事件执行会发起网络请求(为了更简洁,这里只是打印一下而已),而对应防抖、节流输入框则经过相应的处理。

函数防抖(debounce)

如果我们在普通输入框快速键入 12345,可以从控制台上的打印结果看到,它会发起 5 次网络请求(假设我们这个是一个简单的搜索引擎)。

还不知道用什么截屏/录屏软件可以生成 GIF 动图,有时间再研究下...

从实际场景考虑,如果每键入一个字符就立刻发起网络请求,去检索结果,这是非常影响体验的。假设我们限制为:用户在停止输入后 1s 后才发起网络请求。

要实现这样的需求,我们只有使用函数防抖即可。

什么是函数防抖?

概念:在一定时间间隔内,事件处理函数只会执行一次。若在该时间间隔内(多次)重新触发,则重新计时。

怎么理解?

  • 假设用户键入字母 a 后就停止输入了,那么网络请求会在停止键入操作的 1s 后发起。这个很好理解。
  • 若用户继续键入字母 b 后,若有所思地停了一会(这个时间在 1s 之内,假设为 800ms 吧),接着键入字母 c,之后就停止键入了。网络请求会发生在键入字母 c 的 1s 后被发起,而不是键入字母 b 之后的 1s 发起。因为函数防抖会在键入 c 之后重新计时。

函数防抖实现

debounce(func, wait)

实现思路:

首先,接收两个参数 func(要防抖的函数,一般是事件回调函数)和 wait(需要延迟的时间间隔,单位毫秒)。然后 funcsetTimeout 中执行,而 setTimeout 的延迟时间就是 wait。而重新计时的话,则在每次触发的时候 clearTimeout 即可实现。

需要注意下,func 的执行上下文(this)及其入参。

// debounce.js
function debounce(func, wait) {
  let timerId
  return function () {
    // 当前运行上下文环境,以及实参
    const context = this
    const args = arguments

    // 重新计时(关键是这一步)
    // 在 wait 时间内,若重新触发,清除 clearTiemout,以达到重新计时的效果
    if(timerId) clearTimeout(timerId)

    timerId = setTimeout(function () {
      // 绑定上下文和参数,否则实参 func 的 this 指向 window 对象,参数为空
      func.apply(context, args)
    }, wait)
  }
}

借助 ES6 的 Rest 参数箭头函数语法,简化一下:

function debounce(func, wait) {
  let timerId
  return function (...args) {
    if (timerId) clearTimeout(timerId)
    timerId = setTimeout(() => {
      func.apply(this, args)
    }, wait)
  }
}

依次在对应输入框内键入 12345,对比下防抖前后的结果:

两次键入速度差不多,而且每个字符键入时间间隔小于 1s(可调大延迟执行时间,更容易对比)。

// 普通输入框
inputElem1.current.addEventListener('keyup', request)
// 防抖输入框
inputElem2.current.addEventListener('keyup', debounce(request, 1000))

无防抖处理

防抖处理

对比以上无防抖处理和防抖处理的结果,可以看到前者每键入一个字符都会执行回调函数,而后者则会在最后一次触发的 N 毫秒(即 wait 延迟时间)之后才会执行一次回调函数。

还有一种是“立即执行”的函数防抖:区别在于第一次触发时,是否立即执行回调函数。

再结合以上的“非立即执行”的防抖,完整方法如下:

/**
 * 函数防抖
 * @param {Function} func 要防抖的函数
 * @param {number} wait 需要延迟的毫秒数
 * @param {boolean} immediate 是否立即执行
 * @returns {Function} 返回新的 debounced(防抖动)函数
 */
function debounce(func, wait = 0, immediate = false) {
  let timerId
  return function (...args) {
    if (timerId) clearTimeout(timerId)

    if (immediate && !timerId) {
      func.apply(this, args)
    }

    timerId = setTimeout(() => {
      func.apply(this, args)
    }, wait)
  }
}

当我们修改成:

inputElem2.current.addEventListener('keyup', debounce(request, 1000, true))

从以下结果可以看到,当我在防抖输入框键入 12345 的时候,它会在键入 1 时立刻发起一次网络请求,由于每个字符键入的时间间隔都在 1s 之内,因此它只会在最后停止键入的 1s 后才会发起网络请求。

函数节流(throttle)

概念:在一定时间间隔内只会触发一次函数。若在该时间间隔内触发多次函数,只有第一次生效。

3.1 函数节流实现

function throttle(func, wait) {
  // 记录上一次执行 func 的时间
  let prev = 0
  return function (...args) {
    // 当前触发的时间(时间戳)
    const now = Number(new Date()) // +new Date()
    
    // 单位时间内只会执行一次
    if (now >= prev + wait) {
      // 符合条件执行 func 时,需要更新 prev 时间
      prev = now
      func.apply(this, args)
    }
  }
}

3.2 函数节流优化

以上节流方法有个问题,假设节流控制间隔时间为 1s,若最后一次触发时间在 1.5s,则最后一次触发并不会执行。因此,需要在节流中嵌入防抖思想,以保证最后一次会被触发。

function throttle(func, wait) {
  // 记录上一次执行 func 的时间
  let prev = 0
  let timerId
  return function (...args) {
    // 当前触发的时间(时间戳)
    const now = Number(new Date()) // +new Date()

    // 保证最后一次也会触发
    // 我看到很多文章,将清除定时器的步骤放到 2️⃣ 里面
    // 我认为应该放在这里才对,原因看我下面举例的场景。
    if (timerId) clearTimeout(timerId)
    
    if (now >= prev + wait) {
      // 1️⃣
      // 符合条件执行 func 时,需要更新 prev 时间
      prev = now
      func.apply(this, args)
    } else {
      // 2️⃣
      // 单位时间内只会执行一次
      // if (timerId) clearTimeout(timerId) // 不应该放在这里
      timerId = setTimeout(() => {
        prev = now
        func.apply(this, args)
      }, wait)
    }
  }
}

假设我将 clearTimeout() 放在了 2️⃣ 里面,而不是在外层。基于 throttle(func, 1000) 考虑以下场景:

我在 4s 时触发了一次,应该走 1️⃣ 逻辑。然后在 4.9s 时又触发了一次,这会走的 2️⃣ 逻辑并记录了一个定时任务。然后时间到了 5s,我又触发了一次(后面就停止操作了),它会走 1️⃣ 逻辑一次,接着时间来的 5.9s,它还会执行一遍 fn.apply(this, args),因为在 5s 触发时,没有 clearTimeout()。因此,清除定时器的步骤应该放在外层,以保证每次被触发是都清掉最后一次的定时器,避免在一些边界 Case 触发两次。

当然,以上场景是在理想的状态,实际场景可能几乎碰不到这些边界。但从严谨的角度去看问题,应该也要考虑的。

写到这里,我又在想刚刚的“立即执行的函数防抖”,跟这个优化版的节流是不是有点像,第一次触发都会执行回调函数。但区别是防抖会重新计时,而节流在第一次触发后面的每个间隔时间点都会触发,非间隔点的最后一次触发也将会被执行。

我在节流输入框内,依次键入 1234567890,可以看到:在键入字符 1 时执行了回调;接着键入的 23467 字符都属在上一个时间间隔内,因此无法执行回调。其中键入的 90 字符应属于 8 之后的 1s 周期之内,由于键入 0 字符属于最后一次的非时间间隔内的触发动作,因此回调会在键入 0 的 1s 后被执行。(可打印时间戳的形式,更精细地对比)

inputElem3.current.addEventListener('keyup', throttle(request, 1000))

节流处理

防抖与节流

其实,函数防抖和函数节流都是为了防止某个时间段频繁触发某个事件。它俩在某个时间间隔内多次重复触发,都只会执行一次回调函数。区别在于函数防抖最后一次触发有效,而函数节流则是第一次触发有效。

而在上面,都对函数防抖和函数节流做了“拓展”,例如:

  • 在函数防抖中,增加了 immediate 的参数,用于控制第一次是否执行回调。
  • 在函数节流中,允许最后一次在非时间间隔的触发动作有效。

应用场景:

  • 函数防抖(debounce)

    • 搜索场景:防止用户不停地输入,来节约请求资源。
    • window resize:调整浏览器窗口大小时,利用防抖使其只触发一次。
  • 函数节流(throttle)

    • 鼠标事件、mousemove 拖拽
    • 监听滚动事件

如果还是不太明白 debounce 和 throttle 的差异,可以在以下这个页面,可视化体验。

参考

@toFrankie toFrankie added 代码片段 记录一些代码片段 JS 与 JavaScript、ECMAScript 相关的文章 labels Feb 26, 2023
@toFrankie toFrankie added the 2021 2021 年撰写 label Apr 26, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
2021 2021 年撰写 JS 与 JavaScript、ECMAScript 相关的文章 代码片段 记录一些代码片段
Projects
None yet
Development

No branches or pull requests

1 participant