你从未见过如此详细的“闭包和作用域链”

来源:互联网 发布:淘宝获取店铺失败原因 编辑:程序博客网 时间:2024/04/27 21:39

0.前言

前两天,现有朋友问我,他现在看闭包,根本不明白闭包到底是怎么回事,完全不理解,这是为什么?

出于对这个问题的考量,自己回去查阅了大量的资料,通过整理和自己添加内容,于是就有了这篇文章。

本文适合人群:

  1. 新手小白
  2. 一定JS程度的同学
  3. 闲着过来凑热闹的

好吧,最后一个是卖萌,= ̄ω ̄=,废话不多说了,正文开始。


后记:

有读者反映,内容中有一些图片全部裂了,现已经全部重新上传,请查收。

1. 对象

要理解闭包,首先要先知道闭包的基本原理是什么。

说白了,最常见的闭包就是一个函数嵌套另外一个函数,然后通过突破作用域链,来将函数内部的变量和方法传递到外面。

纳尼,我们要突破作用域链?

什么是作用域链呀?

既然要说什么是作用域链,首先我们就得从 JS 中非常重要的一个概念说起。

这个概念就是对象。要知道,就想我现在的个性签名一样。

万物皆虚
万事皆允

我们的 JS 中可也是号称 “万物皆是对象”的呀。

为什么这么说,其实在 JS 中,任何事物都可以通过一些特定的方法转化成对象。

甚至一个标签,我们也能将其转化为对象,甚至可以直接添加方法。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body>    <div></div></body><script type="text/javascript">    var div = document.querySelector('div');    div.a = function(){        alert('hello, 我是李鹏');    }    console.log(typeof div);    console.log(div);</script></html>

这里写图片描述
可以看见,我们直接给 div 添加了一个新的属性,叫做 a,而且这时候我们去打印我们的 a 的类型,你会发现,它直接就是一个 object,是不是感觉略屌?

既然说到我们万物皆对象,那么创建对象总共有几种方式呢?

1.1 对象的第一种创建方式

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    // 注意,这三种创建方式,是由系统提供的对象    var str = new String();    var num = new Number();    var arr = new Array();    // 而下面,则是一种自定义对象    var obj = new Object();</script></html>

1.2 对象的第二种创建方式

需要注意,如果你看见成对的大括号,赋给一个变量,那么这个变量就一定是对象没跑了。

而且,需要注意,在 { } 中书写的内容都是用 , 连接,而且最后一个属性一定不要加 ;,别问我是怎么知道的。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    // 创建对象    var obj = {        a:'lipeng',        b:18,        c:function(){        }    };    //给对象添加属性    obj.d = 'hello';    console.log(obj);</script></html>

这里写图片描述

其实这种写法还存在另外一种变种,那就是下面这种。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    // 创建对象    var obj = {};    //给对象添加属性    obj.d = 'hello';    console.log(obj);</script></html>

这么写的话,就相当于一个 new Object 了。

1.3 对象的第三种创建方式

我们除了上面的那两种书写方式,其实我们还可以去套用一下工厂模式,去直接创建一个对象。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    function creatObject(sex,age){        var a = {};        a.sex = sex;        a.age = age;        return a;    }    var obj = creatObject('男',18);    console.log(obj);</script></html>

这里写图片描述

是不是跟其他语言中的便利构造器非常类似呢?

1.4 对象的第四种创建方式

其实我们还可以通过去构造一个函数,去直接创建一个对象。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    function my(){        this.age = '18';        this.sex = 'nan';    }    var obj = new my();    console.log(obj);</script></html>

这里写图片描述

等等,发现貌似不太对,为什么这个类型直接是 my呢?

回头看看上面的内容,你会发现,当我们打印这个内容的时候,实际上我们也会直接打印出这个类型具体是什么。

其实在上看到的 Object {age: "18", sex: "nan"},这种东西,在系统内容其实跟现在这种方法也是类似的。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    function Object(){        this.XXX = XXX;        .        .        .    }    var obj = new Object();</script></html>

这里我补充一句,我们的 new 实际是开辟一个空间,那么我们根据谁去开辟空间呢?

我们根据的是函数去开辟空间,那么我们开辟了空间去给谁呢?

当然是给我们的 obj。

这时候我们就可以说,我们先开辟了一个空间给 a , 之后由开辟的这个空间去调动 my() 这个方法。

