在上一章,我们讲到,我们将作用域比作一套规则,这套规则用于管理引擎在当前作用域及其子作用域下根据标识符名称进行变量查找。
在深入理解作用域之前我们先了解两个概念:动态作用域、词法作用域
词法分析域:作用域在词法阶段就已经建立了,它的作用域是静态的。你在写代码时将变量和块作用域写在哪,词法作用域就在哪。当词法分析器处理代码时会保持作用域不变(绝大多数编程语言采用)。
动态分析域:代码调用是动态的确定,它由函数调用栈来确定变量的值。它不关心变量在何处声明以及如何声明,只关心在何处调用
借助下面的例子你会理解这两个概念的。
举个例子
var a=2;
function foo(){
console.log(a);
};
function bar(){
var a=3;
foo()
};
如果此处是词法分析域的话,在foo()中查询不到a的值,那么往外层作用域查询,在第一行a=2。输出结果2
动态作用域的话,在foo()中查询不到a,那么便顺着调用栈,向bar()处查询,在bar中a=3,输出结果3
1.1词法阶段
大部分编译器的第一个工作阶段是词法化,词法化的过程会对源代码的字符进行检查,如果是有状态的解析过程还会赋予单词语义。
词法作用域的定义是:在词法阶段就已经确定好的作用域。通俗一点解释,词法分析域是由你在写代码时将变量和块作用域写在哪来确定的。
下面我们举个例子来更好的认识词法作用域
function foo(a){
var b=a*2;
function bar(c){
console.log(a,b,c);
};
};
foo(a);
这个例子中有三个逐级嵌套的作用域,
第一级为最外层的作用域,包含了foo()函数以及引用
第二级为中间的作用域,foo()函数内定义的内容(var b=a*2以及bar函数)
第三极为最里层的作用域,bar()函数定义的内容。
作用域气泡在哪由对应的作用域代码块在哪来决定,它们是逐级包含逐级嵌套的。
在上面bar()气泡在foo()气泡内,是我们希望它在那(定义)的
查找
作用域气泡位置以及结构给了引擎足够的信息来查找变量。
比如在上面的代码中,引擎执行console.log(a,b,c)声明,c变量在bar()内就有,a和b不在,便往上一级查询,在foo()中查询到了a和b。
总结:引擎查找从最开始的气泡向更高级气泡查找,一旦查找到了第一个符合的目标则停止查找。
这什么意思呢?在这里我们要引出一个概念,遮蔽效应。在多层嵌套的作用域中,我们可以定义同名的标识符。抛开遮蔽效应,作用域查找始终从运行的最内部的作用域开始查起,逐级向外向上进行,直到遇到第一个匹配的标识符为止。
注意!!!在我们定义了多个标识符,但是我们又想使用最顶层的标识符,那该怎么办?在Java Script中有着一个重要特点,全局变量会自动成为全局对象(如浏览器的window)的属性,因此可以间接的通过访问全局对象的属性来访问全局变量。
window.a
注意,非全局变量一旦被遮蔽了就再也无法访问了。
无论函数在哪被调用以及如何被调用,它的词法作用域只在被声明时所处的位置决定。
词法作用域只会查找一级标识符,比如a、b、c。如果代码里面引用了foo.bar.baz。词法作用域只会查找第一个foo标识符,找到这个变量后对象属性访问规则会分别接管bar、baz的访问
1.2欺骗词法
词法分析域并不是不可改变的,严格来讲不算改变,而是欺骗引擎词法分析域的位置。
现在普遍有两种方法来欺骗词法域,在解释这两种方法之前,我们要先了解下,欺骗词法域是会降低性能的
1.2.1eval
JavaScript中的eval()函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序的这个位置的代码。通俗一点解释就是,可以在你写的代码中(传入的字符串)用程序生成代码并运行,就好像代码原本就在那个位置的一样。
根据之前我们说的欺骗词法域,它将我们输入的字符串转化成原本就在那个位置的代码,于是起到了欺骗的效果
为了更好的理解,我们来看个例子
function foo(str,a){
eval(str);
console.log(a,b);
}
var b=2;
foo("var b=3",2);
eval中我们传入了"var b=3"为参数,这段代码会被当做本来就在那里一样来处理,而且词法作用域丝毫没有察觉。它声明了一个新的b来取代之前的全局变量b(遮蔽效应,局部变量遮蔽全局变量)。这其实已经修改了词法作用域了。
让我们来看看输出,console.log只会看到内部的b=3和a=2,而不会看到外部变量b=2。
默认情况下,eval(....)中执行的代码包含一个或多个声明(无论是变量或者函数),就会对eval()所在的词法作用域进行修改。技术上通过一些技巧可以间接调用eval(...)来使其运行在全局作用域中,并对全局作用域进行修改。
但是无论怎样,eval(...)中都可以在运行期间修改在书写期就已经决定的词法作用域。
尽管eval(...)看起来很厉害,但是在严格模式下,eval有自己的词法作用域,这就意味着,在严格模式下eval(...)不能修改所在的词法作用域
举个例子
function foo(str)
{
"use strict"
eval(str);
document.write(a); //ReferenceError: a in not difined
};
foo("var a=2");
调试结果
总结:eval可以在非严格模式下通过传入代码类的字符串修改所在的词法作用域,但是它带来的好处无法抵消性能上的伤害。
1.2.2 with
with()函数的作用是可以重复的引用对象的不同属性从而简略对象的编写,什么意思呢?
举个例子
var obj={
a:1;
b:2
c:3
};
//单调的引用
obj.a=4;
obj.b=5;
obj.c=6;
//使用with()函数
with(obj){
a=4;
b=5;
c=6;
}
从上面可以看出,在不运用with的时候,我们对obj对象属性的引用,每一次都要声明obj,而使用了with可以减少obj的书写次数。
那么,with是如何欺骗词法域的呢?通过下面例子我们来进行说明
function foo(obj){
with(obj){
a=2;
};
};
var o1={
a:3
};
var o2={
b:5
};
foo(o1);
console.log(o1.a); //a=2;
foo(o2);
console.log(o2.a); //undefined
console.log(a); //a=2
输出结果
这个例子中式创建了两个对象,一个是o1,另一个是o2。o1中存在a属性,值为3。o2中存在b属性,值为5。
在foo()中传入一个对象,在foo()函数的内部存在:with(obj){ a=2;}。分别对foo()函数传入了o1,o2两个对象。输出o1.a、o2.a、a。
通过输出我们可以看到----o1.a=2、o2.a=undefined、a=2;
with改变o1存在的a值,o2中不存在a所以输出undefined,可是"a=2"赋值操作创建了一个全局变量a???
这是因为with()可以将一个都没有或者有一至对个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理成定义在这个作用域的词法标识符。通俗一点讲,便是在with中定义的属性会被创建为with作用域的变量(必须要引用)
eval(...)是修改所在的词法作用域,with(...)是根据你传给它的对象,凭空创建了一个全新的词法作用域。
1.1.3性能
也许你会认为eval()以及with()所带来的好处大于它的坏处,然而并不是这两种方法很严重的形象到了Java Script的性能,
Java Script在编译阶段会进行数段优化,其中有些优化依赖于能够根据代码中的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行的时候快速找到标识符。
假如引擎在代码中发现了eval(...)、with(...),它会假设之前做的关于变量和函数的位置都是错的,因为无法在词法分析阶段就知道eval(...)传入了什么代码,这些代码如何对词法作用域进行修改。也无法知道传递给with用来创建词法作用域的对象的内容到底是什么。
更为糟糕的情况是,在eval(...)以及with(...)出现以后,做什么优化都是无效的,因此引擎将不做任何优化。