作用域
你真的了解作用域吗
在讲解作用域之前先提出几个问题:
- JavaScript有几种作用域?
- 以下代码是否会报错?
let a = 1;
let a = 2;
- 以下代码是否会报错?
let i = 1;
for (let i = 2; i < 3; i++);
- 以下代码打印结果是多少?
const err = 'err1';
try {
throw 'err2';
} catch (err) {
console.log(err);
}
(看以下结果之前可以先自己猜想一下或者试验一下)
问题1:
一些说法将JavaScript的作用域分为四种:全局作用域,函数作用域,模块作用域,块作用域(参考:Scope-MDN)。let, const声明的变量可以存在于块作用域,var声明的变量只存在另外三种作用域
问题2:
会报错,而且是在编译时报错:无法重新声明块范围变量“a”。在同一个作用域内不能重复声明变量
SyntaxError: Identifier 'a' has already been declared
问题3:
不会报错。如果按照问题1的结论,两个i同属于一个作用域全局作用域,因为并没有函数或者块符号{}分隔。再根据问题2的结论,同一个作用域内不能重复声明变量,这里理应会报错?别着急,后面会给你答案
问题4:
打印结果是err2。同样的思路,按照问题1的作用于分类,这里上边的err和catch (err)这里的err应该同属于一个作用域,上边的err声明为const常量,那么为什么值还能被修改为err2呢?别着急,后面会给你答案
重新认识作用域
作用域是程序维护变量或标识符可访问的范围。作用域是一种规则,规定了变量标识符存储于何处,从何处读取。作用域是一个语言无关的概念,不同语言有各自的作用域规则。但从整体上,作用域分为两种类型:动态作用域和静态作用域
静态作用域是定义在词法阶段的作用域,简单来说就是由写代码时变量和函数声明的位置来决定的。动态作用域是在代码运行时动态确定作用域范围,换句话说,动态作用域不关心函数和变量是如何声明以及在何处声明的,只关心他们从何处调用
参考资料:《你不知道的JavaScript(上卷)》
动态作用域
动态作用域只有较少语言在使用(比如:Bash,Perl)以下Bash脚本你觉得会输出什么?
#!/usr/bin/bash
x=1
function g() { echo "g: $x" ; x=2; }
function f() { local x=3 ; g; echo "f: $x"; }
f
echo $x
结果是:
g: 3
f: 2
1
对于动态作用域来说,执行f时会调用g,g会访问f中的变量,所以g能看到local x=3,输出g: 3。g中设置x=2后,仅仅只是在f的内层嵌套函数中设置,所以x=2对g和f(因为g是f的一部分)都可见,但对f文本段外部不可见,所以f中输出f: 2,最后一行输出1。如果按照静态作用域,g中访问的x将会是函数外的x=1
参考资料:www.cnblogs.com/f-ck-need-u…
静态作用域
大部分语言(包括JavaScript)使用静态作用域。使用静态作用域规则时,变量的可访问性在分词阶段即可确定。考虑以下代码
let x = 1;
function g() {
console.log(`g: ${x}`);
x = 2;
}
function f() {
let x = 3;
g();
console.log(`f: ${x}`);
}
f();
console.log(x);
和上边Bash脚本相同的结构,输出结果却是:
g: 1
f: 3
2
静态作用域下,在f中执行g时访问的x是g函数外的let x = 1,也就是全局变量。f函数中输出的x则是f中定义的函数作用域下的局部变量let x = 3。最后输出的x则是全局变量x,此时已经被g函数中赋值为2。这也是符合我们JavaScript常识的结果
静态作用域特性
嵌套
当一个作用域内包含另一个作用域时,就发生了作用域的嵌套。在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(全局作用域)为止。考虑以下代码:
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar(b * 3);
}
foo( 2 ); // 2 4 12
在这里例子中有三个逐级嵌套的作用域。我们可以将他们想象为几个逐级包含的气泡
- ①包含着整个全局作用域,其中只有一个标识符:
foo - ②包含着
foo创建的作用域,其中有三个标识符:a,b,bar - ③包含着
bar创建的作用域,其中只有一个标识符:c
当引擎执行console.log(a, b, c)时,从最内层的作用域查找a, b, c三个变量,作用域查找会在找到第一个匹配的标识符时停止。最终在③中找到c,在②中找到a, b
参考资料:《你不知道的JavaScript(上卷)》
遮蔽
作用域存在嵌套关系,变量的查找是从内向外查找。如果不同层级的作用域同时声明了相同的变量,比如在上图③中声明b = 1,那么打印结果会是2 1 12
function foo(a) {
var b = a * 2;
function bar(c) {
const b = 1;
console.log( a, b, c );
}
bar(b * 3);
}
foo( 2 ); // 2 ` 12
作用域查找从运行时所处的最内部作用域③开始,逐级向外或者说向上进行,直到遇见第一个标识符b(在③作用域中)为止。在多层的嵌套作用域中可以定义同名的标识符,这被称作遮蔽效应,内部的标识符遮蔽了外部的标识符
JavaScript中全局变量会自动成为全局对象(比如浏览器中的 window 对象)的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问。比如window.a,通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量如果被遮蔽了,无论如何都无法被访问到
var a = 0;
function test(){
var a = 1;
console.log(window.a); //0
}
test();
词法环境
词法环境(LexicalEnvironment)是一种ES规范对象。静态作用域也被叫做词法作用域,它的作用域范围是在编译分词阶段确定的,所以和代码的词法相关。词法环境可以看作是作用域这一机制在ES语言规范层面的实现,其作用就是用来解析当前上下文绑定的标识符绑定。从数据结构上来看,它是一种Environment Records类型,可以将其简单的看作是一种Map类型,用来定义标识符和声明变量与函数之间关系的特定类型
参考资料:tc39.es/ecma262/#ta…
认识词法环境
词法环境是具体的环境记录类型Environment Record的实例,Environment Record是基于词法嵌套结构,维护变量函数标识符绑定关系的抽象类,其存在不同的子类实现:
- Declarative Environment Record:用于定义函数声明,变量声明,
catch块等标识符绑定- Function Environment Record:声明式环境记录的一种,和函数调用保持一致,代表了函数内的顶级作用域,包含了
this绑定记录和super函数调用的处理 - Module Environment Record:声明式环境记录的一种,代表模块内顶层作用域,包含了模块内顶层声明的标识符和
import绑定
- Function Environment Record:声明式环境记录的一种,和函数调用保持一致,代表了函数内的顶级作用域,包含了
- Object Environment Record:创建于
with表达式,用于维护对象属性的绑定关系 - Global Environment Record:
Script脚本顶层作用域,作为最外层作用域,没有更外层作用域,[[OuterEnv]]为null。其包含了全局对象和其属性的绑定关联
Environment Record包含了抽象方法来操作标识符绑定关系,比如CreateMutableBinding, CreateImmutableBinding, InitializeBinding, HasBinding等
变量是如何解析的
变量的标识符绑定记录在词法环境中,那么变量是如何从词法环境中解析到的呢?我们从最基本的变量引用计算语法出发。变量引用的计算其实是执行ResolveBinding这一抽象操作。ResolveBinding内会拿到当前执行上下文的词法环境对象(词法环境绑定在执行上下文上,当前的词法环境就是当前执行上下文的词法环境,执行上下文部分会讲解),然后执行GetIdentifierReference操作。在GetIdentifierReference操作中,会通过env.HasBinding(name)操作判断当前词法环境是否存在标识符名称name,如果存在则返回引用记录Reference Record对象,否则将env设为env.[[OuterEnv]]也就是外层词法环境对象,重复执行GetIdentifierReference操作(每个词法环境对象都会有[[OuterEnv]]指向外层词法环境或null)
词法环境可以理解为作用域在JavaScript语言规范中的实现,词法环境通过[[OuterEnv]]形成链式关系,并在查找时沿着这条链查找标识符,这也就实现了作用域的嵌套和遮蔽,形成了作用域链
参考以下代码:
const a = 1;
function foo() {
var b = 2;
function bar() {
console.log(a, b);
}
}
以上代码形成了三层作用域,同时也是三层词法环境,最内层bar函数的词法环境①通过[[OuterEnv]]链到外层foo的词法环境②,词法环境②通过[[OuterEnv]]链到外层最顶层词法环境③。当查找标识符b时会先拿到当前执行上下文(bar函数创建的执行上下文)的词法环境③env=bar,判断env.HasBinding(b),③中不存在标识符b的绑定,再拿到外层词法环境②env=env.[[OuterEnv]],判断env.HasBinding(b),此时②中存在标识符b,则返回标识符b对应的Reference Record对象
词法环境是怎么创建的
要了解作用域范围和嵌套关系就要知道词法环境是在什么时间创建的,[[OuterEnv]]又是如何绑定的。我们以最常用到的函数词法环境为例
函数的创建主要有两种方式InstantiateOrdinaryFunctionObject和InstantiateOrdinaryFunctionExpression,两种函数创建的方式都会通过OrdinaryFunctionCreate实现,调用OrdinaryFunctionCreate时会将当前执行上下文的词法环境作为env参数传入,函数内部的实现中会把env保存在函数的[[Environment]]属性上。这样也就记录到了函数创建时所处的词法环境
接下来是函数的调用,函数调用通过[[Call]]来实现。函数的执行主要有PrepareForOrdinaryCall准备阶段和OrdinaryCallEvaluateBody执行计算阶段。在准备阶段主要有两个操作,一是创建新的执行上下文推入执行上下文栈,并把新的执行上下文作为当前运行的执行上下文,二是创建新的函数环境记录NewFunctionEnvironment并作为新的执行上下文的词法环境。在NewFunctionEnvironment操作中,创建新的词法变量的同时会将新的词法变量的[[OuterEnv]]设为F.[[Environment]]也就是上述绑定到函数对象的函数创建时所处的词法环境。这样就在不同层级的词法环境之间创建了联系
仍以上文的代码为例:
当执行全局代码时会创建foo函数实例,创建过程中会把当前执行上下文的词法环境(全局词法环境)绑定到foo函数实例的[[Environment]]属性上,执行foo函数时创建新的foo函数的执行上下文以及新的词法环境,并将foo函数实例的[[Environment]]属性(全局顶层词法环境)作为新的词法环境的[[OuterEnv]],这样就把词法环境③和词法环境②链接起来。同样道理在创建bar函数实例时会把词法环境②绑定在bar实例的[[Environment]]属性,在执行bar时创建新的执行上下文和词法环境①,并把②作为①的[[OuterEnv]],最后形成一条嵌套的词法环境链
变量是如何绑定的
函数执行在准备阶段创建了新的执行上下文和词法环境。在第二个阶段执行计算阶段OrdinaryCallEvaluateBody就使用到了新的词法环境
执行计算OrdinaryCallEvaluateBody会先通过FunctionDeclarationInstantiation初始化,然后才会执行函数体内的表达式。我们先看一下FunctionDeclarationInstantiation操作的解释:
当执行函数体计算并创建新的执行上下文时,会创建新的函数环境记录来初始化函数的形参。函数体内的声明也会被实例化(创建标识符绑定)。如果函数形参不包含默认初始值设定,那么函数体的声明会和参数绑定在同一环境记录,否则会创建另一个新的环境记录来绑定函数声明。形参和函数的标识符会在FunctionDeclarationInstantiation操作中初始化,其他标识符绑定会在执行函数体内表达式时初始化
标识符绑定分为创建CreateMutableBinding/CreateImmutableBinding和初始化InitializeBinding两步,在FunctionDeclarationInstantiation操作(函数体表达式执行前)中会创建全部标识符绑定,并且会初始化参数和函数(另外包括var变量声明,这里其实就是变量提升和函数提升的语法规范)
以上解析了函数执行时对变量标识符的绑定操作,其他如全局代码,模块代码的标识符绑定类似,可参考具体规范实现。函数体执行前绑定标识符,函数体执行时根据赋值语句再通过SetMutableBinding对标识符进行赋值
通过以上解读,一个变量的完整读写过程就已经十分清楚了:执行代码前创建词法环境并创建标识符绑定(涉及变量提升,函数提升),执行代码时通过ResolveBinding操作从词法环境上解析变量获取变量值(涉及作用域,作用域链),或者通过Environment Record的抽象操作对标识符绑定进行赋值删除等。函数执行完后其实会有执行上下文出栈的操作,同时由于词法环境绑定在执行上下文上,出栈后当前词法环境自然也就变更为外层词法环境
词法环境分类
上文讲述了Environment Record有不同的子类实现,这里要讨论的词法环境分类并不是Environment Record的实现分类。而是讨论哪些代码表达式会创建词法环境,比如最开始提到的常见的全局作用域,函数作用域,模块作用域,块作用域就对应四种词法环境。完整的词法环境分类如下:
- 全局词法环境:在全局代码创建,创建全局执行上下文后就会创建全局词法环境
- 函数词法环境:函数执行时创建,参考上文解析,形参标识符在函数词法环境内绑定,所以不会和外部词法环境变量冲突,并可以在内部访问
- 模块词法环境:参考模块语法规范,模块词法环境以全局词法环境为
[[OuterEnv]] - 块词法环境:参考Block语法规范,计算块表达式时会创建块词法环境
- for循环词法环境:参考For循环语法规范,
for循环会创建新的词法环境,并且for循环括号中的词法声明会绑定在新的词法环境,并不会和外部标识符冲突(回答了最开始的问题3)。另外for循环体每次迭代也会创建新的词法环境并将迭代标识符当前值绑定在新的词法环境中 - for-in/for-of循环词法环境:参考语法规范,
for-in/for-of循环表达式也会在执行前创建新的词法环境并绑定迭代参数 - switch词法环境:参考语法规范,不过这个词法环境并不会绑定额外标识符。根据语法,
switch后是CaseBlock,所以将switch词法环境简单视为块词法环境也是可以的 - catch词法环境:参考Catch语法规范,
catch表达式会先创建新的词法环境并绑定参数到新的词法环境,不会和外部词法环境冲突(回答了问题4) - 类词法环境:参考Class语法规范,类词法环境会额外绑定类名标识符
- eval词法环境:参考Eval语法规范
- with词法环境:参考With语法规范
with表达式会创建一个新的Object Environment Record(相当于根据绑定对象凭空创建一个词法环境),在解析标识符时会在绑定对象上查找
所以其实严格从规范来讲,JavaScript的作用域范围并不支持上文提到的四种:全局作用域,函数作用域,模块作用域,块作用域。这里也解释了为什么for循环括号中的变量声明不会和外层的变量声明冲突(因为其作用域范围在for循环内),不过for循环,catch语句,switch等语句后边一般都会跟上大括号,所以为了方便记忆,简单理解记忆为块作用域也没有太大问题
let/const和var的区别
JavaScript变量声明分为var和let/const两种。从语法规范上来讲,let/const属于词法声明,var属于变量表达式。两者的区别在于:
var存在变量提升,let/const不存在变量提升,但是存在暂时死区var只在全局作用域,函数作用域,模块作用域生效,不支持块级作用域,而let/const支持块级作用域var可以重复声明更新,let/const不能重复声明,const不能更新var在全局作用域声明的属性会绑定在全局对象上,let/const不会
上面我们了解到词法环境是对作用域机制的实现,变量都绑定在词法环境对象上。那么let/const和var的区别在词法环境规范上又是如何体现的呢?
变量提升vs暂时死区
前面关于函数执行部分其实已经提到变量提升的问题,变量提升其实就是在函数体执行前创建var生命的变量标识符并进行初始化。这里仍以函数作用域的变量提升为例:
在27, 28中我们可以看到会遍历varNames然后通过CreateMutableBinding创建绑定,之后马上就通过InitializeBinding进行初始化默认值。27, 28不同之处在于有参数且参数和函数内var变量标识符相同时会取参数值作为初始值,否则则为undefined。具体效果可以对比以下两段代码:
function fn(a) {
console.log(a); // 3
var a = 2;
console.log(a); // 2
}
fn(3);
function fn() {
console.log(a); // undefined
var a = 2;
console.log(a); // 2
}
fn();
接下来看一下对let/const的处理
let/const作为词法声明记录在lexDeclarations,然后遍历创建绑定关系,如果是const则创建不可变绑定CreateImmutableBinding。之后并没有通过InitializeBinding来初始化变量。所以let/const声明的变量只在词法环境中创建了标识符绑定,并没有初始化值。那么访问时报错的暂时死区是如何形成的呢?
让我们回到变量引用的过程中。上文讲述的变量解析过程并不会判断标识符是否已初始化,但是引用变量时还会通过GetValue操作获取解析到的标识符Reference Record的值。比如我们看一下赋值操作的执行过程:
在步骤3, 4计算右侧表达式取得Reference Record或者一个具体的值,比如a = b这种赋值语句,3步骤的计算结果就是标识符b对应的Reference Record对象(注意解析结果的[[Base]]绑定的是对应的词法环境对象,见下图),然后通过GetValue操作b的值,GetValue内部实现上是通过词法环境对象的GetBindingValue来获取结果
可以看到最终在词法环境对象上调用GetBindingValue时触发报错ReferenceError。这就是暂时死区触发报错的整个流程。所以其实var的变量提升和let/const的暂时死区本质上就在于是否对标识符绑定进行了初始化,再GetValue获取标识符值时未初始化则会报错
不支持vs支持块作用域
根据我们上面的讲述,每个作用域其实对应的是一个词法环境对象,变量声明记录在词法环境中,那么为什么var声明的变量不会记录在块级词法环境呢?这里仍以上文的函数词法环境的创建流程为例,我们主要对比以下两处:
与词法环境相似,这里有一个变量环境(VariableEnvironment),两个环境记录是同一个对象,都记录在执行上下文中。不同之处在于:var声明的变量记录在变量环境,let/const声明的变量记录在词法环境。块表达式只会创建新的词法环境,并不会创建新的变量环境,所以当获取当前执行上下文的变量环境时,获取到的是在函数顶层创建的环境记录,var声明的变量自然也就记录在函数顶层,不会记录在块词法环境中。参考以下代码的变量环境和词法环境嵌套关系:
const a = 1;
function foo() {
var b = 2;
{
var c = 1;
console.log(a, b);
}
}
其中③是全局创建的词法环境,同时也是全局执行上下文中的变量环境。②是foo函数创建的词法环境,同时也是foo函数中执行上下文的变量环境。①是块语句创建的词法环境,但是不作为变量环境。当foo函数执行前绑定标识符时,var c作为变量表达式是会记录在当前执行上下文(foo函数对应的执行上下文)的变量环境中,所以最终是记录在②中并不是①中。这就是为什么var变量不支持块级作用域
可重复声明vs不可重复声明
参考函数实例化操作规范内容,varNames绑定会进行去重操作
let/const对应的lexDeclarations没有找到相关语法操作内容,但是以下Note明确表示了词法声明的标识符名称不能和函数,形参或者var标识符相同
挂载vs不挂载全局对象属性
参考NewGlobalEnvironment的实现,全局环境记录会通过[[ObjectRecord]]绑定一个对象环境记录作为全局对象。全局代码解析时会通过全局环境记录的CreateGlobalVarBinding操作绑定var声明的变量,该操作会取得全局环境记录的[[ObjectRecord]]并在此对象环境变量上创建标识符绑定
再来看变量的读取操作,以HasBinding操作的实现为例,全局环境变量会先在[[DeclarativeRecord]](用于记录词法声明变量)环境记录上查找,不存在的话就从[[ObjectRecord]]这一对象环境记录上查找
对象环境记录可以看作对绑定对象的代理(上边with词法环境有提到),从中查找标识符其实就是查找绑定对象的属性。所以全局环境记录其实相当于声明式环境记录和对象环境记录的结合。let/const作为词法声明记录在全局环境记录的[[DeclarativeRecord]]上,var声明的变量通过CreateGlobalVarBinding操作在全局环境记录的[[ObjectRecord]]上。同时[[ObjectRecord]]作为对象环境变量,操作的其实是绑定对象(这里就是全局对象)的属性。这样在读写var变量的同时就将变量同时挂载在全局对象属性上
执行上下文
上边讲述词法环境时多次提到过执行上下文(Execution Contexts),词法环境和变量环境都记录在执行上下文中,另外还有私有环境(PrivateEnvironment)私有环境用于记录类中定义的私有名称
执行上下文的创建
执行上下文主要分为四种:
- 全局执行上下文:执行全局代码时,会编译全局代码并创建全局执行上下文,全局执行上下文只会创建一个,并且永远处于执行上下文栈的栈底
- 函数执行上下文:调用一个函数时,会创建函数执行上下文
- 模块执行上下文:执行模块代码时会初始化模块运行环境,其中包括创建模块执行上下文
- eval执行上下文:执行eval函数时创建
eval执行上下文
变量环境和词法环境都保存在执行上下文中,在创建执行上下文的同时会创建新的环境记录作为词法环境和变量环境。下图中可以看到每个执行上下文创建后都会Set the VariableEnvironment/LexicalEnvironment of context
调用栈
执行上下文以栈的形式记录,形成了函数调用栈。参考以下代码的执行过程
var c = 1;
let a = 2;
// step 1
function bar() {
console.log(d); // undefined
const e = 2;
// step 2
if (e) {
var d = 1;
const f = 2;
// step 3
}
// step 4
console.log(e); // 2
}
bar();
// step 5
- 程序首先初始化全局环境,创建全局执行上下文推入调用栈,创建词法环境和变量环境。创建标识符绑定
c,a,bar,并且初始化c为undefined,绑定bar函数值 - 程序执行到step 1,此时变量
c,a完成赋值,参考下图①
- 程序调用函数
bar(),创建新的函数执行上下文推入调用栈,创建新的词法环境(以全局执行上下文的词法环境作为[[OuterEnv]])和变量环境。创建标识符绑定d,e并且初始化e的值为undefined - 程序执行到step 2,变量
e完成赋值,参考下图②
- 程序进入
if语句,创建新的块级词法环境(以当前词法环境作为新的词法环境的[[OuterEnv]]),但不会创建执行上下文,不改变变量环境 - 程序执行到step 3,变量
d,f完成赋值,参考下图③
if块执行完毕,程序执行到step 4,块中的词法环境从执行上下文中移除,原有的词法环境(块词法环境的[[OuterEnv]]重新绑定到执行上下文中),参考下图④
- 函数
bar执行完毕,程序执行到step 5,块中的bar函数的执行上下文退出栈,全局执行上下文作为新的当前执行上下文,参考下图⑤
总结
执行上下文以栈的形式存储从而形成了调用栈。执行上下文记录了变量环境和词法环境,变量环境和词法环境主要用于区分var和let/const声明的变量,参考上边函数执行过程图,体现了var和let/const在作用域上表现的差异(块级作用域)。每个执行上下文活动期间只创建一个变量环境,但是可能创建多个词法环境,词法环境之间通过[[OuterEnv]]绑定形成作用域链
闭包
闭包是指能够记住并有权访问外部作用域中的变量的函数,也就是能够访问外层作用域的函数就是闭包。JavaScript中任何函数都能够访问全局作用域,所以其实JavaScript中每个函数都是一个闭包。在上文词法环境部分已经讲述了函数记住外部作用域的实现(通过绑定到[[Environment]]),在函数执行时会将其作为函数作用域的[[OuterEnv]],这样也就能够访问其作用域的变量。所以闭包其实是词法作用域规则和函数可以作为返回值或参数在其他位置调用两种特性结合产生的
闭包的原理
我们同样通过执行上下文和词法环境图的形式来分析闭包的原理
// step 1
function gen() {
const name = "df";
// step 2
function exec(act) {
// step 5
console.log(`${act} ${name}`);
}
return exec;
}
function call(exec) {
const act = "hello";
// step 4
exec(act);
}
const res = gen();
// step 3
call(res);
- 程序执行全局代码从
step 1开始,创建全局执行上下文,绑定变量环境和词法环境并初始化函数值,此时已经创建gen和call函数并绑定[[Environment]]为全局执行上下文的词法环境Lex Env(A)
- 程序调用
gen函数,创建新的函数执行上下文压入调用栈,创建新的变量环境和词法环境并建立[[OuterEnv]]绑定关系,初始化gen函数内部的函数声明,exec函数的[[Environment]]绑定为新的执行上下文中的词法环境Lex Env(B)
gen函数执行完毕,其执行上下文移出调用栈,exec函数作为返回结果返回并保存在全局执行上下文的词法环境中作为变量res的值。但此时仍然保留了exec.[[Environment]]到LexEnv(B)的引用关系
- 程序调用
call函数并将exec作为参数传入,此时创建新的执行上下文和词法环境,变量环境。新的词法环境LexEnv(C)的[[OuterEnv]]绑定为LexEnv(A)
call函数中将act变量作为参数传入并执行exec函数。此时exec创建新的执行上下文,绑定act到变量环境初始值为参数值hello,新的词法环境LexEnv(D)的[[OuterEnv]]为exec.[[Environment]]也就是LexEnv(B),此时建立了LexEnv(D)->LexEnv(B)->LexEnv(A)的作用域链(同调用栈顺序不同)
- 最终程序执行输出语句,从当前执行上下文的变量环境解析到参数
act,从词法环境链(LexEnv(D)->LexEnv(B))中解析到变量name,输出结果hello df
以上就是闭包的调用栈和作用域链分析,由于词法作用域规则,作用域链的顺序与调用栈顺序并不是一一对应。闭包的作用域链上仍能够保留已经被移出调用栈的执行上下文的词法环境(上例中的LexEnv(B))的路径,且闭包是唯一可以访问到该词法环境的入口,这样就隐藏了LexEnv(B)中的变量,不能通过其他路径访问,只有通过闭包函数访问他们。所以说闭包可以用来实现私有属性或方法的特性
V8的优化
按照规范的理论,闭包时内层函数保留了外层词法环境的引用。即使这个函数并不执行,外层的词法环境也会以F.[[Environment]]的形式被保留引用,JavaScript并不知道你之后的代码中会不会执行闭包函数,所以理论上要一直保持引用避免被垃圾回收。这样的话会引出新的问题,JavaScript中闭包是极为常见的操作,如果外层的词法环境(包括整个词法环境链)一直被引用,那么会造成极大的空间浪费。比如一大部分词法环境的变量并没有被闭包函数引用到。参考以下代码,最内层函数c作为最终的闭包函数返回,他会保留函数b创建的词法环境和函数c创建的词法环境(函数b的词法环境的[[OuterEnv]]),两个词法环境有四个变量,但是闭包函数c中只引用到其中一个变量
function a() {
const a1 = 1;
const a2 = 2;
return function b() {
const b1 = 1;
const b2 = 2;
return function c() {
debugger;
console.log(a1);
}
}
}
实际上,JavaScript引擎会对这个问题进行优化。V8会通过分析函数的变量引用关系来实现只保留函数引用到的变量,以上代码在V8执行时只会保留函数a的词法环境,且只保留a1变量(具体实现涉及到惰性解析和预解析)
eval是比较特殊的情况,eval中的代码V8不会预解析其中的变量引用,V8也就没有办法对闭包进行优化,所以当内层函数存在eval表达式时,你可以看到作用域链中全部的变量
function a() {
const a1 = 1;
const a2 = 2;
return function b() {
const b1 = 1;
const b2 = 2;
return function c() {
debugger;
eval('console.log(a1)');
}
}
}
参考资料:
总结
本文主要从ES规范的角度来讨论作用域的实现(也就是词法环境),通过变量读写的周期来分析作用域的范围,理清了作用域的嵌套和遮蔽规则,作用域链是如何形成的,明确了JavaScript中具体有哪些作用域范围。通过ES规范的角度分析了var, let/const的区别,也涉及到了变量提升。从作用域出发,牵出了执行上下文和调用栈的概念,认识到词法环境和执行上下文的关系,作用域链和调用栈的区别。最后讨论了JavaScript中重要的闭包概念,经过前边讲述词法环境和执行上下文的铺垫,我们可以从规范的角度很清晰的理解闭包的原理
如果觉得本文对你有帮助,希望点个赞点个关注!