从执行上下文(ES3,ES5)的角度来理解"闭包"

Posted Echoyya、

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从执行上下文(ES3,ES5)的角度来理解"闭包"相关的知识,希望对你有一定的参考价值。

惰性十足,就是不愿意花时间把看过的东西整理一下,其它的任何事都比写博客要有吸引力,嗯... 要反省自己。

今天看到一篇关于闭包的文章,里面有这样一句话 “就我而言对于闭包的理解仅止步于一些概念,看到相关代码知道这是个闭包,但闭包能解决哪些问题场景我了解的并不多”,这说的不就是我么,每每在面试中被问及什么是闭包,大部分情况下得到的答复是(至少我以前是)A函数嵌套B函数,B函数使用了A函数的内部变量,且A函数返回B函数,这就是闭包。而往往面试官想要听到的并不是这样的答案,如果在多几个这样的回答,那么恭喜你,基本就凉了。

在之前的面试中,关于闭包总是有种莫名的恐惧,想赶快结束这个话题,进入下一环节,有没有?我原本想是深入学习一下闭包就好了,但经过我多方考查学习,发现闭包牵涉的知识点是很广的,需要明白JS引擎的工作机制和一些底层的原理,了解了相关知识点之后,在回过头理解闭包就容易多了,文章的最后,会介绍闭包的概念,形成、实现,和使用,以及对性能和内存的影响,其实还是很好理解的,学完这篇文章,至少可以让你在下一次面试中,侃侃而谈5分钟吧。开始正文

介绍执行上下文和执行上下文栈概念

JS中可执行的代码一共就分为三种:全局代码函数代码eval代码。由于eval一般不会使用,这里不做讨论。而代码的执行顺序总是与代码编写先后顺序有所差异,先抛开异步问题,就算是同步代码,它的执行也与预期的不一致,这说明代码在执行前一定发生了某些微妙的变化,JS引擎究竟做了什么呢?

执行上下文

其实JS代码在执行前,JS引擎总要做一番准备工作,这里的“准备工作”,用个更专业一点的说法,就叫做"执行上下文(execution context)",对应上述可执行的代码,会产生不同的执行上下文

1.全局执行上下文:只有一个,在客户端中一般由浏览器创建,也就是window对象,能通过this直接访问到它。全局对象window上预定义了大量的方法和属性,在全局环境的任意处都能直接访问这些属性方法,同时window对象还是var声明的全局变量的载体。我们通过var创建的全局对象,都可以通过window直接访问。
2.函数执行上下文:可存在无数个,每当一个函数被调用时都会创建一个函数上下文;需要注意的是,同一个函数被多次调用,都会创建一个新的上下文。

执行上下文栈

那么接下来问题来了,写的函数多了去了,如何管理创建的那么多执行上下文呢? javascript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文。简称执行栈也叫调用栈,执行栈用于存储代码执行期间创建的所有上下文,具有FILO(First In Last Out先进后出)的特性。

JS代码首次运行,都会先创建一个全局执行上下文并压入到执行栈中,之后每当有函数被调用,都会创建一个新的函数执行上下文并压入栈内;由于执行栈FILO的特性,所以可以理解为,JS代码执行完毕前在执行栈底部永远有个全局执行上下文

栈中的执行顺序为:先进后出

伪代码模拟分析以下代码中执行上下文栈的行为

function a() {
  b()
}

function b() {
  c()
}

