【译】词法环境——闭包的隐秘角落

586 阅读8分钟

原文:Lexical Environment — The hidden part to understand Closures
译文:【译】词法环境——闭包的隐秘角落
转载请注明原文及译文出处

对刚进入 JavaScript 的新手村的玩家来说,闭包可能是一个令人望而生畏的概念。你可以在互联网上找到很多关于闭包的定义,但我觉得这些定义大多都是模糊的,不能解释闭包存在的根本原因。

今天我们会根据 ECMAScript 262 规范来揭开这些概念的神秘面纱,包括执行上下文词法环境以及标识符解析。值得一提的是,由于这些机制,我们将会了解到 ECMAScript 中的所有函数都是闭包的。

我首先会解释一下上述提到的术语,然后再展示一段代码示例,解释所有的这些部分是如何协同运作起来的,这将有助于你们巩固理解。

执行上下文

JavaScript 解释器在即将执行我们编写的函数或脚本时创建一个新的上下文。每一段脚本/代码都是从一个叫做 全局执行上下文(global execution context) 的地方开始的。当每次调用一个函数的时候,都会创建一个新的执行上下文,并将其放在 执行栈(execution stack) 的顶部。在函数中嵌套调用另一个函数时,将会遵循相同的模式:

来看看上图所述代码在执行时都发生了什么:

  • 创建了一个 全局执行上下文(global execution context) 并将其放入到执行栈 的底部。
  • 调用 bar 时,创建了一个新的执行上下文(bar Execution Context)并将其放在全局执行上下文的顶部。
  • bar 调用嵌套函数 foo 时,又创建了一个新的执行上下文(foo Execution Context)并将其放在 bar 执行上下文的顶部。
  • foo 执行结束返回时,它的执行上下文就从执行栈中弹出,然后返回到了 bar 的执行上下文中。
  • bar 执行结束后,便回到了全局执行上下文中,直到最后整个执行栈清空。

执行栈以先进后出(LIFO)的方式运行,底部的执行上下文会等待顶部的执行上下文结束后再继续执行。

从概念上来说,执行上下文的数据结构如下所示:

// Execution context in ES5

ExecutionContext = {
	ThisBinding: <this value>,
	VariableEnvironment: { ... },
	LexicalEnvironment: { ... }
}

如果对上边的数据结构感觉到难以理解,不用担心,我们将很快介绍这些结构组成。要记住的关键点是,对每一个执行上下文的调用都有两个阶段:创建阶段(Creation Stage)执行阶段(Execution Stage)。创建阶段是指执行上下文已经创建但尚未调用。

创建阶段(Creation Stage) 会发生的事情:

  • VariableEnvironment 组件用于变量,参数和函数声明的存储。使用 undefined 来初始化 var 声明的变量。
  • 确定 this 的指向。
  • LexicalEnvironment 只是现阶段 VariableEnvironment 的副本(拷贝)。

到了执行阶段(Execution Stage):

  • 变量赋值。
  • LexicalEnvironment 用于解析绑定(标识符)。

到此,让我们开始了解什么是词法环境。

词法环境(Lexical Environment)

如 ECMAScript 规范262(8.1)所述:

词法环境是一种规范,用于在 ECMAScript 代码的词法嵌套结构中定义标识符、特定变量以及函数的关联。

简化一下,词法环境主要由两部分组成:环境记录(environment record)对外部词法环境的引用(reference to the outer lexical environment)

var x = 10;

function foo() {
	var y = 20;
	console.log(x + y); // 30
}

// 理论上来说,词法环境主要由两部分组成:
// 环境记录,对外部词法环境的引用

// 全局词法环境
globalEnvironment = {
	environmentRecord: {
		x: 10
	},
	outer: null // 全局词法环境没有外部引用,所以箭头指向了 null
}

// "foo" 函数的词法环境
fooEnvironment = {
	environmentRecord: {
		y: 20
	},
	outer: globalEnvironment
}

整体看起来就像下边这样:

正如你所见,当程序开始在 foo 的执行上下文中解析标识符 “x” 的时候,将会通过外部词法环境引用来获取到。这个过程被称为标识符解析,标识符解析发生在执行上下文的运行时。

综上所述,基于对词法环境的了解,再回头看一下执行上下文的结构,看看发生了什么:

  • 变量环境组件(VariableEnvironment):其 environmentRecord 用于变量,参数,函数的初始存储,在进入执行阶段(Execution Stage)之后会进行变量的赋值。
function foo(a) {
	var b = 20;
}
foo(10);

