JavaScript 作用域

本篇是我搭建博客的第一篇,其实之前我在自己的微信号已经有写过一些小文章,后面会慢慢的移到这边来。这里废话不多说吧。JavaScript的作用域是一个很关键也很常见的技术点,很关键是因为所有编程语言都有一套作用域的规则,理解所使用的编程语言的作用域可以在开发时少走点弯路,不会踩到奇奇怪怪的坑,很常见是因为作用域存在整个开发项目的任何地方,避不开。再者说面试官也很喜欢考这个问题呢。

刚好我看了《你不知道的JavaScript(上)》,对作用域有了一些更深入的了解,所以就想整理一下,对作用域的认识做一个详细的介绍。

一般对js作用域的了解

怎么理解作用域

首先,我要说明一下,现在讨论的JavaScript作用域指的是ES5的作用域。在ES5中,只有全局作用域跟函数作用域,没有块作用域,这个会放到ES6的作用域一节中做介绍。

代码1.1:

1
2
3
4
5
var i = 10;
function a() {
alert(i);
}
a();

代码1.2:

1
2
3
4
5
6
var i = 10;
function a() {
alert(i);
var i = 5;
}
a();

代码1.1跟1.2最终输出是不同的。这个就跟作用域有关。

代码1.1中,在全局声明变量i并赋值10,然后声明了一个函数a()。函数a()中调用了全局变量i,所以alert的结果是10。这个没有什么异议。一般不明白的是代码1.2。

变量提升

代码1.2的最终输出为undefined,原因是虽然函数a()中是在alert(i)之后才声明函数中的局部变量i并赋值5,但实际上JavaScript在处理函数a()代码时是先把var i = undefined提前到函数执行其他代码前声明了。也就是说,JavaScript代码在执行时,会在每个作用域内,优先把声明变量,函数的代码提前执行(预处理),然后在执行其他代码(逐行解读)。这就是变量提升。

变量提升中,函数的声明是怎么处理的?其实这个问题还得分两种情况:如果函数的声明是写成var a = function () {},则a是一个变量,在预处理中,只会声明var a = undefined,在后面的逐行解读中,就有可能出现a未定义的情况;如果函数声明是写成function a() {},则a()是一个函数,在预处理中,会把整个函数进行预处理,当逐行解读,解读到a()时,就会正常执行函数。

当然,千万不要理解成代码1.2中a()在执行时把var i = 5提前了。变量提升只是提升了变量声明。对于为什么只提前执行了变量声明,我们留一个悬念。

注意:

1
2
3
4
5
6
7
8
// 如果是在函数(局部作用域)中,出现了如下的情况:
alert(n);
function f1() {
n = 999;
}
f1();
// 虽然n会变成全局变量,但是alert(n)在这个位置会报错,具体原因也会在后面做解释。
// 一般应该防止变量n这种情况出现

作用域链

代码1.1其实本身也涉及另外一个内容,就是作用域链。简单的说,全局作用域在执行到a()时,会先创建a()的作用域,然后将该作用域放在链表的头部,然后把指针指向全局作用域。当执行到alert(i)时,先会在当前作用域内查找已声明的变量,看是否存在变量i,存在则直接使用,不存在则根据作用域链向上一级父级作用域查找,直到顶级作用域window(网页在浏览器运行,实际是在浏览器这个全局作用域下运行,所以window是网页作用域链的最高作用域)。

闭包

闭包是个很有趣的概念,它有一些特点。我们先举个例子:

代码1.3:

1
2
3
4
5
6
7
8
9
var foo = function(){
var i = 0;
return function inner(){
i++;
console.log(i);
}
}
var bar = foo();
bar();

首先,在代码1.3中,先声明了变量foo并引用一个函数,然后函数内部返回函数inner()。inner()调用了foo()中的i。然后在全局作用域声明了bar并引用了foo(),实际最终是得到了函数inner()的引用。这里可以发现一个问题,就是正常来说,在一个作用域链上,父作用域是调用不了子作用域的变量,但是反之可以。但是这里的全局变量bar()执行之后却得到了foo()中的局部变量i的值,实现了在作用域外变量的调用。

