这一次, 我终于理解了闭包(从ES6词法环境的角度看闭包)

1,014 阅读22分钟

这一次, 我终于理解了闭包(从ES6词法环境的角度看闭包)

如果要列举JavaScript当中最难理解的三大概念的话, 我会列出三大巨头: 闭包, 异步编程(EventLoop), 类型转换, 其中虽然闭包基本定义和使用最简单, 但是要深入理解的话, 当属是最难之首.

关于闭包的解释文章非常之多, 似乎对于闭包的探讨是永无止境的, 当我学习JavaScript第一次接触到闭包概念的时候, JS红宝书上是这样解释闭包的:

在嵌套函数中, 如果一个内层函数引用外层及更外层的函数当中定义的变量, 那么这个函数拥有包含这些变量数据的闭包. 当该内层函数被返回出去的时候, 其作用域链不会被摧毁.

说实话, 我看完非常迷惑. 当我在网上搜索闭包相关内容的中英文博客和书籍时, 一系列概念向我冲来. 比如, 自由变量, 执行上下文, 执行上下文栈, 函数调用栈, 作用域链, 变量对象, 活动变量对象, 词法环境, 静态作用域, 词法作用域, 环境记录, 变量环境记录, 词法环境记录, [[Scopes]], [[Environment]], 有es5之前用于对闭包解释的概念, 也有es6规范中新提出的概念.

各种概念以及不同的观点交织在一起, 错综复杂, 让我大脑直呼受不了, 我似乎懂了, 但是又没有完全懂. 过去几个月, 在查询各种资料仍无法得到一个完整系统的认识后, 我放弃了. 最近敲代码的时候, 脑海中总是会偶尔闪过一个想法: "你连闭包都不懂, 怎么看框架源码?", 这个想法挥之不去, 仿佛成为了我脑海中的底噪, 让我辗转难眠. 我想, 是时候将脑中混乱的想法倒腾倒腾, 总结成有序的知识.

本文内容大纲如下:

  • 函数被调用的时候会发生什么?

  • 什么是执行上下文?

  • 什么是拥有自由变量的函数?

  • 如何处理拥有自由变量的函数?

  • 什么是闭包? 为什么要生成闭包?

  • 不同时刻调用函数生成的闭包是相同的吗?

  • 函数的作用域与词法环境记录的关系, 函数的作用链是什么?

  • 内部函数从环境记录当中被传出的时候, 如何处理该函数的作用域链?

  • 函数执行时如何处理块级语句? 块级作用域和环境记录之间的关系是什么?

  • 块级作用域的闭包

  • 闭包常见情况及应用场景

  • 补充

    • 非严格模式下的执行上下文初始化机制, 它与严格模式下的初始化机制的区别
    • with语句的闭包
    • 如何处理函数表达式
  • todo

    • 什么是私有环境记录[[PrivateEnvironmentRecord]]/私有作用域[[PrivateScope]]

    • 如何处理class声明的变量, 它被存放到哪里了? class当中的函数会被提升吗还是有其他处理方式?

    • 执行上下文创建时, 对函数参数的处理

0 预先说明

本文不会对全局执行上下文进行分析, 因为它大部分内容与函数执行上下文相同.

另外前面大部分内容内容都是在严格模式下进行分析. 这是因为在非严格模式下, js代码会出现很多难以预料的结果, 同时执行机制更加复杂. 这样不利于分析闭包原理, 因为要对严格模式和非严格模式下的闭包形成路线进行区分.

不过在文章的末尾会对非严格模式下和严格模式下的执行上下文初始化和闭包机制进行对比分析, 以保证主题内容的完整性.

这里顺便吐槽一下: 非严格模式的执行上下文和环境记录/作用域真的让人非常头疼, 比如下面这段代码. 你永远想不通, 为什么最后一段代码打印的结果是1 !!! 不过, 希望你看完这篇文章后, 能知道这个问题的答案.

console.log(a) // undefined
{
	a = 0
	function a () {}
	a = 1
}
console.log(a) // 0. 哈哈这里并不是1

