chapter1-what-is-scope

You Don't Know JS: Scope & Closures

Chapter 1: What is Scope?

几乎所有编程语言最基本的范例之一是能够在变量中存储值,然后检索或修改这些值。实际上,存储值和从变量中提取值的能力是给出程序 状态 的能力。

没有这样的概念,程序可以执行一些任务,但它们将非常有限并且不是非常有趣。

但是将变量包含在我们的程序中会产生我们现在要解决的最有趣的问题:这些变量在哪里 存在 ?换句话说,他们存放在哪里?而且,最重要的是,我们的程序在需要时如何找到它们?

这些问题说明需要一套定义良好的规则来在某些位置存储变量,并在以后查找这些变量。我们称这组规则为:作用域。

但是,这些作用域规则在何处以及如何设定?

Compiler Theory

这可能是不言而喻的, 也可能是令人惊讶的, 这取决于你与各种语言的互动水平, 但尽管 javascript一般属于 "动态" 或 "解释" 语言的类别, 但它实际上是一种编译语言。它 没有 像许多传统编译语言那样提前很好地编译,编译的结果也不能在各种分布式系统之间移植。

但是,尽管如此,JavaScript引擎仍然执行许多与传统的语言编译器相同的步骤,尽管这些步骤比我们通常所知的任何传统语言编译器都要复杂。

在传统的编译语言过程中,一大块源代码(程序)在执行之前通常会经历三个步骤,大致称为“编译”:

  1. 分词/词法分析 将一串字符分解为有意义的(对于语言)块,称为令牌(token,也可看做标记)。例如,考虑程序:var a = 2;。该程序可能会被分解为以下标记:vara=2;。空格可能会也可能不会作为令牌持久存在,具体取决于它是否有意义。

    注意 分词(标记化)和词法分析之间的区别是微妙的和学术性的,但它的核心在于这些标记(token)是否以 无状态有状态 的方式被识别。简而言之,如果分词器要调用有状态解析规则来确定a是一个不同的令牌(token, 标记)还是另一个令牌(token, 标记)的一部分,那就是 词法分析

  2. 解析 获取令牌流(数组)并将其转换为嵌套元素树,这些元素共同代表程序的语法结构。该树称为“AST”(Abstract Syntax Tree -- 抽象语法树)。

    对于var a = 2;这个🌲,可能开始从顶层有个节点叫做VariableDeclaration,其中一个名为Identifier的子节点(其值为a),另一个名为AssignmentExpression的子项本身有一个名为NumericLiteral的子项(其值为2)。

  3. 代码生成 获取AST并将其转换为可执行代码的过程。这部分根据语言,目标平台等情况因而有很大差异。

    因此,我们不必陷入细节的泥潭,我们只需挥手,说有一种方法可以将上面描述的AST用于var a=2;并将其转换为一组机器指令,以实际创建一个名为a的变量(包括保留内存等),然后将值存储到a中。

    注意: 引擎管理系统资源的细节比我们所要挖掘的更深,所以我们只是理所应当地认为引擎能够根据需要创建和存储变量。

与大多数其他语言编译器一样,JavaScript引擎比这三个步骤复杂得多。例如,在解析和代码生成过程中,肯定有优化执行性能的步骤,包括压缩冗余元素等。

所以,我只是用粗线条来勾勒。但是我认为你很快就会明白为什么我们所涉及的这些细节,即使是在高层,也是相关的。

首先,JavaScript引擎没有像其他语言编译器那样有足够的时间进行优化,因为JavaScript编译不会像其他语言一样提前构建步骤。

对于JavaScript,在许多情况下,在执行代码之前发生的编译仅发生微秒(或更少!)。为了确保最快的性能,JS引擎使用各种技巧(如JIT,懒惰编译甚至热重新编译等),这些都超出了我们讨论的“作用域”。

为了简单起见, 我们只想说, javascript 的任何片段都必须在执行之前 (通常是之前!) 进行编译。因此,JS编译器将获取程序var a=2;并首先编译它,然后准备执行它,通常是立即。

