关于闭包

本文章著作权归饥人谷_Lyndon和饥人谷所有,转载请注明出处。

闭包对于我而言是一个难点,但闭包又是一个很有用的知识点,很多高级应用都需要依赖闭包。
所以在参考一些文章加上大量练习后,我来写一写自己理解闭包的过程,首先是弄清楚以下几个知识点。


>>> Part 1. 变量的作用域

JS中,变量的作用域只有两种:全局作用域、函数作用域。对应的变量也只有两种:全局变量、局部变量。

函数内部可以直接读取全局变量。

var a = 1;
function f(){
    console.log(a);
}
f();  // 1

但是函数外部无法读取到函数内部的局部变量。

function f(){
    var a = 1;
}
console.log(a);  // Uncaught ReferenceError: a is not defined

这一个Part是比较好理解的。


>>> Part 2. 如何从外部读取到局部变量?

在禄永老师的公开课中,老师将从外部读取局部变量这一情况称作“伟大的逃脱”。总结而言,有两种方法来实现。

  • 返回值的方法:函数作为返回值
function f1(){
    var a = 1;
    function f2(){
        console.log(a);
    }
    return f2;
}
var result = f1();
result();  // 1

函数f2包裹在函数f1内,根据作用域链的原理:子对象会一级一级向上寻找父对象的变量,f1所有的局部变量都可以被f2访问到,反之则不行。因此只要把f2作为返回值,就可以在f1外部读取到其中的内部变量。

  • 句柄的方法:定义全局变量
var innerHandler = null;
function outerFunc(){
    var outerVar = 1;
    function innerFunc(){
        console.log(outerVar);
        var innerVar = 2;
    }
    innerHandler = innerFunc;
}
outerFunc();
innerHandler();  // 1

这一方法首先定义了一个值为null的全局变量innerHandler,然后让innerHandler等于函数内部的函数,函数内部的函数则可以通过作用域链访问到父对象的变量outerVar,之后在外部调用innerHandler的时候,就可以访问到outerFunc函数中的内部变量outerVar


>>> Part 3. 闭包

网络上有千万种对闭包的解释,其实闭包就是上面例子中的两个函数:f2以及innerFunc。书面解释就是:能够读取其他函数内部变量的函数

在JS中,因为父函数内部的子函数才能够读取局部变量,因此闭包的常见形式就是:定义在函数内部的函数。前者是后者的充分不必要条件。

一言以蔽之,闭包就是连接函数内部外部的渠道。


>>> Part 4. 对示例代码段的解答

  • 第一段代码
function outerFn() {
    console.log("Outer function");
    function innerFn() {
        var innerVar = 0;
        innerVar++;
        console.log("Inner function\t");
        console.log("innerVar = "+innerVar+"");
    }
    return innerFn;
}

var fnRef = outerFn();
fnRef();
fnRef();

var fnRef2 = outerFn();
fnRef2();
fnRef2();

在这一段代码当中,innerFn不是一个闭包,因为它并不需要读取其他函数的内部变量,唯一的变量innerVar就在innerFn函数内部。在第一个fnRef()之后,结果就是首先输出Outer function,然后输出Inner function,由于innerVar是函数innerFn的内部变量且自增,因此从0变为1,再输出innerVar = 1.

这时候需要明白,当再次运行fnRef()时,由于fnRef本身已经变成了函数innerFn,所以其输出结果就不再有Outer Function这一句,而是直接输出:Inner function以及innerVar = 1.原因是此时的innerVar是一个内部变量,其作用域限定在innerFn函数中,每次调用执行innerFn函数,innerVar都会被重写。

对于下面的fnRef2(),也是同理。最后的输出结果见下图:

关于闭包_第1张图片
  • 第二段代码
var globalVar = 0;
function outerFn() {
    console.log("Outer function");
    function innerFn() {
        globalVar++;
        console.log("Inner function\t");
        console.log("globalVar = " + globalVar + "");
    }
    return innerFn;
}

var fnRef = outerFn();
fnRef();
fnRef();

var fnRef2 = outerFn();
fnRef2();
fnRef2();

