js进阶系列(二):javascript词法作用域

/ 1评 / 0

在之前我们讲过,将作用域定义成“一套规则”,这套规则用于管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称来进行变量的访问。作用域就我知道的有两种,一种是词法作用域,也可以叫做静态作用域,js的作用域就是词法作用域(静态作用域),我会尽我所能去讨论这个词法作用域;还有一种就是动态作用域,比较少用(比如Bash脚本等)。

简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。

function fun1(a) {
	var b = a * 2;
	function fun2(c) {
		console.log( a, b, c );
	}
	fun2( b * 3 );
}
fun1( 2 ); // 2, 4, 12

在这个例子中有三个逐级嵌套的作用域,

1、是全局作用域,里面有一个标识符  fun1;

2、包含fun1所创建的作用域,其中有三个标识符 :a 、b、fun2

3、包含fun2所创建的作用域,其中三个标识符:a 、b、c

一、查找

引擎会根据作用域的来查找标识符的位置,引擎执行console.log(..) 声明,并查找a、b 和c 三个变量的引用。它首先从最内部的作用域,也就是fun2(..) 函数的作用域气泡开始查找。引擎无法在这里找到a,因此会去上一级到所嵌套的fun1(..) 的作用域中继续查找。在这里找到了a,因此引擎使用了这个引用。对b 来讲也是一样的。而对c 来说,引擎在fun2(..) 中就找到了它。如果a、c 都存在于fun1(..) 和fun2(..) 的内部,console.log(..) 就可以直接使用fun2(..)中的变量,而无需到外面的fun1(..) 中查找。

作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应,作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止。

全局变量会自动成为全局对象(比如浏览器中的window 对象)的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问。window.a通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量如果被遮蔽了,无论如何都无法被访问到。

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。词法作用域只会查找一级标识符,何为一级标识符呢?比如  上面代码的  a 、b、c,如果查找的是obj.a.b;那么它只会试图查obj 标识符,找到这个变量后,对象属性访问规则会分别接管对a和b属性的访问。

二、欺骗词法(改变词法作用域)

前面我们讲过,词法作用域是有变量和快作用域的位置决定的,但是在某些特殊情况下,这种情况会发生改变,也就是欺骗词法,以下就是特殊的情况,我们继续探讨:

1、eval()

2、width()语句

eval

eval()函数可以接受一个字符串,并执行其中的的 JavaScript 代码。函数的返回值是通过通过计算 string 得到的值(如果有的话)。换句话说就是可以让你在写代码中用程序动态生成的代码并运行,就相当于代码真的是写在了那个位置了。

在执行代码的时候,引擎是不知道或是不在意你写在eval()函数内的代码的,也就是说引擎会一如既往的按照词法作用域来查找代码。比如

var b=1;
function foo(srt,a){
    eval(str);        //欺骗
    console.log(a+‘,’+b);
}
foo('var b=2',3);      //3,2

 通过这个例子我们可以发现,eval('var b=2');这段代码会被当作本来就在那里一样来处理。由于那段代码声明了一个新的变量b,因此它对已经存在的foo(..) 的词法作用域进行了修改。这个操作对foo()里面的词法作用域进行欺骗,从而屏蔽了foo()对外部变量b(全局变量)进行查找。所有foo()最后输出的永远是3,2而不会是正常情况下的1,3.

在这个例子中,我们传递eval()的是不变的"var b=2";而实际情况,我们非常可能会根据业务逻辑需要而传递进去动态生成的代码拼接而成的字符,也就是说非常有可能代码包含有一个或多个声明(无论是变量还是函
数),就会对eval(..) 所处的词法作用域进行修改。但无论何种情况,eval(..) 都可以在运行期修改书写期的词法作用域。或许有人会想,这个功能不错啊,可能以后需要这个业务需求就可以用这个eval()了,但是现实是残酷的,eval()有很大的弊端。具体是什么弊端,等讲完with再一起总结。

在严格模式的程序中,eval(..) 在运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域。

function foo(str) {
     "use strict";
     eval( str );
     console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2" );

with

另一个能欺骗词法的是with关键字。with语句在《JavaScript》权威指南的定义是:一个可以按序检索对象列表,通过它可以进行变量名解析。with语句用于临时拓展作用域链。

这句话看的我云里雾里的,在这里我通过一些实例来理解with

var obj = {
    a: 1,
    b: 2,
    c: 3
};
obj.a=3;
obj.b=4;
obj.c=5;
console.log(obj.a+" "+obj.b+" "+obj.c);   //3 4  5

结果是毫无疑问的,with在这个情况下可以把它当成一种快捷方式:

var obj = {
    a: 1,
    b: 2,
    c: 3
};
with(obj){
	a=3;
	b=4;
	c=5;
}
console.log(obj.a+" "+obj.b+" "+obj.c);   //3 4  5

输出结果也毫无疑问,实际上with不仅仅是为了方便的访问对象属性。

function foo(obj) {
	with (obj) {
	a = 2;
	}
}
var obj1 = {
	a: 3
};
var obj2 = {
	b: 3
};
foo( obj1 );
console.log( obj1.a ); // 2
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2 ——不好,a 被泄漏到全局作用域上了!相当于内存泄漏了!!!

上面我们定义了两个对象,obj1和obj2,obj1定义一个a属性,而obj2没有定义a属性,但是有b属性,foo(..) 函数接受一个obj 参数,该参数是一个对象引用,并对这个对象引用执行了with(obj) {..}。在with 块内部,看起来只是对变量a 进行简单的词法引用,实际上就是一个LHS(不理解的话可以参考上一篇文章。) 引用,并将2 赋值给它。当我们将obj1 传递进去,a=2 赋值操作找到了obj1.a 并将2 赋值给它,这在后面的console.log(obj1.a) 中可以体现。而当obj2 传递进去,obj2 并没有a 属性,因此不会创建这个属性,obj2.a 保持undefined。但是可以注意到一个奇怪的副作用,实际上a = 2 赋值操作创建了一个全局的变量a。这是怎么回事?

with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。

尽管with 块可以将一个对象处理为词法作用域,但是这个块内部正常的var声明并不会被限制在这个块的作用域中,而是被添加到with 所处的函数作用域中。

eval(..) 函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。可以这样理解,当我们传递obj1 给with 时,with 所声明的作用域是obj1,而这个作用域中含有一个同obj1.a 属性相符的标识符。但当我们将obj2 作为作用域时,其中并没有a 标识符,因此进行了正常的LHS 标识符查找

o2 的作用域、foo(..) 的作用域和全局作用域中都没有找到标识符a,因此当a=2 执行时,自动创建了一个全局变量(因为是非严格模式)。with 这种将对象及其属性放进一个作用域并同时分配标识符的行为很让人费解。

理论上,我们通过一些技巧可以实现动态代码去修改词法作用域(欺骗词法),但是这种情况有个很大的弊端就是欺骗:词法作用域会导致性能下降。怎么会这样呢?前面我们有讨论过引擎,JavaScript 引擎要复杂得多,JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。但如果引擎在代码中发现了eval(..) 或with,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道eval(..) 会接收到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给with 用来创建新词法作用域的对象的内容到底是什么。最悲观的情况是如果出现了eval(..) 或with,所有的优化可能都是无意义的,因此最简单的做法就是完全不做任何优化。如果代码中大量使用eval(..) 或with,那么运行起来一定会变得非常慢。无论引擎多聪明,试图将这些悲观情况的副作用限制在最小范围内,也无法避免如果没有这些优化,代码会运行得更慢这个事实。

 

 

发表评论

电子邮件地址不会被公开。 必填项已用*标注