浅析 JavaScript 中的闭包(-------------------------------------------)

一、前言

对于 JavaScript 来说,闭包是一个非常强大的特征。但对于刚开始接触的初学者来说它又似乎是特别高深的。今天我们一起来揭开闭包的神秘面纱。闭包这一块也有很多的文章介绍过了,今天我就浅谈一下自己对闭包的的一些理解,希望能提供一点鄙陋的见解帮助到正在学习的朋友。该文章中能使用口语化的我将尽量使用口语化的叙述方式,希望能让读者更好理解,毕竟文章写出来宗旨就是要让人读懂。文章难免有不足之处还希望帮忙指出。

二、Javascript 的作用域链

在了解闭包之前,我们先来看看几个准备知识。

  1. 变量的作用域

    首先,什么是作用域?域,区域。简单来理解就是一个变量能被访问的范围(区域)。换言之就是这个变量能起作用的区域。按这个标准来划分我们将变量分为 全局变量 和 局部变量 两种

    以定义的方式来区分有以下特点:

    定义在函数内部的变量是局部变量,定义在函数外部的变量是全局变量。(这个并不只是 Javascript 语言的特点)局部变量在函数内部能被访问,在函数外部不能被直接访问,所以局部变量就是从定义它的地方开始到函数结束的位置结束。当然这里有个细节--变量声明提升。等下我们用一小段代码提一下变量声明提升是什么。我们先来看看局部变量和全局变量的代码

    复制代码
    var a = 0;
    
    function testFunc(){
        var b = 1;
        console.log('-------------函数内输出-------------');
        console.log(a);//0
        console.log(b);//1
    }
    
    //调用函数
    testFunc();
    
    console.log('-------------函数外输出-------------');
    console.log(a);//0
    console.log(b);//Uncaught ReferenceError: b is not defined
    复制代码

     

    执行以上代码结果如下图所示

    代码执行结果

    在代码的最后一行抛出了 b 未定义的异常.也就是说我们在函数外部访问不到在函数内部定义的局部变量。但是第六行代码的正常输出,可见在函数内部我们是可以访问到在函数外部定义的全局变量 a

    变量声明提升

    相信如果学过 C 语言的话,应该会很熟悉一句话 "先声明后使用"。就是说一个变量或者函数在使用它之前必须是要先找得到这个变量或函数的声明的。例如:

    复制代码
    //C 语言正确写法
    int a = 0;
    printf(a);
    
    //错误写法,下面代码没办法通过标准编译(直接报异常)
    printf(a);
    int a = 0;
    复制代码

     

    我们再来看看 Javascript 代码

    var a = 0;
    console.log(a);//输出结果 0

     

    上面这种普通写法我们不探讨,重点看下面的这段代码

    console.log(a);//输出结果 undefined
    var a = "hello";
    console.log(a);//输出结果 hello

    运行结果如下

    上面这个例子就恰好说明了变量声明提升的特点,我们在没有声明变量 a 之前就直接访问变量a 输出结果为 undefined 而并不是直接报异常。所以最直观的感觉是变量的声明被提升到使用之前了。实质上代码如下:

    var a;//声明被提升到这里
    console.log(a);//输出结果 undefined
    a = "hello";
    console.log(a);//输出结果 hello

     

    小结一下

    • 函数内部定义的变量是局部变量,函数外部定义的变量是全局变量。
    • 局部变量不能被外界直接访问,全局变量可以在函数内被访问。
    • 变量声明提升
  2. 嵌套函数的作用域特点

    搞清楚上面的小结部分我们缕一缕思路继续探讨另一个话题,javascript 中的嵌套函数,我们先上一段代码:

    复制代码
    function A(param){
        var vara = 1;   
        function B(){
            var varb = 2;
            console.log("----Function B----------")
            console.log(vara);//函数B中访问A函数中定义的变量
            console.log(param);//A函数中传进来的变量
            console.log(varb);//访问自身函数内定义的变量
        }
        B();
        console.log("----Function A----------")
        console.log(vara);//访问自身函数内定义的变量
        console.log(param);//A函数中传进来的变量
        console.log(varb);//访问B函数中定义的变量--异常
    }
    A("hello");
    复制代码

    运行结果如下:

    函数嵌套

    由此可见嵌套函数(B)可以继承容器函数(A)的参数和变量,但是嵌套函数(B)中的变量对于他的容器函数来说却是B私有的,也就是说 A 无法访问 B 中定义的变量。换句话说,B 函数形成了一个相对独立的环境(空间)使得它自身的变量只能由它自己来访问,但是 A 函数里的变量 B 也可以访问,这里嵌套函数 B 就形成了一个闭包。有一句话很适合 B 来说 “你的就是我的,我的还是我的”

    从语法上看是函数 A 包含着函数 B,但是从作用域上来看是函数 B 的作用域包含着函数 A 的作用域,关系如下图所示:

    函数嵌套

    假设:函数 B 下面又包含了函数 C。此时函数 C 为函数 B 的嵌套函数,函数 B 为函数 C 的容器函数。对于C来说也具有刚刚讲过的 “你的就是我的,我的还是我的” 的特点。以此类推层层嵌套的话就形成了一条链条, 作用域按此规律也形成了 Javascript 中的作用域链。

    函数嵌套

