JS节流和防抖串烧(一)

423 阅读10分钟

在你职业生涯中,一定有人问过你节流防抖。在这里我冒昧猜测,绝大多数人可能只是简短的说了下概念,讲个七八句就结束了。其实,如果细说可以由此展开几个知识点,同时,也向想要了解你技术的人展示你掌握知识的扎实程度。

进入正题,阅读本文你将会对防抖节流,函数applycallbind,以及事件循环相关概念等有更深入的了解,对你以往的知识也能起到融会贯通的作用,希望本文不失众望,Let's GO。

我们新建一个测试文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>节流与防抖</title>
</head>
<body>
<button id="buy">buy</button>
<button id="buy1">buy1</button>
</body>
<script type="application/javascript">
    function debounce(fn, delay = 500) {
        let timer = null;
        return () => {
            if (timer) {
                clearTimeout(timer);
            }
            timer = setTimeout(() => {
                fn();
                clearTimeout(timer); // 及时清除
                timer = null; // 垃圾回收变量
            }, delay)
        }
    }
    function buy(param1) {
        console.log('buy ... ', param1)
    }
    function buy1() {
        console.log('buy 1 ... ')
    }
    document.getElementById('buy').addEventListener('click', debounce(buy))
    const throttle = (f, delay) => {
        let timer = 0;
        return () => {
            if (timer) return
            timer = setTimeout(() => {
                f();
                clearTimeout(timer)
                timer = 0
            }, delay)
        }
    }
    document.getElementById('buy1').addEventListener('click', throttle(buy1))
</script>
</html>

关于节流和防抖

核心是闭包延时器的应用。更深层次会涉及到变量作用域,宏任务,以及内存泄露的问题。所以展开说,每一行代码都有深意,意味着我们在实际开发中如何规范的开发。
为什么要深入,因为我们要走的更远更规范,试想一下,公司的一个产品交给你,像某猫级别的,庞大的代码体系中存在着代码不规范,内存泄露等问题,日积月累,整个产品还能存活吗?凭借产品生存的公司,以及为公司勤勤恳恳奉献的员工都将面临风险,这将是整个产品、公司的悲哀,所以为了自己能承担更重要的事情,我们必须要搞清楚更深层次的原因。
故不积跬步,无以至千里;不积小流,无以成江海。

手写一个防抖

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

我们运行上面的代码,触发事件,再触发几次,然后再刷新,再触发事件,然后周而复始,重复操作,得到下图的性能图

截取图片_20230329095956.png

图1

你可能对上图还不是很敏感,看不出到底有没有内存泄露
接下来我们写一段内存泄漏的代码,我们看看泄露的情况又是什么样子呢,相隔时间去手动刷新页面

function Tack() {
    let index = 0
    return () => {
        index++
        return index
    }
}
let t1 = new Tack()
console.log(t1())

截取图片_20230329102557.png

在上面代码再加一个置空的代码

function Tack() {
    let index = 0
    return () => {
        index++
        return index
    }
}

let t1 = new Tack()
console.log(t1())
t1 = null

截取图片_20230329103222.png

通过上面的对比,不难发现,发生内存泄露的代码,图表呈现递增趋势,不是一个起伏的状态。所以最开始的防抖的代码并没有导致内存泄露。

上面的代码是完整的吗?我们看下,再想想。
你能想出哪些问题,或者说是,基于这个代码你还能说出哪些问题呢?我在这里罗列一下,看看你和我想的是否一样呢?

接下来我们将讲到哪些内容?

  1. 是否有内存泄露嫌疑,如何分析内存泄露?
  2. 如果fn有参数,如何处理
  3. setTimeout的返回值有0、1的可能吗?这个关系到if判断的严谨性和对setTimeout的熟悉层度
  4. 如果把箭头函数换成function,如何写?
  5. timer什么时候才会被垃圾回收清除?关系到闭包的变量的生命周期
  6. 关于宏任务的执行时机,关系到微任务,事件循环,消息队列,延迟队列,事件触发的两种机制事件捕获和事件冒泡的话题
  7. 闭包的写法和结构特点有没有让你想到es6的class,闭包不能滥用,使用不当会导致内存泄露,但是class的写法非常相似于闭包,那es6使用class有内存泄露风险吗?和function的区别是什么?