这非常类似于一个类的概念,但是实际上这其实是一个函数调用的过程。

而且需要注意,在这里添加属性的时候,必须要去使用 this。

甚至我们可以更灵活一点,让这些内容直接作为形参。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    function my(sex,age){        this.sex = sex;        this.age = age;    }    var obj = new my('nan',18);    console.log(obj);</script></html>

这里写图片描述

2.变量的作用域

我们刚才说了一大堆的对象,那刚才那些内容和作用域链有什么关系呢?

这是因为,我们的函数对象其实和其他对象一样,都拥有可以通过代码访问的属性,和一系列提供给 JS 引擎访问的内部属性。

而这些属性当中,存在这个一个内部属性 [[Scope]],因为很多个,放在一个类数组中,所以用两个中括号包裹,而这个内部属性就是 作用域

下面引用一段话。

Scope 该属性内部包含了函数被创建的作用域中对象的集合,
这个集合被称之为 函数的作用域链,
它决定了哪些数据能够被函数访问。

——–《ECMA-262 标准第三版》

既然我们想要继续了解作用域链到底是什么东西,我们接下来就一起再来看看,什么是变量的作用域。

变量的作用域实际上就是“程序源代码中定义这个变量的区域”,

其中全局变量有全局的作用域,它在 JS 的任何地方都是有定义的。

因为所有内容都笼罩在 Window 的光辉之下(笑)。

而函数内部声明的变量只在函数体内有定义,他们只是一个局部的变量,作用域也只局限在函数内部。

要知道,函数参数也是局部变量,他们只在函数体内有定义。

而且在声明全局变量的时候,可以不用去加 var,而声明局部变量的时候,必须要加 var。

说到这里,简单的说一下,函数的作用域。

2.1 函数的作用域

在一些类似于C 语言的编程语言中,花括号中每一段代码都具有各自的作用域,而且变量在声明他们的代码片段以外是不可见的,我们称之为 块级作用域。

但是 JS 中没有块级作用域,取而代之的是函数作用域,变量在声明他们的函数体以及这个函数体嵌套的任意函数体内都是有定义的,一定要注意,这里说的都是函数。
这里写图片描述

而且在这里有一个非常有意思的东西,就是我们假如先不去声明函数,直接调用,回事什么样子的呢?

2.2 声明提前(变量提升)

我们在说这些之前,先来看一段代码。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    var scope = 'lipeng';    function aa(){        console.log(scope);        var scope = "李鹏";        console.log(scope);    }    aa();</script></html>

这两个log 分别会打印什么呢?

这里写图片描述

怎么样,是不是完全没想到?

这是因为,JS 函数中声明的变量(但不涉及到赋值),在 JS 引擎预编译的时候,都会将变量“提升”至函数的顶部,代码的实际效果其实是这个样子的。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    var scope = 'lipeng';    function aa(){        var scope;        console.log(scope);        scope = "李鹏";        console.log(scope);    }    aa();</script></html>

注意,函数外的 scope 完全是一个迷惑项,因为下面的内容从新声明了一个 scope ,根据作用域的说明,它找到函数内部的内容就不会向它的上一级去进行寻找了,所以出现的才是 undefined

这里还体现出了 JS 编程时一个非常重要的原则,就是 “变量声明全部放在函数体的顶部”。

2.3 属性的变量

除了上面说的几点之外,我们在日常的开发中还需要注意这么一件事。

当声明一个 JS 全局变量的时候,实际上是定义了全局对象的一个属性,

当使用 var 声明一个变量的时候,创建的这个属性实际实际上就是已经不可配置的了,

也就是说这个变量无法通过 Delete 运算符删除。

但是如果是非严格模式的情况下,给一个没有声明的变量进行赋值的时候,

JS 会自动创建一个全局变量,通过这种方式创建的对象可以配置其属性值,

并且还可以删除他们。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    // 不可删除的全局变量    var scope_1 = 1;    // 全局对象的一个可删除的属性        scope_2 = 2;    console.log(delete scope_1);    console.log(delete scope_2);</script></html>

这里写图片描述

第一个删除的时候,会直接返回 false,而第二个,它是作为一个全局对象的属性,删除的时候 会直接返回 true。

这是因为,全局变量是全局对象的属性,这是在ECMAScript规范中强制规定的,

