这本书之前囫囵地看了一遍,确实点明了很多以前不清不楚的点,但是仅仅看一遍是没什么用的,最近面试遇到不少原理相关的题感觉答得不理想,回头看下其实以前都理解过,但是没有记下来,正好结合实际的问题来再学习一下书上的内容。
第一个问题:JavaScript是如何查找变量的?
这本讲解JavaScript的书首先讲的却是编译原理,一开始看起来让人费解,但实际上从后面内容我们可以发现,JavaScript的很多特性都与编译原理有着极大的关系。我们通常称JavaScript是动态解释执行语言,因为它不是提前编译的,而是根据执行时的情况来对代码进行处理。
在传统编译语言的流程中,代码的执行通常分成三个步骤:
将语句分解成词法单元(Token),例如 var a = 2;
会被分解成: var、a、=、2、;。需要注意的是分词和词法分析有少许的区别,
如果词法单元生成器在判断a是一个独立的词法单元还是其他词法单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为词法分析。
理解不了这一句中的 有状态的解析规则
将词法单元流转换成树形结构的的过程。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST).
将AST转换成可执行代码的过程。简单来说就是把 var a = 2;
的AST转化为一组机器指令,用来创建一个叫做a的变量(包括分配内存),并将一个值储存在a中。
与传统编译语言的编译器相比,JavaScript引擎要更复杂,最明显的区别就体现在编译时间上,JavaScript引擎没有时间进行优化,编译过程不是在构建之前,通常发生在代码执行前的几微秒内。在作用域的背后,JavaScript引擎使用了各种方法(比如JIT延迟编译甚至实施重编译)来保证性能。
在理解作用域之前还有一些前置的概念需要理解,在JavaScript执行的时候有三个重要组件:
还是上面的var a = 2;
这个例子,编译器在处理时会分成两步:
var a
,编译器先询问作用域同一个作用域中是否已经有这样一个名称的变量。如果是,编译器则忽略这个声明,继续编译;如果否,编译器则要求作用域在当前作用域的集合中声明一个新的变量,命名为a。a = 2
,编译器会为引擎生成运行所需要的的代码来执行这个赋值操作。引擎运行时同样先询问作用域,当前作用域集合中是否有一个叫做a的变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量了(如何查我们在下一节说明,因为查找的是其他作用域的)。如果再细致地说明引擎的查找过程可以把它分成LHS查询和RHS查询,“L”和“R”很好理解,就是左侧和右侧,具体来说就是一个赋值操作的左侧和右侧。
变量在赋值操作左侧时就进行LHS查询,出现在右侧时就进行RHS查询,但实际上会这么简单吗?肯定不是,LHS其实是找到变量的容器本身并对其赋值,RHS则相反,意思比较接近于“取到它的原值”或“得到某某的值”。看下面这个例子:
console.log(a);
这里对a的引用就是一个RHS引用,因为没有为a赋任何值,而是获取了a的值并传递给了console.log()。而相比a = 2
,很明显就是把“= 2”交给了“a”。
也就是说LHS和RHS并不是简单的左侧和右侧,而是“赋值操作的目标是谁”和“谁是操作的源头”。
理解了之后再看这个例子,除了一个RHS操作是否还能找出一个LHS操作呢?
function foo(a){ console.log(a); // 2 } foo(2);
这是一个很容易被忽略的细节,代码中存在一个隐式的a = 2
操作,2倍当做参数传递给了foo()函数,这里要给参数a分配值,所以需要一次LHS查询。
这个问题说起来很简单,就是当引擎需要变量时,会先在当前作用域寻找,如果没有这个变量就到上一级作用域查找,直到最外层,也就是全局作用域,到达这里以后即使没找到也会停下来。
function foo(a){ console.log(a + b); // 2 } var b = 2; foo(2); // 4
上面我们费了半天劲来理解LHS和RHS有什么意义呢?看下面这段代码:
function foo(a){ console.log(a + b); b = a; } foo(2);
显而易见这段代码会报异常,因为对b的RHS查询无法找到该变量,b是未声明的变量,但如果是进行LHS查询则不同,上面说了,引擎在当前作用域未能查找到对象就会向上一级,直到全局作用域还未能找到时,全局作用域就会创建一个具有该名称的变量,并将其交给引擎(前提是在非“严格模式”下)。偶尔会看到一些可以印证这一例子的不规范代码——未声明的变量被使用,如果是在ES5的“严格模式”下,会和RHS一样报ReferenceError的异常。