三、闭包的特点

我们先来总结上面提到的两点

  • 嵌套在容器函数(A)内部的嵌套函数(B)只能在容器函数(A)内被访问
  • 嵌套函数(B)继承了容器函数(A)的变量,但是 B 函数中的变量只有它自己能访问,也就是嵌套函数(B)的作用域包含容器函数(A)的作用域。
闭包之保存变量

我们还是先上一段代码

复制代码
function A(a){
    function B(b){
        return a + b;
    }
    return B;
}
var C = A(1);
var result = C(2);
console.log(result);//输出结果 3 
复制代码

函数 B 形成了一个闭包,A 函数调用之后返回函数 B 的引用。执行 C 之后发现结果等于3,这也就说明了我们调用 A 的时候 传进去的参数 1 没有被销毁,而是被保存起来了,这就是闭包保存变量的特点。

有保存就有销毁那我们被闭包保存的变量在什么时候销毁?答案是当 B 没有再被引用的时候,就会被销毁.

闭包的注意点--命名冲突

我们还是先上一段代码

复制代码
function A(){
    var num = 6;//外部的名为num 的变量
    function B(num){
        return num;//当做参数传进来的num 变量,命名冲突发生在这
    }
    return B;
}
var result = A()(10);
console.log(result);//输出结果10
复制代码

上述代码的执行结果

闭包中的命名冲突

通过上面的代码我们能看到有一个容器函数内的名为 num 的变量以及一个嵌套函数内同样名为 num 的变量。这样的执行代码结果以嵌套函数内的变量优先。可能这里说成就近原则更容易记得住。这个就是闭包在实际应用中应该注意的一点。

四、闭包在开发中的应用。

关于闭包在开发中的使用,最多的体现应该还是在 Javascript 插件的开发上面。使用闭包可以避免变量污染。也就是说你在闭包中使用的变量名称不会影响到其他地方同样名称,换个角度来讲,我将我嵌套函数内部的变量给保护起来了,外部没办法随便修改我内部定义的变了。也就是虽然名字一样但是你是你我是我。代码体现如下:

复制代码
function A(){
    function B(num){
        var c = 10;//内部变量 c
        return num + c;
    }
    return B;
}

var c = 20;//外部变量c
var result = A()(c);
console.log(c);//20 
console.log(result)//30 
复制代码

以上特点应用在插件开发中就可以很好的保护了插件本身,避免了外界的串改,保证了插件的稳定。

简单的插件

初步代码

复制代码
//编写插件代码
var plugin = (function(){
    function SayHi(str = '你好啊!'){
        console.log(str);
    }
    return SayHi;
})();

//使用插件
plugin('hello');
plugin();
复制代码

插件初步

上面代码闭包部分我就不在累述了,我们来看看新出现的一种语法--自调用匿名函数:

(function{
    //code
})();

实际作用是创建了一个匿名函数,并在创建后立即执行一次。作用等价于下面的代码,唯一的区别就是下面的函数不是匿名的。

//创建
var func = function(){
    //code
}   
//调用
func();

当然,我们编写插件不可能只提供一个API给外部使用,如何返回多个API,我们这里使用字面量形式返回。改进之后的代码如下

