JS基础系列 之 你眼里的闭包

762 阅读8分钟

前言

⾼级程序设计三中描述:闭包是指有权访问另外⼀个函数作⽤域中的变量的函数。可以理解为(能够读取其他 函数内部变量的函数)

看似简单的单一概念,其实它还涉及到执行上下文 、作用域、作用链、内存管理的等其他知识点。再我们看 闭包 前,先了解一下其他与之相关的知识点。走起......

开胃菜 -- 脑图

执行上下文

为什么先了解 执行上下文?我认为,只有先 摸清 执行上下文,才能更好的理解 变量提升、作用域、闭包等概念。let's go.....

 先 猜猜 以下代码输出结果是什么

myName()
var name;
function myName() {
    console.log("myName is .....")
}
console.log(name)

what?感觉这是要报错的节奏哇?nonono..... 并没有, 先输出**"myName is ....."**,后输出:**undefined。**why?为什么,这不科学!

okay,这里其实是变量提升了。纳尼~~~变量提升,是什么鬼?

所谓 “变量提升” 是代码函数、变量的声明会被移动到代码的最最最最前面,在编译阶段 Javascript引擎将其存入内存中。没错,一段 js 代码在执行之前是被引擎编译,先编译阶段完成后,再进入执行阶段。

执行上下文是Javascript执行一段代码时的运行环境

我们看看执行流程图:

执行上下文中存在环境变量对象,该对象就是保存变量提升的内容。so,“变量提升”由此而来。让我们把上述代码拆分两段来分析。

编译阶段

// 声明部分
var name = undefined;
function myName() {
    console.log("myName is .....")
}

我们分析一下编译过程:

var name  通过 var 声明 Javascript引擎 创建一个名为 name 的属性,并使用 undefined 对其进行赋值;

function myName() Javascript引擎 发现一个通过 function 定义的函数,所以将函数存储到中,并在环境中创建一个 myName 的属性;

执行阶段

// 可执行部分
myName()
console.log(name)

Javascript引擎开始执行  “可执行代码”, 按照顺序,逐行执行,我们看看如下执行过程:

myName() 执行 myName 函数时,Javascript引擎开始从变量对象环境中查找函数,变量对象中存在该函数引用,Javascript引擎执行该函数,输出: "myName is ....." 结果。

console.log(name)  打印 name 信息,Javascript引擎继续从变量对象环境中查找,变量对象中存在 name 变量,值为 undefined,这时候输出 undefined。

呃.....这里有个问题,如果出现 相同 函数、变量如何是好?

function myName() {
    console.log("myName is .....")
}
myName()
function myName() {
    console.log("胖圈圈")
}
myName()

来分析一下执行流程:

**首先编译阶段。**遇到第一个 myName 函数 存到变量环境,遇到 第二个 myName 函数,发现已经存在,那就直接覆盖。环境变量只存了第一个 myName 函数。

**再来执行阶段。**先执行第一个 myName 函数,由于是从变量环境查找的 myName 函数 所以最终调用的是第二个函数。第二次执行走相同流程,输出结果都是 "胖圈圈"。

调用栈

前面我们说 每调用一个函数, Javascript引擎会为其创建执行上下文,并把该执行上下文加入 调用栈(执行栈),然后开始执行函数代码。

一般来说创建执行上下文有三种情况:

  1. 全局上下文,在整个页面的生命周期内,仅此一份。
  2. 调用函数的时候,函数体被编译,并创建执行上下文,函数执行结束之后执行上下文会被销毁。
  3. 当使用eval 函数的时候,eval 代码也会被编译,并创建执行上下文。

我们看一段代码:

var lastName = "xiaotuan";
function getName() {
    var firstName = "yu";
    return firstName + lastName;
}
getName();

看都看了,分析一下吧:

首先从 全局执行上下文 中,提出 getName函数代码。

其次,对函数代码进行编译,并创建该函数的执行上下文可执行代码。

最后执行代码输出结果。

也就是说,Javascript引擎会管理 很多很多很多 的执行上下文,怎么管理呢?对了,就是通过**一种叫做 栈(Stack) 的数据结构来管理。**栈,我们这里不详解。 栈有什么特点呢?

单行线,栈中元素满足后进先出的特点。

我们看一段感情线略微复杂de代码:

var lastName = "xiaotuan";function getLastName() {
    return lastName;
}
function getFullName() {
    var firstName = "yu";
    var res = getLastName();
    return firstName + res;}
getFullName();

来分析一下吧:

执行过程

第一步,创建全局中下文,push 到 栈底,变量 lastName,函数 getLastName、函数 getFullName都保存到了全局上下文的变量环境对象中。

全局执行上下文进栈后,Javascript引擎开始执行全局代码,首先执行  lastName = "xiaotuan" 赋值操作,执行语句将 全局上下文中变量环境对象中 lastName 的值设置 为 "xiaotuan"。

第二步,调用 getFullName 函数,当调用函数时,创建其执行上下文,将该函数上下文 push 到执行栈中。执行操作,将 firstName 变量由 undefined,变为 "yu" 。

第三步,调用  getLastName 函数,同样创建执行上下文,加入执行栈中。

当 getLastName 函数返回时,该函数执行上下文就会从 **栈顶弹出,**并将 res 的值设置为 getLastName 函数的返回值。

跟着 getFullName 函数执行相加并返回。getFullName 函数的执行上下文也会从 **栈顶弹出,**现在就剩下全局上下文了。

okay。到此,执行栈流程结束了。**调用栈是 JavaScript 引擎追踪函数执行的一个机制。**通过调用栈能够追踪函数之间的调用关系。

栈溢出 (Stack Overflow)

