Javascript进阶--说说作用域与编译

学习内容大部分案例及语言来自《你不知道的Javascript–KYLE SIMPSON》

顺手推荐一波,这套书真的很棒,适合想进阶JS但不知道学什么的同学~

过完年回到学校,我终于成为老学长了。现在留在学校里的,除了零零散散的大四考研学生以外,本科生就数大三资历最老了……

因为坚定了好好学习JS,找到一份高级点的前端码农工作,所以开始深入学习一下。接下来将从较为基础的部分开始学习及复习,把学习内容一一记录下来,写成一个像是总结的东西。一个是记下来以免有所忽略和遗忘,一个是万一哪天我博客火了还能给别人当个教程参考哈哈哈哈哈哈哈~


互联网早期,Javascript就出现并已经成为了支撑网页内容交互体验的基础技术。而经过这么多年,现在的Javascript毫无疑问已经成为了世界上使用范围最广的软件平台——互联网的——的核心技术。

虽然Javascript可能是最容易上手的语言之一,但由于其特殊的历史原因、特性,能熟练掌握Javascript并不是一件容易的事。Javascript有很多复杂微妙的概念,单纯的使用很难真正理解它的内里、机制,所以接下来我想更深入的、从本身的机制来审视这个语言。

那么首先看第一个概念,作用域是什么?

1. 作用域是什么?

作用域是语言中非常常见的词汇,有过编程基础的人基本都大致了解作用域是个什么东西。百度百科里的解释是:

作用域(scope),程序设计概念,通常来说,一段程序代码中所用到的名字并不总是有效/可用的,而限定这个名字的可用性的代码范围就是这个名字的作用域…

但今天要探究的不是作用域的定义,而且实际使用时细枝末节的探究定义用处也不大。所以我想知道的是作用域在实际中对代码的影响,以及这是不是使语言具有一定的特异性。研究明白作用域就能规避很多错误,甚至能提高编写的程序的运行效率(这在后面会涉及到)。

先给出我个人认为的,较通俗的作用域解释:为了方便找到变量的设计的良好规则。

那么在Javascript中的这种规则是怎样呢?首先引出一个概念——编译。

1.1 编译

很多人说JS是一门“解释执行”或“动态”语言,但实际上它是一门编译语言。不过与传统的编译语言不同,它并不提前编译,编译结果也不能在分布式系统中进行移植。虽说如此,JS引擎进行编译的步骤也与传统的编译语言十分相似,某些环节甚至更为复杂。

由于众所周知的JS的“不严格”性,且浏览器用户对于网页的加载速度要求极高,JS引擎需要在语法分析和代码生成阶段等特有阶段对运行性能进行优化,包括对冗余数据的优化等。

所以,了解编译的机制和过程有助于提高代码的运行的效率,作用域正是其中一环。

1.2 作用域在编译中的参与

以对程序 var a = 2; 进行的在编译过程中处理为例:

  1. 遇到 var a ,编译器会询问作用域是否已经有一个名为 a 的变量存在于同一个作用域集合(已声明的名字的集合)中。

    如果有,忽略声明继续编译;否则编译器将要求作用域(将作用域理解成一个具有真正职能的模块)在当前作用域的集合中声明一个叫做 a 的新变量

  2. 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a = 2; 这个赋值操作。引擎将会询问当前作用域是否存在一个叫做 a 的变量。

    如果有,引擎就会使用这个变量;否则引擎将会继续查找该变量。

如果引擎最终找到了 a 变量,就会将 2 赋值给它;否则引擎就会抛出一个异常。

在上述过程中,由作用域协作的、引擎执行的查找,将会影响最终的查找结果。为了进一步理解这个过程,一些编译器的术语就必须被提出来。

上述例子中,引擎为 a 的查询为 LHS 查询,而另一种查询类型叫作 RHS 。其中, L 和 R 指的分别是左侧和右侧,即赋值操作的左侧和右侧。

换句话说,当变量出现在赋值操作左侧的时候执行 LHS 查询,出现在右侧的时候执行 RHS 查询。更准确一些的说, RHS 只是简单的为了找到变量的值,LHS 则是找到容器的本身并对其赋值。

例如 a = 2; 中对 a 的引用是 LHS 引用; console.log( a ); 中 a 的引用是一个 RHS 引用,因为并没有对 a 进行赋值。

来一个更复杂的例子吧~

1
2
3
4
5
function foo(a) {
console.log( a ); //2
}

foo( 2 );

