JavaScript之函数防抖、节流

配图源自 Freepik

一、前言

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

先放出一个示例:

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 后才发起网络请求。

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

2.1 什么是函数防抖?

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

怎么理解?

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

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 的差异,可以在以下这个页面,可视化体验。

五、拓展

还是那句话:

生产环境请使用 Lodash 库,对应的方法是 _.debounce()_.throttle()

毕竟 Lodash 是经过社区考验的,肯定会完善很多。而我这篇文章可能会有一些我未曾想到的场景没有处理的,面向学习和面试(手动狗头)。

如有不足,欢迎指出 👋 ~

TODO List:

六、参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容

  • 背景 当我们进行窗口resize、scroll、input框内容校验等操作时,如果事件函数调用频率不加控制。会加重...
    夏末远歌阅读 305评论 0 0
  • 一.认识防抖和节流 JavaScript是事件驱动的,大量的操作会触发事件,加入到事件队列中处理。 对于某些频繁的...
    matexia阅读 138评论 0 1
  • 概述 函数防抖是指将多次触发合并成一次执行,一般情况下都是合并到最后一次触发执行。函数节流是指在一段时间内执行一次...
    jaimor阅读 750评论 0 7
  • 在前端开发中,经常会给元素添加一些事件,例如:click、scroll、input、mousemove。 这些事件...
    前端很忙阅读 365评论 0 3
  • 表情是什么,我认为表情就是表现出来的情绪。表情可以传达很多信息。高兴了当然就笑了,难过就哭了。两者是相互影响密不可...
    Persistenc_6aea阅读 124,505评论 2 7