再举几个非严格模式下产生闭包的例子.

  1. 在块级作用域外部可以获取到其内部声明的函数, 如果这个函数引用了块级作用域的变量, 还会生成闭包

    function foo() {
      console.log(bar) // undefined
      
      // block statement
    	{
    	  let a = 10
    		function bar() {
    			console.log(a) // 自由变量a
    		}
    	}
      
      console.log(bar) // [[Scopes]]: {window: ..., Block: {a: 10}}
    }
    
  2. 同样的, with语句结束后, 其中声明的带有自由变量的函数, 也可能会生成闭包(Note: with语句是vue中生成render函数的基本原理, with(vm) { _c(...) }, 它可以为其中的methods等函数生成闭包 )

function foo() {
	let person = {name: "Nicholas"}
	// with statement
	with (person) {
		function bar() {
			console.log(name)
		}
	}
	
	console.log(bar) // [[Scopes]]: {window: ..., With(person): {name: "Nicholas"}}
}
  1. 执行上下文中的环境记录(Environment Record)由变量环境记录(Variable ER)和词法环境记录(Lexical ER)组成.
    • 在非严格模式下, 变量环境记录 != 词法环境记录, 两者不相同, 词法环境记录会有一个[[OuterEnv]]属性指向变量环境记录.varfunction声明的变量放到变量环境记录当中, letconst声明的变量放到词法环境记录当中
    • 在严格模式下, 变量环境记录 = 词法环境记录, 两者相同. var, function, let, const声明的变量都放到词法环境记录, 由于词法环境记录和变量环境记录相同, 所以也可以说放到变量环境记录当中

本节内容最后, 对严格模式下的一些执行上下初始化机制进行说明. 这些内容也会在最后与非严格模式进行对比的时候进行补充介绍.

在严格模式下, 我们不需要考虑块级语句当中的function提升到外部, 也不需要考虑with语句(with语句会创建一个Object Environment Record, 严格模式下使用with语句会报错). 同时执行上下文的环境记录只有一个: Lexical Environment Record, 不用再将其于Variable Environment Record进行区分, 函数当中的所有变量声明都提升到其执行上下文的Lexical Environment Record当中.(ref: ECMAScript® 2022 - functiondeclarationinstantiation)

Note: 本文的环境记录(Environment Record)皆为词法环境记录(Lexical Environment Record)

下面将先对函数的执行上下文创建和执行的两个阶段进行解析, 然后再分析闭包形成的原理

1 函数被调用的时候会发生什么?

代码如下, 当foo()被调用的时候会发生什么?

function foo() {
  var a = 10
  let b = 20
  
  function bar() {
    let c = 30
  }
  
  {
    var d = 40
    let e = "in block"
    function boo() {
      let msg = "in boo"
      console.log(e)
    }
  }
  
  bar()
}
foo()

函数foo被调用的时候, 会:

  1. 先进入执行前的准备阶段(PrepareForOrdinaryCall, ref:ECMAScript® 2022 Language Specification ). 在这个阶段会创建一个新的执行上下文, 然后创建一个函数环境记录Environment Record(简写为ER), 并且将函数foo[[Environment]](外部环境的环境记录, 这里为全局执行上下文的环境记录)作为ER[[OuterEnv]]属性的值.

  2. 然后进入函数的执行阶段(EvaluteFunctionBody, ref:ECMAScript® 2022 Language Specification).

  3. 在代码执行之前, 会对函数foo对象进行初始化(FunctionDeclarationInstantiation), 对var声明的变量进行提升, 将变量名存放到环境记录当中, 然后将var声明的变量的值初始化为undefined, 并在环境记录创建letconst声明的变量(标记为unintialized, 未初始化), 最后将function声明的变量初始化到环境记录内部, 生成函数对象(OrdinaryFunctionCreate, ref: ECMAScript® 2022 Language Specification)存放到堆内存当中,把当前执行上下文的环境记录赋值给函数对象的[[Environment]]属性. 这个复杂的过程是函数执行上下文初始化的核心机制.

  4. 在变量声明结束之后, 会将函数当中的代码转换为可执行代码. 比如函数foo的代码转换为可执行代码如下. 其中块级语句中的代码不会被预编译(var除外), 在等到执行到块级语句的时候, 才会对它其中的代码进行处理. 同样的, 对于if, for, switch, while, do while等也是如此. 可执行代码如下, var, letconst声明关键字全被清除, 函数声明也被清除, 只留下可以执行的语句.

