再谈JavaScript作用域——你确定你真的知道?

2,081 阅读9分钟

什么是作用域?

作用域,这个词在编程界经常能听到看到,每一个程序员几乎都有被问到过。在前端圈,面试JavaScript相关知识,这可以算说是一个非常基础的问题了。但早年间我长期陷入了一种“只可意会不可言传”的地步,我不知道是不是有许多小伙伴与我曾经有一样的经历,所以我就抽时间把书本中看到的东西整理了一下。把提炼的东西分享给大家,如有不正确之处烦请指正。可能大多对作用域的通用解释是这种:

作用域就是变量(标识符)适用范围,控制着变量的可见性。

但他具体是什么,是一个区域?还是一种规则呢?

我记得《JavaScript权威指南》中对变量作用域有这么一段描述:

一个变量的作用域(scope)是程序源代码中定义这个变量的区域。全局变量拥有全局作用域,在JavaScript代码中的任何地方都是有定义的。然而在函数内声明的变量只在函数体内有定义。它们是局部变量,作用域是局部性的。函数参数也是局部变量,它们只是在函数体内有定义。

这段描述大致的告诉了读者作用域是个啥,可以在这里理解为一个“区域”。

两年前我第一次看到这句话还是答不出作用域是啥,虽然已经在脑海里有一个大致的轮廓。我觉得我应该继续深究一下,作用域究竟是啥。

什么是编译?

要搞清楚作用域是啥,我们需要或多或少的知道一点点JavaScript的编译原理,从第一次接触JavaScript开始,我接触到的所有知识就告诉我,JavaScript是一门“动态”或“解释执行”语言,但后来我才知道,它实际上是一门编译语言。是不是很惊讶?与传统的编译语言不同的是,JavaScript不是提前编译的,编译结果也不能在分布式系统中进行移植。

那么编译过程是啥呢?可以分为这么三步:

  1. 分词/词法分析(Tokenizing/Lexing) 这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。例如,考虑程序var a = 2;。这段程序通常会被分解成下面这些词法单元:var、a、=、2、;。空格是否会被当作词法单元,取决于空格在这门语言中是否具有意义。
  2. 解析/语法分析(Parsing) 这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree, AST在各大框架及Babel中我们都会看到它的身影)。 var a = 2;的抽象语法树中可能会有一个叫做VariableDeclaration的顶级节点,接下来是一个叫做Identifier(它的值是a)的子节点,以及一个叫做AssignmentExpression的子节点。AssignmentExpression节点有一个叫做NumericLiteral(它的值是2)的子节点。
  3. 代码生成 将AST转换为可执行代码的过程被称为代码生成。这个过程与语言、目标平台等息息相关。 抛开具体细节,简单来说就是有某种方法可以将var a = 2;的AST转化为一组机器指令,用来创建一个叫做a的变量(包括分配内存等),并将一个值存储在a中。

当然,对比就这三步的编译语言来说,JavaScript引擎要复杂的多。但JavaScript的编译大多发生在代码执行前的几微秒,甚至更短。在我们要讨论的作用域背后,JavaScript引擎用尽了各种方法(比如JIT,可以延迟编译甚至实施重编译)来保证最佳性能。

理解作用域

在这里,我们会无数次用到作用域这个词,你完全可以按照之前的理解来阅读。这并不影响我们最终对作用域的理解。

还是var a = 2这行代码,通过上面的什么是编译部分我们可以知道,编译器首先会将这段代码分解成词法单元,然后将词法单元解构成一个树结构(AST),但是当编译器开始进行代码生成时,它对这段代码的处理方式会和预期的有所不同。

当我们看到这行代码,用伪代码进行跟别人进行概括时,可能会这样去表述:“为一个变量分配内存,并将其命名为a,然后将值2保存到这个变量(内存)中。” 然而,这并不完全正确。

事实上编译器会进行如下操作:

  1. 遇到var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a。
  2. 接下来编译器会为引擎生成运行所需的代码,这些代码被用来处理a = 2这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中,是否存在一个叫作a的变量,如果是,引擎就会使用这个变量;如果否,引擎就会继续查找该变量。

总结起来就是:1、编译器在作用域声明变量(如果没有);2、引擎在运行这些代码时查找该变量,如果有就进行赋值;

