作者: 她不美却常驻我心
博客地址: https://blog.csdn.net/qq_39506551
微信公众号:老王的前端分享
每篇文章纯属个人经验观点,如有错误疏漏欢迎指正。转载请附带作者信息及出处。
从零开始学前端 - 19. JS闭包
一、什么是闭包?
我们在上一节介绍了 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
。这是因为按照定义域链的规则:当前作用域没有找到变量时,会逐步向外层查找。而 fn1
是 fn2
的父函数,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
的计算结果。
fn1
和 fn2
分别传入不同的值来接收这个匿名函数,它们使用相同的函数进行定义,但传入的值不同,且 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
函数,变量 a
是 fn1
内部的变量 a
的一个缓存,而 fn2
则是 fn1
内部函数 fn2
的一个引用。前面我们提到,闭包里面的环境都是独立的,互不干扰。所以所 test1
和 test2
分别是两个独立的执行环境,结果互不影响。
下方代码的打印结果:
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 。