一罩润、前言
相信無論在實(shí)際應(yīng)用場(chǎng)景玖翅、亦或是面試,都會(huì)經(jīng)常遇得到函數(shù)防抖割以、函數(shù)節(jié)流等金度,下面我們來聊一聊吧。
先放出一個(gè)示例:
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">節(jié)流輸入框:</label>
<input name="input3" ref={inputElem3} defaultValue="" />
</div>
</div>
)
}
以上 Demo 只有三個(gè)輸入框严沥,很簡單猜极。我給每個(gè)輸入框綁定了一個(gè) keyup
鍵盤事件,該事件執(zhí)行會(huì)發(fā)起網(wǎng)絡(luò)請(qǐng)求(為了更簡潔消玄,這里只是打印一下而已)跟伏,而對(duì)應(yīng)防抖、節(jié)流輸入框則經(jīng)過相應(yīng)的處理翩瓜。
二受扳、函數(shù)防抖(debounce)
如果我們?cè)?strong>普通輸入框快速鍵入 12345
,可以從控制臺(tái)上的打印結(jié)果看到兔跌,它會(huì)發(fā)起 5 次網(wǎng)絡(luò)請(qǐng)求(假設(shè)我們這個(gè)是一個(gè)簡單的搜索引擎)勘高。
還不知道用什么截屏/錄屏軟件可以生成 GIF 動(dòng)圖,有時(shí)間再研究下...
從實(shí)際場(chǎng)景考慮,如果每鍵入一個(gè)字符就立刻發(fā)起網(wǎng)絡(luò)請(qǐng)求华望,去檢索結(jié)果蕊蝗,這是非常影響體驗(yàn)的。假設(shè)我們限制為:用戶在停止輸入后 1s 后才發(fā)起網(wǎng)絡(luò)請(qǐng)求立美。
要實(shí)現(xiàn)這樣的需求匿又,我們只有使用函數(shù)防抖即可。
2.1 什么是函數(shù)防抖建蹄?
概念:在一定時(shí)間間隔內(nèi)碌更,事件處理函數(shù)只會(huì)執(zhí)行一次。若在該時(shí)間間隔內(nèi)(多次)重新觸發(fā)洞慎,則重新計(jì)時(shí)痛单。
怎么理解?
- 假設(shè)用戶鍵入字母
a
后就停止輸入了劲腿,那么網(wǎng)絡(luò)請(qǐng)求會(huì)在停止鍵入操作的 1s 后發(fā)起旭绒。這個(gè)很好理解。 - 若用戶繼續(xù)鍵入字母
b
后焦人,若有所思地停了一會(huì)(這個(gè)時(shí)間在 1s 之內(nèi)挥吵,假設(shè)為 800ms 吧),接著鍵入字母c
花椭,之后就停止鍵入了忽匈。網(wǎng)絡(luò)請(qǐng)求會(huì)發(fā)生在鍵入字母c
的 1s 后被發(fā)起,而不是鍵入字母b
之后的 1s 發(fā)起矿辽。因?yàn)楹瘮?shù)防抖會(huì)在鍵入c
之后重新計(jì)時(shí)丹允。
2.2 函數(shù)防抖實(shí)現(xiàn)
debounce(func, wait)
實(shí)現(xiàn)思路:
首先,接收兩個(gè)參數(shù) func
(要防抖的函數(shù)袋倔,一般是事件回調(diào)函數(shù))和 wait
(需要延遲的時(shí)間間隔雕蔽,單位毫秒)。然后 func
在 setTimeout
中執(zhí)行宾娜,而 setTimeout
的延遲時(shí)間就是 wait
批狐。而重新計(jì)時(shí)的話,則在每次觸發(fā)的時(shí)候 clearTimeout
即可實(shí)現(xiàn)碳默。
需要注意下贾陷,
func
的執(zhí)行上下文(this
)及其入?yún)ⅰ?/p>
// debounce.js
function debounce(func, wait) {
let timerId
return function () {
// 當(dāng)前運(yùn)行上下文環(huán)境,以及實(shí)參
const context = this
const args = arguments
// 重新計(jì)時(shí)(關(guān)鍵是這一步)
// 在 wait 時(shí)間內(nèi)嘱根,若重新觸發(fā)髓废,清除 clearTiemout,以達(dá)到重新計(jì)時(shí)的效果
if(timerId) clearTimeout(timerId)
timerId = setTimeout(function () {
// 綁定上下文和參數(shù)该抒,否則實(shí)參 func 的 this 指向 window 對(duì)象慌洪,參數(shù)為空
func.apply(context, args)
}, wait)
}
}
借助 ES6 的 Rest 參數(shù)和箭頭函數(shù)語法,簡化一下:
function debounce(func, wait) {
let timerId
return function (...args) {
if (timerId) clearTimeout(timerId)
timerId = setTimeout(() => {
func.apply(this, args)
}, wait)
}
}
依次在對(duì)應(yīng)輸入框內(nèi)鍵入 12345
,對(duì)比下防抖前后的結(jié)果:
兩次鍵入速度差不多冈爹,而且每個(gè)字符鍵入時(shí)間間隔小于 1s(可調(diào)大延遲執(zhí)行時(shí)間涌攻,更容易對(duì)比)。
// 普通輸入框
inputElem1.current.addEventListener('keyup', request)
// 防抖輸入框
inputElem2.current.addEventListener('keyup', debounce(request, 1000))
對(duì)比以上無防抖處理和防抖處理的結(jié)果频伤,可以看到前者每鍵入一個(gè)字符都會(huì)執(zhí)行回調(diào)函數(shù)恳谎,而后者則會(huì)在最后一次觸發(fā)的 N 毫秒(即 wait
延遲時(shí)間)之后才會(huì)執(zhí)行一次回調(diào)函數(shù)。
還有一種是“立即執(zhí)行”的函數(shù)防抖:區(qū)別在于第一次觸發(fā)時(shí)憋肖,是否立即執(zhí)行回調(diào)函數(shù)因痛。
再結(jié)合以上的“非立即執(zhí)行”的防抖,完整方法如下:
/**
* 函數(shù)防抖
* @param {Function} func 要防抖的函數(shù)
* @param {number} wait 需要延遲的毫秒數(shù)
* @param {boolean} immediate 是否立即執(zhí)行
* @returns {Function} 返回新的 debounced(防抖動(dòng))函數(shù)
*/
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)
}
}
當(dāng)我們修改成:
inputElem2.current.addEventListener('keyup', debounce(request, 1000, true))
從以下結(jié)果可以看到岸更,當(dāng)我在防抖輸入框鍵入 12345
的時(shí)候鸵膏,它會(huì)在鍵入 1
時(shí)立刻發(fā)起一次網(wǎng)絡(luò)請(qǐng)求,由于每個(gè)字符鍵入的時(shí)間間隔都在 1s
之內(nèi)怎炊,因此它只會(huì)在最后停止鍵入的 1s
后才會(huì)發(fā)起網(wǎng)絡(luò)請(qǐng)求谭企。
三、函數(shù)節(jié)流(throttle)
概念:在一定時(shí)間間隔內(nèi)只會(huì)觸發(fā)一次函數(shù)评肆。若在該時(shí)間間隔內(nèi)觸發(fā)多次函數(shù)债查,只有第一次生效。
3.1 函數(shù)節(jié)流實(shí)現(xiàn)
function throttle(func, wait) {
// 記錄上一次執(zhí)行 func 的時(shí)間
let prev = 0
return function (...args) {
// 當(dāng)前觸發(fā)的時(shí)間(時(shí)間戳)
const now = Number(new Date()) // +new Date()
// 單位時(shí)間內(nèi)只會(huì)執(zhí)行一次
if (now >= prev + wait) {
// 符合條件執(zhí)行 func 時(shí)瓜挽,需要更新 prev 時(shí)間
prev = now
func.apply(this, args)
}
}
}
3.2 函數(shù)節(jié)流優(yōu)化
以上節(jié)流方法有個(gè)問題攀操,假設(shè)節(jié)流控制間隔時(shí)間為 1s,若最后一次觸發(fā)時(shí)間在 1.5s秸抚,則最后一次觸發(fā)并不會(huì)執(zhí)行。因此歹垫,需要在節(jié)流中嵌入防抖思想剥汤,以保證最后一次會(huì)被觸發(fā)。
function throttle(func, wait) {
// 記錄上一次執(zhí)行 func 的時(shí)間
let prev = 0
let timerId
return function (...args) {
// 當(dāng)前觸發(fā)的時(shí)間(時(shí)間戳)
const now = Number(new Date()) // +new Date()
// 保證最后一次也會(huì)觸發(fā)
// 我看到很多文章排惨,將清除定時(shí)器的步驟放到 2?? 里面
// 我認(rèn)為應(yīng)該放在這里才對(duì)吭敢,原因看我下面舉例的場(chǎng)景。
if (timerId) clearTimeout(timerId)
if (now >= prev + wait) {
// 1??
// 符合條件執(zhí)行 func 時(shí)暮芭,需要更新 prev 時(shí)間
prev = now
func.apply(this, args)
} else {
// 2??
// 單位時(shí)間內(nèi)只會(huì)執(zhí)行一次
// if (timerId) clearTimeout(timerId) // 不應(yīng)該放在這里
timerId = setTimeout(() => {
prev = now
func.apply(this, args)
}, wait)
}
}
}
假設(shè)我將 clearTimeout()
放在了 2?? 里面鹿驼,而不是在外層≡辏基于 throttle(func, 1000)
考慮以下場(chǎng)景:
我在
4s
時(shí)觸發(fā)了一次畜晰,應(yīng)該走 1?? 邏輯。然后在4.9s
時(shí)又觸發(fā)了一次瑞筐,這會(huì)走的 2?? 邏輯并記錄了一個(gè)定時(shí)任務(wù)凄鼻。然后時(shí)間到了5s
,我又觸發(fā)了一次(后面就停止操作了),它會(huì)走 1?? 邏輯一次块蚌,接著時(shí)間來的5.9s
闰非,它還會(huì)執(zhí)行一遍fn.apply(this, args)
,因?yàn)樵?5s
觸發(fā)時(shí)峭范,沒有clearTimeout()
财松。因此,清除定時(shí)器的步驟應(yīng)該放在外層纱控,以保證每次被觸發(fā)是都清掉最后一次的定時(shí)器辆毡,避免在一些邊界 Case 觸發(fā)兩次。
當(dāng)然其徙,以上場(chǎng)景是在理想的狀態(tài)胚迫,實(shí)際場(chǎng)景可能幾乎碰不到這些邊界。但從嚴(yán)謹(jǐn)?shù)慕嵌热タ磫栴}唾那,應(yīng)該也要考慮的访锻。
寫到這里,我又在想剛剛的“立即執(zhí)行的函數(shù)防抖”闹获,跟這個(gè)優(yōu)化版的節(jié)流是不是有點(diǎn)像期犬,第一次觸發(fā)都會(huì)執(zhí)行回調(diào)函數(shù)。但區(qū)別是防抖會(huì)重新計(jì)時(shí)避诽,而節(jié)流在第一次觸發(fā)后面的每個(gè)間隔時(shí)間點(diǎn)都會(huì)觸發(fā)龟虎,非間隔點(diǎn)的最后一次觸發(fā)也將會(huì)被執(zhí)行。
我在節(jié)流輸入框內(nèi)沙庐,依次鍵入 1234567890
鲤妥,可以看到:在鍵入字符 1
時(shí)執(zhí)行了回調(diào);接著鍵入的 234
拱雏、67
字符都屬在上一個(gè)時(shí)間間隔內(nèi)棉安,因此無法執(zhí)行回調(diào)。其中鍵入的 90
字符應(yīng)屬于 8
之后的 1s 周期之內(nèi)铸抑,由于鍵入 0
字符屬于最后一次的非時(shí)間間隔內(nèi)的觸發(fā)動(dòng)作贡耽,因此回調(diào)會(huì)在鍵入 0
的 1s 后被執(zhí)行刀森。(可打印時(shí)間戳的形式磨淌,更精細(xì)地對(duì)比)
inputElem3.current.addEventListener('keyup', throttle(request, 1000))
四头滔、防抖與節(jié)流
其實(shí)饰恕,函數(shù)防抖和函數(shù)節(jié)流都是為了防止某個(gè)時(shí)間段頻繁觸發(fā)某個(gè)事件抖剿。它倆在某個(gè)時(shí)間間隔內(nèi)多次重復(fù)觸發(fā)欺抗,都只會(huì)執(zhí)行一次回調(diào)函數(shù)县耽。區(qū)別在于函數(shù)防抖最后一次觸發(fā)有效吓揪,而函數(shù)節(jié)流則是第一次觸發(fā)有效至耻。
而在上面氏涩,都對(duì)函數(shù)防抖和函數(shù)節(jié)流做了“拓展”届囚,例如:
- 在函數(shù)防抖中,增加了
immediate
的參數(shù)是尖,用于控制第一次是否執(zhí)行回調(diào)意系。 - 在函數(shù)節(jié)流中,允許最后一次在非時(shí)間間隔的觸發(fā)動(dòng)作有效饺汹。
應(yīng)用場(chǎng)景:
-
函數(shù)防抖(debounce)
- 搜索場(chǎng)景:防止用戶不停地輸入蛔添,來節(jié)約請(qǐng)求資源。
- window resize:調(diào)整瀏覽器窗口大小時(shí)兜辞,利用防抖使其只觸發(fā)一次迎瞧。
-
函數(shù)節(jié)流(throttle)
- 鼠標(biāo)事件、mousemove 拖拽
- 監(jiān)聽滾動(dòng)事件
如果還是不太明白 debounce 和 throttle 的差異逸吵,可以在以下這個(gè)頁面凶硅,可視化體驗(yàn)。
五扫皱、拓展
還是那句話:
生產(chǎn)環(huán)境請(qǐng)使用 Lodash 庫足绅,對(duì)應(yīng)的方法是 _.debounce() 和 _.throttle()。
畢竟 Lodash 是經(jīng)過社區(qū)考驗(yàn)的韩脑,肯定會(huì)完善很多氢妈。而我這篇文章可能會(huì)有一些我未曾想到的場(chǎng)景沒有處理的,面向?qū)W習(xí)和面試(手動(dòng)狗頭)段多。
如有不足首量,歡迎指出 ?? ~
TODO List:
- 詳細(xì)閱讀 Lodash 的防抖和節(jié)流源碼。
- window.requestAnimationFrame