function c() {
  console.log(\'c\');
}
a()

定义一个数组来模拟执行上下文栈的行为: ECStack = [];

当 JavaScript 开始要解释执行代码时,最先遇到肯定是全局代码,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,用 globalContext 表示它,并且只有当整个应用程序结束的时候,ECStack 才会被清空,所以程序结束之前, ECStack 最底部永远有个 globalContext:

ECStack = [
    globalContext
];

执行一个函数,都会创建一个执行上下文,并且压入执行上下文栈中的栈顶,当函数执行完毕后,就会将该函数的执行上下文从栈顶弹出。

// 按照执行顺序,分别创建对应函数的执行上下文,并且压入执行上下文栈的栈顶
ECStack.push(functionAContext)    // push a
ECStack.push(functionBContext)    // push b
ECStack.push(functionCContext)    // push c
 
// 栈执行,首先C函数执行完毕,先进后出,
ECStack.pop()   // 弹出c
ECStack.pop()   // 弹出b
ECStack.pop()   // 弹出a

// javascript接着执行下面的代码,但是ECStack底层永远有个globalContext,直到整个应用程序结束的时候,ECStack 才会被清空
// ......
// ......

代码模拟实现栈的执行过程

class Stack {
  constructor(){
    this.items = []
  }
  push(ele) {
    this.items.push(ele)
  }
  pop() {
    return this.items.pop()
  }
}

let stack = new Stack()
stack.push(1)
stack.push(2)
stack.push(3)
console.log(stack.pop())    // 3
console.log(stack.pop())    // 2
console.log(stack.pop())    // 1

通过ES3提出的老概念—理解执行上下文

我在阅读相关资料时,遇到了一个问题,就是关于执行上下文说法不一,不过大致可以分为两种观点,一个是变量对象,活动对象,词法作用域,作用域链,另一个是词法环境,变量环境,一番查阅可以确定的是,变量对象与活动对象的概念是ES3提出的老概念,从ES5开始就用词法环境和变量环境替代了,因为更好解释。
先大致讲一下变量对象,活动对象,词法作用域,作用域链吧

1.变量对象和活动对象

变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。不同执行上下文中的变量对象不同,分别看一下全局上下文中的变量对象函数上下文中的变量对象

全局上下文中的变量对象

全局上下文中的变量对象就是全局对象。W3School 中有介绍:

  • 全局对象是预定义的对象,作为 JavaScript 的全局函数和全局属性的占位符。通过使用全局对象,可以访问所有其他所有预定义的对象、函数和属性。
  • 在顶层 JavaScript 代码中,可以用关键字 this 引用全局对象。因为全局对象是作用域链的头,这意味着所有非限定性的变量和函数名都会作为该对象的属性来查询。

函数上下文中的变量对象

在函数上下文中用活动对象(activation object, AO)来表示变量对象(VO)。

活动对象和变量对象其实是一个东西,只是变量对象是规范上的或者说是引擎实现上的,不可在 JavaScript 环境中访问,只有进入一个执行上下文中时,这个执行上下文的变量对象才会被激活,所以才叫活动对象,而只有被激活的变量对象(也就是活动对象)上的各种属性才能被访问。
换句话说:未进入执行阶段之前,变量对象(VO)中的属性都不能访问!进入执行阶段之后,变量对象(VO)转变为了活动对象(AO),里面的属性可以被访问,并开始进行执行阶段的操作。它们其实都是同一个对象,只是处于执行上下文的不同生命周期

但是从严格角度来说,AO 实际上是包含了 VO 的。因为除了 VO 之外,AO 还包含函数的 parameters,以及 arguments 这个特殊对象。也就是说 AO 的确是在进入到执行阶段的时候被激活,但是激活的除了 VO 之外,还包括函数执行时传入的参数和 arguments 这个特殊对象。
AO = VO + function parameters + arguments
活动对象是在进入函数上下文时刻被激活,通过函数的 arguments 属性初始化。

执行上下文的代码会分成两个阶段进行处理,预解析和执行:

  • 预解析的过程会激活AO对象,解析形参,变量提升及函数声明等
  • 在代码执行阶段,会从上到下顺序执行代码,根据代码,修改变量对象的值。

2.词法作用域

作用域是指代码中定义变量的区域。其规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。
JavaScript 采用词法作用域(lexical scoping),也就是静态作用域,函数的作用域在函数定义的时候就决定了。
词法作用域根据源代码中声明变量的位置来确定该变量在何处可用。嵌套函数可访问声明于它们外部作用域的变量

// 词法作用域
var value = 1;

function foo() {
    console.log(value);
}

function bar() {
    var value = 2;
    foo();
}

bar();    // 1

分析下执行过程:执行 foo ,先从 foo 内部查找是否有局部变量 value,如果没有,就根据书写的位置,查找上一层作用域,也就是 value=1,所以打印 1。
看个例子:

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f();
}
checkscope();
var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}
checkscope()();

两段代码都会打印:local scope。原因也很简单,因为JavaScript采用的是词法作用域,函数的作用域基于函数创建的位置。

虽然两段代码执行的结果一样,但是两段代码究竟有什么不同呢?词法作用域只是其中的一小部分,还有一个答案就是:执行上下文栈的变化不一样