其次,如果再执行一次bar(),会得到2的结果。这个结果表明,当全局变量得到了函数inner()的引用之后,函数inner()会一直保持在内存中,而其依赖的foo(),也因此一直保持在内存中,并没有在调用结束后备垃圾回收机制回收。

闭包的这些有趣的特点让我想起了克莱因瓶(脑洞有点大,这两个事物确实有点类似)。那为什么会出现这么有趣的闭包呢?下面,我们就带上上面的这些问题,一起来再深入一步了解一下js的作用域吧。

深入了解js作用域

想要再深入的了解js作用域,就应该了解js是怎么执行的。简单的说,我们把js代码的执行分成三部分:引擎,编译,作用域。

引擎,这里简单来说就是负责整个js代码的编译跟执行工作。这个工作又分为两部分,一部分是编译,编译程序员编写的代码字符串,另一部分是执行,执行编译后得到的机器指令,这里涉及了作用域。

编译原理

首先,一般编程语言的编译经历了三个步骤:分词/词法分析(Tokenizing/Lexing),解析/语法分析(Parsing)及代码生成。js的编译也差不多。

分词/词法分析(Tokenizing/Lexing)

我们平时写的代码,其实对于机器来说是一段字符串。于是,机器就得先把代码做一次拆解,把每一个独立的,有意义的代码块(词法单元)分解出来。比如说,var n;,会被拆解成varn;

解析/语法分析(Parsing)

当完成词法分析之后,就要开始做语法分析了。原本一个个词法单元就需要拼接成一个具有程序语法的结构。那什么结构可以达到这样的效果呢?这个结构我们都应该会想到的吧,就是树,叫抽象语法树(Abstract Syntax Tree, AST)。比如上面的var n;,在拆解成varn;之后,就可以开始语法分析。那分析的结果就会解析成一个顶级节点(VariableDeclaration)下,有一个节点(AssignmentExpression)的值为n,这个节点下有一个节点(NumericLiteral)的值为undefined(因为这个代码只是声明了一个变量,所以这里就假定值为undefined)。

代码生成

在生成AST之后,就要把它转换为可执行的代码,这个过程叫做代码生成。比如说,上例中var n;的AST转换后,将变成机器指令,创建变量n。

JavaScript的编译过程在某些步骤上要复杂一些,但是其编译过程也是大同小异。为了后面比较容易理解作用域,我们可以暂时先这么理解。

作用域

重点来了,机器指令生成之后,作用域是要干嘛呢?其实,作用域就是要把这些机器指令中涉及的标识符,按一套严格的规则,进行分类维护形成一系列查询,确认机器指令对他们的访问权限。

怎么理解作用域呢?我这里做一个假设,方便理解。

作用域是要能把变量跟函数划分作用范围的,比如全局作用域中的变量可以在全局调用,计算跟赋值,函数可以被掉用,而局部作用域中的变量只能在当前作用域及下级作用域中调用计算等等,函数亦是如此,比如:

代码2.1:

1
2
3
4
5
6
7
8
var i = 10;
var x = 5;
function test () {
var x = i;
console.log(x);
}
console.log(x);
test();

上例中,在完成代码的编译后,会先扫描出全局作用域所有的变量声明跟函数声明(函数表达式其实只会提升变量声明部分)形成这样一个作用域:

代码2.1全局作用域(parentScope):

变量/方法 地址
i 地址1 undefined
x 地址2 undefined
test() 地址3 {…}

当执行到test();时,又会扫描test函数的函数作用域:

test函数作用域

变量/方法/上级作用域 地址
x 地址4 undefined
parentScope 地址0

这里我把作用域模拟成以链表的形式,方便理解。

然后,开始执行机器指令。

变量的查找

如当执行到x = 5的指令时,引擎会去找当前作用域,即全局作用域进行变量查询,查询是否存在变量x(准确的说法是引擎为变量x进行LHS查询)。由于我把作用域理解成链表了,所以查询的模拟就是对链表上当前结点的查询。当查询到变量x已存在,就会返回其内存地址,然后进行赋值。