a = 10
b = 20

// block statement
// 执行时才会被预编译, 进行function变量声明提升, 
// 创建let和const声明的变量, 然后将代码转换为可执行代码
// 它其中的var声明会被预先处理
{
  d = 40
  let e = "in block"
  function boo() {
    let msg = "in boo"
    }
}

bar()
5. 当可执行代码生成以后, 会将初始化完成的执行上下文推到执行上下文栈顶部, 成为正在运行的执行上下文(running execution context)
6. 逐行解析和运行可执行代码

函数执行上下文的的初始化完成后的示意图如下图所示:

执行上下文初始化.jpg

这里对上图进行几点补充说明.

  1. 变量声明提升:在函数foo执行前, var, letconst声明的变量都会"提升"到其执行上下文的环境记录当中, var声明的变量的值初始化为undefined, 但是, letconst声明的变量不会被初始化, 它们的状态是uninitialized. 所以在函数foo执行的时候, 可以在赋值语句之前获取变量a的值为undefined. 如果尝试获取let声明的变量b的值,那么会报错: Error: can't get uninitialized variable b

  2. 环境记录和可执行代码存放在栈内存当中. 我们知道, js的数据类型分为原始值类型和引用值类型, 原始值类型的数据存放在栈内存当中, 而引用值类型的值存放在堆内存当中. 而所有的变量都被初始化到执行上下文的环境记录当中, 并且以栈内存的形式保存这些变量的值. 对于引用值类型的变量, 它在环境记录当中的值是一个地址, 该地址指向堆内存当中的数据. 我们可以把环境记录看作为一个"散列表", 所以虽然它有键值对, 不过仍然是一个栈结构

  3. 执行上下文栈是一个栈结构. 栈结构通常用于维护那些需要频繁插入和删除的操作, 而当执行js代码的时候, 会涉及到多次函数执行上下文的推入和弹出操作, 所以执行上下文栈被设计为栈结构, 当一个执行上下文被销毁的时候, 只需要将指针移到下方的执行上下文的头部指针即可, 上面的所有栈内存数据会全部被删除. 而要添加一个执行上下文, 直接将其栈内存数据推入到执行上下文栈即可, 非常方便.

这一节简单介绍了最简单的情况下, 函数执行上下文是如何初始化的. 下面还有更复杂的内容, 比如如果函数内部当中有包含自由变量的函数时, 执行上下文如何初始化; 在函数执行阶段, 如果遇到块级语句会如何操作? 执行上下文切换时, 如何处理被传出的函数的环境记录/作用域链等等问题.

下面将一一进行详细说明.

2 执行上下文初始化详解

通过第一节内容我们大概了解了执行上下文初始化的基本内容: 变量声明提升, 函数声明提升, letconst声明的变量会在环境记录当中创建(记住是创建, 不是初始化, 初始化变量会对变量进行初始赋值)并标记为uninitialized

执行上下文初始化过程中最重要的点是函数声明提升, 也是这一节的主要内容. 变量声明提升这个基本概念大家都很熟悉了, 也不是什么很难理解的点, 所以就一带而过.

2.1 什么是带有自由变量的函数

自由变量没有那么难以理解, 其实就是在函数执行过程中使用, 但是没有在函数内部定义的变量. 比如, 下面的函数free有自由变量a, 在其执行过程中, 需要去函数外部的环境记录/作用域当中查询变量a的值.

	function free() {
    console.log(a) // 自由变量, 没有通过var, function, let, const声明过
    let b = 10
    let c = 20
  }

2.2 函数声明提升的时候, 如何处理带有自由变量的函数?

这部分内容是闭包的核心内容, 而且涉及到多种情景, 所以我将从简单的情况讲起, 然后拓展到其他场景.

2.2.1 执行上下文初始化时, 环境记录当中的闭包

首先看下面一段代码, 函数foo内部定义的函数bar是一个带有自由变量a, b的函数

function foo() {
  let a = 10
  var b = 20
  let c = 30
  
  function bar() {
    console.log(a) // 自由变量
    console.log(b)
  }
}
foo()

foo()被调用的时候, 会为函数foo生成一个新的执行上下文, 然后进行变量声明提升: a <- uninitialized, b <- undefined, c <- unintialized, 环境记录此时为ER = [a: uninitalized, b: undefined, c: uninitialized]

