研磨JavaScript系列(四):代码的时空
对于过程式编程来说,代码执行的时间与数据标识的空间是密不可分的。我们只有把指令执行的具体时刻与标识映射的具体地址结合起来,才能确定程序在执行瞬间的上下文状态。于是,代码时刻与数据标识的结构,就形成了作用域的概念。在一个作用域中的上下文状态,对于另一个作用域来说是不适用的。
当定义了一个个标识符,写下一条条语句时,我们只得到了程序的一个静态映像。还必须把这些元素放到动态的作用域环境去思考,才能理解整个程序的运行世界。下面我们来讨论一下JavaScript的作用域。
任何程序都会在一个原始的环境中开始运行,这个原始的环境就被称为全局环境。全局环境中包含了一些预定义的元素,这些元素对于我们的程序来说是自然存在的。它们本来就在那儿了,我们拿来就可使用。
在JavaScript里的全局环境就是一个对象,这个对象是JavaScript运行环境的根。对于浏览器中的JavaScript来说,这个根对象就是window。对于全局的JavaScript语句来说,window对象就相当于当前的作用域。
1 var myName = "Kevin"; //定义了window作用域下的一个变量myName 2 3 myName = "Kevin"; //定义了window对象的一个属性myName
在这里,window作用域的一个变量myName和window对象的一个属性myName几乎是等价的。对于全局的JavaScript语句来说,加不加var都无所谓,两种写法非常相似。
但是,对于一个函数体内的语句,有没有var就要小心了。
1 <script type="text/javascript"> 2 myName = "Kevin"; 3 var yourName = "Rose"; 4 5 alert(myName + " like " + yourName); //显示Kevin like Rose 6 7 changeName(); 8 9 function changeName(){ 10 alert("yourName is " + yourName); //显示yourName is undefined 11 alert("myName is " + myName); //显示myName is Kevin 12 13 var yourName = "Marry"; 14 myName = "Steven"; 15 16 alert(myName + " like " + yourName); //显示Steven like Marry 17 } 18 19 alert(myName + " like " + yourName); //显示Steven like Rose 20 </script>
这里我们看到,显然用var修饰的yourName标识符在函数内外是两个东西。外面的Rose不会因为changeName函数内改成了Mary而改变,回到外面还是Rose,然而,myName没有用var修饰,就是一个东西了,函数内的修改也就在函数外表现出来了。
还有,我们要注意的是changeName函数中的一句话,我们本是希望输出最外面的yourName的值,但此时的输出却表明yourName的值是undefined。而第二句中输出的myName却又是我们所期望的值。这是为什么呢?
这是因为使用var定义的是作用域上的一个变量,没有var的标识符却可能是全局跟对象的一个属性。当代码运行在全局作用域时,作用域就是跟对象window,所以,有没有var都无所谓。
但是,当代码运行进入一个函数时,JavaScript会创建一个新的作用域,来作为当前作用域的子作用域。然后,将当前全局作用域切换为这个新建的子作用域,开始执行函数逻辑。JavaScript执行引擎会把此函数内的逻辑代码,当一个代码段单元来分析和执行。
在第一步的预编译分析中,JavaScript执行引擎将所有定义式函数直接创建为作用域上的函数变量,并将其值初始化为定义的函数代码逻辑,也就是为其建立了可调用的函数变量。而对于所有var定义的变量,也会在第一步的预编译中创建起来,并将初始值设为undefined。
随后,JavaScript开始解释执行代码。当遇到对函数名或变量名的使用时,JavaScript执行引擎会首先在当前作用域查找函数或变量,如果没有找到,就到上层作用域查找,依次类推。因此,前面的语句引用后面语句定义的var变量时,该变量其实已经存在,只是初始值为undefined而已。
因此,用var定义的变量只对本作用域有效,尽管此时上层作用域有同名的东西,都与本作用域的var变量无关。退出本作用域之后,此var变量消失,回到原来的作用域该有啥就还有啥。
其实,函数在每次调用时都会产生一个子作用域,退出函数时,这个子作用域就会消失,下次调用相同函数时又是另一个子作用域了。在运行的函数内再调用另外的函数是,又产生了另一个作用域,这就会随着函数调用的深入自然形成一种叫作用域链的东西。甚至在递归调用的情况下,作用域链会变得很长很长。
JavaScript查找标示符时,除非指明特定对象,否则会沿着作用域链寻找符合这一标识符的变量或属性,甚至会自动创建该标识符。正是由于JavaScript的这种灵活性,我们就可能在不经意间覆盖了原来的变量或属性,或者无意中创建了大量无用的全局属性,甚至在毫不知情的情况下影响了其他的代码逻辑,从而造成许多很难排除的Bug。
因此,当我们在编写和调试JavaScript代码时,我们的思绪应该尽量放到具体的作用域时空上去思考。这样,我们就能明白为什么标识符尽量不要与已有的东西重名?为什么变量一定要加var?为什么访问属性一定要指明对象?理解和注意这些问题,也就能轻易地排除某些奇怪的bug。
虽然,我们的代码没有办法访问到JavaScript内部的作用域对象,但JavaScript还是给我们提供了与作用域相关的某些可用元素,我们还是能知道当前作用域上下文中德某些信息的。JavaScript提供的调用上下文信息由几个,一个是函数本身,一个是函数的caller属性,另外还有this关键字和arguments隐含对象。
1 <script type="text/javascript"> 2 function func(){ 3 alert(func.toString()); //函数体内可以使用自身的标识符 4 } 5 6 func(); 7 </script>
函数自身有一个caller属性,其标示调用当前函数的上层函数。这就是提供了一个可以追溯函数调用来源的线索,特别对于调试JavaScript来说是不可多得宝贝。
1 <script type="text/javascript"> 2 function whoCallerMe(){ 3 alert("My Caller is " + whoCallerMe.caller); //输出自己的caller 4 } 5 6 function callerA(){ 7 whoCallerMe(); 8 } 9 10 function callerB(){ 11 whoCallerMe(); 12 } 13 14 alert(whoCallerMe.caller); //输出null 15 16 whoCallerMe(); //输出My Caller is null 17 18 callerA(); //输出My Caller is function callerA(){whoCallerMe();} 19 20 callerB(); //输出My Caller is function callerB(){whoCallerMe();} 21 </script>
函数的caller属性是null,表示函数没有被调用或者是被全局代码调用。函数的caller属性值实际上是动态变化的。函数的初始caller值都是null,当调一个函数时,如果代码已经运行在某个函数体内,JavaScript执行引擎将被函数的caller属性设置为当前函数。在退出被调用函数作用域时,被调函数的caller属性会恢复为null值。
this关键字表示函数正在服务的这个对象。
arguments对象,从字面上看是表示调用当前函数的参数们。除了我们可以直接使用函数参数定义列表中的那些标示符莱访问之外,还可以用arguments对象按数组方式来访问参数。
有一个质的注意的情况是,用eval()函数动态执行的代码并不创建新的作用域,其代码就是在当前作用域中执行的。eval()中的动态代码可以访问当前作用域的this,arguments等对象,因此可以使用eval()实现一些高级的多态和动态扩展方面的应用。
文章声明:本文部分内容参考自《悟透JavaScript》,这是一本学习JavaScript非常好的书。