函数节流和函数防抖

函数节流

还记得上篇文章中说到的图片懒加载吗?我们在文章的最后实现了一个页面滚动时按需加载图片的方式,即在触发滚动事件时,执行一个判断图片高度并根据这个高度决定是否加载图片的函数。
从实现效果上来看,这样做是没有任何问题的,但还有没有可以优化的地方呢?当然。
我们的回调函数和事件进行绑定,导致每次触发事件时,就会去执行回调函数,如果滚动比较频繁,那么回调函数就会一直执行,非常浪费页面性能。同时,在低版本浏览器中还可能出现假死。
看一个栗子:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>取个什么名字好呢</title>
    <style>
        #box{
            width: 300px;
            max-height: 500px;
            border: 1px solid red;
            margin: 0 auto;
            overflow: auto;
        }

        #display{
            height: 2000px;
        }
    </style>
</head>
<body>
    <div id = "box">
        <ul id = "display">
            
        </ul>
    </div>
</body>
<script>
    let count = 0;
    const box = document.getElementById("box");
    const display = document.getElementById("display");

    const print = (e) => { display.innerHTML += `<li>回调函数执行了 ${++count} 次</li>` }
    box.addEventListener("scroll",print)
</script>
</html>

执行效果是酱紫的:

触发滚动时回调函数总是执行.gif

如果回调函数中包含了大量的DOM操作、循环操作或者网络请求等,那么是灰常浪费资源的。
我们想让函数有规律的调用,但不要太频繁,而是每隔一段时间调用一次,这种实现就是函数节流。比如:我们可以让 scroll 中的回调函数每隔 500ms 调用一次,而不是每触发一次滚动就进行一次函数调用。

函数节流实现

实现函数节流需要这两个要素:

  • 被节流的函数
  • 延迟时间

关键点在于延迟时间,我们需要在延迟之间之后调用被节流函数,如果在延迟时间之内,就不做操作。因此我们需要用到两个时间戳:

  • 节流之前的时间戳
  • 节流之后的时间戳

可以将时间戳保存在全局变量中,或者函数的属性上,或者使用闭包。为了复习下闭包,这里给出一个闭包的实现:

function throttle(fn,delay){
    let startTime = 0;
    return (...args) => {
        let timeNow = +new Date();
        if(timeNow - startTime >= delay){
            fn(...args);
            startTime = timeNow;
        }
    }

}

将需要节流的函数使用 throttle 函数进行包装,throttle 函数执行返回一个匿名函数,该匿名函数首先会计算当前的时间(timeNow),并和起始时间(timeStart)进行比较,如果时间差大于延迟时间(delay)就执行被节流的函数,否则不进行任何操作。被节流函数执行成功后,更新开始时间(timeStart)。
注:这里假定被节流的函数中没有异步操作,如果被节流函数中有异步操作(需返回 Promise),可以进行下面的改造:

function throttle(fn,delay){
    let startTime = 0;
    return async (...args) => {
        let timeNow = +new Date();
        if(timeNow - startTime >= delay){
            await fn(...args);
            startTime = timeNow;
        }
    }
}

这种情况适用于限制网络请求,比如点击按钮时请求某一个接口,如果一直点击按钮,就会重复请求接口,如果后端 GG 说需要限制下接口请求频率,就可以对异步请求操作进行节流,在满足后端 GG 的同时还优化了前端用户体验。
现在对前面的 print 函数进行节流:

let count = 0;
const box = document.getElementById("box");
const display = document.getElementById("display");

function throttle(fn,delay){
    let startTime = 0;
    return (...args) => {
        let timeNow = +new Date();
        if(timeNow - startTime >= delay){
            fn(...args);
            startTime = timeNow;
        }
    }
}

const print = (e) => { display.innerHTML += `<li>回调函数执行了 ${++count} 次</li>` }
box.addEventListener("scroll",throttle(print,1000))

看下执行效果:

函数节流效果.gif

现在,print 函数不再是每次触发滚动就执行了,而是每隔一秒执行一次。
注:虽然 print 函数不再是每次触发滚动操作就执行,但包装它的函数是每次触发滚动都在执行的,这个包装函数的每次执行都会进行时间戳比对,如果大于等于延迟时间就执行被节流函数,相比于每次执行被节流函数 print,这个包装函数的主要开销就是计算当前时间,而不是执行被节流函数中复杂的逻辑,显然性能更好了。