最后, 会对函数内部的函数声明进行提升. 函数foo当中只有一个函数声明 function bar. 在该函数声明被提升前, 会快速检查其内部的代码, 查看其是否包含有自由变量. 检查后的结果当然是函数bar当中包含有自由变量a,b, 此时会将自由变量与环境记录中记录的变量进行匹配, 比如环境记录当中的变量a, b与自由变量a, b相匹配. 然后根据匹配的变量以及这些变量的状态, 在堆内存当中创建一个闭包对象closure(foo): {a: unintialized, b: undefined}, 将变量ab从环境记录中清除, 最后把闭包对象的在堆内存的地址(注意是地址不是数据)推入环境记录当中.

函数声明检查结束后, foo的执行上下文的环境记录为: ER = [closure(foo): { a: uninitialized, b: undefined}, c: uninitialized]

以上论述说明了一点: 如果函数内部的top-level声明的函数包含自由变量, 那么在该函数初始化的时候, 就会生成一个闭包, 不过这个闭包属于当前函数而不是其内部定义的函数.(Note: top-level指的是函数内部首层的代码)

最后才真正会进行函数声明提升:

  1. (重点): 在堆内存当中创建函数对象bar, 将当前执行上下文的环境记录, 即函数foo的环境记录, 赋值给函数对象bar[[Environment]]属性(ref: ECMAScript® 2022 Language Specification). 这一步是函数闭包链产生的关键, 函数在定义的时候就已经拥有了其定义所在的函数的环境记录的指针. 正是因为如此, 不管这个函数对象传到哪里, 它总能通过自身的[[Environment]]属性找到其静态定义所在的函数的环境记录. 这个机制为动态调用的函数的静态作用域链提供了支持.
  2. 函数对象bar创建完成后, 在环境记录当中创建变量bar, 并将函数对象的地址赋值给该变量

函数foo的执行上下文初始化完成后,其环境记录为: ER = [closure(foo): {a: unintialized, b: undefined}, c: uninitialized, bar: function () {...}]. (注: closure(foo)bar指向的值都是对象在堆内存的地址)

上述过程的示意图如下:

带自由变量的简单函数执行上下文初始化过程.jpg

2.2.2 如何处理函数内部多个带有自由变量的函数?

下面的代码与上一节的代码基本相同, 只不过函数foo当中多了一个带有自由变量c的函数boo. 那么函数boo被提升的时候, 会如何处理呢?

在执行上下文的初始阶段, 函数内部所有的top-level函数声明都会以逐个遍历的方式进行提升, 所以每个函数声明的提升步骤都相同: 检查自由变量 -> 自由变量与环境记录变量匹配(只有匹配到了, 才会创建闭包) -> 创建闭包 -> 清除变量 -> 推入闭包

函数foo内部的函数声明及其自由变量如下:

  • bar: 自由变量为ab
  • boo: 自由变量为c

因此, 执行上下文初始化阶段, 会创建闭包closure(foo): {a: unintialized, b: undefined, c: unintialized}. 此时, 执行上下文的环境记录为: Environment Record = [closure(foo): {a: unintialized, b: undefined, c: unintialized}].

function foo() {
  let a = 10
  var b = 20
  const c = 30
  
	function bar() {
		console.log(a) // 自由变量a
    console.log(b) // 自由变量b
	}
  
  function boo() {
    console.log(c) // 自由变量c
  }
}

2.2.3 如何处理函数内部的孙级带有自由变量的函数?

在为内部top-level函数的自由变量生成闭包的时候, 实际上, 内部函数的后代函数(嵌套定义的所有层级函数)的自由变量也会被检查, 并进行处理.

例如, 下面的代码中, 函数bar当中的函数car包含有自由变量msgname. 当函数bar被提升的时候, 它的自由变量以及内部的后代函数的自由变量汇总后为: a, b, msg, name.

这里需要注意的是, 函数car的自由变量name不会被放入到函数foo的闭包closure(foo)当中, 因为name在其的父级函数bar中有定义, 所以该自由变量name的提升, 需要在函数bar被调用, 函数bar的执行上下文初始化的时候, 放入的闭包closure(bar)当中.

