前言
上面两章节简单的介绍了一下什么是JS与特性,而本章节开始会正式进入到JS中,而一开始我们先从作用域开始介绍,什么是作用域?作用域就是一个变数的生存範围,一旦超出了这么範围就无法存取到这个变数
,定义了变量在哪里存活与我们能在哪里找到他,这就是作用域。
Compiling Code
作用域主要是在编译的时候定义的,所以需要了解JS编译与执行之间的关係。
在一般的编译器中,编译一般分为三个阶段 :
1.Tokenizing/Lexing : 将程式码分解为对JS有意义的token,举个例子,若有一段程式码var a = 2;
那们它将会被分解为var
、a
、=
、2
和;
,至于空格则取决于是否有意义而选择性的转换。
2.Parsing : 获取所有的token并将他们转换为AST(Avstract Syntax Tree)。举例来若将var a = 2;
转换为AST,可以从最顶端的节点VariableDeclaration
开始,它底下有两个子节点,一个是代表着变量a的identifier
与代表拥有数值的AssignmentExpression
,而代表有数值节点下有一个NumericLiteral
的子节点代表数值(2)。
3.Code Generation : 执行AST并将它转换为可以执行得代码,这个转换会因为不同语言而有不同的结果,以JS来说它会对var a = 2;
这个程式码转换成一组机械指令,将创建一个称为a的变量并将2赋予给这个变量。
由于JS与大部分语言一样它不会再build的时期就提前编译,而这个动作必须发收生在执行代码前几ms的时间,为了确保拥有最快的性能,JS引擎就使用了各种技巧(JIT,lazy compile...)这些就不再此处进行讨论。
为了简单的描述JS对于程式的处理,可以简单的分为解析/编译然后是执行,虽然JS没有明确的要求进行编译
,但是它对于程式实质上的行为却是需要编译后才能执行的。
我们以下面三个例子来证明这点 :
var greeting = 'Hello';console.log(greeting);greeting = .'Hi'; //SyntaxError: unexpected token .
上面的程式中"Hello"并不会被输出,它掷出了一个SymtaxError,因为在Hi前面有一个非预期的token,以上面的例子中,若JS是由上而下一行一行的编译的话,那么应该会先输出"Hello"后才发生错误才对,但事实上JS引擎能够知道在第三行中有一个syntaxError,所以JS引擎会在执行前先解析整个程式。
console.log("Howdy");saySomething("Hello","Hi");// Uncaught SyntaxError: Duplicate parameter name not// allowed in this contextfunction saySomething(greeting,greeting) { "use strict"; console.log(greeting);}
上面的例子中也会掷出一个SyntaxError,因为我们在saySomething这个函数中使用了严格模式,而严格模式禁止function的参数使用一样的名子,虽然抛出的错误并不是语法错误,但是在严格模式下这个错误会在执行前被掷出并被当成early error
。
而这个例子再次证明了JS会在执行代码前对程式完全的解析,因为不这么做的话它就不会知道function中有两个一样名子的参数与funciton中使用的是严格模式。
function saySomething() { var greeting = "Hello"; { greeting = "Howdy"; // error comes from here let greeting = "Hi"; console.log(greeting); }}saySomething();// ReferenceError: Cannot access 'greeting' before// initialization
上面了例子中,因为在let宣告greeting之前就使用了这个变量(let不会hoasting)所以导致了ReferenceError,这个例子也证明了JS是需要先将全部程式编译后才会执行。
Compiler Speak
在了解了JS引擎处理程式的两个阶段后,我们再回到JS引擎是如何识别便量并确定它在程式中的使用範围。
为了更深入地理解需要了解更多的编译器术语,这边会介绍LHS查询(Left-hand Side)
和RHS查询(Right-hand Side)
,简单来说当一个变量出现在赋值的左边时会进行LHS查询,当一个变量出现在赋值操作的右手边会进行RHS查询,说得更準确一点:
var students = [ { id: 14, name: "Kyle" }, { id: 73, name: "Suzy" }, { id: 112, name: "Frank" }, { id: 6, name: "Sarah" }];function getStudentName(studentID) { for (let student of students) { if (student.id == studentID) { return student.name; } }}var nextStudent = getStudentName(73);console.log(nextStudent); //Suzy
Targets
我们先找到上面例子中的Targets。
students = [...]
这个很显然是一个Target,因为students被赋予
了一个阵列的值,它与nextStudent = getStudentName(73)
一样都是赋值操作。
for(let student of students)
这里是一个比较难发现的Target,这句程式的意思是将students这个阵列迭代给student这个变数,所以也是一个赋值(只是不太明显)。
getStudentName(73);
这里也是一个隐性的Target,因为它将73这个数值赋予给getStudentName这个function的参数。
Source
for(let student of students)
上面提到student是属于被赋值的target,那么students这个阵列便是给予
数值的source
if(student.id == studentID)
在这边的student与studentID都是属于source reference。
return student.name
这边的student也是属于source,因为它提供了可以retuen的值。
在举个简单的例子:
function foo(a){ console.log(a);}foo(2);
上面的例子既有LHS也有RHS,我们仔细地对它进行分析 :
当程式调用了foo(...),代表着函数调用要求一个指向foo
的RHS,它代表着去查询foo的值并将结果交给引擎。当值2做作为参数传递给foo时,2被赋予给了参数a,这边使用了隐含的参数赋值,所以进行了一个LHS。而当结果被传递给console.log(...)
时,它会对console这个物件进行一个RHS查询,查询是否有一个log的mathod。最后当值2被传入log(...)
时,因为2被赋予给了log的参数,所以也进行了一次LHS。Nested Scope
作用域是通过标示福明成查询变量的规则,但是通常会有多余一个作用域的问题需要考虑,就像一个程式嵌套在另一个程式码区域或函数中,作用域也会被嵌套在其他作用域中,所以如果再直接作用域找不到需要的变量,则会往外层作用域
寻找,如此继续找到最外层作用域(全域作用域)。
举个例子 :
function foo(a){ console.log(a + b);}var b = 2;foo(2); //4
以上面的例子来说,当JS引擎在直接作用域(函式foo中)找不到b这个变量,那么它就会访问外层的作用域(全域作用域)看是否有b这个变数可以让它进行RHS。
遍历嵌套作用域的规则很简单,JS引擎从当前执行的作用域开始查找变量,如果没有找到就向上走一级继续查找,如此类推。如果到了最外层(全局作用域),那么查找就会停止无论它是否找到了变量。
Errors
为什么需要了解LHS与RHS?
因为在一个变量还没被宣告的情况下,这两种类型的查询做了完全不同的行为。
function foo(a){ console.log(a + b); b = a;}foo(2);
当b的第一次RHS查询发生时,因为它是一个没有被宣告过的变量,所以在作用域中找不到它,因此如果在所有的作用域中都找不到b这个变量的时候,JS引擎会抛出一个ReferenceError
。相比之下若JS引擎在做LHS查询,虽然达到了顶层作用域都没有找到这个变量,若是没有在严格模式(strict)下,那么就会在全局作用域中创建一个同名的新变量。而若一个RHS查询到了需要的变量,但是却对这个变量做这个值不可能做到的事,比如将这个非函数的值当作函数执行,或是引用null
或undefined
,那们JS引擎就会抛出种类错误TypeError
。
所以ReferenceError
是对于作用域解析失败,而TypeError
则是作用域解析成功了但是对对这个解析成功的变量做非法/不可能的操作。
参考文献 :
You Don'y Know JavaScript
You Don'y Know JavaScript 2nd