作用域是什么?
编译原理
传统编译语言的流程中,程序的源代码在执行之前经过三个步骤,称为编译。
分词/词法分析(Tokenizing/Lexing)
解析/语法分析(Parsing)
代码生成
将AST(Abstract Syntax Trees)抽象语法树转换为可执行代码的过程被称为代码生成。
理解作用域
首先理解几个概念:
- 引擎:从头到尾负责整个JavaScript程序的编译及执行过程。
- 编译器:负责语法分析及代码生成。
- 作用域:负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套严格的规则,确定当前执行的代码对这些标识符的访问权限。(一套规则,用来管理引擎如何在当前作用域以及嵌套的作用域中根据标识符名称进行变量查找)
以上代码在引擎有两个完全不同的声明,一个由编译器在编译时处理,另一个由引擎在运行时处理。var a = 2;
编译器首先会将这段程序分解成词法单元,然后将词法单元解析成一个树结构。
编译器遇到var a,会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果存在,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a。
接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理a = 2的赋值操作。引擎运行时会首先询问作用域,在当前作用域集合中是否存在一个叫作a的变量。如果是,引擎就会使用这个变量;否则引擎会通过作用域链继续向上查找。
如果找到了变量a,就会将2赋值给它。否则引擎就会抛出一个异常。
变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量,然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。
LHS(left-hand side)和 RHS (right-hand side)
当变量出现在赋值操作的左侧时进行LHS查询,出现在右侧时进行RHS查询。LHS查询时试图找到变量的容器本身,从而可以对其进行赋值;RHS查询则是得到某个变量的源值。
LHS可以理解为赋值操作的目标是谁;RHS可以理解为谁是赋值操作的源头。
作用域嵌套
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或者抵达最外层的作用域(全局作用域)为止。
function foo(a){
console.log(a + b);
}
var b = 2;
foo(2); //4
对b进行的RHS引用无法在函数foo内部完成,但可以在上一级作用域中完成。
遍历嵌套作用域链的规则:引擎从当前的执行作用域开始查找变量,如果找不到,就向上一级继续查找。当抵达最外层的全局作用域时,无论找到还是没找到,查找过程都会停止。
异常
区分LHS和RHS是一件很重要的事情,因为在变量还没有声明(在任何作用域中都无法找到该变量)的情况下,这两种查询的行为是不一样的。
function foo(a){
console.log(a + b);
b = a;
}
foo(2);
第一次对b进行RHS查询时是无法找到该变量的,在任何相关的作用域中都无法找到它,说明这是一个“未声明”的变量。
如果RHS查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出ReferenceError异常。
相较之下,当引擎执行LHS查询时,如果在顶层中无法找到目标变量,全局作用域中会创建一个具有该名称的变量,并将其返回给引擎,前提是程序运行在非“严格”模式下。
ES5中引入了“严格模式”。严格模式下禁止自动或隐式创建全局变量。因此在LHS查询失败时,并不会创建并返回一个全局变量,引擎会抛出同RHS查询失败时类似的ReferenceError异常。
如果RHS查找到了一个变量,但是尝试对该变量的值进行不合理的操作时,比如一个非函数类型的值进行函数调用,或者引用null或undefined类型的值中的属性,那么引擎会抛出另一种类型的异常TypeError。
ReferenceError相当于作用域判别失败,TypeError代表作用域判别成功,但是对结果的操作是非法或不合理的。
词法作用域
词法阶段
上面提过编译器的第一个工作阶段叫做词法化,词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。
而词法作用域是定义在词法阶段的作用域。词法作用域就是在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析其处理代码时会保持作用域不变。
function foo(a){
var b = a * 2;
function bar(c){
console.log(a,b,c);
}
bar(b * 3);
}
foo(2); // 2 4 12
上例存在三个逐级嵌套的作用域:
全局作用域,其中只有一个标识符:foo。
foo创建的作用域,其中有三个标识符:a、bar、b。
bar创建的作用域,其中只有一个标识符:c。
作用域的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符的位置。
在上方代码中,引擎执行console.log()声明,并查找a,b,c三个变量的引用。首先从最内部的作用域bar函数的作用域中开始查找,引擎无法在这里找到a,因此会去上一级嵌套的foo作用域中继续查找。这里找到了a,则使用这个引用。对b也是一样的。对c来说,引擎在bar函数作用域中就找到了它。
如果bar作用域和foo作用域中都存在一个c,console.log(c)则会使用bar作用域中的变量,无需到外面查找。
作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部标识符“遮蔽”了外部的标识符)。
全局变量会自动成为全局对象(例如浏览器的window对象)的属性,因此可以通过对全局对象属性的引用对其进行访问。
无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。
欺骗词法
如果词法作用域完全由写代码期间函数所声明的位置来定义,怎么才能在运行时来“修改”词法作用域呢?
eval
JavaScript中的eval()函数可以接受一个字符串为参数,并将其中的内容视为好像书写时就存在于程序中这个位置的代码。
在执行eval()之后的代码时,引擎并不知道前面的代码是以动态形式插入进来,并对词法作用域的环境进行修改的。引擎只会如往常进行词法作用域查找。
function foo(str,a){
eval(str); //欺骗
console.log(a,b);
}
var b = 2;
foo("var b = 3;" 1); // 1,3
eval()调用中的“var b = 3;”这段代码会被当作本来就在那里一样来处理。由于那段代码声明了一个新的变量b,因此它对已经存在的foo的词法作用域进行了修改,遮蔽了外部作用域的同名变量。
在严格模式的程序中,eval()在运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域。
function foo(str){
"use strict";
eval(str);
console.log(a); //ReferenceError: a is not defined
}
foo("var a = 2");
with
with通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。
var obj = {a: 1, b: 2, c: 3};
//快捷方式
with(obj){
a = 3;
b = 4;
c = 5;
}
但实际上不仅仅是方便访问对象属性,例如以下代码:
function foo(obj){
with(obj){
a = 2;
}
}
var o1 = {a: 3};
var o2 = {b: 3};
foo(o1);
console.log(o1.a); // 2
foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2,a被泄露到全局作用域上了
当调用foo(o2)时,实际上a = 2赋值操作创建了一个全局的变量a。
with可以将一个或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中词法标识符。
函数作用域和块作用域
函数中的作用域
function foo(a){
var b = 2;
function bar(){
// do something
}
var c = 3;
}
以上代码中的foo作用域包含了标识符a,b,c,bar。无论标识符声明出现在作用域中的何处,这个标识符所代表的变量或函数都将附属于所处作用域。
bar函数拥有自己的作用域,全局作用域也有自己的作用域,它只包含了一个标识符:foo。
由于标识符a、b、c、bar都附属于foo作用域,因此无法重foo函数的外部对他们进行访问。也就是说这些标识符无法从全局作用域中进行访问。但是这些标识符在foo函数内部都是可以被访问的,在bar函数内部也可以被访问。
函数作用域的含义指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(在嵌套作用域中也可以使用)。这种设计方案非常有用,能充分利用JavaScript变量可以根据需要改变值类型的“动态”特性。
隐藏内部实现
function doSomething(a){
b = a + doSomethingElse(a * 2);
console.log(b * 3);
}
function doSomethingElse(a){
return a - 1;
}
var b;
doSomething(2); // 15
以上代码中,变量b和函数doSomethingElse应该是doSomething内部具体实现的“私有”内容。给与外部作用域对b和doSomethingElse的“访问权限”不仅没有必要,而且可能很“危险”,因为它们可能被有意或无意以非预期的方式使用。更合理的设计会将这些私有的具体内容隐藏在doSomething内部。
function doSomething(a){
function doSomethingElse(a){
return a - 1;
}
var b;
b = a + doSomethingElse(a * 2);
console.log(b * 3);
}
doSomething(2); // 15
规避冲突
“隐藏”作用域中的变量和函数所带来的的另一个好处,是可以避免同名标识符之间的冲突,两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。冲突会导致变量的值被意外覆盖。
function foo(){
function bar(a){
i = 3; // 修改for循环所属作用域中的i
console.log(a + i);
}
for(var i = 0; i < 10; i++){
bar(i * 2); // 无限循环
}
}
foo();
bar内部的赋值表达式 i = 3 意外地覆盖了声明在foo内部for循环的i,导致无限循环。
bar内部的复制操作需要一个本地变量来使用,采用任何名字都可以,var i = 3;就可以满足这个需求(遮蔽效应)。
函数作用域
在任意代码片段外部添加包装函数,可以将内部的变量和函数定义隐藏起来,外部作用域无法访问包装函数内部的任何内容。虽然这种技术可以解决一些问题,但不理想,因为会导致一些额外的问题,声明具名函数会“污染”所在作用域。
匿名和具名
对于函数表达式最熟悉的场景应该就是回调函数了。
setTimeout(function(){
console.log('wait');
},1000);
这是匿名函数表达式,因为function()…没有名称标识符,函数表达式可以是匿名的,而函数声明则不可以省略函数名—-在JavaScript的语法中非法。
立即执行函数表达式
var a = 2;
(function foo(){
var a = 3;
console.log(a);
})();
console.log(a);
由于函数被包含在一对()括号内部,因此成为了一个表达式,通过在末尾加上另一个()可以立即执行这个函数,第一个()将函数变成表达式,第二个()执行了这个函数。
这种模式在社区被规定了一个术语:IIFE(Immediately Invoked Function Expression)立即执行函数表达式。
块作用域
try/catch
JavaScript的ES3规范中规定了try/catch的catch分局会创建一个块作用域,其中声明的变量仅在catch内部使用。
try{
undefined(); //执行一个非法操作来强制制造一个异常
}catch(err){
console.log(err);
}
console.log(err); // ReferenceError: err not found
let
ES6提供了新的let关键字,提供了除var以外的的另一种变量声明方式。
let关键字可以将变量绑定到所在的任意作用域中(通常是{}内部)。换句话说,let为其声明的变量隐式地劫持了所在的块作用域。
var foo = true;
if(foo){
let bar = foo * 2;
console.log(bar);
}
console.log(bar); // ReferenceError
使用let进行的声明不会在块作用域中进行提升。声明的代码被运行之前,声明并不“存在”。
{
console.log(bar); // ReferenceError
let bar = 2;
}
const
除了let以外,ES6还引入了const,同样可以用来创建块作用域变量,但其值是固定的常量。之后任何试图修改值的操作都会引起错误。
var foo = true;
if(foo){
var a = 2;
const b = 3; //包含在if中的块作用域常量
a = 3;
b = 4; //错误
}
console.log(a); //3
console.log(b); //ReferenceError
提升
考虑如下代码
a = 2;
var a;
console.log(a);
很多人会认为输出undefined,因为var a 声明在 a = 2之后,认为变量重新赋值了,因此会被赋值为undefined。但是真正的结果是2。
编译器中提升
引擎在解释JavaScript代码之前首先对其进行编译。编译阶段中一部分工作就是找到所有的声明,并用合适的作用域将他们关联起来。这个机制,也是词法作用域的核心内容。
变量和函数在内的所有声明都会在任何代码执行前首先被处理。
当看到var a = 2时,JavaScript实际上会将其看成两个声明:var a; 和 a = 2;第一个定义声明在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。
只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。
foo();
function foo(){
console.log(a); // undefined
var a = 2;
}
foo函数的声明被提升了,因此第一行中的调用可以正常执行。值得注意的是,每个作用域都会进行提升操作。
foo(); // 不是ReferenceError,而是TypeError
var foo = function bar(){
// do someting
}
函数声明会被提升,但是函数表达式却不会被提升。
即使是具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用:
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar(){
// ...
}
这些代码片段经过提升后,实际上会被理解成以下形式:
var foo;
foo(); //TypeError
bar(); //ReferenceError
foo = function(){
var bar = ...self...
}
函数优先
函数声明和变量声明都会被提升,但是是函数首先被提升(这个细节可以出现在有多个“重复”声明的代码中),然后才是变量。
foo(); // 1
var foo;
function foo(){
console.log(1);
}
foo = function(){
console.log(2);
}
这段代码被引擎理解成如下形式:
function foo(){
console.log(1);
}
foo(); //1
foo = function(){
console.log(2);
};
尽管var foo 出现在function foo()…声明之前,但是它是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前。
foo(); // 3
function foo(){
console.log(1);
}
var foo = function(){
console.log(2);
}
function foo(){
console.log(3);
}
尽管重复的var声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。
foo(); //TypeError: foo is not a function
var a = true;
if(a){
function foo(){
console.log('a');
}
}else{
function foo(){
console.log('b');
}
}
一个普通块内部的函数声明通常会被提升到所在作用域的顶部。这个行为并不可靠,有可能在未来的JavaScript版本中改变,因此应该尽可能避免在块内部声明函数。
练习
function foo(){
console.log(a); // ????
}
function bar(){
var a = 3;
foo();
}
var a = 2;
bar();
var b = 'boy';
console.log(b);
function fighting(){
console.log(a);
console.log(b);
if(a === 'apple'){
a = 'Alice';
}else{
a = 'Ada';
}
console.log(a);
var a = 'Andy';
middle();
function middle(){
console.log(c++);
var c = 100;
console.log(++c);
samall();
function samall(){
console.log(a);
}
}
var c = a = 88;
function bottom(){
console.log(this.b);
b = 'baby';
console.log(b);
}
bottom();
}
fighting();
console.log(b);