而对于局部变量则没有如此规定,

但是我们可以想象,我们可以将局部变量当做是一个函数对象的属性。

ECMAScript 称该对象为“调用对象”,ECMAScript 中规范为 “声明上下文对象”,

JS 中允许使用 this 关键字来引用全局对象,却没办法引用局部变量中存放的对象,

这种存放局部变量的对象的特有性质,是一种对我们不可见的内部实现。

所以我们要去通过作用域链去配合使用一下。

但是从刚才赋值的地方就开始说 this,现在又再次说道了 this,那我们再简单的来看一看,this 又是一个什么鬼?

3. this

在说 this 之前,首先先说一个东西,我们的全局变量全都是 window 的属性。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    var lipeng = 18;    console.log(window);</script></html>

这时候我们发现了一个问题。

这里写图片描述

我们刚才声明的 lipeng,实际在 window 中是作为一个属性放在里面的。

那假如我现在去声明一个函数呢?

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    function aa(){        console.log(this);    }    aa();</script></html>

这时候我们的函数中的 this ,又应该指向谁呢?

答案其实很简单,直接声明的函数是怎么声明的?

那有是谁去调用的呢?

当然是我们的 window 咯。

这里写图片描述

那我其实去调用 aa() 的时候,其实也可以写成 window.aa( );

那假如我现在换了一种写法,

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    function aa(){        getCall = function(){            console.log(this);        }        return this;    }</script></html>

这时候 this 又应该是谁呢?

在说 this 之前,先问大家一个问题,我这个函数内部的 getCall 应该怎么调用?

既然 getCall 是放在 aa() 内部的,那么你想调用 getCall ,就必须先调用 aa( )。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    function aa(){        getCall = function(){            console.log(this);        }        return this;    }    aa();    getCall();</script></html>

除此之外,我们其实还可以直接通过点的方式去调用,即 aa().getCall();

这时候我们就应该能看见了,我们答应出来的 this 实际还是 window。

为什么呢?

这时因为,aa( ) 是 window 的,而这时候,我们的 getCall 实际也作为属性赋值给了 window 。

我们将一个匿名函数给了 一个变量,而这个变量是全局变量,那你打印 this 不是window 还是 什么呀?

如果你觉得上面的内容你都明白了,那么再看这个呢?

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title>    <style type="text/css">        div{            width: 300px;            height: 300px;            background: red;        }    </style></head><body>    <div></div></body><script type="text/javascript">    var div = document.querySelector('div');    div.onclick = function(){        console.log(this);        var timer = setInterval(function(){            console.log(this);        },1000);    }</script></html>

这两个 this 分别指了什么呢?
这里写图片描述
第一个 this 指的 是 div , 因为我们是通过 div.onclick 去调用的。

而第二个 计时器,为什么打印的 window 呢?

这是因为,计时器实际上是一个 “函数回调”。

谁去对它进行回调呢? 自然是 window。

所以这才是打印 window 的原因。

4. 作用域链

当我们将刚才的内容都看懂了,明白我们可以将一个局部变量作为自定义对象的属性的话,我们就可以从另外一个角度去解析变量作用域了。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    function add(num1,num2){        var sum = var1 + var2;        return sum;    }</script></html>

我们在 函数 add 被创建的时候,add 函数的作用域中就会被放进一个作用域链,而这个作用域链中又存放了一个全局对象,在这个全局对象中存放了当前所有的全局变量。

这里写图片描述

需要注意,当执行该函数的时候,会去创建一个叫做“运行期上下文”的内部对象,它定义了函数执行的时候,环境是什么样子的。

每个运行期上下文都有自己的作用域链,用来进行标示符的解析,当运行上下文被创建的时候,它的作用域链被初始化为当前运行函数中的 “[[Scope]] 包含的对象” 。

这时候他们会直接按照在函数中出现的顺序,被赋值到作用域链中,这时候他们又组成了一个新的对象,叫做 “活动对象”。

活动对象中包含了函数的所有局部变量,命名参数,参数集合,this等等。

这时候刚刚获得的这个对象,就会被放在作用域链的最前端,方便之后的调用。

如果运行期上下文被销毁,或者说函数执行完毕,那么里面的活动对象等也会随之被销毁。

这里使用一张别人的图,可以很清楚明白的体现出来刚才所说的内容。

