likes
comments
collection
share

浅析闭包与防抖函数并学习underscore的实现

作者站长头像
站长
· 阅读数 8

本文参加了由公众号@若川视野 发起的每周源码共读活动,    点击了解详情一起参与。

这是源码共读的第25期 | 跟着underscore学防抖

本文将弄懂防抖的原理,快速实现一个简易的防抖函数, 并会实现一个较为完整的防抖函数供大家参考

前言

前端业务开发场景中,会遇到一些场景如:

  1. 频繁的事件触发,浏览器的滚动事件或者缩放
  2. 按钮的多次点击提交
  3. 输入框的多次提交和搜索

我们需要将这些频繁的事件触发进行控制,就不可避免的需要用到 节流 以及防抖函数,本文目标就是学习防抖函数的原理和实现

原理

弄明白防抖函数的实现,需要对闭包有一定的了解,简单来说,在函数执行完后,函数应该是要被销毁了,但函数内部的局部变量具有上层作用域的引用关系而仍然存活于内存当中,GC并没有对其进行回收,这个作用域就形成了闭包

闭包例子

function Closures() {
//Closures函数执行完毕后,Closures函数被进行销毁,但返回的新函数中访问了timer这个变量
//导致timer这个变量并没有被销毁存活在内存中,而形成的闭包作用域->Closures
	let timer = "Queto"; 
	return function () {
		return timer; //访问了上层作用域中的timer变量 形成引用关系
	};
}

let closure = Closures(); // closure内存上指向了Closures return的function的堆空间
closure()

防抖机制

本质就是控制事件的触发次数

  1. 事件触发时,对应的函数不会立即触发,而是等待一段时间
  2. 当事件密集触发时,函数的触发会被推迟
  3. 只有等待了一段时间也没有事件触发才会执行响应函数

每次触发时,刷新触发事件的等待时间,等用户不再进行事件的触发并且等待时间到达后才执行事件

一些理解

A函数内部能够直接访问使用外部函数B内部的变量,那B函数就提供了A函数的闭包作用域,而A函数就是闭包函数。 即百科上说的 访问了外部自由变量 ,如果还是不能理解它是个什么东西,可以直接当做是一个存活在局部作用域并且只有引用关系消失或者脚本完毕后才会销毁的变量

如果函数f内定义了函数g,那么如果g存在自由变量[在函数外部定义但在函数内被引用],且这些自由变量没有在编译过程中被优化掉,那么将产生闭包。

function a() { //闭包作用域 a
	let close = true;  //闭包变量 close
	return function () {  //闭包函数
		close = !close;
		console.log(close);
	};
}
let consoleA = a(); //consoleA 指向a函数返回的新函数
consoleA(); //false
consoleA(); //true
let consoleB = a(); //consoleB 返回一块新的闭包作用域
//consoleA 与consoleB 是两块作用域即两个闭包函数
consoleB(); //false
consoleB(); //true

内存泄漏与性能影响

闭包紧跟着而来的也是一个与之关联的问题, 内存泄漏。 因为其不会被回收且一直存活在内存中,当然得益于V8以及现代浏览器中所使用的的GC回收机制,我们现在不需要太过担心这个问题。但在运行时还是需要注意大量的读写操作且不进行释放的操作也可能造成性能影响的问题

function createArray(){
    var arr = new Array(1024*1024).fill(1)
    return function(){
      console.log(arr.length) //函数内部访问了arr.length 闭包形成,GC不会清除arr
    }
}
  
  //arrayFn 指向 return回来的函数,而函数内部又指向了arr,那么arr产生的内存就不会释放
  var arrayFn = createArray()

防抖函数

最简易实现

得益于闭包的机制, timer并不会像普通函数那样 在调用时候创建多个定时器,而是重复改变自身的定时器,而实现防抖的最核心的机制,就是等待时间到达后才执行

function debounce(wait) {
	let timer = null;
	return function () {
		//每次触发事件进来都会清理一次定时器
		clearTimeout(timer);
		timer = setTimeout(() => {
			console.log("Harexs");
			timer = null;
		}, wait);
	};
}

let debounceFn = debounce(2000);
debounceFn(); //函数执行, 闭包变量timer产生 并赋值为一个定时器,返回值为1
//由于闭包它的特性,并不会被直接销毁,下次函数在进来的时候 timer依然是存活的
debounceFn(); //执行时 清空了timer的定时器,重新给timer产生定时器,相当于重置了wait时间
debounceFn(); //执行时 清空了timer的定时器,重新给timer产生定时器,相当于重置了wait时间
//最终两秒后 会输出 Harexs

once函数

以此类推你也可以实现常见once函数,即只执行一次的函数

function once(fn) {
	let run = true;
	return function (...args) {
		if (run) {
			fn.apply(this, args);
			run = false;
		}
	};
}