Understanding Scope

我们学习作用域的方法是从对话的角度来思考这个过程。但是,谁在说话呢?

The Cast

让我们来看看处理var a = 2;程序的交互角色,这样我们就能理解他们的对话,稍后我们将听到他们的对话:

  1. 引擎:负责从头到尾编译和执行我们的JavaScript程序。

  2. 编译器:引擎的朋友之一;处理解析和代码生成的所有脏工作(参见上一节)。

  3. 作用域:引擎的另一位朋友;收集并维护所有声明的标识符(变量)的查找列表,并对当前执行的代码如何访问这些规则强制执行一套严格的规则。

为了让你 充分了解 JavaScript的工作原理,你需要开始像引擎(和朋友)一样思考,询问他们提出的问题,并回答相同的问题。

Back & Forth

当你看到程序var a = 2;时,你很可能将其视为一个语句。但这不是我们的新朋友引擎看到它的方式。实际上,引擎会看到两个不同的语句,一个是编译器在编译期间处理的语句,另一个是引擎在执行期间将处理的语句。

那么,让我们来详细说明引擎和他的朋友将如何处理程序var a=2;

编译器将对此程序执行的第一件事是执行词法分析将其分解为令牌,然后将其解析为树。但是当编译器进入代码生成时,它会对这个程序的处理方式有所不同。

一个合理的假设是,编译器将产生的代码,可以总结为以下伪代码: "为变量分配内存,将其标记为a,然后将值2插入该变量"。不幸的是,这不太准确。

编译器 将改为:

  1. 遇到var a编译器作用域 查看该特定作用域集合中是否已存在变量a。如果是这样,编译器会忽略此声明并继续前进。否则,编译器要求作用域为该作用域集合声明一个名为a的新变量。

  2. 编译器然后为引擎生成代码以便稍后执行,以处理a = 2赋值。运行的代码引擎将首先询问作用域是否存在当前作用域集合中名为a的可访问的变量。如果有,引擎是有这个变量。如果没有,引擎会查找其他位置(请参阅下面的嵌套Scope部分)。

如果引擎最终找到一个变量,它会为其赋值2。如果没有,引擎将举手并大喊错误!

总结一下:对变量赋值采取两种不同的操作:首先,编译器声明一个变量(如果以前没有在当前作用域中声明),其次,在执行时,引擎在作用域中查找该变量,如果找到,则将其赋值。

Compiler Speak

我们需要更多的编译器术语来进一步理解。

当引擎执行编译器为步骤(2)生成的代码时,它必须查找变量a以查看是否已声明,而这个查找是查询作用域。但是查找引擎执行的类型会影响查找的结果。

在我们的例子中,引擎将对变量a执行“LHS”查找。另一种查找称为“RHS”。

我打赌你可以猜出“L”和“R”是什么意思。这些术语代表"Left-hand Side(左手边)"和"Right-hand Side(右手边)"。

什么的边?赋值操作符

换句话说,当变量出现在赋值操作的左侧时,将执行LHS查找;当变量出现在赋值操作的右侧时,将执行RHS查找。

实际上,让我们更精确一点。对于我们的目的,RHS查找与简单地查找某个变量的值是不可区分的,而LHS查找是试图找到变量容器本身,以便它可以赋值。通过这种方式,RHS本身并不意味着“赋值的右侧”,更准确地说,它意味着“不是左侧”。

稍微有点油嘴滑舌,你也可以认为“RHS”的意思是“检索他/她的源(值)”,即表示“获取…的值”。

让我们深入研究。

当我说:

console.log( a );

a的引用是RHS引用,因为没有为a分配任何内容。相反,我们正在查找a的值,以便可以将值传递给console.log(..)

相比之下:

a = 2

a在这里的引用是LHS引用,因为我们实际上并不关心当前值是什么,我们只想找到变量作为= 2赋值操作的目标。

注意: LHS和RHS的意思是“赋值的左/右端”,但并不一定是“=赋值运算符的左/右端”。分配的发生还有其他几种方式,因此最好在概念上将其视为:“谁是赋值的目标(LHS)”和“谁是赋值的来源(RHS)”。