注意:栈是有大小的。因为 电脑不可能把所有 内存都分配给你,JavaScript 引擎感到栈太多,有危险时,就会报 栈溢出 错误。这种情况,在循环、递归代码中最常遇到。

超过了最大栈调用大小(Maximum call stack size exceeded)错误信息

作用域 (scope)

为什么 JavaScript 会存在 **“变量提升”**呢?我们需要从作用域入手。

作用域是指在程序中定义变量的区域,作用域控制变量和函数的可见性和生命周期。

ES6之前,ES的作用域只有两种:全局作用域 和 函数作用域。

  • 全局作用域 中的对象在代码任何地方都能访问,其生命周期伴随着页面的生命周期。
  • 函数作用域 就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁(闭包除外)。

ES6之后,支持了块级作用域,块级作用域就是使用一对大括号包裹的一段代码,比如函数、if语句、循环语句,甚至单独的 {} 大括号都是块级作用域。

再回到**变量提升,**变量提升会带来如下问题:

  • 变量容易被覆盖
  •   // 变量内容覆盖
      function test() {
          var name = 1;
          if (true) {
              var name = 2;
              console.log(name); // 2
          }
          console.log(name); // 2
      }
    

这里在 执行上下文 变量环境中先创建 name 变量 赋值1,继续执行,发现 变量环境存在 name 变量,值为2,变量值被覆盖了,输出结果是:2,2。

  • 本应销毁的变量没有被销毁
  •   function test() {
          for (var i = 0; i < 7; i++) {}
          console.log(i); // 7
      }
      
      test()
    

这里 for 循环结束后并没有被销毁,结果打印出 7。变量提升导致,在创建执行上下文时,变量 i 已经被提升,所以 i 并没有被销毁。

**问题如何解决呢?**就看 ES6 带来的 let 和 const 关键字,它们的到来 有了 块级作用域。二者的区别就是 const 声明的变量值不可改变。我们将上面示例改改,看结果:

function test() {
    let name = 1;
    if (true) {
        let name = 2;
        console.log(name); // 2
    }
    console.log(name); // 1
}

相同的代码,我们只是 将 var 改成 let,输出结果是:2,1。JavaScript引擎不会把 if 块中通过 let 声明的变量存放到变量环境中,而是存在词法环境栈中,所以不会提升到全函数可见。

有一个问题,就是 ES6 如何实现即支持变量提升,又支持作用域块呢?我们到作用域链中寻找答案。

作用域链

我们通过分析下面代码,来看看 JavaScript 引擎如何通过 变量环境、词法环境来查找变量。其实也就是作用域链生产过程。

var name = "李星星";

function a() {
    var name = "饭团团";
    b();
    console.log(name) // 饭团团
}

function b() {
    console.log(name) // 李星星
}

a();

编译过程

全局执行上下文的环境变量存在 name 变量值为 李星星、a 函数

a 函数执行上下文的环境变量存在 name变量值为 饭团团

执行过程

执行 a 函数,当代码使用一个变量时,JavaScript 引擎先从 “当前的执行上下文” 找变量,然后 找到 饭团团,打印出来。

执行 b 函数,JavaScript 引擎在 “当前的执行上下文” 中没有找到变量,这时它会去外部引用的执行上下文中查找,我们把这个外部引用称做 outer。outer 现在指向的是 全局执行上下文,所以找到 name = 李星星,打印输出。

由此我们可以总结出来,a 函数、b 函数的outer都是指向全局执行上下文,如果函数中使用了外部变量,那么 JavaScript 引擎会去 引用的outer,这里是全局执行上下文中查找。我们把这个查找的链条叫做作用域链。

but,a 函数调用的b函数,为什么b函数的outer是全局执行下文,而不是a函数的执行上下文呢?这里,我们有涉及到前面提过的词法作用域。

词法作用域

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

我们通过代码来看:

function a() {
    function b() {
        function c() {
            ......
        }    
    }
}

****JavaScript 作用域链是由词法作用域决定的。****所以这里作用域链的顺序是:c 作用域 —> b 作用域 —> a 作用域 —> 全局作用域。**词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。**c 和 b的上级作用域都是 a, 如果 c 、b 中使用了他们没有定义的变量,那么它们会去到全局作用域查找。

主角--闭包

回到最开始,什么是闭包?你是否已经有答案?再来一个示例,理解一下

function test() {
    var name = 'a';
    let b = 'b';
    const c = 'c';
    var show = {
        getName: function() {
            console.log(b); // b            return name;        },
        setName: function(newName) {
            name = newName;
        }    }

    return show;
}

var testObj = test();testObj.setName("闭包");
testObj.getName(); // 闭包

首先,当执行到 test 函数内部的 return show这行代码时,show 对象 包含了 getName、setName 方法,并且在方法内部使用了 name、b  这两个变量。

**根据词法作用域的规则,内部函数 getName、setName 总是可以访问它们外部函数 test 中的变量,**所以,当 shwo 对象返回给全局变量testObj时,虽然 test 函数已经执行结束,但getName、setName 方法依然可以使用 test 函数 中的 name、b 变量,它们保存在内存中。

我们把这些变量的集合称为**“闭包”。** JavaScript 引擎先从 “当前执行上下文 ---> test 函数闭包 ---> 全局执行上下文” 的顺序查找 name 变量。

至此,闭包的概念,经过我们都捋了一边。but,还有一个问题,闭包内的变量存在于内存中,如果使用不正确,很容易会造成内存泄露。那么,我们又需要关注一下,闭包如何回收。

内存管理

这里先初略的写一下相关内容,笔者也还没深入学习这块 “姿势”,后期深入学习后再出一篇关于内存管理、垃圾回收机制的文章。

在我们知识体系里必须知道,闭包跟内存泄露、垃圾回收有一定关联。