这里的globalVar是一个外部变量,也是一个全局变量,处于全局作用域下。所以当执行innerFn时,innerFn函数将会访问到一个每次都自增的全局作用域下的活动对象,因此输出的结果会从globalVar = 1一直到globalVar = 4.在执行间歇中,globalVar处于两个函数的作用域之外,天高地远谁也管不了,所以它的值会被保存在内存中,并不会立刻被抹去。最后的输出结果见下图:

关于闭包_第2张图片
  • 第三段代码
function outerFn() {
    var outerVar = 0;
    console.log("Outer function");
    function innerFn() {
        outerVar++;
        console.log("Inner function\t");
        console.log("outerVar = " + outerVar + "");
    }
    return innerFn;
}

var fnRef = outerFn();
fnRef();
fnRef();

var fnRef2 = outerFn();
fnRef2();
fnRef2();

闭包来临了,这里的fnRef是一个闭包innerFn函数,但是此时的变量outerVar来到了父函数的作用域内,不像之前一样处于子函数作用域内或者处于全局作用域下。可以发现,这和Part 2中的例子非常相似。

其原理是:外部函数的调用环境为相互独立的封闭闭包的环境,第二次的fnRef2调用outerFn没有沿用第一次调用fnRefouterVar的值,第二次函数调用的作用域创建并绑定了一个新的outerVar实例,两个闭包环境中的计数器是相互独立,不存在关联的。

进一步来说,在每个封闭闭包环境中,外部函数的局部变量会保存在内存中,并不会在外部函数调用后被自动清除。原因在于:outerFninnerFn的父函数,而innerFn被赋值给一个全局变量,因此innerFn始终在内存当中,而它又依赖于outerFn,所以outerFn也必须始终在内存中,不会再函数被调用后就被抹去,因此闭包也有一点点不好,有可能造成内存泄漏。

所以,结果应该是:outerVar = 1, outerVar = 2, outerVar = 1, outerVar = 2.结果如下图所示:

关于闭包_第3张图片

我写到这自己已经完全明白了,我现在要用自己的理解来理顺一下最经典的问题。


>>> Part 5. 理顺最经典问题

0 1 2 3

最经典的问题是:为什么我点击任何数字,控制台的输出结果永远是4?

这里可使用作用域链来帮助理解,不妨将以上代码转化为:

// function只是传递给了NodeList类型对象中的元素却并未执行,因为后面无括号
spans[0] = function fn0(){console.log(i)};
spans[1] = function fn1(){console.log(i)};
spans[2] = function fn2(){console.log(i)};
spans[3] = function fn3(){console.log(i)};
globalContext = {
    AO: {
        i: undefined, // 0(fn0)1(fn1)2(fn2)3(fn3)4(终止循环)
        spans:[0], [1], [2], [3]
    },
    scope: null
}
fn0[[scope]] = globalContext.AO,
fn1[[scope]] = globalContext.AO,
fn2[[scope]] = globalContext.AO,
fn3[[scope]] = globalContext.AO

fn0Context = {
    AO:{
    },
    scope: fn0[[scope]]
}

fn1Context = {
    AO:{
    },
    scope: fn1[[scope]]
}

fn2Context = {
    AO:{
    },
    scope: fn2[[scope]]
}

fn3Context = {
    AO:{
    },
    scope: fn3[[scope]]
}

最后点击span元素的时候i早已变为4,因此永远输出4.

改进的方法可以使用闭包,也就是:

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

这个闭包也可以用作用域链来理解:

globalContext = {
    AO:{
        i: undefined,
        spans: [0], [1], [2], [3]
    }
}
fn0.scope = globalContext.AO,
fn1.scope = globalContext.AO,
fn2.scope = globalContext.AO,
fn3.scope = globalContext.AO

fn0Context = {
    AO:{
        i: 0,
        function: anonymous
    }
    fn0[[scope]] = fn0.scope // globalContext.AO 
}

function_anonymousContext = {
    AO: {
    }
    function_anonymous[[scope]] = fn0Context.AO
}
...

>>> Part 6. 闭包的问题

如同刚才的分析一样,当涉及到闭包时,函数中的变量都会被保存在内存中,因此需要避免滥用闭包,否则就有可能导致内存泄露。


>>> 参考资料

  • 阮一峰:学习Javascript闭包(Closure)
  • 认识闭包-禄永老师
  • segmentfault: for因闭包引起的实际问题
  • Stack Overflow: How do JavaScript closures work?

你可能感兴趣的:(关于闭包)