函数节流配合拖拽

拖拽的核心功能是 mousemove 事件,当鼠标在页面上移动时,不断计算 left 和 top 值并改变元素的位置,这个过程中页面会不断的重绘,这篇文章中讲到了拖拽的两种实现方式。我们也可以将函数节流和拖拽结合使用,用来限制 move 函数触发的频率:

class Drag{
    constructor(subEle,supEle) {
        // 根元素
        const rootEle = document.documentElement || document.body;
        // 被拖动元素
        this.subEle = subEle;
        // 父级元素,默认为根元素
        this.supEle = supEle || rootEle;
        this.offsetX = null;
        this.offsetY = null;

        // 被拖动元素的宽高
        this.subOffsetWidth = this.subEle.offsetWidth;
        this.subOffsetHeight = this.subEle.offsetHeight;

        // 父级元素的可视区宽高
        this.supClientWidth = this.supEle.clientWidth;
        this.supClientHeight = this.supEle.clientHeight;

        // 速度相关
        this.lastX = 0;
        this.lastY = 0;
        this.speedX = 0;
        this.speedY = 0;

        this.drag();
    }

    // 节流函数
    throttle(fn,delay){
        let startTime = 0;
        return (...args) => {
            let timeNow = +new Date();
            if(timeNow - startTime >= delay){
                fn(...args);
                startTime = timeNow;
            }
        }

    }

    drag(){
        // 为拖动元素添加事件,初始化
        this.subEle.addEventListener("mousedown",this.dragDown.bind(this));
    }

    // 处理鼠标按下
    dragDown(e){
        this.offsetX = e.clientX - this.subEle.offsetLeft;
        this.offsetY = e.clientY - this.subEle.offsetTop;

        // 将 dragDown 和 dragUp 函数另存一份
        // 解决抬起鼠标后无法 removeEventListener 的问题
        // 对 dragMove 函数进行节流,时间为 50 毫秒
        this.move = this.throttle(this.dragMove.bind(this),50);
        this.up = this.dragUp.bind(this);

        document.addEventListener("mousemove",this.move);
        document.addEventListener("mouseup",this.up);
    }

    // 处理鼠标移动
    dragMove(e){
        let left = e.clientX - this.offsetX;
        let top = e.clientY - this.offsetY;

        if(left <= 0){
            left = 0;
        }else if(left >= this.supClientWidth - this.subOffsetWidth){
            left = this.supClientWidth - this.subOffsetWidth;
        }

        if(top <= 0){
            top = 0;
        }else if(top >= this.supClientHeight - this.subOffsetHeight){
            top = this.supClientHeight - this.subOffsetHeight;
        }
        this.subEle.style.left = left + "px";
        this.subEle.style.top = top + "px";

        // 更新 speedX、speedY、lastX、lastY
        this.speedX = left - this.lastX;
        this.speedY = top - this.lastY;
        this.lastX = left;
        this.lastY = top;

        // 防止选择拖动
        window.getSelection ? window.getSelection().removeAllRanges():document.selection.empty();
    }

    // 清除事件
    dragUp(e){
        document.removeEventListener("mousemove",this.move);
        document.removeEventListener("mouseup",this.up);
    }

}

// 新建一个对象,让其可以拖动
new Drag(document.getElementById("inner"),document.getElementById("par"));

注:对拖拽进行函数节流时,延迟时间(delay)不可设置的过大,否则在拖动过程中会出现不连贯的情况。

函数防抖

函数防抖就是在事件完成某段时间后执行相应的函数,一个最普遍的例子就是注册用户时的用户名验证或者下拉模糊搜索。
这类效果一般是在向搜索框中输入字符时,从后台服务器拉取相应的验证结果或者模糊查询的结果。通常做法是在键盘抬起(keyup)时触发某个函数,用来向后端请求数据。
这样做的缺陷是:如果每次键盘抬起都进行一次请求,那我们在搜索过程中就会进行炒鸡炒鸡多的请求,而我们实际需要的只是对最后一次键盘抬起时输入框中的文字进行请求。
常用的做法是:在键盘抬起后的一段时间中,如果不进行按键操作,就执行回调函数。这种做法就是函数防抖(debounce)。
先来看一下不应用函数防抖的例子:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>取个什么名字好呢</title>
    <style>
        #box{
            width: 300px;
            max-height: 500px;
            border: 1px solid red;
            margin: 0 auto;
            overflow: auto;
            text-align: center;
        }

        #display{
            height: 2000px;
        }
    </style>
