js-闭包

100 阅读10分钟

闭包,相信对不少中初级前端开发人员来讲是一个梦魇。但想要在js上有阶段性的进步,就必须跨过这个噩梦!俗话讲直面恐惧才能消除恐惧,从今天开始就试图来剖析下这恐惧背后的面纱。

1. 执行上下文

这是一个在js语言中经常听到的词汇,但自始至终都没有去深入的了解,它是什么?一个实体的对象?一个虚拟的存在?分别看以下几种情况:

// 第一种情况
console.log(a)  // 报出引用错误

// 第二种情况
console.log(a)  // undefined
var a = 10

// 第三种情况
var a = 10
console.log(a) // 10

// 第四种情况
console.log(fun)  // function fun () {...}
function fun () {
    console.log('fun函数')
}

// 第五种情况
console.log(this)  // window

对于接口能很自然而然的说出,这些本来就是一个很简单问题。但是仔细琢磨下,这些结果得出来好像很不“自然”:

  1. 第一种情况没有疑问,没有声明变量a,打印时肯定是引用错误
  2. 第二种情况就有些不自然了,虽然在下面声明了变量a并赋值了10,但是在打印时还没有执行var a = 10,a不但不是引用错误,还是有值的,值为undefined
  3. 第三种情况也没有疑问
  4. 第四种情况就更加不自然了,在函数声明前打印却发现 fun 变量已然赋值了声明的函数
  5. 第五种情况比较复杂,后面要做详细讨论

从实际的迹象上表面,代码在执行前,浏览器肯定做了某些“准备工作”。这些“准备工作”包含:

  1. 对变量的声明并赋值undefined
  2. function声明的函数进行赋值
  3. this 关键字赋值

在此,可以得到一个结论,浏览器在执行js代码前会首先做一些准备工作,然后再去执行。javascript在执行一个代码段之前,都会进行这些“准备工作”来生成执行上下文。代码段主要指两种情况:全局代码和函数体。

2. 函数体的执行上下文

第一小节,了解了js代码段在执行前会生成执行上下文。js的代码段主要包含全局的代码段和函数体代码段。函数体的执行上下文具体是什么样的?

function fun (a) {
    console.log(arguments)  // aruments数组
    console.log(a) // 10
}
fun(10)

函数体内的代码块在执行前也做了一些“准备工作”:就是对aruments和参数进行赋值。并且函数每被调用一次就会生成一个新的执行上下文。

到此,对于执行上下文可以做一个简单的总结:js语言在代码执行前要进行准备工作,在此又分为两种情况。

  1. 全局的js代码执行前 做的准备工作,暂且称为 全局上下文
  2. 函数体的js代码在执行前做的准备工作,暂且称为 函数上下文 ,函数上下文环境不仅包括第一节所述那些,还有对arguments和参数进行赋值,以及函数体内自由变量的取值作用域的确定(后续详解)

js代码在执行时,会有大量的函数调用,就意味着生成大量的执行上下文。这些执行上下文是如何管理的?

3. 执行上下文栈

对于栈结构,都有所了解。它是一种数据结构,只能在栈的顶端添加数据或者删除。就好比向箱子中放盘子,最后放进盘子永远在最上面,同时取盘子时也只能从最上面取。在函数调用时,将生成的函数执行上下文压入栈中,当函数执行完毕时,此上下文从栈中销毁。

将非常详细的探索下以下代码的执行过程

var a = 10, 
        fn, 
        bar = function (x) {
          var b = 5;
          fn(x+b)
        }
    fn = function (y) {
      var c = 5;
      console.log(y + c)
    }
    bar(10)
  1. 第一步:全局代码执行前将做些准备工作,生成全局上下文,全局上下文环境已经初始化数据如下:
变量a   赋值为 undefined
变量fn  赋值为 undefined
变量bar 赋值为 undefined
此时执行上下文栈中已压入全局上下文,栈结构简图:【全局上下文】
  1. 第二步:代码块执行bar(10)调用之前
var a = 10 // 变量a赋值为10
bar = function (x) { // 变量bar赋值一个函数体
    var b = 5;
    fn(x+b)
}
fn = function (y) { // fn赋值为一个函数体
    var c = 5;
    console.log(y + c)
}
  1. 第三步:代码块执行bar(10)执行

执行前:

生成bar函数的执行上下文,并压入执行上下文栈,栈结构简图如下:【全局上下文,bar函数上下文】
bar函数执行前将做如下工作:
1. 对参数x进行赋值
2. 变量b赋值为undefined
bar函数的执行上下文环境初始化数据如下:
参数x 赋值为 10
变量b 赋值为 undefiend

执行时:

var b = 5; // 变量b赋值为5
fn(x+b) // 调用fn(x+b) x + b = 15,实际是执行 fn(15)
  1. 第四步 fn(15)执行

执行前:

生成fn函数的执行上下文,并压入执行上下文栈,栈结构简图如下:【全局上下文,bar函数上下文,fn函数上下文】
fn函数上下文环境初始化数据如下
参数y 赋值为 15
变量c 赋值为 undefiend

执行:

变量c 赋值为 5
console.log(y + c) // 打印出20

fn执行完后,相应的fn执行上下文也将从出栈并销毁,释放内存。 栈结构简图如下:【全局上下文,bar函数上下文】,fn上下文执行完毕并销毁后,bar函数也执行完毕,bar函数上下文已将出栈销毁,栈结构简图如下:【全局上下文】