模拟第一段代码运行时栈中的变化:

ECStack.push(<checkscope> functionContext);
ECStack.push(<f> functionContext);
ECStack.pop();
ECStack.pop();

模拟第二段代码运行时栈中的变化:

ECStack.push(<checkscope> functionContext);
ECStack.pop();
ECStack.push(<f> functionContext);
ECStack.pop();

3.作用域链

每个函数都有自己的执行上下文环境,当代码在这个环境中执行时,会创建变量对象的作用域链,作用域链类似一个对象列表,它保证了变量对象的有序访问。作用域链的最前端是当前代码执行环境的变量对象,也称“活跃对象AO”,当查找变量的时候,会先从当前上下文的变量对象中查找,如果找到就停止查找,如果没有就会继续向上级作用域(父级执行上下文的变量对象)查找,直到找到全局上下文的变量对象(全局对象)

特别注意:作用域链的逐级查找,也会影响到程序的性能,变量作用域链越长对性能影响越大,这也是为什么要尽量避免使用全局变量的一个主要原因。

那么这个作用域链是怎么形成的呢?
这是因为函数有一个内部属性 [[scope]]:当函数创建时,会保存所有父变量对象到其中,可以理解 [[scope]] 就是所有父变量对象的层级链,当函数激活时,进入函数上下文,会将当前激活的活动对象添加到作用链的最前端。此时就可以理解,查找变量时首先找自己,没有再找父亲

下面以一个函数的创建和激活两个时期来讲解作用域链是如何创建和变化的。

function foo() {
    function bar() {
        ...
    }
}

函数创建时,各自的[[scope]]为:

foo.[[scope]] = [
  globalContext.VO
];

bar.[[scope]] = [
    fooContext.AO,
    globalContext.VO
];

当函数激活时,进入函数上下文,就会将当前激活的活动对象添加到作用链的前端。
这时候当前的执行上下文的作用域链为 Scope = [AO].concat([[Scope]]);

以下面代码为例,结合变量对象和执行上下文栈,来总结一下函数执行上下文中作用域链和变量对象的创建过程:

var scope = "global scope";
function checkscope(){
    var scope2 = \'local scope\';
    return scope2;
}
checkscope();

执行过程如下(伪代码):

// 1.checkscope 函数被创建,保存父变量对象到 内部属性[[scope]] 
checkscope.[[scope]] = [
    globalContext.VO
];

// 2.执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
ECStack = [
    checkscopeContext,
    globalContext
];

// 3.checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数[[scope]]属性创建作用域链
checkscopeContext = {
    Scope: checkscope.[[scope]],
}

// 4.用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明
checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: undefined
    },
    Scope: checkscope.[[scope]],
}

// 5.将活动对象压入 checkscope 作用域链Scope的顶端
checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: undefined
    },
    Scope: [AO, [[Scope]]]
}

// 6.准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值
checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: \'local scope\'
    },
    Scope: [AO, [[Scope]]]
}

// 7.查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出
ECStack = [
    globalContext
];

4.活学活用 — 案例分析

通过案例分析的形式,串联上述所有知识点,模拟执行上下文创建执行的过程

var scope = "global scope";
function checkscope(){
  var scope = "local scope";
  function f(){
      return scope;
  }
  return f();
}
checkscope();

// 1.执行全局代码,创建全局执行上下文,全局上下文被压入执行上下文栈
  ECStack = [
    globalContext
  ];

// 2.全局上下文初始化
  globalContext = {
    VO: [global],
    Scope: [globalContext.VO],
    this: globalContext.VO
  }

// 3.初始化的同时,checkscope 函数被创建,保存作用域链到函数的内部属性[[scope]]
  checkscope.[[scope]] = [
    globalContext.VO
  ];

// 4.执行 checkscope 函数,创建 checkscope 函数执行上下文,并压入执行上下文栈
  ECStack = [
    checkscopeContext,
    globalContext
  ];

// 5.checkscope 函数执行上下文初始化:
/**
 * 复制函数 [[scope]] 属性创建作用域链,
 * 用 arguments 创建活动对象,
 * 初始化活动对象,即加入形参、函数声明、变量声明,
 * 将活动对象压入 checkscope 作用域链顶端。
 * 同时 f 函数被创建,保存作用域链到 f 函数的内部属性[[scope]]
 */
  checkscopeContext = {
    AO: {
      arguments: {
          length: 0
      },
      scope: undefined,
      f: reference to function f(){}    // 引用函数
    },
    Scope: [AO, globalContext.VO],
    this: undefined
  }

