引子
在进入本文的主题前,首先请大家判断如下代码输出结果为什么?并说明理由。
function foo(){
console.log(a);
}
function bar(){
var a=3;
foo();
}
var a=2;
bar();
不管大家的答案是什么,这里正确得答案是2,至于为什么,就请先看下文。相信大家看完本文之后,应该能找到真正得原因。
为什么需要作用域?
在介绍作用域前,首先请看如下两个函数,哪个函数功能更强大?
function func1(){
return 1+2
}
function func2(a,b){
return a+b;
}
显而易见,使用了变量的函数功能更为强大。变量给予了程序更加强大的功能,如果没有变量,程序只能做一些很简单得运算,有了变量,就能做更多更有意思的事情,但是程序在运行时,在需要时又是如何找到变量并使用它呢?
此时就引出了本文今天的主角——作用域,作用域永远都是任何一门编程语言中的重中之重,它控制着变量的可见性与生命周期。在javascript中,作用域在代码编译与运行过程中帮助javascript引擎根据标识符名称(即变量名)进行变量的查找与访问。
javascript作用域
从源代码到运行结果
大家都知道我们现有的冯诺依曼体系的计算机,归根结底运行的始终只能是机器语言,机器语言是用二进制代码表示的计算机能直接识别和执行的一种机器指令的集合。而我们平常编程采用的编程语言,比如java,c,c++,Object C,javascript等,都是属于高级语言,高级语言是面向用户的语言,因为它对人类更友好,更易理解与编写,但是我们采用高级语言编写的代码始终是要在计算机上运行的,所以,我们用任何一种高级语言编写的代码程序,都必须要经过处理,生成为机器可以理解执行的机器语言,而把高级语言代码转换为机器语言指令的过程就叫编译。
传统的编程语言,在执行前会经过如下三个步骤:
- 词法分析:这个过程将由字符组成的代码字符串分解成有意义得代码块,这些代码块即为词法单元,如var a=1,这个语句会被分解为如下词法单元:var、a、=、1;
- 语法分析:这个过程是将词法单元(数组)转换为一个由元素逐级嵌套所组成的代表了程序语法结构的树,称为“抽象语法树”(Abstract Syntax Tree,AST);
- 代码生成:将AST转换为可执行代码的过程被称为代码生成。即将AST转换为机器指令。
如c语言这类静态编译语言,首先编译生成对象文件,然后再使用对象文件去运行,所以可以在运行前进行大量的细致的优化处理,而javascript是动态编译的,它是编译了就马上执行,并不生成特定对象文件。这就决定了javascript无法在编译阶段进行过多得优化处理。这就是javascript执行性能始终不如C这类静态编译语言的原因。这两者的区别如图:
也许大家会好奇,本文的主旨不是作用域吗?怎么说到编译来了,这是因为javascript在编译阶段就已经使用了作用域!
javascript编译与运行阶段的作用域
在说javascript编译与运行阶段的作用域前,首先,这里说一下javascript中得三个关键概念:
- 引擎:从头至尾负责整个javascript的编译及运行;
- 编译器:引擎对代码进行的编译即由编译器支持的,主要负责语法分析,代码生成等;
- 作用域:负责在javascript编译阶段与运行阶段收集并维护所有声明的标识符(变量)的查找与访问权限的控制;
三者关系如下图:
下面以 var a=1
为例,阐述一下作用域如何参与到javascript的编译阶段与运行阶段中。
大部分程序员看到var a=1
这条语句时,都会以为这是一句声明,但是对于javascript引擎来说,却并非如此,javascript引擎会把这条代码一分为二,拆开来看,分别为var a;
与a=1
,前者由编译器在编译时进行处理,后者,由引擎在运行时处理;
- 当javascript引擎遇到
var a
时,编译器会在当前作用域中查找该变量a,如果能找到,编译器会忽略该声明,否则,它会要求在当前作用域中声明一个新的变量,并命名为a;然后编译器会为引擎生成运行时所需得代码; - 当javascript引擎在运行代码遇到
a=1
这个赋值操作时,也会首先询问作用域中是否存在一个叫做a的变量,如果存在,则使用该表明了,如果不存在,在根据作用域链规则,去当前作用域的上一层级的作用域中查找变量,最终找到变量a,则将1赋值给它,否则引擎抛出异常。
由此可见,javascript引擎,在编译时,如果遇到变量声明,就会去作用域中查询是否已有该变量,没有则在作用域中声明记录,有则跳过;在运行阶段,当程序需要使用变量时,则会去作用域中查找该变量,有则,使用变量,无则抛出异常。
javascript的作用域模型
目前,作用域共有两种主要的工作模型,第一种是最为普遍,被大多数编程语言如java、c、c++等所采用的词法作用域,另一种是动态作用域,只有少数编程语言使用,如bash脚本,perl等;而javascript采用的就是词法作用域。
词法作用域:词法作用域就是定义在词法阶段(编译阶段的词法分析阶段)的作用域。换句话说,词法作用域是由程序员在编写代码时将变量与函数声明写在哪里来决定的。词法作用域的作用域链是基于代码中得作用域嵌套。
-
动态作用域:动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用,也就是说,动态作用域的作用域链是基于调用栈的,而不是代码中得作用域嵌套。
这里我们回到文件开头得那段代码:function foo(){ console.log(a); } function bar(){ var a=3; foo(); } var a=2; bar();
在词法作用域与动态作用域下,作用域关系图分别如下:
在词法作用域下,函数foo中并没有变量a,所以会沿着作用域链向它得上一级作用域,也就是全局作用域查找变量a,此时查找到a,且a的值为2,所以,代码输出结果为2;
而在动态作用域下,函数foo中也没有变量a。所以会沿着作用域链向它得上一级作用域,也就是函数bar的作用域中查找变量a,此时,函数bar中定义了一个局部变量a,且a的值为3,所以输出结果会为3。
那么接下来,请思考,如果把代码改为如下形式,输出结果又是什么?
function foo(){
console.log(a);
}
function bar(){
var a=3;
foo();
}
bar();
var a=2;
如果改成如下代码,结果又如何?
function foo(){
console.log(a);
}
function bar(){
var a=3;
foo();
}
bar();
let a=2;
ES6明确规定,如果区块中存在let和const命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。
总之,在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称 TDZ)。
结语
本文简单介绍了一下javascript的作用域的基本知识,作用域是所有编程语言的重中之重,更是javascript中得重中之重,是理解javascript中闭包概念的基础。如果你的目标是精通JavaScript语言,深入的理解它的各个组成,那么理解作用域便是你的起点。