到此,js代码的执行过程从这个角度理解就是一个个执行上下文不断创建入栈和出栈销毁的过程。到这里算是对执行上下文栈如何管理执行上下文有了一个清晰的认识。

4. 作用域和作用域链

上节的例子只不过是一个很简单的例子的剖析,并不包含函数执行中的一个常见情况就是函数体内变量的取值

var a = 10, b = 10
function fn () {
    var b = 20
    console.log(a)
    console.log(b)
}
fn() // 打印 10 20

在此便有个疑问?fn在执行前会生成fn执行上下文,但在fn执行上下文环境中并没有对a变量的赋值,它怎么会取到全局上下文环境的值呢?这就是本节的作用域和作用域链。

到目前为止,js语言有三种作用域: 全局作用域,函数作用域,块作用域(es6新增,在如下先不做讨论),全局作用域是本身就存在的,除此之外只有函数能够创建作用域,并且作用域在函数创建时就生成。作用域的作用又是什么?

如上例子所示,作用域的最大作用就是隔离变量,同时也是变量取值的空间。

function fn () {
    var a = 10
    function bar () {
        var a = 10,b = 20
    }
}

同时函数里面嵌套函数,便会形成嵌套的作用域,好比套娃,层层包裹。这种层层嵌套的作用域称为作用域链。作用域链实际就是一套变量取值规则: 变量取值首先从当前作用域寻找变量值,如果没有,便顺着作用域的嵌套关系,层层向外部的作用域中依次寻找变量值,到全局作用域还未找到,变量值就是undefined。

---------------------------------------------------------------------------------
var a = 10                                      全局作用域
function fn () {
------------------------------------------
    console.log(a)   fn函数作用域
------------------------------------------
}
function bar () {
------------------------------------------
    var a = 20       bar函数作用域
    fn()
------------------------------------------
}
bar() // 打印 10
-----------------------------------------------------------------------------------

以上例子将执行上下文环境和作用域同时引进进行分析:

  1. 因为作用域是函数在创建时就已形成的,做出如上图的划分

然后是分析代码的执行过程(主要分析代码执行时执行上下文环境的变量取值):

  1. 第一步: 全局代码执行生成全局上下文环境执行赋值如下
变量a   :  10
变量fn  :  fn函数
变量bar :  bar函数
  1. 第二步: bar函数执行 生成bar函数的执行上下文环境并赋值如下
变量a  :  20
  1. 第三步: fn函数执行 生成fn函数的执行上下文环境并赋值如下
变量a  :  10 

疑问就在这一步,为什么变量a赋值为10而不是20? 注意:虽然fn函数在bar函数的作用域内执行,但并不是意味着fn函数作用域在bar函数作用域内。看如上作用域划分图,函数的作用域在声明时就已经定型,fn函数作用域外层是全局作用域,根据作用域链的取值规则,可知a将赋值为10,最终打印就是10.

在此可以将执行上下文和作用域揉合到代码的执行过程:

代码执行前,创建相对应的执行上下文环境,代码执行时,当前执行上下文环境的变量会被赋值进行相应运算,而赋值就是按照作用域链的规则进行的。

5. 闭包函数

上面几个小节介绍了执行上下文和作用域,这些都是理解闭包的基础。在此正式面对-闭包

-------------------------------------------------------------------------------------------
var a = 1                                                        全局作用域
function fn () {
--------------------------------------------------------
    var a = 10                          fn函数作用域
    return function () {
------------------------------------
        a++            匿名函数作用域
        console.log(a)
------------------------------------
    }
---------------------------------------------------------
}
var bar = fn()
bar() // 11
bar() // 12
------------------------------------------------------------------------------------------

先不分析结果,先看fn函数的特点: 返回值是一个函数,而上节已知道函数是可以创建作用域的。根据作用域创建规则可以划分出如上的作用域链。这个可以轻易的看出a的取值肯定是匿名函数作用域外部的fn作用域里面取值,a = 10。

问题的关键在于第一个bar()执行后按道理来说,其对应的执行上下文环境会出栈销毁,第二个bar()执行时会创建新的执行上下文,而执行上下文环境中a的值并不为10.而是11,所以打印出结果为12。

所以还是有必要从执行上下文的角度分析下执行流程:

  1. 第一步: 全局代码块执行生成全局上下文环境并压入执行栈
a   :  1
fn  :  fn函数

执行环境栈简图:【全局上下文环境】

  1. 执行到 var bar = fn(),生成fn的执行上下文环境,问题的关键就在此,fn返回一个函数,函数是能够创建作用域的,恰好这个函数体内有变量需要从fn的上下文环境中取值,所以fn()执行完成后,fn的执行上下文环境并没有被出栈销毁,而是依旧存在栈内。
a  :  10

执行环境栈简图:【全局上下文环境,fn函数上下文环境】

  1. 执行第一个bar(),生成bar的执行上下文环境 执行环境栈简图:【全局上下文环境,fn函数上下文环境,bar函数上下文环境】 执行完成后bar函数上下文出栈并销毁

  2. 执行第二个bar(),又生成一个bar的执行上下文环境 执行环境栈简图:【全局上下文环境,fn函数上下文环境,bar函数上下文环境】 执行完成后bar函数上下文出栈并销毁

  3. 全部执行完成后 fn函数上下文环境才会被销毁,然后全局上下文环境销毁

6.闭包的应用

将在以后的积累中逐渐加入