在上面的第二步中,引擎执行“运行时所需的代码”时,会通过查找变量a来判断它是否已经声明过。查找的过程由作用域进行协助,但时引擎执行怎么查找,会影响最终的查找结果。

还是var a = 2;这个例子,引擎会为变量a进行LHS查询。当然还有一种RHS查询。那么LHS和RHS查询是什么呢?这里的L代表左侧,R代表右侧。通俗且不严谨的解释LHS和RHS的含义就是:当变量出现在赋值操作的左侧时进行LHS查询,出现在右侧时进行RHS查询。

那么描述的更准确的一点,RHS查询与简单的查找某个变量的值毫无二致,而LHS查询则是试图找到变量的容器本身,从而可以对其赋值。从这个角度说,RHS并不是真正意义上的“赋值操作的右侧”,更准确的说是“非左侧”。所以,我们可以将RHS理解成Retrieve his source value(取到它的源值),这意味着,“得到某某的值”。

那我们来看一段代码深入理解一下LHS与RHS。

function foo(a) {
  console.log(a)
}

foo(2)

从这段代码中,我们先看看: console.log(a)

其中a的引用是一个RHS引用,因为我们是取到a的值。并将这个值传递给console.log(...)方法。

相比之下,例如: a = 2 // 调用foo(2)时,隐式的进行了赋值操作 这里对a的引用就是LHS引用,因为我们实际上不关心当前的值时什么,只要想把=2这个赋值操作找到一个目标。

当然上面的程序并不只有一个LHS和RHS引用:

function foo(a) {
  // 这里隐式的进行了对形参a的LHS引用。
  
  // 这里对log()方法进行了RHS引用,询问console对象上是否有log()方法。
  // 对log(a)方法内的a进行RHS引用,取到a的值。
  console.log(a)
}

// 此处调用foo()方法,需要调用对foo的RHS引用。意味着“去找foo这个值,并把它给我。”
foo(2)

需要注意的是:我们经常会将函数声明function foo(a) {...} 转化为普通的变量赋值 var foo = function(a) {...},这样去理解的话,这个函数是LHS查询。但是有一个细微的差别,编译器可以在代码生成的同时处理声明和值的定义,比如引擎执行代码时,并不会有线程专门用来将一个函数值“分配给”foo,因此,将函数声明理解成前面讨论的LHS查询和赋值的形式并不合适。

到这里,是否对作用域的工作有了一个理解呢?但是它是什么,还是有些模糊,不知道该怎么去表述。先不管,先看看什么是作用域链。

作用域链

问道作用域,跑不掉的就是作用域链了,我们来看一个代码例子:

function foo(a) {
  console.log(a + b)
}
var b = 2;

foo(2); // 4

通过上面我们得知,对b的RHS引用无法在函数内部完成的,因为函数内部并没有定义b,但是在这个例子中,我们可以在上一级作用域(这里是全局作用域)中完成。

那么这个查找规则就很简单了:引擎从当前的执行作用域开始查找变量,如果找不到,就像上一级继续查找,当抵达最外层的全局作用域时,无论找没找到,查找都会停止。那么这么一个自上而下的查找关系,是一个链式的查找关系。

那么没找到会发生什么呢?进行RHS引用时,如果RHS查询所有的嵌套的作用域中遍寻不到所需的变量,引擎就会抛出一个ReferenceError的异常。

相较之下,当引擎执行LHS查询时,如果全局作用域下都无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并返回给引擎。前提是该程序运行在非“严格模式”下。反之则会抛出ReferenceError异常。

那么在写代码过程中,ReferenceError异常与作用域判别失败相关,而TypeError则代表着作用域判别成功,但是对结果的操作时不合法的。

总结

所以,写到这里,对作用域是干什么的有了一个比较清晰的理解呢? 好,我来试着重新表述一下什么是作用域:

作用域是一套“标识符的查询规则”(注意我这里用的词是规则),根据查找的目的进行LHS与RHS查询。确定了在何处(当前作用域、上级作用域...全局作用域)如何查找(LHS、RHS)。

当然,这篇文章也发布在我的个人博客《再谈JavaScript作用域》,有兴趣的小伙伴可以看一看。

参考文献

Flanagan. JavaScript权威指南[M]. 北京:机械工业出版社, 2012.
Kyle Simpson. 你不知道的JavaScript[M]. 北京:人民邮电出版社, 2015.