JavaScript 作用域

232 阅读9分钟

本文力争做将词法作用域讲的最清晰的文章。看过的兄弟姐妹有疑惑的地方评论区留言,我来改进。

作用域是什么?

几乎所有的编程语言最基本的功能之一就是储存变量的值,以便稍后对该值进行访问和修改。这种储存和访问变量的值的能力将状态带给了程序。

有了状态这个概念,我们就需要考虑变量储存在哪里?我们怎样找到他?

上面这两个问题需要一套设计良好的规则来储存变量,JavaScript里面就引入了词法作用域规则

理解作用域

JS代码在执行之前需要先交给编译器处理,然后才是引擎的执行。

编译器做了什么?

假设编译器遇到的是var num = 100

1、首先编译器会询问当前作用域是否已经有一个名字为num的变量存在

  • 如果存在,编译器忽略之前的声明,继续编译
  • 如果不存在,编译器在当前作用域集合中声明一个新的变量,命名为num

2、接下来编译器会为引擎生成运行时所需代码。这些代码被用来处理num = 100这样的赋值操作。

3、引擎出场,在执行num = 100的时候,引擎会询问当前作用域,是否有一个num变量

引擎和作用域之间的对话

上面说过,引擎会根据规则在作用域中查找变量,这里的规则就是LHS和RHS,意为左查询和右查询。也就是,变量出现在赋值操作的左边时进行LHS左查询,变量出现在赋值操作的右边时进行RHS右查询。

上面对LHS和RHS的描述不是特别准确,更准确一些应该是:RHS查询和简单查找某个变量的值别无二致,而LHS查询是试图找到变量容器本身,以便赋值。

例如:console.log(a)会进行RHS查询,因为这里没有对a进行赋值操作。

但是a = 2这样的操作会进行LHS查询。

考虑下面的代码:

function foo(a) {
    console.log(a) // 2
}
foo(2)

查询过程分析:

  • 最后一行代码foo(2)需要进行RHS查询找到foo函数。
  • 找到foo函数之后,需要进行a = 2的赋值操作。需要对a进行LHS的查询操作。
  • 执行console.log(a)的时候进行的是RHS操作。

经过上面的分析,让我们来看一下引擎和作用域之间是怎样对话的呢?

引擎:作用域大哥,我要对foo进行RHS查询,你见过他吗?

作用域:嗯?奥奥,我见过,编译器刚刚声明过他,是个函数,这给,给你。

引擎:谢谢大哥,太好了。那我要执行foo了。

引擎:对了,大哥。你见过a这个家伙吗?我还要对他进行LHS查询。

作用域:见过见过,这个是foo的一个形参,编译器之前生命过了。这个也给你了。

引擎:大恩不言谢,那我要把2赋值给a。

引擎:哎呦呦,不好意思,哥哥,我还要对console进行RHS操作。你认识他吗?

作用域:没事儿没事儿,你别说,我还真知道,这个是内置对象,也给你了。

引擎:不好意思,大哥,我还要对log进行RHS操作,你看~

作用域:这有什么,他是console内置对象里面的一个函数……

引擎,最后麻烦大哥一次,虽然我之前也知道a,但是现在我还要对a进行RHS操作,把他输出。

作用域:好嘞,a在……

引擎:谢谢作用域大哥帮了我这么多忙,我终于完成任务了。

作用域嵌套

前面讲到过,作用域是根据名称查找变量的规则。实际的情况中,我们需要兼顾几个作用域。

当一个块或者函数嵌套在另一个块或者函数中的时候,就会出现作用域的嵌套。在当前作用域中无法查到某个变量的时候,引擎就会在外层嵌套的作用域中查找该变量,一直抵达作用域的最外层(全局作用域)。

考虑以下代码:

function foo(a) {
    console.log( a+b )
}
var b = 2
foo(2) // 4

在foo的作用域内对b进行RHS查询,不会得到结果,需要查询foo上面的作用域。

这时候引擎和foo之间的对话可能就是:

引擎:foo作用域兄弟,你见过b吗?我需要对它进行RHS操作。

foo作用域:没听过b这个家伙啊,你去我大哥哪里问问吧。

引擎:好的,foo的上级作用域兄弟,你见过b吗?我需要对他进行RHS查询。

foo的上级作用域:兄弟,你来对了,我这里是全局作用域,如果在我这里还找不到你就不可能在我所有的作用域兄弟那里找到了。还好,编译器是在我这里声明的b,给你。

将作用域模拟为一个建筑

图片.png

这个建筑代表程序中嵌套的作用域链。第一层代表当前的执行作用域,顶层是全局作用域。

LHS和RHS都会在当前楼层进行查找,如果没有找到,就会坐电梯前往上一层,以此类推,一直到达顶层,到达顶层之后可能会找到你要的变量也可能找不到,但是查找过程都将会终止。