复制代码
//编写插件代码
var plugin = (function(){
    var _sayhi = function(str = '你好啊!'){
        console.log(str);
    }
    var _sayhello = function(){
        console.log("这个API能做很牛逼的事情");
    }
    return {
        SayHi : _sayhi,
        SayHello : _sayhello
    }
})();

//通过插件提供的API使用插件
plugin.SayHi('hello');
plugin.SayHello();
复制代码

执行结果

闭包中的命名冲突

五、后语

今天对于闭包的看法暂时先写到这了,秉承着学以致用的原则,下两篇文章我将介绍 javascript 插件的几种开发形式,以及实践--开发一个原生的 Javascript 插件。

本文为作者原创,转载请注明出处!

 

 

 

前提

首先,我们需要知道 JavaScript 里面的函数会创建内部词法作用域,是的,JavaScript 是词法作用域,也就是说作用域与作用域的层级关系在你书写的时候就已经确定了,而不是调用的时候,调用的时候确定的称为动态作用域,由于不是本篇文章的重点,就不再详细解释了,举两个例子自己领悟:

1
2
3
4
5
6
7
8
9
10
var name = 'fruit'
function apple () {
console.log(name)
}
function orange () {
var name = 'orange'
apple()
}
 
orange() // fruit

 

由于 JavaScript 是词法作用域,所以 apple 函数的局部作用域的上层作用域是全局作用域,从书写的位置就看出来了。假设 JavaScript 是动态作用域,就要看函数的调用顺序了,由于 apple 是在 orange 中调用的,所以 apple 的上层作用域是 orange 的局部作用域,那样的话会输出 orange!

这样的话,就制定了一套作用域访问的规则,这也是会有闭包的原因之一!

什么是闭包?

函数记住并访问其所在的词法作用域,叫做闭包现象,而此时函数对作用域的引用叫做闭包。

当我看到这句话的时候,泪流满面,国外的作者就是一语道破真相。简单的说,闭包就是引用,对谁的引用呢,对作用域的引用,只不过这种引用是有条件的——首先要记住作用域,然后再访问作用域!

什么叫记住作用域?

首先,我们都知道,在 JavaScript 里面,如果函数被调用过了,并且以后不会被用到,那么垃圾回收机制就会销毁由函数创建的作用域,我们还知道,对象(函数也是对象)的传递属于传引用,也就是类似于C语言里面的指针,并不会把真正的值拷贝给变量,而是把对象所在的位置传递给变量,所以,当函数被传引用到一个还未销毁的作用域的某个变量,由于变量存在,所以函数得存在,又因为函数的存在依赖于函数所在的词法作用域,所以函数所在的词法作用域也得存在,这样一来,就记住了该词法作用域。也就解释了该节的标题!下面举个例子说明一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 没有闭包现象的时候
function apple () {
var count = 0
 
function output () {
console.log(count)
}
 
fruit(output)
}
 
function fruit (arg) {
console.log('fruit')
}
 
apple() // fruit

 

当我们在调用 apple 的时候,本来 apple 在执行完毕之后 apple 的局部作用域就应该被销毁,但是由于 fruit(output) 将 output 传引用给了 arg,所以在 fruit 执行的这段时间内,arg 肯定是存在的,被引用的函数 output 也得存在,而 output 依赖的 apple 函数产生的局部作用域也得存在,这就是所谓的“记住”,把作用域给记住了!

但是,上面的例子是闭包现象吗?不是,因为函数 output 内部并没有访问记住的词法作用域的变量!在执行 fruit(output) 的过程中,只发生了 arg = output 的传引用赋值,而这个过程,只是把二者关联起来了,并没有去取 arg 引用的对象的值,所以实际上也并没有访问 output 所在的词法作用域!

记住并访问

上面的代码,稍微修改一下就会产生闭包现象了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function apple () {
var count = 0
 
function output () {
console.log(count)
}
 
fruit(output)
}
 
function fruit (arg) {
arg()
}
 
apple() // 0

 

现在,调用 fruit 时,apple 的局部作用域处于“记住”的状态,这时候, fruit 内部调用了 arg(),因为传引用,实际上访问并执行了 apple 局部作用域的 output,不仅仅是这样,output 内部还访问了 count 变量,这两次对 apple 局部作用域的引用都是闭包!

