从零开始学前端 - 19. JS闭包

作者: 她不美却常驻我心
博客地址: https://blog.csdn.net/qq_39506551
微信公众号:老王的前端分享
每篇文章纯属个人经验观点,如有错误疏漏欢迎指正。转载请附带作者信息及出处。

一、什么是闭包?

  我们在上一节介绍了 JS 的作用域,推荐将上篇文章和这一篇连在一起看。
  JS 将作用域分为了全局作用域和局部函数作用域,这表明了在函数的外部是不能访问函数内部的变量的,而内部函数总是可以访问其所在的外部函数中声明的参数和变量,即使在其外部函数被返回了之后。当函数运行结束之后,函数内部定义的变量也会被回收机制回收。而闭包的作用就是通过作用域链,使函数对象相互链接,使内部变量不被回收,保存在函数的作用域内,让可以我们可以在外部访问它。
  闭包是什么样子呢?简单的理解,就是一个函数中嵌套着另一个函数,就是一个闭包,例:

var a = 1;
function fn1(){
    var a = 2;
    function fn2(){
        console.log(a);
    }
    return fn2();
}
fn1()	// 2

   fn1 声明了一个局部变量 a,并定义了一个返回变量 a 的值的函数 fn2 ,此时,按照函数作用域链的规则,我们可以知道,当调用 fn1 之后,返回的结果是局部作用域中定义的变量 a 的值。

二、闭包的特性

  首先来我们更改一下上方的函数:

var a = 1;
function fn1(){
    var a = 2;
    function fn2(){
        console.log(a);
    }
    return fn2;
}
fn1()()	// ?

  我们将代码稍作更改,返回内部嵌套函数,而不是直接返回函数运行结果,此时在定义函数的作用域外调用 fn2,再看一下它的返回结果。
  fn2的返回值依然是 2 ,并不是全局变量 1。这是因为按照定义域链的规则:当前作用域没有找到变量时,会逐步向外层查找。而 fn1fn2 的父函数,fn2 会首先去它的父函数作用域中查找变量 a ,不管在任何地方调用,都会按照定义 fn2 时创建的作用域链来查找变量。由此我们可以得出闭包的第一个特性:

  • 函数外部可以引用函数内部的变量,且避免了全局变量对函数内部的污染;
function add(x){
    return function(y){
        return x + y;
    };
}
var fn1 = add(1);
var fn2 = add(2);
fn2(4);		// 6
fn1(3);		// 4

  首先我们定义一个函数 add,它接受一个参数 x ,返回一个匿名函数,接受一个参数 y,返回 x+y 的计算结果。
  fn1fn2 分别传入不同的值来接收这个匿名函数,它们使用相同的函数进行定义,但传入的值不同,且 fn2 传入的值并没有覆盖掉 fn1 传入的值。两个函数的词法环境不同,所以计算出的结果也不同。

  由于 fn2 并没有覆盖掉 fn1 ,这就说明每次外部函数执行的时候,都会重新创建一个新的对象,也就是说外部函数的引用地址不同。闭包可以创建一个独立的环境,每个闭包里面的环境都是独立的,互不干扰。这就说明当操作不当时,由于存储的内容过多,会有发生内存泄漏的可能。

  词法环境指函数创建时可访问的所有变量。

  • 闭包里面的环境都是独立的,互不干扰,内部变量会常驻内存中,不会被垃圾回收机制回收,有发生内存泄漏的可能。

  上面的例子可能不能很好的说明 内部变量会常驻在内存中,所以我们来看下面的代码:

function fn1() {
    var a = 1;
    return function fn2() {
        var b = 0;
        console.log(++a);
        console.log(++b);
    }
}
var fn = fn1();
fn();       // a = 2  b = 1
fn();       // a = 3  b = 1

  一般情况下,当函数执行完毕之后,会被垃圾回收机制所注销,但由于 fn2 被赋值给了 fn ,所以这时候 fn 相当于:

var fn = function(){
	var b = 0;
	console.log(++a);
	console.log(++b);
}

  而因为函数内部中又引用着外部环境变量 a ,所以变量 a 并没有被销毁。而变量 b 随着 fn 的每次调用被重新创建,调用完毕之后又会被回收机制所销毁。
  至于为什么外部环境变量 a 为什么没有被销毁呢?上一篇文章说过:当函数执行完毕后,如果不存在嵌套函数或其他引用指向该函数的对象,它就会被当做垃圾被删除掉。

  我们来总结一下闭包的特点和优劣:
特点:

  • 一个函数嵌套另一个函数。
  • 函数外部可以引用函数内部的变量。
  • 内部变量常驻内存,不会被垃圾回收机制回收。

优点:

  • 可以使一个变量长期存储在内存中;
  • 可以避免全局变量对函数内部的污染;
  • 可以给局部声明私有变量;

缺点:

  • 变量常驻内存,增加内存使用量,使用不当会很容易造成内存泄露;
  • 闭包可以在父函数外部,改变父函数内部变量的值,容易造成疏忽;

三、JS常见闭包面试题

下方代码的打印结果:

function fn1() {
    var a = 0;
    function fn2() {
        console.log(++a);
    }
    return { a: a, fn2: fn2 }
}
var test1 = fn1();
var test2 = fn1(); 
test1.fn2();    // 1   函数第一次调用,沿作用域链查找变量 a 并将其存储于内存中, a 初始值为 0 ,打印结果 1;
test1.fn2();    // 2   因为内部变量常驻内存,不会被回收,所以此时调用函数的时候 a 初始值为 1,打印结果 2;
test1.a;   		// test.a 是函数返回对象中的变量 a,它指向的是函数 fn1 的局部变量 a ,而不是函数 fn2 执行环境中的私有变量 a;
test2.fn2();    // 1

  首先,fn1 在自己的函数作用域内声明了一个变量 a,以及一个函数 fn2,这时 fn2 就作为一个闭包可以在外部访问到 fn1 内部的变量 a,并对其在使用前进行一次自增。fn1 返回一个对象,这个对象有一个 a 变量以及一个 fn2 函数,变量 afn1 内部的变量 a 的一个缓存,而 fn2 则是 fn1 内部函数 fn2 的一个引用。前面我们提到,闭包里面的环境都是独立的,互不干扰。所以所 test1test2 分别是两个独立的执行环境,结果互不影响。