// 6.执行 f 函数,创建 f 函数执行上下文,f 函数执行上下文被压入执行上下文栈
  ECStack = [
    fContext,
    checkscopeContext,
    globalContext
  ];

// 7.f 函数执行上下文初始化, 以下跟第 5 步相同:
  /**
  复制函数 [[scope]] 属性创建作用域链
  用 arguments 创建活动对象
  初始化活动对象,即加入形参、函数声明、变量声明
  将活动对象压入 f 作用域链顶端
  */
  fContext = {
    AO: {
      arguments: {
          length: 0
      }
    },
    Scope: [AO, checkscopeContext.AO, globalContext.VO],
    this: undefined
  }
// 8.f 函数执行,沿着作用域链查找 scope 值,返回 scope 值

// 9.f 函数执行完毕,f 函数上下文从执行上下文栈中弹出
  ECStack = [
    checkscopeContext,
    globalContext
  ];

// 10.checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出
  ECStack = [
    globalContext
  ];

通过ES5提出的新概念—理解执行上下文

执行上下文创建分为创建阶段执行阶段两个阶段,较为难理解应该是创建阶段。
创建阶段主要负责三件事:

  • 确定this
  • 创建词法环境(LexicalEnvironment)
  • 创建变量环境(VariableEnvironment)

创建阶段

ExecutionContext = {  
    ThisBinding = <this value>,  // 确定this
    LexicalEnvironment = {},     // 创建词法环境
    VariableEnvironment = {},    // 创建变量环境
};

1. 确定this
官方称呼为:This Binding,在全局执行上下文中,this总是指向全局对象,例如浏览器环境下this指向window对象。而在函数执行上下文中,this的值取决于函数的调用方式,如果被一个对象调用,那么this指向这个对象。否则this一般指向全局对象window或者undefined(严格模式)。

2. 词法环境
词法环境中包含标识符和变量的映射关系,标识符表示变量/函数的名称,变量是对实际对象【包括函数类型对象】或原始值的引用。

词法环境由环境记录外部环境引入记录两个部分组成。

  • 环境记录:用于存储当前环境中的变量和函数声明的实际位置;
  • 外部环境引入记录:用于保存自身环境可以访问的其它外部环境,有点作用域链的意思

那么全局执行上下文函数执行上下文,也导致了词法环境分为全局词法环境函数词法环境两种。

  • 全局词法环境:对外部环境的引入记录为 null,因为它本身就是最外层环境,除此之外它还记录了当前环境下的所有属性、方法位置。
  • 函数词法环境:包含用户在函数中定义的所有属性方法外,还包含一个arguments对象(该对象包含了索引和传递给函数的参数之间的映射以及传递给函数的参数的长度)。函数词法环境的外部环境引入可以是全局环境,也可以是其它函数环境,这个根据实际代码而定。

环境记录在全局和函数中也不同,全局中的环境记录叫对象环境记录,函数中环境记录叫声明性环境记录,下方有展示:

  • 对象环境记录: 用于定义在全局执行上下文中出现的变量和函数的关联。全局环境包含对象环境记录。
  • 声明性环境记录 存储变量、函数和参数。一个函数环境包含声明性环境记录。
GlobalExectionContext = {    // 全局环境
  LexicalEnvironment: {      // 全局词法环境
    EnvironmentRecord: {     // 类型为对象环境记录
      Type: "Object", 
      // 标识符绑定在这里 
    },
    outer: < null >
  }
};

FunctionExectionContext = {   // 函数环境
  LexicalEnvironment: {       // 函数词法环境
    EnvironmentRecord: {      // 类型为声明性环境记录
      Type: "Declarative", 
      // 标识符绑定在这里 
    },
    outer: < Global or outerfunction environment reference >
  }
};

3. 变量环境

它也是一个词法环境,它具备词法环境所有属性,同样有环境记录与外部环境引入。

在ES6中唯一的区别在于词法环境用于存储函数声明与let const声明的变量,而变量环境仅仅存储var声明的变量

let a = 20;  
const b = 30;  
var c;