如执行到x = i的指令时,引擎同样会去当前作用域,即test函数作用域进行LHS查询,返回内存地址。这里就出现了函数作用域中的变量x对全局作用域中的变量x的遮蔽。然后引擎再对i进行查询(引擎为变量i进行RHS查询)。先是从当前作用域开始查询,结果没有找到,因此拿到全局作用域parentScope的地址,找到全局作用域,继续查询找到之后,得到储存的值,然后再做赋值。

其他概念的理解

上面的代码从编译到机器指令执行的模拟,还可以对一些概念的做一些理解:变量提升,其实是代码执行运算前的解析,并形成作用域;变量的遮蔽,其实是作用域权限的效果;作用域链,其实是变量查询的规则导致的。当然,还有一些更深的内容,比如上面简单提及的LHS查询,得到的是变量容器(我的理解就是得到变量的内存地址),RHS查询,得到的是储存的值(并不需要得到变量的内存地址,因为只是做赋值,当然不同的数据类型返回的值也可能是内存地址,比如数组)。

这里也可以对闭包有了更深入的理解:闭包的操作上,是把一个局部作用域赋值给一个全局变量,使这个局部作用域中的变量可以以一种特殊的方式在全局作用域中获得。但因为这个局部作用域被保留在一个全局变量中,所以它也将无法再调用结束后被回收,并一直保持原有的变量跟值。

ES6的作用域

这里之所以要提到ES6的作用域,是因为ES6已经是前端工程师必需学习的内容了,再者ES6的作用域确实跟我们所知道的JavaScript(ES5)的作用域有所不同。这里就做一些介绍。

let

估计很多人都知道ES6 let的存在了,这里就只是对一些点做介绍,给后面ES6的作用域做铺垫。

首先,let所声明的变量只在所在的代码块内有效。这个就跟var有比较大的区别了,比如下面的代码:

代码3.1:

1
2
3
4
5
6
{
var a = 5;
let b = 10;
}
console.log(a); // 5
console.log(b); // ReferenceError: b is not defined

上面的代码就可以看出,在{}内用let声明的b在全局作用域上无法调用,直接报错了。

代码3.2:

1
2
3
4
5
6
{
console.log(a); // undefined
console.log(b); // ReferenceError: b is not defined
var a = 5;
let b = 10;
}

把代码3.1修改成代码3.2,运行的结果也很明显了:let也不支持变量提升。let声明的变量必须在声明后使用(包括赋值,运算等),否则会报错。

代码3.3:

1
2
3
4
5
6
7
8
9
var b = 15;
var c = 20;
{
console.log(a); // undefined
console.log(c); // 20
console.log(b); // ReferenceError: b is not defined
var a = 5;
let b = 10;
}

再改成改成代码3.3,运行的结果就可以得到另一个结论:存在let声明的区域不受外部的影响,被锁区了,叫暂时性死区

当然,还有一个特性,就是let声明的变量不可以重复声明,这个大家就自己去尝试了。

const

const很多特性跟let一样,比如上面提及let的四个特性也是const的特性。但const跟let有一个比较大的区别,就是const声明的是常量,这个常量只能被读取,不能二次修改写入。而且必须要在声明的同时进行赋值。

块级作用域

上面的let跟const其实都是铺垫,最终还是要回归到作用域这个话题上来。ES6跟ES5在作用域上最大的不同就是支持块级作用域,而let跟const就是块级作用域的坚强后盾。

从我的理解看来,暂时性死区其实就是让全局作用域跟函数作用域中的某一块代码变成相对独立的块级作用域,这个块级作用域的形成需要有let或const的声明作为标志。

块级作用域的特点从let跟const的四个特性可见一斑。外部作用域无法获得块级作用域内let变量跟const常量,块级作用域内部let声明的变量也不会覆盖外部作用域声明的变量。这样就可以防止像for循环中i变量的全局污染。

还有一个特点可以自己去尝试一下的,函数本身的作用域在其所在的块级作用域之内。这是一个需要注意的知识点。

结语

关于JavaScript的作用域写了很多,上述一些内容,有来自《你不知道的JavaScript(上)》前面几章,也有来自阮一峰老师的《ES6标准入门(第二版)》相关的章节(目前已经出了第三版了),掺杂着一些个人的理解跟学习心得。我相信对作用域有这样深一层的理解,会对相关的问题有更准确的判断。