所以,之所以说所有回调函数的调用都会产生闭包现象,也是因为这个回调函数被传给了另外一个函数的参数,所以在另外一个函数的作用域消失之前,回调函数所在的词法作用域都被记住了,由于回调函数一定会被执行,所以回调函数所在的词法作用域至少被访问了一次,也就是至少访问回调函数本身,而这个对作用域的引用就是闭包。

闭包的作用

根据上面的讲解,估计你自己都能倒背如流了:

  1. 记住了函数所在的词法作用域,使其不被销毁;
  2. 能够访问函数所在词法作用域的变量;
  3. 创建模块(设计私有变量、公有函数等等)

还有很多,就不一一说了,下面就是利用闭包来解决一个常见的问题:

1
2
3
4
5
6
for (var i = 0; i < 5; i++) {
// 为了方便说明,给函数起名叫 apple
setTimeout(function apple () {
console.log(i) // 5 个 5
}, 0)
}

 

首先读者们先思考一个问题,这会产生闭包吗?

其实,上面也也会产生闭包,只不过 apple 记住并访问的是全局作用域,为什么呢?因为回调函数被当做 setTimeout 的参数传引用过去了,假设 setTimeout 实现如下

1
2
3
4
var setTimeout = function (callback, delay) {
// 延迟
callback()
}

 

看到没,因为 setTimeout 属于异步函数,所以会等到 JS 执行完毕之后再调用 callback,所以这段时间 callback 一直存在,所以函数 apple 也一直存在,所以全局作用域并不会等 JavaScript 执行完毕后就销毁(函数 apple 属于全局作用域的),这时候循环早结束了,所以 i 也变成了 5,于是乎,这个时候 apple 对全局作用域的引用称为闭包!

上面也说了回调函数调用都会产生闭包,这里就当举例说明一下!

那么怎么解决以上问题呢,很简单,让回调函数记住不同的作用域就行了!

1
2
3
4
5
6
7
8
for (var i = 0; i < 5; i++) {
// 为了方便说明,给函数起名叫 apple
(function baz (i) {
setTimeout(function apple () {
console.log(i)
}, 0)
})(i) // 0 1 2 3 4
}

上面用立即执行函数解决了问题,因为函数有局部作用域,所以调用 5 次函数会产生 5 个局部作用域,每个作用域的 i 由各次循环的 i 传递赋值,而每个作用域内都存在 apple ,都记住了各自的作用域,也就取到了不同的 i

不过通常来说,闭包都是按以下方式产生:

1
2
3
4
5
6
7
8
9
10
11
12
function apple () {
var name = 'apple'
 
var output = function () {
console.log(name)
}
 
return output
}
 
var out = apple()
out() // apple

 

上述将函数传引用给了全局作用域的变量,显然,闭包(对 apple 作用域的引用)在全局作用域都存在的情况下都可能发生,而且后面也执行了 out()

更常见的写法是下面这种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Apple () {
var name = 'apple'
 
var output = function () {
console.log(name)
}
 
var setName = function (arg) {
name = arg
}
 
return {
output: output,
setName: setName
}
}
 
var apple = Apple()
apple.output() // apple
apple.setName('Apple')
apple.output() // Apple

 

这就是模块的一个例子,name 通常被称为私有变量!

结语

闭包没什么了不起的,这是被人玩的过于玄乎,其实这是人们很自然的想法:我在别的地方调用函数,总得保持函数正常运行吧!“闭包”这种机制很轻松的帮你解决了这个问题,我们不必搞懂闭包是什么也经常在实现它(如果这句话写在前面,会不会很多人都不看了,哈哈),这是语言设计者的过人之处,但是,你不搞懂它,总被人质疑:你不懂闭包吧!实际上,我们都实现了很多次闭包,所以,你把内部机制详细搞清楚了,就不会再害怕别人的质疑了,哈哈!当然,如果你喜欢钻研,更有必要了解其中的机制了,体会到寻找语言设计者设计思路的快感!

最后,再总结一下:函数记住并访问其所在的词法作用域,叫做闭包现象,而此时函数对作用域的引用叫做闭包。
最后的最后,再强调一下:闭包就是引用!

posted @ 2016-10-27 22:54  最骚的就是你  阅读(241)  评论(0编辑  收藏  举报