因而, 函数foo的执行上下文初始化的时候, 会对函数bar和函数car的自由变量a, b, msg进行处理(正如上面所说的, name不会被处理到闭包当中), 然后将这些自由变量与自身的环境记录的变量进行匹配, 创建闭包对象: closure(foo): {a: unintialized, b: undefined, msg: unintialized}

最后, 执行上下文的环境记录为: ER = [closure(foo): {a: unintialized, b: undefined, msg: unintialized}, name: uninitialized]

function foo() {
  let a = 10
  var b = 20
  let name = "foo"
  const msg = "get in car()"
  function bar() {
    console.log(a) // 自由变量a
    console.log(b) // 自由变量b
    let name = "bar"

      function car() {
        console.log(msg) // 自由变量msg
        consloe.log(name) // 自由变量name
      }
  }
}

上面的处理逻辑同样适用于top-level函数声明内部有更多的嵌套函数层级.

2.2.4 函数在不同时刻调用生成的闭包是相同的吗?

根据第2节的内容我们可以很快得出, 下面的代码中, 函数foo在被调用的时候, 会创建一个新的执行上下文, 然后对执行上下文进行初始化, 创建一个闭包closure(foo): {a: uninitialized}推入到函数foo执行上下文的环境记录当中.

function foo() {
  let a = 10

  function bar() {
    console.log(a)
  }
}

setTimeout(foo, 1000) // 1秒后调用函数foo
setTimeout(foo, 2000) // 2秒后调用函数foo

思考一下: 函数foo在1s后被调用生成的闭包t1 - closure(foo)和在2s后被调用生成的闭包t2 - closure(foo)是相同的吗?

答案是: 不同. 因为函数foo被调用的时候, 会重新创建一个执行上下文, 也会重新在堆内存创建一个闭包对象, 所有的过程都重新来一遍, 所以这些变量数据在栈内存和堆内存的地址是不同的.

本小结内容也同样适用于第3.3节for循环的闭包

3 执行上下文执行阶段详解

这一章主要对函数执行过程当中的部分操作进行说明, 如赋值查找和取值查找, 以及块级语句的执行.

3.1 赋值查找和取值查找

在逐行执行函数可执行代码过程中, 会涉及到大量的变量取值和赋值操作. 比如取值console.log(a), 赋值a = 10. 对变量取值和赋值的操作定义为术语取值查找和赋值查找.

简单来说的话, 取值查找和赋值查找就是去函数执行上下文的环境记录或者其外部环境/作用域链当中查找变量, 然后获取该变量的值, 或者给该变量赋值.

3.2 块级语句的环境记录和闭包

回到第1章当中的代码, 我重新贴到下面.

function foo() {
  var a = 10
  let b = 20
  
  function bar() {
    let c = 30
  }
  
  {
    var d = 40
    let e = "in block"
    function boo() {
      let msg = "in boo"
      console.log(e)
    }
  }
  
  bar()
}
foo()

函数foo在执行上下文初始化结束之后, 它的环境记录和可执行代码分别为:

环境记录

Environment Record = [a: undefined, b: unintialized, bar: <0x..., 堆内存函数对象地址>, d: undefined]

可执行代码

这里进行了简单分行, 方便叙述

  a = 10 // line 1
  b = 20 // line 2

  { // line 3
    d = 40 
    let e = "in block"
    function boo() {
      let msg = "in boo"
      console.log(e)
    }
  }

  bar() // line 4