</head>
<body>
    <div id = "box">
        <input type="text" id = "inp">
        <ul id="display"></ul>
    </div>
</body>
<script>
    let count = 0;
    const inp = document.getElementById("inp");
    const display = document.getElementById("display");

    const print = (e) => { display.innerHTML += `<li>回调函数执行了 ${++count} 次</li>` }
    inp.addEventListener("keyup",print)
</script>
</html>

执行效果如图:

不应用函数防抖.gif

可见,每次键盘抬起都会执行回调函数,如果回调函数中是一些高耗操作,性能可想而知。

函数防抖实现

函数防抖和函数节流的实现方式相似,都是采用一个外层函数对目标函数进行包装,然后根据条件决定是否执行目标函数。
不同点是,函数节流是采用计算时间差来决定是否执行目标函数,而函数防抖是根据定时器来决定是否执行目标函数。
下面是闭包版函数防抖的实现:

function debounce(fn,delay){
    let timer = null;
    return (...args) =>{
        clearTimeout(timer);
        timer = setTimeout(()=>{
            fn(...args)
        },delay);
    }
}

调用 debounce 函数时,创建一个空的定时器对象,debounce 函数执行返回一个匿名函数,该匿名函数执行时,首先清除定时器,而后重新创建一个定时器对象,在指定的延迟之后执行目标函数。如果在定时器等待执行期间再次执行了匿名函数,就清除这个定时器对象,重新创建一个定时器对象,直到指定延迟(delay)时间后执行目标函数。
在代码中使用函数防抖:

let count = 0;
const inp = document.getElementById("inp");
const display = document.getElementById("display");

function debounce(fn,delay){
    let timer = null;
    return (...args) =>{
        clearTimeout(timer);
        timer = setTimeout(()=>{
            fn(...args)
        },delay);
    }
}

const print = (e) => { display.innerHTML += `<li>回调函数执行了 ${++count} 次</li>` }
inp.addEventListener("keyup",debounce(print,500))

看下执行效果:

使用函数防抖.gif

这样的效果是不是更加友好呢?

总结

本文讲到了两种处理高耗函数操作的两种方式:函数节流和函数防抖。
二者都广泛应用于事件处理相关的操作上,不同点是:

  • 函数节流是降低事件回调函数的执行频率,当事件一直被触发时,回调函数将以某个频率不断的执行。
  • 函数防抖是在某事件结束后的一段时间内,如果不再触发该事件,就执行相应的函数。

二者具有各自的应用场景,但实现方式都类似:

  • 都是将目标函数进行包装,根据条件判断决定是否执行该函数
  • 都用到了闭包的特性
  • 函数节流是通过时间差决定是否执行目标函数,函数防抖是通过不断的开启/关闭定时器,最终执行目标函数

完。

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

推荐阅读更多精彩内容

  • 函数节流和函数防抖,两者都是优化高频率执行js代码的一种手段.在一定时间内,代码执行的次数不一定要非常多。达到一定...
    madpluto阅读 368评论 1 0
  • 某些情况下,例如响应鼠标移动、窗口大小调整、页面滚动的事件,触发的频率较高。如果这个时候需要处理的函数稍微复杂点,...
    Tiny_z阅读 1,241评论 0 0
  • 首先要明白 节流 Throttle 和 去抖动 Debounce 两者是有区别的,很多人一开始都会搞混。先讲讲去抖...
    圭宁_2ce3阅读 5,025评论 1 9
  • 不知道从什么时候开始,听歌会认真的听歌词了,原来一些很熟悉的歌,细细听来,别有一番滋味,更会带入莫名的伤感。 ...
    另一种好阅读 149评论 0 1
  • 又是一个个炎炎夏日 , 早上七点 半我们的我与水情调研小分队就已经 整装待发了 , 今天是我们暑期社会实 践的第二...
    emmmmmm哦阅读 197评论 0 0