function multiply(e, f) {  
 var g = 20;  
 return e * f * g;  
}

c = multiply(20, 30);

上述代码中执行上下文的创建过程:

//全局执行上下文
GlobalExectionContext = {
    ThisBinding: Global Object,  // this绑定为全局对象
    LexicalEnvironment: {          // 词法环境
      EnvironmentRecord: {         
        Type: "Object",            // 对象环境记录
        // let const创建的变量a b在这
        a:  uninitialized ,  
        b:  uninitialized ,  
        multiply: < func >  
      }
      outer: null                // 全局环境外部环境引入为null
    },

    VariableEnvironment: {         // 变量环境
      EnvironmentRecord: {         
        Type: "Object",            // 对象环境记录
        // var创建的c在这
        c: undefined,  
      }
      outer: null                // 全局环境外部环境引入为null
    }  
  }

// 函数执行上下文
FunctionExectionContext = {
  ThisBinding: Global Object, //由于函数是默认调用 this绑定同样是全局对象
  LexicalEnvironment: {          // 词法环境
    EnvironmentRecord: {         
      Type: "Declarative",       // 声明性环境记录
      // arguments对象在这
      Arguments: {0: 20, 1: 30, length: 2},  
    },  
    outer: GlobalEnvironment    // 外部环境引入记录为Global
  },

  VariableEnvironment: {          // 变量环境
    EnvironmentRecord: {          
      Type: "Declarative",        // 声明性环境记录
      // var创建的g在这
      g: undefined  
    },  
    outer: GlobalEnvironment    // 外部环境引入记录为Global
  }  
}

这会引发我们另外一个思考,那就是变量提升的原因:我们会发现在创建阶段,代码会被扫描并解析变量和函数声明,其中let 和 const 定义的变量没有任何与之关联的值,会保持未初始化的状态,但 var 定义的变量设置为 undefined。所以这就是为什么可以在声明之前,访问到 var 声明的变量(尽管是 undefined),但如果在声明之前访问 let 和 const 声明的变量就会报错的原因,也就是常说的暂时性死区,

在执行上下文创建阶段,函数声明与var声明的变量在创建阶段已经被赋予了一个值,var声明被设置为了undefined,函数被设置为了自身函数,而let const被设置为未初始化。这是因为执行上下文创建阶段JS引擎对两者初始化赋值不同。

执行阶段

上下文除了创建阶段外,还有执行阶段,代码执行时根据之前的环境记录对应赋值,比如早期var在创建阶段为undefined,如果有值就对应赋值,像let const值为未初始化,如果有值就赋值,无值则赋予undefined。

执行上下文总结

  1. 全局执行上下文一般由浏览器创建,代码执行时就会创建;函数执行上下文只有函数被调用时才会创建,同一个函数被多次调用,都会创建一个新的上下文。

  2. 调用栈用于存放所有执行上下文,满足FILO特性。

  3. 执行上下文创建阶段分为绑定this,创建词法环境,变量环境三步,两者区别在于词法环境存放函数声明与const let声明的变量,而变量环境只存储var声明的变量。

  4. 词法环境主要由环境记录与外部环境引入记录两个部分组成,全局上下文与函数上下文的外部环境引入记录不一样,全局为null,函数为全局环境或者其它函数环境。环境记录也不一样,全局叫对象环境记录,函数叫声明性环境记录。

  5. ES3之前的变量对象与活动对象的概念在ES5之后由词法环境,变量环境来解释,两者概念不冲突,后者理解更为通俗易懂。

闭包

上文说了这么多,其实我本意只是想聊一聊闭包的,终于回归正题。

闭包是什么?

MDN 对闭包的定义简单理解就是闭包是由函数以及声明该函数的词法环境组合而成的。该环境包含了这个闭包创建时作用域内的任何局部变量(闭包维持了一个对它的词法环境的引用:在一个函数内部定义的函数,会将外部函数的活跃对象添加到自己的作用域链中)。所以可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

人们常说的闭包无非就是:函数内部返回一个函数,一是可以读取并操作函数内部的变量,二是可以让这些变量的值始终保存在内存中。

而在《JavaScript权威指南》中讲到:从理论的角度讲,所有的JavaScript函数都是闭包。

  1. 从理论角度:所有的函数。因为它们都在创建时保存了上层上下文的数据。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
  2. 从实践角度:闭包无非满足以下两点:
    • 闭包首先得是一个函数。
    • 闭包能访问外部函数作用域中的自由变量,即使外部函数上下文已销毁。(也可以理解为是自带了执行环境的函数)