这其中既有 LHS 也有 RHS 引用。最为明显的就是最后一行的对 foo 函数的 RHS 引用,后面的 ( .. ) 代表着 foo 的值需要被执行,因此还必须确认它是一个函数类型的值。

那你会问了, LHS 在哪里呢?这是一个容易忽略的但十分重要的细节——调用函数中隐式的 a = 2 操作可能很容易被忽略掉。当 2 被作为参数传递给函数时,2 会被分配给参数 a 。为了给参数隐式的分配值,需要进行一次 LHS 引用。

而对于函数内部的 console.log( a ) 还有一个对于 a 进行的 RHS 引用,且 console.log( .. ) 本身也需要一个引用才能执行,即对 console 对象进行 RHS 查询,并且检查得到的值中是否有一个叫做 log 的方法。至于 log 内部的参数调用等所引发的引用就不一一列举了,引用的过程和类型与上述内容大同小异。

P.S. 有人可能会问, foo 函数的声明类似于 var foo, foo = function... ,不会进行类似于变量声明的 LHS 查询么?其实编译器可以同时处理声明和值的定义,并不会有线程专门用来将一个函数值“分配给” foo ,即没有寻找容器并赋值的操作,也就是说当前的 foo 函数的声明是一个完整的过程,并不会如 var a = 2; 一样分开处理。

将上述的内容总结一下:

引擎发起 foo RHS 引用 -> 作用域将 foo 送给引擎 -> 引擎发起 a LHS 引用 -> 作用域通知引擎 a 为形式参数 -> 引擎把 2 赋值给 a ,发起对 console 的 RHS 引用 -> 作用域通知引擎其为内置对象,送至引擎 -> 引擎找到 log 函数,RHS 引用 a 以再次确认 -> 作用域确定变量没有变更 -> 引擎将 a 的值传递进 log( .. ) -> ……

1.3 作用域嵌套

作为根据名称查找变量的规则,我们通常要同时顾及几个作用域。即当一个块或函数嵌套在另一个块或函数时,就发生了作用域的嵌套。

因此,在当前作用域中无法找到某个变量的时候,引擎就会在外层嵌套的作用域继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域为止)。

对于,LHS 和 RHS 都是如此,一级级查找中没找到就向上层查找,一旦抵达顶层的全局作用域,无论是否找到所需变量查找过程都将停止。

既然嵌套中 LHS 和 RHS 的查找过程都差不多,那为什么还要分这么清楚呢?这不是白费力气么?嘿嘿,这不是闲得没事做才干的,马上,重头戏就来了——异常。

1.4 异常

看一看下面的代码

1
2
3
4
5
6
function foo(a) {
console.log( a + b );
b = a;
}

foo( 2 );

因为任何相关的作用域都无法找到 b ,所以第一次在 console.log( .. ) 中对 b 的 RHS 查询是无法找到的,也就是说,这是一个“未声明”的变量。因此,当 RHS 查询遍历查找到全局作用域时,引擎就会抛出一个 ReferenceError 异常——这是一个非常重要的异常。

对于 LHS 会怎么样呢?假设后续的 b = a; 得以继续执行,即当 b 进行 LHS 查询遍历查找到全局作用域也无法找到时,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎(前提是在严格模式下)。

看到这你会不会有一种“哦~~~”的感觉?反正当我知道这件事情的时候是有点恍然大悟的——它解释了为什么不加以声明的变量是全局变量。能理解以前只是强行记住的语言规则,这就是理解 JS 运行机制的好处之一了~

话说回来,在 ES5 中引入的“严格模式”和宽松的模式相比,严格模式在行为上有很多不同。其中的一个不同就是严格模式禁止自动或隐式的创建全局变量。因此,在严格模式中 LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出和 RHS 时类似的 ReferenceError 异常。

多提一句,当 RHS 查找成功后,你尝试对这个变量的值进行的操作不合理时(如对非函数类型的值进行函数调用),引擎会抛出另一类异常—— TypeError 。

所以当调试自己的代码时,看到了 ReferenceError 代表着有变量作用域判别失败,而 TypeError 代表作用域判别成功了但是对结果的操作非法或不合理。

1.5 小结

讲了 LHS 和 RHS ,以说明作用域在不同查询下的作用与反应,顺带解释了不声明的全局变量问题。看起来字不少,但这只是简单讲了作用域的表现,作用域的具体工作模式、工作模型,都会在接下来的文章记录下来。

P.S. 人生首次一次性打这么多字,手腕和后背好痛….