这里写图片描述

上面说的都是一些原理性的东西,那么我们接下来去说一些实质性的内容。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    // 全局作用域    var a = 1;    function A(){        // A 的作用域,可以访问到 a, b        // 但是访问不到 c        var b = 2;        function B(){            // B 的作用域,可以访问到 a, b, c            var c = 3;            console.log(a);        }        B();    }    A();    // 当 console.log(a); 的时候,    // JS 引擎沿着 B 的作用域,A 的作用域,全局作用域的顺序去进行查找    // 而这三个作用域实际上是存在循序的    // 我们将这种有序集合,叫做 作用域链</script></html>

用文字性的东西来描述一下。

每一段 JS 代码(全局代码或者函数)都有一个与之关联的作用域链,这个作用域链是一个对象列表或者链表,这组对象定义而这段代码“作用域中”的变量。当 JS 需要查找变量x 的时候(这个过程叫做变量解析),它会从链中的第一个对象开始查找,如果这个对象有一个属性名为 x 的属性,则会直接使用这个属性的值。如果第一个对象不存在名为 X 的属性,JS 会继续查找链上的下一个对象,以此类推,如果作用域上没有任何一个对象含有属性 x,那么就会认为这段代码的作用域链上不存在 x,并最后爆出一个引用错误(ReferenceError)异常。

这时候我们应该已经明白了,作用域链是一个什么东西。

说白了,不就是一层嵌套一层么?

但是这个只是作用域链非常浅显的一些东西,我们在下面的章节去说明。

现在的话,首先先来补充一下,在使用作用域链还存在一些注意事项。

4.1 作用域链的深度问题

从作用域链的结构可以看出,在运行期上下文的作用域链中,标识符所在的位置越深,读写速度就会越慢。

如上图所示,因为全局变量总是存在于运行期上下文作用域链的最末端,因此在标识符解析的时候,查找全局变量是最慢的。

所以,在编写代码的时候应尽量少使用全局变量,尽可能使用局部变量。

一个好的经验法则是:如果一个跨作用域的对象被引用了一次以上,则先把它存储到局部变量里再使用。

这里写图片描述

4.2 改变作用域链

实际上运行上下文的过程中,还可以将作用域链临时做一个更改,这时候其实可以用到两种方法。

  1. with 语句
  2. try catch 语句

今天我就主要来说一下 with 语句。

with 语句的作用实际上就是某个对象添加到作用域链的头部,然后执行内部的内容,当内容执行完毕之后,在将作用域链去恢复。

但是,实际上在严格模式(”use strict”;)中是禁止使用 with 语句的,而且在非严格模式下也是不推荐使用 with 语句的。

我们要尽可能的避免使用 with 语句,因为使用 with 的 JS 代码非常难于优化,而且运行速度也要比正常代码的运行速度更慢。

一般来说,我们只有在对象嵌套层级很深的情况下,才会去使用一下 with。

例如,我需要访问 document.forms[0].people.lipeng

这时候每一次查询,我都需要查询非常多的内容,直到最后遍历到 lipeng。