闭包的形成与实现

上文中介绍过JavaScript是采用词法作用域的,讲的是函数的执行依赖于函数定义的时候所产生的变量作用域。为了去实现这种词法作用域,JavaScript函数对象的内部状态不仅包含函数逻辑的代码,还包含当前作用域链的引用。函数对象可以通过这个作用域链相互关联起来,函数体内部的变量都可以保存在函数的作用域内

let scope = "global scope";
function checkscope() {
    let scope = "local scope";   // 自由变量
    function f() {    // 闭包
        console.log(scope);
    };
    return f;
};

let foo = checkscope();
foo();
// 1. 伪代码分别表示执行栈中上下文的变化,以及上下文创建的过程,首先执行栈中永远都会存在一个全局执行上下文。
ECStack = [GlobalExecutionContext];

// 2. 此时全局上下文中存在两个变量scope、foo与一个函数checkscope,上下文用伪代码表示具体是这样:
GlobalExecutionContext = {     // 全局执行上下文
    ThisBinding: Global Object  ,
    LexicalEnvironment: {      // 词法环境
        EnvironmentRecord: {
            Type: "Object",    // 对象环境记录
            scope: uninitialized ,
            foo: uninitialized ,
            checkscope: func 
        }
        outer: null   // 全局环境外部环境引入为null
    }
}
// 3. 全局上下文创建阶段结束,进入执行阶段,全局执行上下文的标识符中像scope、foo之类的变量被赋值,然后开始执行checkscope函数,于是一个新的函数执行上下文被创建,并压入执行栈中:
ECStack = [checkscopeExecutionContext,GlobalExecutionContext];

// 4. checkscope函数执行上下文进入创建阶段:
checkscopeExecutionContext = {      // 函数执行上下文
    ThisBinding: Global Object,
    LexicalEnvironment: {           // 词法环境
        EnvironmentRecord: {
            Type: "Declarative",    // 声明性环境记录
            Arguments: {},
            scope: uninitialized ,
            f: func 
        },
        outer: GlobalLexicalEnvironment   // 外部环境引入记录为<Global>
    }
}

// 5. checkscope() 等同于window.checkscope() ,所以checkExectionContext 中this指向全局,而且外部环境引用outer也指向了全局(作用域链),其次在标识符中记录了arguments对象以及变量scope与函数f
// 6. 函数 checkscope 执行到返回函数 f 时,函数执行完毕,checkscope 的执行上下文被弹出执行栈,所以此时执行栈中又只剩下全局执行上下文:
ECStack = [GlobalExecutionContext];

// 7. 代码foo()执行,创建foo的执行上下文,
ECStack = [fooExecutionContext, GlobalExecutionContext];

// 8. foo的执行上下文是这样:
fooExecutionContext = {
    ThisBinding: Global Object ,
    LexicalEnvironment: {             // 词法环境
        EnvironmentRecord: {
            Type: "Declarative",      // 声明性环境记录
            Arguments: {},
        },
        outer: checkscopeEnvironment  // 外部环境引入记录为<checkscope>
    }
}
// 9. foo()等同于window.foo(),所以this指向全局window,但outer外部环境引入有点不同,指向了外层函数 checkscope(原因是JS采用词法作用域,也就是静态作用域,函数的作用域在定义时就确定了,而不是执行时确定)
/**
 * a. 但是可以发现的是,现在执行栈中只有 fooExecutionContext 和 GlobalExecutionContext, checkscopeExecutionContext 在执行完后就被释放了,怎么还能访问到 其中的变量?
 * b. 正常来说确实是不可以,但是因为闭包 foo 外部环境 outer 的引用,从而让 checkscope作用域中的变量依旧存活在内存中,无法被释放,所以有时有必要手动释放自由变量。
 * c. 总结一句,闭包是指能使用其它作用域自由变量的函数,即使作用域已销毁。
 */

闭包有什么用?

说闭包聊闭包,结果闭包有啥用都不知道,甚至遇到了一个闭包第一时间都没反应过来这是闭包,说说闭包有啥用:

1.模拟私有属性、方法