let fn = () => console.log("test");
let onceFn = once(fn);
onceFn(); //仅本次执行
onceFn();
onceFn();

结合场景的代码实现

  1. HTML代码 用于频繁触发事件的HTML相关代码
<style>
    #container {
        display: flex;
        justify-content: center;
        align-items: center;
        width: 100vw;
        height: 20vh;
        background-color: #000000;
        font-weight: bolder;
        font-size: 22px;
        color: white;
    }
</style>
<body style="margin:0;">
    <div id="container">
    </div>
</body>
  1. Js代码
function debounce(fn, wait) {
	let timer = null;
	return function () {
		//每次触发事件进来都会清理一次定时器
		if(timer) clearTimeout(timer);
                //访问了原本执行后要销毁了的栈环境中的变量->上层作用域timer->闭包
		timer = setTimeout(() => {
			fn();
			timer = null;
		}, wait); //此时wait作为上层作用域中的形参,也是会产生闭包的
	};
}

let addSum = 0;
const con = document.querySelector("#container");

function testFn() {
	addSum++;
	con.innerHTML = `${addSum}`;
}

con.addEventListener("mousemove", debounce(testFn, 1000));

this指向以及event事件对象

function debounce(fn, wait) {
	let timer = null;
	return function (...args) {
		//每次触发事件进来都会清理一次定时器
		if(timer) clearTimeout(timer);
		timer = setTimeout(() => {
			fn.apply(this, args);
			timer = null;
		}, wait);
	};
}

let addSum = 0;
const con = document.querySelector("#container");

function testFn(...args) {
	console.log(this, args);
	addSum++;
	con.innerHTML = `${addSum}`;
}

con.addEventListener("mousemove", debounce(testFn, 1000));

理解代码中args来自哪里以及this的指向 只需要 看这段代码

con.addEventListener("mousemove", debounce(testFn, 1000));
👇
con.addEventListener("mousemove", function (...args) { //此时args就是这个事件对象了
		//每次触发事件进来都会清理一次定时器
		if(timer) clearTimeout(timer);
		timer = setTimeout(() => {
                //this自然就是指向 事件的调用者 即DOM #container
			fn.apply(this, args); 
			timer = null;
		}, wait);
	};);

实现取消功能

function debounce(fn, wait) {
	let timer = null;
	const _bounce = function (...args) {
		//每次触发事件进来都会清理一次定时器
		if (timer) clearTimeout(timer);

		timer = setTimeout(() => {
			fn.apply(this, args);
			timer = null;
		}, wait);
	};
        //改变之前的写法,以命名函数的形式,给函数增加一个属性用于取消的函数
	_bounce.cancel = function () {
		if (timer) clearTimeout(timer); //清除定时器
		//初始化闭包
		timer = null;
	};
	return _bounce;
}

function testFn(...args) {
	console.log(this, args);
	addSum++;
	con.innerHTML = `${addSum}`;
}

//调用
const DomEvent = debounce(testFn, 1000);

con.addEventListener("mousemove", DomEvent);

con.addEventListener("mouseleave", DomEvent.cancel);

这里的取消函数 涉及一个原型链知识点,function本质来说和array 也是复杂数据类型即对象,因为它们都是通过Object这个最顶层的构造函数创建出来的Function构造函数 以及Array构造函数所创建出来的

得到返回结果

这一步比较简单,写个回调函数去获取

function debounce(fn, wait, cb) {
	let timer = null;
	const _bounce = function (...args) {
		//每次触发事件进来都会清理一次定时器
		if (timer) clearTimeout(timer);

		timer = setTimeout(() => {
			const res = fn.apply(this, args);
			timer = null;
                        //回调拿到执行结果
			if (cb && typeof cb === "function") cb(res);
		}, wait);
	};
	_bounce.cancel = function () {
		if (timer) clearTimeout(timer); //清除定时器
		//初始化闭包
		timer = null;
	};
	return _bounce;
}

//函数内部给个return
function testFn(...args) {
	console.log(this, args);
	addSum++;
	con.innerHTML = `${addSum}`;
	return "I am Result";
}

浅析闭包与防抖函数并学习underscore的实现

改为首调用

首调用即 事件是立马触发的,而不是一直等时间到达后再运行, 并且触发后的一段时间内 都不会再触发, 我们只需要增加一个变量来控制它即可

function debounce(fn, wait, immediate = false, cb) {
	let timer = null;
	let isInvoke = false; //中间变量 控制首调用触发

	const _bounce = function (...args) {
		//每次触发事件进来都会清理一次定时器
		if (timer) clearTimeout(timer);

		//如果是首调用的情况那么定时器只做改变isInvoke的事情,时间到达后不会再调用
		if (!isInvoke && immediate) {
			const res = fn.apply(this, args);
			if (cb && typeof cb === "function") cb(res);
			isInvoke = true;
		}

		timer = setTimeout(() => {
			if (immediate) {
				//如果是首调用 只做改变isInvoke的操作
				isInvoke = false;
			} else {
				const res = fn.apply(this, args);
				timer = null;
				if (cb && typeof cb === "function") cb(res);
			}
		}, wait);
	};
	_bounce.cancel = function () {
		if (timer) clearTimeout(timer); //清除定时器
		//初始化闭包
		timer = null;
		isInvoke = false;
	};
	return _bounce;
}