在执行上下文执行阶段, 函数的可执行代码会被逐行解析执行:

  1. line1 & line 2: a = 10, b = 20, 这里也就是对变量a, b进行赋值查找
  2. line3: 发现块级语句{ block statement list}, 此时会对块级语句进行解析. 下面是解析块级语句的重点(ER相当于Environemnt Record的简写):
  3. 当发现块级语句时, 会生成一个新的环境记录ER(block), 将该环境记录作为当前执行上下文的环境记录, 并将函数foo的环境记录ER(foo)赋值给ER(block)[[OuterEnv]]属性, 也就是说块级语句的环境记录的外部环境为它所在的函数的环境记录(ref: ECMAScript® 2022 Language Specification)
  4. 然后会进行块级语句的声明初始化(BlockDeclarationInstantiation), 这部分过程与函数执行上下文初始化基本相同, 不同的点在于, 它里面已经没有var关键字了. 所以它只会对let, const, function声明的变量进行提升, 块级语句的变量声明提升和函数声明提升都是提升到块级语句的环境记录ER(block)当中.
  5. 首先将let声明的变量e提升到环境记录当中, 然后将其标记为uninitialized.
  6. 接着是对函数boo进行提升. 首先预检查其中的自由变量. 比如这里函数boo有自由变量e, 该自由变量与ER(block)当中的变量e相匹配. 此时, 会在堆内存当中创建一个block对象用于管理内部函数的自由变量: block: {e: uninitialized}, 然后将ER(block)当中的变量e清除, 将block对象的指针存放到ER(block)当中.
  7. 块级语句的声明初始化结束之后, 其环境记录为: ER(block) = [block: {e: unintialized}]
  8. 接着将它当中的代码转换为可执行代码, 块级语句当中的可执行代码就两段: d = 40, e = "in block"
  9. d = 40, 首先去块级环境记录ER(block)当中查找变量d, 没有查找到, 则去块级环境记录的外部环境ER(block).[[OuterEnv]]也就是ER(foo)当中查找变量d. 由于d已经被提升到ER(foo)当中,所以可以查找到, 然后将值undefined改为40
  10. e = "in block, 在块级环境记录当中ER(block)可以找到变量e, 将其值改为"in block
  11. 块级语句执行结束, 摧毁ER(block), 将执行上下文的环境记录重新还原为ER(foo)
  12. line4: 执行bar(), 这里将调用ER(foo)当中的变量bar执行的函数对象, 然后会创建一个新的执行上下文, 并将其推入执行上下文栈栈顶. 这一步与函数foo在全局被调用的步骤相同, 略过, 后面会再讲解.

最后, 顺便提一下, 块级语句的环境记录和闭包机制同样适用于if (<condition>) { statementLsit }语句

3.3 for循环语句的闭包

如果你理解了块级语句的闭包, 那么for循环语句的闭包也就相当好懂了, 只不过是执行到块级语句结束后, 在条件为true的时候回到块级语句开头, 所有过程重新再来一遍.

另外, 由于执行上下文是栈结构, 所以, 如果需要再循环一遍for循环的块级语句的话, 只需要将指针再移回循环语句的开头即可, 非常方便.

这里举一个简单的例子.

function repeat() {
  // block: {i : uninitialized}
  for (let i = 0; i < 3; i++) {
    function bar() {
      console.log(i);
    }
    console.log(i)
  }
}
repeat()

上面的代码中的for循环次数为3次, 不过实际上, for循环的环境记录初始化会重复4次. 第1次初始化和后面3次初始化有所不同

for循环的结构为for(lexical declaration, condition expression, increment expression), 第1次初始化就是对lexical delaration(即let i, a, ...)进行处理, 我暂时忽略var i, a, ...的情况

上述代码中的for循环第1次初始化的步骤如下:

第1次初始化

  1. 首先, 在执行到for循环语句的时候, 会创建一个新的块级环境记录, 然后将函数repeat的环境记录赋给该新建的环境记录的[[OuterEnv]]属性(ForLoopEvaluation, ref: ECMAScript® 2022 Language Specification)
  2. 为了方便说明, 我将第1步中的块级环境记录命名为ER(init).
  3. 接下来是进行for循环括号()当中的变量声明lexical declaration提升, 变量声明let i会被提升到ER(init)当中, 标记为uninitialized
  4. 第1次初始化结束之后, ER(init)[i: uninitialized]. 变量i称作为perIterationNames, 即每次迭代都会用到的变量.
  5. 执行i = 0, 将变量i的值修改为0, ER(init) = [i: 0]

在第一次初始化结束之后, 就开始进行for循环语句的循环执行了.在每次执行前都会进行初始化操作.

