Scope
几乎所有的程式语言都能设变数并且储存值,之后我们可以从变数取值或是修改变数的值,这种利用变数储存值,可供我们使用的机制,让程式语言的执行过程中,保留了某种状态。
当使用了变数之后,问题是要去哪找这变数呢?
所以就必须定义一组规範,让我们在需要的时候,可以找到变数,而所谓的规範可称之为:範畴
Compiler
一般认为JavaScript是「dynamic(动态)」或「interpreted(直译)」式的程式语言,但本书作者认为JavaScript是属于「compiled(编译)」式。
原文:
It may be self-evident, or it may be surprising, depending on your level of interaction with various languages, but despite the fact that JavaScript falls under the general category of "dynamic" or "interpreted" languages, it is in fact a compiled language.
传统的编译器处理程式原始码(source code)会经历3个步骤:
Tokenizing / Lexing将字串拆解成tokens,例如:
var a=2;
会被拆解成数个tokens:var
,a
,=
,2
,;
Parsing将Tokenizing/Lexing过程中,所拆解出来的tokens转换成
抽象语法树(Abstract Syntax Tree,AST)。
AST是一个树状结构,最顶层的节点为VariableDeclaration,表示宣告关键字
var
,带有两个子结点:Identifier:表示宣告的变数a
。AssignmentExpression:赋值表达式,带有一个子结点NumericLiteral,表示值为2
。Code-Generation将Parsing过程产生的AST转换成机器指令(machine instructions),会在记忆体建立变数
a
,并赋值2
。JavaScript程式码在被执行之前,会先以非常短的时间,微秒(microseconds )或更少,进行编译(compiled)。
JavaScript会使用JITs,lazy compile或是hot re-compile等各种方式,来达到提高效能。
JavaScript会在原始码(source code)即将被执行之前,完成编译,并马上执行。
Understanding Scope
在理解範畴的过程中,有3个主要角色:
Engine负责整个编译流程,并执行程式码。Compiler
负责执行Parsing与Code-Generation阶段。Scope
建立一份由宣告的变数(identifier)所组成的清单,并且规範清单中的变数,是否可由目前正在执行的程式码存取。
Back & Forth
var a = 2;
这段程式码,一般会以叙述句(statement)来看待,但实际的状况是,Engine会将之拆成2个部分,分别由Engine以及Compiler处理。
首先,Compiler先进行语意分析,将之拆解为tokens,之后,再转换为"AST" (Abstract Syntax Tree).
接下来的步骤,我们可能会认为,在记忆体配置一个空间给变数a
,再赋值2
,但实际上并不是如此。
Compiler会如此处理:
遇到var a
,Compiler会跟Scope做确认,看看a
是否存在于特定的範畴集合中。若存在,Compiler会忽略该次宣告,并往下一步执行。若无,Compiler会要求Scope在範畴集合中宣告a
。Compiler会产生Engine即将要执行的程式码,以处理a = 2
这个赋值运算式。Engine会询问Scope在它的範畴集合中是否有a。若有,就取用。若无,则到他处寻找。如果Engine有找到a
,就会赋值2
,若最终都没找到,则会报错。Compiler
当Engine执行上述Compiler在第二步骤(Parsing)所产生的程式码,它会向Scope询问,a
是否已经被宣告了。
不过,Engine所执行的查询种类,会影响到查询的结果。
在这个案例中,Engine会执行一种名叫LHS的查询,另一种查询则是叫RHS。
LHS:Left-hand Side
RHS:Right-hand Side
非明确的判断LHS或RHS是以指定运算子(assignment operator)「=」为依据:
若变数在「=」的左边,是LHS,右边,则是RHS
但以上述的準则来判断,容易产生误解,为何如此一说?
以LHS来说,首先会找到位于左边的变数,再进行赋值的动作。
但以RHS来说,并不表示变数非得在的右边不可,真正的涵义应该是,变数不在「=」的右边。
换个角度,我们可以解读为,取出该变数的值。
console.log(a);
这边并没有赋值给a,而是要取出a的值,所以这个值会被传入console.log( )
之中
那LHS呢?
a = 2;
就像刚刚说的,首先会先找到a,在这个运算式中,a是什么值并不重要,我们的主要目的,是要把2这个值,赋值给a。
不管是LHS或RHS,都不要聚焦在字面上的意义(Left/Right-hand Side),应该要理解的是
谁是目标「who's the target of the assignment(LHS)」。
谁是来源「who's the source of the assignment(RHS)」。
我们来看看这个範例:
function foo(a) { console.log(a); // 2}foo(2);
呼叫foo(2);
,意思就是,对foo
执行RHS查询,稍早已经定义foo
为一个函式,所以我们会找到foo
的值,并执行该函式。
这边有个细节要注意,我们呼叫foo
的同时,也传入一个值给它,在这种情况之下,2
会做为引数指定给参数a
,所以会隐含地执行a=2;
这个指定运算式,也就是LHS。
执行到console.log(a);
这段,首先会执行对console物件的RHS查询,Engine会查询是否有console这个物件,并找出log这个方法。
所以我们再整理一下整个过程:
把2
传入foo
,再由console.log
输出,先对a
执行LHS,再对console物件执行RHS,最后,要输出a
的值,会对a
执行RHS。
关于宣告foo
函式,如果使用var foo=function(a){…}
的话,或许会认为这是执行LHS,就跟之前对a
执行LHS一样。
但实际上,Compiler会在code-generation这个阶段,就会处理这种方式的赋值,并不会让Engine去处理到这部分,所以如果将函示宣告视为LHS,是不恰当的。
Engine / Scope Conversation
对于上述的範例,我们做个整理:
function foo(a) { console.log(a); // 2}foo(2);
Engine执行foo(2);
,它会对foo
执行RHS查询,并往scope寻找。Compiler在code-generation这个阶段已经宣告foo
函式,所以能够在scope中找到。Engine必须把2
当作引数传给foo
函式,首先它需要找到参数a
,一样往scope寻找。Compiler在宣告foo
函式的同时,一併也宣告参数a
,所以能够在scope中找到。Engine找到参数a
并赋值2
,执行LHS。Engine对console物件执行RHS查询,并往scope寻找。console物件是内建物件,所以一样在scope中找到。Engine得到console物件,找到log方法,这时它需要传入a
的值给log方法,依旧往scope寻找。经由Compiler的宣告,Engine的赋值,此时Engine可以对a执行RHS查询,并将值传给log方法。进阶的範例,让我们更进一步了解LHS与RHS:
function foo(a) { var b = a; return a + b;}var c = foo(2);
执行LHS查询有:
var c = foo(2);
将foo函式赋值给c。foo(2);
隐含地执行a = 2
。var b = a;
将a的值赋值给b。执行RHS查询有:
foo(2);
往scope查询foo
的值。var b = a;
赋值前,先找出a
的值。return a + b;
找出a
跟b
的值,并回传。Nested Scope
所谓範畴,简言之,就是一个可以让我们藉由识别字名称来找到变数的规範,但实际情况,我们所要寻找的範畴可能不只一个,範畴内部也可以包含另一个範畴,这种概念就是所谓的Nested Scope巢状範畴。
如果Engine在目前的範畴找不到目标变数,它就会往外面一层的範畴寻找,直到找到,或是达到最外层的範畴(全域範畴)为止。
function foo(a) { console.log(a + b);}var b = 2;foo(2); // 4
执行b
的RHS查询,无法在foo
内完成,所以Engine会往foo
的外部scope找。
在这个範例,外部scope是指全域範畴,并且在外部scope找到b
。
Errors
在变数未宣告的情况下,使用LHS与RHS会产生不同的结果。
function foo(a) { console.log(a + b); b = a;}foo(2);
会发生以下错误:
这是因为对b
使用RHS查询,但是并没有在所有的範畴中找到b
,因为b
并没有被宣告。
如果RHS无法在範畴找到b
,会丢出一个ReferenceError
类型的错误。
但如果使用LHS查询,一直到最外围的範畴都没找到目标变数的话,若不是在「严格模式」中,
那会在全域範畴中建立跟寻找目标同名的全域变数。
以下情况会发生TypeError
错误:
let a = 10;a();
let a;a.prop;
ReferenceError
错误的产生,与範畴解析的错误有关,表示找不到目标变数。TypeError
表示解析成功,但试图对结果执行非法的行为。
重点整理
範畴是一组规则,用来决定寻找变数(identifier)的位置及方式。寻找的目的有2个:赋值给变数或是从变数取值。在範畴中搜寻,可能会发生在赋值运算式「=」 或是 将引数传入函式中。在程式码执行之前,JavaScript Engine会先将之拆解成2个部分,以var a=2;
为例:var a
,在目前的範畴中,宣告a
a=2
,执行LHS查询,在範畴中找到a
之后,赋值。RHS与LHS会先在目前的範畴中执行,若找不到目标变数,就会往外层範畴搜寻,直到找到或达到全域範畴为止。RHS失败会掷出ReferenceError
类型的错误。LHS失败会在隐含地在全域範围建立一个与目标同名的全域变数。参考来源:
WIKI 抽象语法树
此为You Don't Know JS系列的笔记。