考虑这个程序,它有LHS和RHS引用:

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

foo( 2 );

最后一行调用foo(..)作为函数调用,需要对foo的RHS引用,意思是“查找foo的值,并将其给我”。而且,(..)意味着foo的值应该被执行,所以它最好是一个函数!

这里有一个微妙但重要的赋值。你发现了吗?

你可能在此代码段中错过了隐含的a = 2。当值2作为参数传递给foo(..)函数时会发生这种情况,在这种情况下,将2 给参数a。为(隐式)赋值给参数a,执行LHS查找。

还有一个关于a值的RHS引用,并将结果值传递给console.log(..)console.log(..)需要一个引用去执行。它是console对象的RHS查找,然后出现属性解析,以查看它是否有一个名为log的方法。

最后,我们可以将传递值2(通过变量a的RHS查找)到log(..)中的LHS/RHS交换概念化。在log(..)的原生实现中,我们可以假设它有参数,其中第一个参数(可能称为arg1)在为其赋值2之前有一个LHS引用查找。

注意: 你可能想要概念化函数声明function foo(a){…作为一个普通的变量声明和赋值,如var foofoo = function(a){…。在这样做的时候,很容易把这个函数声明看作是一个LHS查找。

然而,微妙但重要的区别是,编译器在代码生成期间同时处理声明和值定义,这样,当引擎执行代码时,就不需要处理将函数值“赋值”给foo。因此,将函数声明视为我们在此讨论它们的方式中的LHS查找赋值并不合适。

Engine/Scope Conversation

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

foo( 2 );

让我们把上面的交换(它处理这个代码片段)想象成一个对话。谈话会有点像这样:

引擎:嘿,作用域,我有一个对foo的RHS引用。听说过吗?

作用域:是的,我有。编译器 刚刚声明了它。他是一个函数。给你。

引擎:很棒,谢谢!好的,我正在执行foo

引擎:嘿,作用域,我对于a有一个LHS引用,有没有听说过它?

作用域:是的,我有。最近 编译器 将它声明为foo的一个正式参数。给你。

引擎:总是那么棒,作用域。再次感谢。现在,把2分配给a

引擎:嘿,作用域,很抱歉再次打扰你。我需要一个RHS查找console。听说过吗?

作用域:没问题,引擎,这就是我整天都在做的事情。是的,我有console。他是内置的。给你。

引擎:完美。查找log(..)。好的,很棒,这是一个函数。

引擎:呦,作用域。你能帮我查一下关于a的RHS引用吗?我想我记得,只是想再核实一下。

作用域: 你是对的,引擎。同样的家伙,没有改变。给你。

引擎:酷!传递一个a值,他就是2,给log(..)

...

Quiz

检查你到目前为止的理解。确保扮演引擎的角色,并与范围进行“对话”:

function foo(a) {
    var b = a;
    return a + b;
}

var c = foo( 2 );
  1. 识别所有LHS查找(有3个!)。

  2. 识别所有RHS查找(有4个!)。

注意: 关于测验的答案,请查看本章的review!

Nested Scope

我们说作用域是一组通过标识符名称查找变量的规则。但是,通常需要考虑多个作用域。

就像块或函数嵌套在另一个块或函数中一样,作用域嵌套在其他作用域内。因此,如果无法直接在作用域中找到变量,引擎将查询下一个外部包含作用域的变量,一直持续到找到该变量,或者一直到到达最外层(即全局)作用域为止。

考虑下面代码:

function foo(a) {
    console.log( a + b );
}

var b = 2;

foo( 2 ); // 4

b的RHS引用不能在函数foo中解析,但可以在围绕它的作用域中解析(在本例中为全局)。

因此,重新审视引擎和范围之间的对话,我们无意中听到:

引擎:“嘿,foo的作用域,听说过b吗?得到了它的一个RHS引用。”

作用域: “不,从来没有听说过。试试其他的吧。”

引擎: “嘿,foo的作用域外,哦,你是全局作用域,好酷。有没有听说过b?得到了一个他的RHS引用。”

作用域:“是的,当然有。给你。”

遍历嵌套作用域的简单规则: 擎从当前正在执行的作用域开始,在那里查找变量,如果没有找到,则继续上升一个级别,依此类推。如果达到最外面的全局作用域,搜索将停止,无论它是否找到变量。

Building on Metaphors

为了可视化嵌套作用域解析的过程,我希望你考虑一下这个高层建筑。

建筑代表我们程序的嵌套作用域规则集。建筑的第一层表示你当前的执行作用域,无论你在哪里。建筑的顶层是全局作用域。

通过查看当前楼层来解析LHS和RHS引用,如果找不到,就乘电梯到上一层,查看那里,然后再查看上一层,以此类推。一旦你到达顶层(全局作用域),你要么找到你要找的东西,要么找不到。但你不得不停下来。

Errors

为什么我们叫它LHS或RHS很重要?

因为这两种类型的查找在变量尚未声明的情况下表现不同(在任何作用域内都找不到)。

考虑下面代码:

function foo(a) {
    console.log( a + b );
    b = a;
}

foo( 2 );

当第一次发生b的RHS查找时,将无法找到它。就是说这是一个“未声明”的变量,因为它没有在作用域内找到。

如果RHS查找无法在嵌套的作用域中的任何位置找到变量,则会导致引擎引发ReferenceError。请注意,错误属于ReferenceError类型,这一点很重要。

相反, 如果引擎正在执行 LHS 查找, 并且到达顶层 (全局作用域) 而未找到它, 并且如果程序没有在 "严格模式" 中运行, 则全局作用域将 在全局作用域中 创建该名称的新变量, 并将其返回到 引擎

"不,之前没有一个,但我很乐于助人并为你创造了一个。"

在ES5中添加的“严格模式”具有许多与普通/宽松/懒惰模式不同的行为。其中一种这样的行为是它不允许自动/隐式创建全局变量。在这种情况下,没有全局作用域变量可以从LHS查找中返回,而引擎会像RHS情况一样抛出ReferenceError

现在,如果为RHS查找找到一个变量,但你试图对其值执行一些不可能的操作,例如尝试将一个非函数值作为函数执行,或者在一个null值或undefined值上引用一个属性,则引擎会抛出另一种类型的错误,称为TypeError

ReferenceError与作用域解析失败相关,而TypeError则意味着作用域解析成功,但试图对结果执行非法/不可能的操作。

Review (TL;DR)

作用域是一组规则,用于确定查找变量(标识符)的位置和方式。该查找可以用于分配变量,即LHS(左侧)引用,或者它可以用于检索其值,即RHS(右侧) )引用。

LHS引用来自赋值操作。与作用域相关的赋值可以使用=运算符,也可以通过将参数传递给(赋给)函数参数来实现。

JavaScript引擎在执行代码之前首先编译代码,并且这样做会分割语句,如var a = 2;分为两个单独的步骤:

  1. 首先,var a在该作用域中声明它。这是在代码执行之前的开始执行的。

  2. 稍后,a = 2查找变量(LHS引用)并在找到时分配给它。

LHS和RHS引用查询从当前执行的作用域开始,如果需要(也就是说,他们没有找到他们寻找的东西),他们工作的嵌套作用域,一次一个作用域(层级)寻找标识符,直到他们到达全局(顶层)停止,要么找到它,要么没有。

未完成的RHS引用会导致抛出ReferenceError。未实现的LHS引用导致该名称被自动隐式创建在全局(如果不在“严格模式”中)或ReferenceError(如果在“严格模式”中)。

Quiz Answers

function foo(a) {
    var b = a;
    return a + b;
}

var c = foo( 2 );
  1. 识别所有LHS查找(有3个!)。

    c =.., a = 2 (隐式的参数赋值) 和 b = ..

  2. 识别所有RHS查找(有4个!)。

    foo(2.., = a;,a + .... + b

Last updated