第2-4次初始化和执行(ForBodyEvaluation, ref: ECMAScript® 2022 Language Specification)

  1. 执行上下文当前的环境记录为ER(init)
  2. 创建一个新的块级环境记录ER(loop-i), 然后将执行上下文当前的环境记录, 赋值给ER(loop-i)[[OuterEnv]]属性, 并将ER(loop-i)作为当前执行上下文的环境记录.
  3. 从当前环境记录ER(loop-i)[[OuterEnv]]当中获取perIterationNames的变量名和值(这里只有变量i), 并在ER(loop-i)初始化这些变量: ER(loop-i)=[i = <value in outerEnv>]
  4. 由于for循环当中的语句没有let, const的变量声明, 所以不会进行变量声明提升
  5. 然后进行函数声明bar的提升: 检查到有自由变量i -> 创建新的block闭包对象block: {i: <value in outerEnv>} -> 将ER(loop-i)当中的变量i清除 -> 把该block闭包对象的地址推入到ER(loop-i)当中 -> 创建函数对象bar -> 将该函数对象的地址推入ER(loop-i)当中
  6. 函数声明提升结束后, ER(loop-i) = [block: {i: <value in outerEnv>}, bar: <函数对象bar的地址>]
  7. 生成for循环块级语句的可执行代码: 这里函数声明提升后会被清除, 块级语句的可执行代码为空
  8. 判定条件表达式condition expression: i < 3, i从当前环境记录取值
  • 在条件表达式结果为真的时候, 向下执行, 由于示例代码当中的可执行代码为空, 所以直接进入循环结束阶段
  • 如果条件表达式结果为假, 那么循环结束, 将环境记录ER(init)作为执行上下文的当前环境记录, 摧毁环境记录ER(loop-(1 to 3))
  1. 当前循环结束后, 执行步进表达式: i++, i从当前环境记录中进行赋值查找, 回到第1步(注意是第1步, 不是第0步)

在第2-4次初始化和执行过程中, 执行上下文的当前环境记录变化为ER(init) -> ER(loop-1) -> ER(loop-2) -> ER(loop-3), 这组成了类似于链表的结构. 一个需要注意的点是, 每次for循环执行的时候, 其中的函数声明都会被重新检查一遍, 而且会重新生成一个新的与内部函数自由变量对应的块级闭包block: {i: ...}, 这也是下面的经典面试题的底层原理:

function repeat5() {
  for (let i = 0; i < 5; i++) {
    setTimeout(function() {
      console.log(i) // 0, 1, 2, 3, 4
    }, 100)
  }
}
repeat5()

传入setTimeout的回调函数不是函数声明, 而是一个函数表达式, 函数表达式不会在初始化阶段被提升, 只会在执行的时候, 进行检查和处理. 不过在执行的时候, 当前环境记录仍然会为它的自由变量i生成一个全新的块级闭包对象block: {i: ...}. 因而, 面试题中的for循环会生成5对不同的函数对象和块级闭包对象, 它们是相互独立的, 在堆内存的地址互不相同.

大家思考一下, for循环括号当中的let i = 0修改成var i = 0为什么会使得打印结果都是5呢?

答案是:

  • var变量声明在for循环执行前, 就被提升到当前执行上下文的环境记录ER(repeat5)当中, 初始化为undefined. 还记得3.2节的内容吗, 函数内部的块级语句的所有var声明在函数初始化进行初始化, 而不是等到执行块级语句的时候再进行初始化. for (var i = 0; ...) {...}会被编译成for (i = 0; ...), 因而在第1次初始化的时候, 并没有发现有lexical declaration, 所以不会在ER(init)中创建变量i的绑定. 同时, 也使得perIterationNames为空, 在每次循环的时候, 并不会执行上面第2-4次初始化当中的第2步, 即不会在每次生成一个新的块级环境记录的时候, 在块级环境记录当中创建一个变量i的绑定, 在每次循环的时候, 都是在ER(repeat5)当中进行变量i的取值查找和赋值查找
  • 实际上, 面试题中的匿名函数表达式的自由变量i应当去函数执行上下文的环境记录ER(repeat5)当中取值, 当该匿名函数表达式执行的时候, 这种情况会被检测到, 然后在ER(repeat5)创建一个闭包closure(repeat5): {i: undefined}, 并将变量iER(repeat5)当中清除.

4 作用域链和闭包链的底层逻辑(重点)

4.1 相邻执行上下文控制权切换时的闭包处理

4.2 为什么说函数的[[Environment]]`属性是保证静态作用域的核心?

// 未完待续... 先写个大致框架, 拯救我的晚期拖延症. 后续逐渐添加一些示意图, 帮助理解