一、是否有内存泄露嫌疑,如何分析内存泄露?

经过上面的分析,我们检测浏览器运行代码的过程,发现堆栈和事件占用的空间是起伏态势,说明是可释放,也就是是被垃圾回收的。(PS:但是实际情况是我们并没有主动销毁防抖函数debounce的引用,是如何做到垃圾回收的?

接下来我们继续对防抖函数深入处理优化,看看他的性能是否有所改变。

function debounce(fn, delay = 500) {
    let timer = null;
    return () => {
        if(timer) {
            clearTimeout(timer);
        }
        timer = setTimeout(() => {
            fn();
            clearTimeout(timer); // 及时清除
            timer = null; // 垃圾回收变量
        }, delay)
    }
}

11.png

在这个单例中,我们看不到他的影响,在现代浏览器中,基本没什么太大的影响,但是当实际应用中,庞大的代码中还是需要注意垃圾回收引用的问题,这样完善的做法一定是正确的。

二、如果fn有参数,如何处理

function debounce(fn, delay = 500) {
    let timer = null;
    return function () {
        if (timer) {
            clearTimeout(timer);
        }
        timer = setTimeout(() => {
            fn.apply(this, ...arguments);
            clearTimeout(timer);
            timer = null;
        }, delay)
    }
}

VUE中的用法

<input v-model="value" @change="(e) => changeHandle(item)"/>
changeHandle: debounce((row) => {
    console.log('row', row)
})

关于arguments,他是隐形参数之一,另一个是this,拿来即用。
此处还有可讲之处,关于arguments的使用,此时此刻,你是否发现引发出的知识点有很多,这也是为什么防抖和节流会是互联网公司必问的问题,因为太丰富了,里面奥妙无群,无群延伸,几乎要覆盖js进阶的所有关键知识点了。
这里我们要重点说下apply和this的用法。

认识this

this的指向对象到底是谁,这个问题我们要视运行环境而看,标准就是当前的执行环境的对象

let obj = {
    name: 'jack',
    get() {
        return this.name;
    }
}
// 再看下面这种情况
function getName() {
    return this.name
}
let obj = {
    name: 'jack',
    get: getName
}

第一段代码this指向的是obj对象;第二个this指向的是window。
this的指向问题是由具体运行环境决定,那么是否可以指定this指向呢?
这样就出现了apply和call函数了。

认识apply、call和bind

三者作用就是绑定变量作用域,不同处在于参数类型和返回值类型

函数入参返回值
callthis, arg1, arg2, ...-
applythis, [arg1, arg2, ...]-
bindthis, arg1, arg2, ...function

call 、bind 、 apply 这三个函数的第一个参数都是 this 的指向对象,第二个参数差别就来了:
call 的参数是直接放进去的,第二第三第 n 个参数全都用逗号分隔,直接放到后面 obj.myFun.call(db,'成都', ... ,'string' )。
apply 的所有参数都必须放在一个数组里面传进去 obj.myFun.apply(db,['成都', ..., 'string' ])。
bind 除了返回是函数以外,它 的参数和 call 一样。

可以移步阅读概念: apply、call和bind的概念

箭头函数和function以及立即执行函数区别

想要讲清楚这几个概念,首先需要了解清楚变量作用域,js中存在变量提升的问题,其实关于变量提升在从ES6开始之后,在实际工作中淡化了,使用let后不用再忧虑变量提升了,而且研发规范中明确禁止使用var,要使用let替代之。
变量提升是js的特点,但这个特点在构建大型应用并不是优点,容易导致变量泛滥污染,所以出现了let来限制变量提升。
但是我们还是有必要了解清楚他的前世,有利于我们深入的理解。

var lis = [0,1,2,3,4,5,6]
for(var i = 0; i < lis.length; i++) {
    setTimeout(() => {
      console.log('[i] = ', lis[i])  
    })
}
let lis1 = [0,1,2,3,4,5,6]
for(let i = 0; i < lis1.length; i++) {
    setTimeout(() => {
      console.log('[i] = ', lis1[i])  
    })
}

看上下两段代码的区别,他们输出结果一样吗?
按照我们直观的感受,上面的代码都应该输出:0、1、2、3、4、5、6
事实如此吗?

这里我们要重点说下在变量提升存在的情况下,for循环i的问题,你可能之前并没有注意到。

44.png 上面的事实有助于你了解为什么变量提升+异步+循环的情况下,最终所得值让你匪夷所思。
请看下面的:
55.png 这里面问题显而易见

  1. 最终值都是undefined,为什么不是0、1、2、3、4、5、6
  2. 为什么是undefined,哪儿来的? 前一张截图,很明显,最终的i值为7,而数组lis中并没有下标7的值存在所以是undefined
    同时,这种异步,宏任务被放到宏任务队列中异步执行,在最后程序执行的时候,i值变成了7,运行过程中并没有缓冲i值。
    做个改造,请看

66.png

当变量不再提升时,这个时候会看到,循环中即使调用的是异步宏任务,那也会打印出预期的值。
我们换一种写法:

var lis = [0,1,2,3,4,5,6]
for(var i = 0; i < lis.length; i++) {
    (function(i) {
        setTimeout(() => {
          console.log('[i] = ', lis[i])  
        })
    })(i)
}

77.png

文章所及内容有点多,小憩一下吧
其实此处我会好奇一个问题,js在执行的时候到底是怎么处理变量i的?
那就需要了解清楚作用域,作用域分为全局作用域局部作用域,ES6带来了块级作用域,局部作用域包括函数作用域和块级作用域
如果运行在浏览器中,那么全局作用域是浏览器的window对象,在console中做测试

> var a = 123
undefined
> window.a
123
> a = 456
456
> window.a
456

如果运行在node环境中,那么全局作用域就是global对象

PS D:\workplace\ipoint-base\ipoint> node
Welcome to Node.js v14.18.0.
Type ".help" for more information.
> var a = 123
undefined
> a
123
> global.a
123
> a = 456
456
> global.a
456
>

所以我们可以看出ES6之前,对于var声明的变量,对于一个庞大应用的话,是不是犹如发丝一样的多,都挂载到了window对象下。
思路漫漫,请耐心的思考。我们之所以挖掘作用域,是想探究for循环中执行的宏任务为什么i记录到0到6的下标。
我们接着看局部作用域,函数作用域和块级作用域。
这里主要说明一下for循环涉及到几个作用域呢?

  1. 外部作用域 后称outerEnv
  2. ()作用域 后称loopEnv
  3. {}内部作用域 后称innerEnv

重点说明:一定要牢记这三种作用域的存在,在没有覆盖值或者变量提升的情况下,互不影响。如果在innerEnv中声明let i = '213'是不会对loopEnv造成影响的,但是如果直接使用i = '12'则会覆盖loopEnv中的i,循环终止,但不报错

使用var声明,变量会被提升到global或window中 OkHOU7913U.gif

使用let声明,每一次loop都会产生一个innerEnv,所以现在可以理解,为什么可以记录到准确的下标了吧。 111k14lqtmCKj.gif

到此为止,我们对立即执行函数,箭头函数和匿名function的本质区别有了更深的认知,其实就是作用域的区别,同时对于for循环也有了更深的认知,循环过程中,不同的写法导致作用域的区别。

三、setTimeout的返回值有0、1的可能吗?这个关系到if判断的严谨性和对setTimeout的熟悉层度

不会返回0,对于一个空白页,当执行setTimeout的时候,会返回>=1的number,所以可以简写timer && clearTimeout(timer)

22.png

实际上完全可以不做非空判断,请看下面,所以可以直接调用也无妨

> clearTimeout(undefined)
undefined
> clearTimeout(null)
undefined

未完待续···

本文正在参加 人工智能创作者扶持计划