// 可变环境(VariableEnvironment)在foo函数执行上下文的创建阶段(Creation Stage)时
fooContext.VariableEnvironment = {
	environmentRecord: {
		arguments: { 0: 10, length: 1, callee: foo},
		a: 10,
		b: undefined
	},
	outer: globalEnvironment
};

// 在foo函数执行上下文的执行阶段(Execution Stage)之后,
// 可变环境的环境记录(environmentRecord)中变量的值会完成赋值
fooContext.VariableEnvironment = {
  environmentRecord: {
    arguments: { 0: 10, length: 1, callee: foo },
    a: 10,
    b: 20
  },
  outer: globalEnvironment
};
  • 词法环境组件(LexicalEnvironment):在创建阶段时,词法环境只是可变环境的一个副本,在执行上下文中运行,用于确定执行上下文中标识符的绑定(变量的指向)。

可变环境组件(简称:VE)和词法环境组件(简称:LE)本质上是一样的,即在执行上下文创建阶段(Creation Stage)时都静态捕获了当前执行上下文对外部词法环境的引用(Outer),正是因为这样的机制导致了闭包的存在。

静态捕获了当前函数执行上下文对外部词法环境的引用会导致闭包的形成。

标识符解析——作用域链查找

在理解闭包之前,先来了解一下作用域链是如何在执行上下文中创建的。如前所述,每一个执行上下文都有用于解析标识符的词法环境。所有的执行上下文的本地绑定都存储在当前词法环境的环境声明记录表中,如果该词法环境没有找到标识符,则会从外部(父级)的环境记录中查找解析,直到标识符能够成功解析到。如果未能成功找到对应的标识符,则会抛出 ReferenceError 的错误。

这样的机制和原型链非常相似。最关键的是在执行上下文的创建阶段(Creation Stage),词法环境会(静态)捕获外部词法环境的引用(Outer),并在执行阶段(Execution Stage)使用它。

闭包

正如上边所说的,无论这之后是否会使用到这个函数,在这个函数创建阶段,都会静态的保存了外部词法环境的引用在当前执行上下文的词法环境中。来看以下的例子:

例子1:

var a = 10;
function foo() {
	console.log(a);
}

function bar() {
	var a = 20;
	foo();
}

bar(); // 会打印出“10”

函数 foo 在创建阶段捕获绑定了 “a”,即 10。所以在调用函数 foo 的时候,“a” 标识符的解析值为10,而不是20。

从概念上来理解,上述的标识符解析过程如下所示:

// 在 “foo” 的环境记录中查找标识符 “a”
-- foo.[[LexicalEnvironment]].[[Record]] --> "not found"

// 如果没有找到(“not found”),从 foo 的“对外部词法环境的引用(Outer)”中查找
--- global[[LexicalEnvironment]][[Record]] --> "found 10"

// 解析返回标识符的值:10

例子2:

function outer() {
	let id = 1;

	return function inner() {
		console.log(id);
	}
}

const innerFunc = outer();

innerFunc(); // 输出 1

当外部的函数执行结束返回时,其执行上下文会从执行栈中弹出。但当我们稍后调用 innerFunc() 的时候,因为内部函数 inner 的词法环境静态的捕获了外部词法环境的 id 标识符,所以它仍然可以正确的打印出我们想要的值。

// 在 “inner” 的环境记录中查找标识符 “id”
-- inner.[[LexicalEnvironment]].[[Record]] --> "not found"

// 如果没有找到(“not found”),从 inner 的“对外部词法环境的引用(Outer)”中查找
-- outer[[LexicalEnvironment]][[Record]] --> "found 1"

// 解析返回标识符的值:1

总结

  • 执行栈遵循 先进后出(LIFO) 的数据结构。
  • 我们的代码/脚本在一个全局执行上下文中运行。
  • 调用一个函数会创建一个新的执行上下文(a),如果它具有一个嵌套的函数调用,又会创建一个新的执行上下文(b),放在其上下文(a)的顶部。当一个函数执行完成后,其执行上下文会从执行栈中弹出,并回到其下边的执行上下文中去。
  • 词法环境(Lexical Environment)主要由两部分组成:环境记录(Environment Record)和对外部词法环境的引用(reference to outer environment,一般用 Outer 描述)。
  • 变量环境(VariableEnvironment)和词法环境(LexicalEnvironment)都静态地捕获了执行上下文中对外部词法环境的引用。
  • 所有函数在创建阶段(Creation Stage)都静态地捕获了其所在执行环境的外部引用。即便是外部函数已经执行完毕和弹出执行栈,但内部的嵌套函数仍然可以通过词法环境去访问到外部的词法环境。这样的机制是 JavaScript 闭包的原理基础。