再谈JavaScript做用域——你肯定你真的知道?

什么是做用域?

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

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

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

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

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

这段描述大体的告诉了读者做用域是个啥,能够在这里理解为一个“区域”。bash

两年前我第一次看到这句话仍是答不出做用域是啥,虽然已经在脑海里有一个大体的轮廓。我以为我应该继续深究一下,做用域到底是啥。框架

什么是编译?

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

那么编译过程是啥呢?能够分为这么三步:分布式

  1. 分词/词法分析(Tokenizing/Lexing) 这个过程会将由字符组成的字符串分解成(对编程语言来讲)有意义的代码块,这些代码块被称为词法单元(token)。例如,考虑程序var a = 2;。这段程序一般会被分解成下面这些词法单元:var、a、=、二、;。空格是否会被看成词法单元,取决于空格在这门语言中是否具备意义。
  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的变量,若是是,引擎就会使用这个变量;若是否,引擎就会继续查找该变量。

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

在上面的第二步中,引擎执行“运行时所需的代码”时,会经过查找变量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.

相关文章
相关标签/搜索