嗯?你以为完了吗?精彩的部分才刚刚开始

如果读者认真地看过了上面的文章,我相信读者能够对作用域有了一些认识。但是在实现的过程中可能还会遇到问题。

例如:闭包函数:

function f1(){
    var a = 'a';
    function f2(){
        var b = 'b';
        console.log(a)
    }
    return f2;
}
f1()(); // a

来来来,觉得看懂了上面对作用域的描述的,请回答:

  1. 在f1()执行之后,明明f1已经退栈了,为什么可以通过f1()()调用f2()?
  2. 在f1()执行之后,明明f1已经退栈了,为什么可以通过f1()()可以访问到a?

之所以还是有这些疑惑,是因为没有理解作用域的内存实现。接下来让我们一起进入内存的世界,看看作用域是怎样实现的。接下来的内容来自于对ES规范的阅读理解。

JS的作用域又被称为词法作用域(其实大多数语言采用的都是词法作用域规则美丽如Java、C#等),当然所谓的词法作用域就是指我们的作用域访问能力(在当前作用域下能够访问哪些作用域)在编码的时候就已经确定了。

规范中没有对Lexical Scope的实现的描述,取而代之的是对Lexical Environment的具体实现的描述。

Lexical Environments

不同的Lexical Environment(词法环境)和不同的数据结构相关联。在接下来的几类代码执行的时候会创建各自对应的词法环境,比如:函数声明语句、with语句、try-catch语句。

大家可能会对这里的Lexical Environment有疑惑,最直接的疑惑就是他是什么?

下面我尝试用比较通俗的概念描述出来。

Lexical Environment:上文中有说过,作用域的访问能力是根据我们的编码决定的。引擎执行到哪一步会在那一步的作用域中使用LHS或者RHS操作查找变量或属性。引擎在当前作用域中没有找到目标的时候会逐层向上访问父级作用域,读者是否想过这个逐层向上的结构是什么?没错这就是Lexical Environment。

所以,简短地说,词法环境就是编译器根据内置的规则在运行之前已经建立好的作用域链结构。

作用域链是链式结构,在每个浏览器(不同浏览器使用不同的引擎,很可能实现作用域链的过程中使用的数据结构是不同的)中具体怎样实现我们先不去研究,这里假设实现的数据结构是数组。现在进入数组中的每个元素中考究一下。

ES规范中规定数组上的每个元素是一个对象,这个对象有固定的名称——LexicalEnvironment或者是VariableEnvironment。(我们稍后讨论LexicalEnvironment和VariableEnvironment的不同,也会讨论作用域最顶层的对象是GlobalEnvironment)如下图所示:

image.png

我们再来看看每个LexicalEnvironment/VariableEnvironment对象中具体的细节是什么?规范中规定有两部分组成:

  • EnvironmentRecord:是一个引用,指向记录当前作用域下的变量,属性的对象。
  • outer:outer引用,指向本词法环境的外部词法环境。(我们的数组就是靠这个引用将每个元素连接起来的)

伪代码:

LexicalEnvironment/VariableEnvironment: {
    EnvironmentRecord = undefined; // Record the content that needs to be recorded under the current scope, for example: variables, properties and so on.
    outer = undefined; //outer LexicalEnvironment/VariableEnvironment Reference
}

image.png

哈哈,一层套这一层,不过还好还好,不是特别复杂。

接下来我们看看EnvironmentRecord的组成部分:规范中写到可以将EnvironmentRecord看做一个抽象的父类,他有两个具体的子类declarative environment recordsobject environment records

  • declarative environment records指向 ECMAScript 语言句法元素(例如 FunctionDeclarations、VariableDeclarations 和 Catch 子句)。
  • object environment records指向 ECMAScript 元素(例如WithStatement)。

结束结束,他们的数据结构我们就分析到这里~

GlobalEnvironment

全局环境是一个独特的词法环境,它是在执行 ECMAScript 代码之前创建的。全局环境的EnvironmentRecord是一个object environment records,其绑定对象是global object。 全局环境的outer引用为null。在执行 ECMAScript 代码时,可能会向global object添加其他属性,并且可能会修改初始属性。

在浏览器中,GlobalEnvironment和window相关联,window是全局对象。

在nodeJs中,GlobalEnvironment和global相关联,global是全局对象。

LexicalEnvironment和VariableEnvironment的不同

LexicalEnvironment 是一个局部词法作用域,例如,对于 let 定义的变量。如果您在 catch 块中使用 let 定义变量,则它仅在 catch 块中可见,为了在规范中实现这一点,我们使用 LexicalEnvironment。 VariableEnvironment 是诸如 var 定义的变量和函数声明。 vars 可以被认为是“提升”到函数的顶部。为了在规范中实现这一点,我们给函数一个新的 VariableEnvironment,但是块继承了封闭的 VariableEnvironment。

参考文章