而如果我们明确我们要查找的内容具体在某个范围的时候,我们可以这么做。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    with(document.form[0]){        // 直接访问表达那种的元素         people.value = "";         lipeng.value = "";    }</script></html>

当然,如果我们不去使用with,直接使用代码也是可以实现相同效果的。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    var f = document.form[0];    f.people.value = "";    f.lipeng.value = "";</script></html>

记住一个问题,只有在查找标示符的时候才会用到作用域链,创建新的变量的时候不使用它。

5. 闭包

可是,有时候,我们却不得不去访问一些局部作用域内部的东西,比如两个模块函数,使用了相同的数据,

这里我们也只能把这些相同的数据放入全局变量,使得两个函数模块,都可以调用这些数据。

但是想想,如果这样的需求很多,那么不久需要很多很多的全局变量,而滥用全局变量的不好之处,前面也说了,所以这并不是一种好的写法。

这时候怎么去解决呢?

我们其实可以在一个函数内部,继续定义函数,就像之前在函数A内部,定义了函数B,这样我们只需要一个函数A的执行,就可以完成一整个逻辑。

内部的调用,都只能算是局部变量的调用,在全局只添加了一个函数A。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    function A(){        var arr = [];        function a(){};        function b(){};        return;    }</script></html>

这样,我们本来需要三个全局变量的问题,就变成了只需要一个。当然,如何减少全局变量的方法是有很多种的,这里不做讨论。

这里,我们就讨论一种我们最常见的方法,也算是很常用的一种代码书写方法吧,它叫:闭包。

闭包实际上是 JS 语言特性的产物,因为 JS 采用的是 词法作用域,

也就是说,函数的执行依赖于变量作用域,这个作用域是在函数定义时决定的,而不是函数调用时决定的。

为了实现这种词法作用域,JS 函数对象的内部状态不仅包含函数的代码逻辑,还必须引用当前的作用域链。

函数对象可以通过作用域链相互关联起来,函数体内部的变量都可以保存在函数作用域内,这种特性叫做闭包。

说的直白点,最常见的闭包就是在一个函数内创建另外一个函数,然后通过另外一个函数访问这个函数的局部变量。

利用闭包我们可以突破作用域链,将函数内部的变量和方法传递到外部。

这也就是闭包。

这时候一起来看一个小栗子。

5.1 闭包的案例

因为这里涉及到很多的逻辑和操作,所以下面使用截图来给大家演示。

这里写图片描述
这里写图片描述
这里写图片描述

这时候发现了一个问题,先引入的变量被后引入的变量直接覆盖掉了。

我的天,这要是正常开发中,你同时非找你拼命不可。

根据我们之前的学习,其实可以定义两个相同名称的变量,而且不会相互影响,那就是函数。
这里写图片描述

函数内的 1000 并不会影响函数外的 10,那我们现在就动了小心思。

我难道不能将两个 JS 文件中的变量也用函数承装起来么?

答案当然是可以的。

这里写图片描述

但是这样的话,还会产生一个新的小问题,你既然定义一个函数,你就一定要去让他执行一遍呀。

还记得之前说过,函数后面直接加 ( ),就是调用函数,所以这时候我们也可以,将我们的函数从新承装一下。

这里写图片描述

注意,前面的函数也需要用括号包裹一下,否则会报错的。

而这个看上去很奇怪的东西,它就是闭包。

5.2 闭包的三种写法

闭包其实是有三种写法的,上面看到的就是第一种。

(function(){})();

实际上,我们还可以将 ( )挪进里面。

(function(){}());

除此之外,还存在这么一种写法。

void function(){}();

这三种写法实际上都是闭包,小伙伴们平常去查看一些第三方库,经过会看到一些类似于上面的内容,如果到时候不明白,记得要回来查看一下这篇文章呦。

5.3 一些特殊的闭包

如果你觉得你明白了上的内容,那么可以试着去解释一下下面这段代码是什么意思吧。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    var s = (function(){        var count = 0;        return function(){return count++};    })();    console.log(s());    console.log(s());</script></html>

粗略来看,这段代码貌似是将函数赋值给一个 变量,

但是这段代码实际上是定义了一段立即调用的函数,这个函数返回值赋值给变量。

而且除此之外,私有变量并不仅仅包含在一个单独的闭包中,还可以让同一个外部函数内的多个嵌套函数,一起访问他,让多个嵌套函数共享一个作用域链。

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>Document</title></head><body></body><script type="text/javascript">    function counter(){        var n = 0;        return{            count:function(){return n++},            reset:function(){return n=0}        }    }    var c = counter(),d = counter();    // 他们之间是不会相互影响的    console.log(c.count());    console.log(d.count());    // reset 和 count 方法共享状态    c.reset();    // 0 : 因为 C 被重置了    console.log(c.count());    // 1 : 因为 D 没有被重置    console.log(d.count());</script></html>

需要注意,每次调用 counter() 都会创建一个新的作用域链和一个新的私有变量。

两个对象,彼此包含不同的私有变量,调用其中一个对象方法,不会影响到另外的一个对象。

6. 结语

这篇文章说到现在,已经算是结束了。

JS 是一门非常博大精深的语言,每一个人在这条路上都是苦行者,学有先后,希望所有的小伙伴,都能在这条路上越走越远。

谨以此自勉。

2016年09月08日12:23:29

本文是作者通过 5个小时,一点点编写出来的,如果复制,请标明出处。
尊重本人劳动成果,十分感谢。

李鹏

4 0
原创粉丝点击