以下内容为自己看原版尝试做的翻译,仅当一个自己的看书记录,书中内容绝大部分都翻译了,但由于个人能力有限,建议各位看客不要迷信翻译的质量,推荐购买其英文原版学习观看。
几乎所有的程序语言的一个最基本的范式(能力)就是在变量中保存值的能力以及随后检索和修改这些值。实际上,存取变量的能力赋予了程序状态(state)。
如果没有这么一个概念,一个程序虽然可以执行一些任务,但是他们将非常受限,并且一点都不有趣。
这些变量住(live)在哪?换句话说,他们被存在哪?并且,最重要的是,我们的程序是如何在需要的时候找到他们?
这些问题提出了一系列明确的存取变量的规则,我们把这些规则称为作用域(scope)
编译理论(Complier Theory)
尽管javascript属于一般类别的“动态”或“解释”语言,它实际上是一个编译型语言。它不和许多传统编译语言一样先编译好。但是javascript引擎也执行和许多传统编译器一样的步骤。
在传统的编译语言处理中,你程序中的一段源代码一般而言将会在其执行前经历三个步骤,可将其大致称为编译:
-
Tokenizing/Lexing(词法分析)
将一段字符串分割成有意义的(语言),称为符号(token)。比如:
对于var a = 2;
程序将可能将其分解成以下符号:var
,a
,=
和;
空格是否作为一个符号,取决于其是否有意义。
The difference between tokenizing and lexing is subtle and
academic, but it centers on whether or not these tokens
are identified in a stateless or stateful way. Put simply, if
the tokenizer were to invoke stateful parsing rules to figure
out whether a should be considered a distinct token
or just part of another token, that would be lexing.
-
Parsing(语法分析)
接受符号流并将其转变成一个代表程序语法结构的嵌套元素树。这个树被称为"AST"(抽象语法树)。
由var a = 2;
生成的树的顶级节点可能叫做VariableDeclaration,他有一个孩子节点叫做Identifier (其值为a),以及另一个孩子叫做AssignmentExpression,他也有一个孩子节点叫做NumericLiteral (其值为2) -
Code-Generation
此阶段接收并处理一个AST并将其转成可执行的代码。不同语言,平台等,在此阶段有很大的不同。
此阶段我们将把上一个阶段的AST转变成一组机器指令来真正的创建(create)一个名为a的变量(也包含内存申请等),并且将一个值保存到里面。
The details of how the engine manages system resources
are deeper than we will dig, so we’ll just take it for granted
that the engine is able to create and store variables as
needed.
和其他大多数语言的编译器一样,JavaScript引擎的处理实际上比上面三个步骤复杂得多。比如,在处理parsing和code-generation的时候,还会有一些步骤来优化执行的性能,包括折叠冗余的元素等。
和其他语言不一样,JavaScript引擎相较于其他语言的编译器并没有富裕的时间来进行代码优化,因为它的编译不是在build阶段就提前完成的。
对于JavaScript而言,大多数情况下,编译发生在代码执行前的几微秒(甚至更少)。为了确保最快的性能,JS引擎使用了各种各样的技巧(like JITs, which lazy compile and even hot recompile, etc.)
为了简单起见,我们只能说,任何JavaScript的代码片段必须在其被执行之前(立即)进行编译。因此,JS编译器将会对var a = 2;
首先进行编译,然后立即准备好执行它,通常是立即执行。
理解作用域
The way we will approach learning about scope is to think of the process
in terms of a conversation. But, who is having the conversation?
我们学习作用域的方法就是根据一个会话来思考其处理的过程。但是,谁拥有这个会话呢?
The Cast
接下来脑补一些角色,他们有助于我们在后面理解会话。
-
引擎:
负责开始和完成编译以及执行我们的JavaScript程序。
-
编译器
一个引擎的朋友,处理所有的关于parsing和code-generation的脏活累活
-
作用域
引擎兄的另一个朋友,收集和维护一个所有声明过的标识符(变量)的查询列表,并且严格执行一组规则来判断当前执行的代码是否能够访问他们。
Back and Forth
当你看到var a = 2;
时,你非常有可能认为那是一条语句。但是我们的新伙计引擎兄不这么看。实际上,引擎看到两个截然不同的语句,其中一个编译器将会在编译时处理,另一个引擎将会在代码执行时处理。
因此,我们来看看,引擎和他的小伙伴们将会怎么处理这段程序:var a = 2;
编译器将会做的第一件事就是执行lexing将其分解成符号,这些符号接下来会被解析成一棵树。但是当编译器进行到code generation的时候,他看待这个程序将和设想的有点不同。
一个合理的假设可能是编译器将会以下面的伪代码描述的方式生产代码:为变量分配内存,并将其标记为a,然后将2写入到前面的变量中。不幸的是,上面描述的不太准确。
编译器实际上将会按下面的步骤处理:
- 当遇到
var a
时,编译器兄问作用域兄是否已经有一个a在这个特定的作用域范围里面存在。如果存在,编译器兄将会忽略这个声明,并继续往前。否则,编译器要求作用域兄在当前作用域范围里面声明一个新的叫做a的变量。 - 编译器接下来为引擎兄生成其随后要执行的代码,来处理
a = 2
的赋值。引擎执行a = 2
时,首先会询问作用域兄在当前作用域范围里面是否存在一个可以访问的变量 a 。如果有,引擎就会使用那个变量。如果没有,引擎就会在其他地方寻找。
如果引擎最终找到一个变量,他将会把值 2
赋给它。否则,引擎会大叫出了一个错误!
总结来说:在进行赋值时,会产生两个截然不同的动作:首先,编译器在当前作用域范围里声明一个变量(如果先前没有声明),然后,当执行的时候,引擎在作用域中如果找到了这个变量,就给他赋值。
编译器有话说
为了进一步理解,我们需要了解更多一些的编译器术语。
当引擎执行编译器为第二步生成的代码的时候,它必须查找变量a,来看其是否已经被声明过,这个查找需要咨询作用域。但引擎执行的查找类型影响查找的结果。
在我们的例子中,据说引擎在查找变量a的时候,会执行一个LHS(lefthand side)查找。另一个查找的类型叫做RHS(righthand side)。
Side…of what? Of an assignment operation
换句话说,一个LHS查找会在一个变量出现在赋值运算符的左边时完成,一个RHS查找会在一个变量出现在赋值运算符的右边时完成。
实际上,更精确来说。一个RHS查找是不易察觉的,更精确的说,RHS意思是"not lefthand side".
你可以认为RHS并不意味着“retrieve his/her source(value)”,它意味着"go get the value of..."
让我们更深入一点。
当我说:
console.log( a );
对 a
的引用就是一个RHS引用,因为这里没有什么赋值给 a
。相反的,我们在检索 a
的值,以便这个值可以传递给 console.log(...)
。
相反的:
a = 2;
这里对 a
的引用就是一个LHS引用,因为我们实际上并不关心它当前的值是多少,我们只是想找到 a
这个变量,并将其作为 = 2
赋值操作的目标。
LHS and RHS meaning “left/righthand side of an assigment”
doesn’t necessarily literally mean “left/right side of the = assignment
operator.” There are several other ways that assignments
happen, and so it’s better to conceptually think about it
as: “Who’s the target of the assignment (LHS)?” and “Who’s the
source of the assignment (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(..)
的native implementation里面,我们可以假设它有参数,并且其第一个参数(可能叫做arg1)在将2
赋值给他时,有一次LHS引用。
You might be tempted to conceptualize the function declaration
function foo(a) {… as a normal variable declaration and
assignment, such as var foo and foo = function(a){…. In so
doing, it would be tempting to think of this function declaration
as involving an LHS look-up.
However, the subtle but important difference is that Compiler
handles both the declaration and the value definition during
code-generation, such that when Engine is executing code,
there’s no processing necessary to “assign” a function value to
foo. Thus, it’s not really appropriate to think of a function
declaration as an LHS look-up assignment in the way we’re
discussing them here.
引擎/作用域的会话
function foo(a){
console.log(a); // 2
}
foo(2);
我们来将程序执行上面的代码段时进行的操作脑补成一段会话。他们的会话可能会是这样:
引擎:嘿,作用域,我有一个对foo
的RHS引用。你听说过它没?
作用域: 我听说过。编译器刚刚声明过它。它是一个函数。给你。
引擎:帅气!好,我们来执行foo
。
引擎:嘿,作用域,我有一个对a
的LHS引用,你听说过它没?
作用域:是的,有。编译器之前声明它是一个foo
的形参。给你。
引擎:赞!多谢!现在将2
赋值给a
。
引擎:嘿,作用域,不好意思再打扰一下。我有一个对console
的RHS引用。听过他没?
作用域:没问题,引擎哥,这是我该做的。是的,我听说过,他是内建的。给你。
引擎:完美。查找log(..)
,嗯。。它是一个函数。
引擎:作用域哥,你可以帮我再看看a
么,我有一个对它的RHS引用,虽然我记得它存在,但是我想再确认一下。
作用域:没错,他在那呢,同样的值,没有改变。给你。
引擎:酷毙了,将a
的值2
传递给log(..)
...
考察
(留空)
嵌套作用域
我们说过作用域是一组通过标识符名字查找变量的规则。然后,通常情况下,不止一个作用域需要考虑。
就像一个函数块嵌套在另一个函数块里面一样,作用域也嵌套在其他作用域里面。因此,如果在直接的作用域中没有找到变量,引擎会查询下一个外层作用域,直到找到,或者达到最外层(即全局global)作用域。
考虑下面的例子:
function foo(a){
console.log(a+b);
}
var b = 2;
foo(2); // 4
对b
的RHS引用无法在foo
函数的内部解决,但是可以在其附近的作用域(此处是global)得到解决。
因此,回到脑补的引擎和作用域的对话中,我们可以听到:
引擎:嘿,函数foo
的作用域,听过b
么?我有一个对它的RHS引用。
作用域:没,完全没听说过。继续奔跑吧,伙计。
引擎:嘿,foo
函数外面的那个作用域,艾玛,原来你是全局作用域,你听说过b
么?我有一个对它的RHS引用。
作用域:是的,搁这儿呢,给你。
遍历嵌套作用域的简单规则就是:引擎从当前正在执行的作用域开始,查找这里的变量,如果没有找到,就继续往更高一级查找。如果达到了最外层的全局作用域,查找就会停止,不管有没有找到需要的变量。
错误
Why does it matter whether we call it LHS or RHS?
因为当变量并没有被声明时(在任何作用域中都没找到),这两个查找的类型在此情况下表现得不同。
考虑如下情况:
function foo(a){
console.log(a+b);
b = a;
}
foo(2);
当对b
的第一次RHS查找时,并不会找到。这时它是一个"undeclared"变量,因为在这个作用域中没有找到。
如果在嵌套作用域的任何地方,对变量进行RHS查找失败时,引擎会抛出一个ReferenceError。注意这里的错误类型是ReferenceError。
相反,如果引擎执行一个LHR查询,并且直到到达全局作用域的时候都没有找到这个变量,当程序没有在"Strict Model"下执行时,全局作用域会创建一个以其命名的变量,并且将其告诉引擎。
如:
function a(){
d = 2;
}
a();
d; // 2
“No, there wasn’t one before, but I was helpful and created one for you.”
在"strict model"下,LHS查询不会隐式的创建全局变量,当没找到时,引擎也会和RHS一样抛出一个ReferenceError。
复习
作用域是一组决定何处以及如何查找一个变量的规则。这个查找可能是赋值给变量,即LHS(lefthand-side)引用,或者可能是为了检索其值,即RHS(righthand-side)引用。
LHS引用源自于赋值操作。作用域相关的赋值既可能发生在=
运算符也可能发生在传递参数给函数。
JavaScript引擎在执行代码时会先编译,基于此,它将如var a = 2;
这种语句分成两个独立的步骤:
-
首先,在当前作用域声明一个
a
。这会代码执行前执行。 -
随后,
a = 2
查找变量a
(LHS引用),如果找到就将2
赋给它。
LHS和RHS引用都是在当前执行的作用域开始查找,并且如果需要的话,它们也会查找嵌套的作用域,一次一个作用域,直到到达全局作用域便会停止,不管有没有找到。
RHS失败时,会抛出ReferenceError
LHS失败时,会自动的,隐式的在全局创建以其命名的变量(非strict mode下),strict mode会抛出ReferenceError