所谓私有属性方法其实就是这些属性方法只能被同一个类中的其它方法所调用,但是JavaScript中并未提供专门用于创建私有属性的方法,但可以通过闭包模拟它:
私有方法不仅有利于限制对代码的访问:还提供了管理全局命名空间的强大能力,避免非核心的方法弄乱了代码的公共接口部分。
例一:通过自执行函数返回了一个对象,只创建了一个词法环境,为三个闭包函数所共享:fn.incrementfn.decrementfn.value,除了这三个方法能访问到变量privateCounterchangeBy函数外,无法再通过其它手段操作它们。

let fn = (function () {
  var privateCounter = 0;

  function changeBy(val) {
    privateCounter += val;
  };
  return {
    increment: function () {
        changeBy(1);
    },
    decrement: function () {
        changeBy(-1);
    },
    value: function () {
        console.log(privateCounter);
    }
  };
})();
fn.value();     // 0
fn.increment();
fn.increment();
fn.value();     // 2
fn.decrement();
fn.value();     // 1

例二:构造函数中也有闭包:

function Echo(name) {
  var age = 26;       // 私有属性
  this.name = name;   // 构造器属性
  this.hello = function () {
      console.log(`我的名字是${this.name},我今年${age}了`);
  };
};
var person = new Echo(\'yya\');
person.hello();    //我的名字是yya,我今年26了

若某个属性方法在所有实例中都需要使用,一般都会推荐加在构造函数的原型上,还有种做法就是利用私有属性。比如这个例子中所有实例都可以正常使用变量 age,将age称为私有属性的同时,也会将this.hello称为特权方法,因为只有通过这个方法才能访问被保护的私有属性age。

2.工厂函数

使用函数工厂创建了两个新函数 — 一个将其参数和 5 求和,另一个和 10 求和。 add5 和 add10 都是闭包。它们共享相同的函数定义,但是保存了不同的词法环境。在 add5 的环境中,x 为 5。在 add10 中,x 则为 10。
利用了闭包自带执行环境的特性(即使外层作用域已销毁),仅仅使用一个形参完成了两个形参求和。当然例子函数还有个更专业的名词,叫函数柯里化。

function makeAdder(x) {
    return function (y) {
        console.log(x + y);
    };
};

var add5 = makeAdder(5);
var add10 = makeAdder(10);
add5(2); // 7
add10(2); // 12

闭包对性能和内存的影响

  1. 闭包会额外附带函数的作用域(内部匿名函数携带外部函数的作用域),会比其它函数多占用些内存空间,过度的使用可能会导致内存占用的增加。
  2. 闭包中包含与函数执行上下文相同的作用域链引用,因此会产生一定的负面作用,当函数中活跃对象和执行上下文销毁时,由于闭包仍存在对活跃对象的引用,导致活跃对象无法销毁,可能会导致内存泄漏。
  3. 闭包中如果存在对外部变量的访问,会增加标识符的查找路径,在一定的情况下,也会造成性能方面的损失。解决此类问题的办法:尽量将外部变量存入到局部变量中,减少作用域链的查找长度。

综上所述:如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为在处理速度和内存消耗方面对脚本性能具有负面影响。

了解了JS引擎的工作机制之后,我们不能只停留在理解概念的层面,而要将其作为基础工具,用以优化和改善我们在实际工作中的代码,提高执行效率,产生实际价值才是我们真正的目的。就拿变量查找机制来说,如果代码嵌套很深,每引用一次全局变量,JS引擎就要查找整个作用域链,比如处于作用域链的最底端window和document对象就存在这个问题,因此我们围绕这个问题可以做很多性能优化的工作,当然还有其他方面的优化,此处不再赘述,如果有帮到你,点个赞再走吧~~~

以上是关于从执行上下文(ES3,ES5)的角度来理解"闭包"的主要内容,如果未能解决你的问题,请参考以下文章

JavaScript原型链以及ES3ES5ES6实现继承的不同方式

JavaScript原型链以及ES3ES5ES6实现继承的不同方式

ts ES5/ES3 中的异步函数或方法需要 'Promise' 构造函数

错误:类型不是 ES5/ES3 中的有效异步函数返回类型,因为它没有引用与 Promise 兼容的构造函数

区别ES3ES5和ES6this的指向问题。区分普通函数和箭头函数中this的指向问题

es5严格模式