underscore的防抖

为方便阅读,我将引入的函数也拆分出来

function restArguments(func, startIndex) {
//startIndex 0  1-1
// func 传进来的函数 func.length 形参的长度  func.length - 1 最后一个参数的位置
//如果 func就一个参数 那startIndex为0
startIndex = startIndex == null ? func.length - 1 : +startIndex;
    return function () {
        // 这里的arguments 对应的就是 事件触发时候  mousemove 给的事件对象
        var length = Math.max(arguments.length - startIndex, 0), //length 1
                rest = Array(length), // rest = ['']
                index = 0; //index = 0
        for (; index < length; index++) {
                //0 < 1;
                //rest[0] = arguments[0+0]  MouseEvent

                rest[index] = arguments[index + startIndex];
        }
        //到这里就可以明白就是为了收集 与arguments 对应的   参数
        //rest 就是模拟 对应的数组展开语法
        //startIndex的作用 就是代表 从哪个位置的形参开始 收集剩余参数
        switch (
                startIndex // 0
        ) {
                case 0:
                        //根据之前的调用这里只会是 0 的情况 直接把 rest传入调用
                        return func.call(this, rest); //rest [MouseEvent]   func debounced传的 函数参数
                // func 对应的就是 function (_args) 这个作为参数的函数
                //作为 mousemove的调用函数 这里的this在运行时实际就会是 <div id="container">
                case 1:
                        //如果 startIndex为1 , 那么arguments[0]就要作为参数传入
                        return func.call(this, arguments[0], rest);
                case 2:
                        return func.call(this, arguments[0], arguments[1], rest);
        }
        //后面的部分就不会执行了前面已经return了
        //过长的参数改为 apply形式调用
        var args = Array(startIndex + 1);
        for (index = 0; index < startIndex; index++) {
                args[index] = arguments[index];
        }
        args[startIndex] = rest;
        return func.apply(this, args);
    };
}

//返回一个时间毫秒戳
function now() {
	return new Date().getTime();
}

function debounce(func, wait, immediate) {
    var timeout, previous, args, result, context;

    var later = function () {
            //passed now() 现在时间 - 之前闭包取的一次时间
            var passed = now() - previous;
            //later执行完 会对比 wait时间 和 bounce函数执行时候 previous的时间差
            if (wait > passed) {
                    //如果还没到时间 就再次刷新时间
                    //并且时间定为 等待时间的时间差 保证到下一次重复触发的时候 对应 wait的时间差
                    timeout = setTimeout(later, wait - passed);
            } else {
                    //如果已经到时间了 则初始化timeout
                    timeout = null;
                    //如果不是首次执行 则执行一次函数的调用 并传入 this 和 事件对象
                    if (!immediate) result = func.apply(context, args);
                    // This check is needed because `func` can recursively invoke `debounced`.
                    //如果timeout null了, args 参数 context this绑定 也初始化
                    if (!timeout) args = context = null;
            }
    };

    var debounced = restArguments(function (_args) {
            // _args 对应的就是 func.call(this, rest); 中的rest
            context = this; // context -> <div id="container">
            //return的函数中会将this 传入到这个 作为形参的函数中 实际这里的this也是对应的事件调用者
            args = _args; //args -> [MouseEvent]
            //闭包previous 取现在时间毫秒戳值
            previous = now();
            //如果timeout 为null 则进入条件
            if (!timeout) {
                    //重新设置定时器  later函数内部做了 timer = null的操作
                    timeout = setTimeout(later, wait);
                    //如果立即执行 调用一次函数 并把结果给到 闭包result
                    if (immediate) result = func.apply(context, args);
            }
            // 不符合条件则只做返回result结果的操作
            //返回闭包结果result
            return result;
    });

    debounced.cancel = function () {
            clearTimeout(timeout);
            timeout = args = context = null;
    };

    return debounced;
}

包了一层restArguments函数的调用还有点绕,其实就是将函数作为一个参数 传给了它去执行。 restArguments函数就是 模拟 ES6的剩余参数展开语法 ,通过ES5的方式实现了它

总结

underscore 的实现与我自己的实现大同小异, 主要区别在underscore增加了时间戳的对比来决定函数的实际调用,而我使用定时器,通过重复清空定时器的方法 来实现类似的效果。

对闭包有了更深刻的认知,但对其应用还是有些不够得心应手,还得继续拓展自己的知识和其应用