下方代码的打印结果:

function fun(n, o) {
	console.log(o);
	return {
		fun: function (m) {
			return fun(m, n);
		}
	}
};

var a = fun(0);     // undefined
a.fun(1);       // 0
a.fun(2);       // 0
var b = fun(0).fun(1).fun(2).fun(3);    // undefined , 0, 1 , 2
var c = fun(0).fun(1);      // undefined , 0
c.fun(2);   // 1
c.fun(3);   // 1

  首先我们先分析函数,最外层函数 fun 接受两个参数,并打印第二个参数 o,返回一个函数名同样为 fun 的函数。第二层函数 fun 接收一个参数 m,而且返回的是一个 对象 ,这个对象中有一个叫做 fun 的匿名函数,这里都使用相同的名字就是为了混淆视线,增加理解难度。第二层的匿名函数实际上是在调用最外层的 fun 函数,将自身接受到的变量 m 和父函数接受的变量 n传递给最外层,此时,对于最外层的 fun 而言,接受的参数 n 为 内层传递的变量 m ,参数 o 为之前自身接受的变量 n
  理解了代码的含义之后,我们可以将代码改成方便理解的形式:

// 改写函数名字,便于区分理解。
function fn1(n, o) {
	console.log(o);
	return {
		fn2: function (m) {
			return fn1(m, n);
		}
	}
};
// 调用最外层函数实际执行代码:
function fn1(n, o){
	console.log(o);
	return fn2;
}
// 调用内层函数实际执行代码:
function fn2(m){
	// 当前作用域无变量 n ,沿作用域链向上查找变量 n ,可知这里传入 fn1 的变量 n 与父函数接收到的 n 为同一个变量。
	// fn1 接受的参数 `n` 为 fn2 传递的变量 `m` ,参数 `o` 为之前自身接受的变量 `n`。
	fn1(m, n);
}

  var a = fun(0) => var a = fn1(0) :此时 fn1 接受参数 ( n = 0 ), 变量 o 不存在,打印 undefined ;
  a.fun(1); => a.fn2(1):此时 fn1 接受参数( n = m = 1 , o = 第一次传入的 n = 0 ),打印 0;
  a.fun(2); => a.fn2(2):此时 fn1 接受参数( n = m = 2 , o = 第一次传入的 n = 0 ),打印 0;

  var b = fun(0).fun(1).fun(2).fun(3);将其拆分为:

var b1 = fn1(0);		// fn1(n = 0)	打印 undefined
var b2 = b1.fn2(1);		// fn1(n = 1 , o = 0)	打印 0
var b3 = b2.fn2(2);		// fn1(n = 2 , o = 1)	打印 1
var b = b3.fn2(3);		// fn1(n = 3 , o = 2)	打印 2

  看懂了上面的解释,c 的输出就一目了然了。

var c1 = fn1(0);		// fn1(n = 0)	打印 undefined
var c = c1.fn2(1,0)		// fn1(n = 1 , o = 0)	打印 0
c.fun(2)	// => c.fn2(2) => c.fn1(n = 2, n = 1)	打印 1
c.fun(3)	// => c.fn2(3) => c.fn1(n = 3, n = 1)	打印 1

下方代码的打印结果:

for (var i = 0; i < 4; i++) {
    setTimeout(function() {
        console.log(i);
    }, 100);
}

  这是一道考验对线程的基础面试题,这里举这个例子是想要通过闭包来解决这个问题。上边打印的结果都是 5,可能部分人会认为打印的是 0、1、2、3、4。

  原因: JS 分为同步任务和异步任务,同步任务都在主线程上执行,当主线程执行完毕之后再执行异步队列。而定时器操作属于异步任务,JS 在执行的时候首先会先执行主线程,异步相关的会存到异步队列里,当主线程执行完毕之后再执行异步队列,主线程执行完毕后,此时 i 的值为 4,所以在执行异步队列的时候,打印出来的都是 4。
  这里需要大家对 event loop (js 的事件循环机制) 有所了解,推荐一个GitHub上的有关事件循环的面试题和讲解,有基础和的经验同学可以 戳这里查看事件循环面试题。
  解决方案:

for (var i = 0; i < 5; i++) {
    setTimeout((function (i) {
        return function () {
            console.log(i);
        };
    })(i), 100);
}

利用闭包给所有的 button 添加点击事件:

var aBtn = document.querySelectorAll("button");
for (var i = 0; i < aBtn.length; i++) {
	aBtn[i].onclick = (function (i) {
		return function () {
			console.log(i);
		};
	})(i)
}

种一棵树,最好的时间是十年前,其次是现在。人的一生,总的来说就是不断学习的一生。
蚕吐丝,蜂酿蜜。人不学,不如物。与其纠结学不学,学了有没有用,不如学了再说。


每篇文章纯属个人经验观点,如有错误疏漏欢迎指正。转载请附带作者信息及出处。您的评论和关注是我更新的动力!
请大家关注我的微信公众号,我会定期更新前端的相关技术文章,欢迎大家前来讨论学习。
在这里插入图片描述
都看到这里了,三连一下呗~~~。点个关注,少个 Bug 。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值