接着看函数——这个具有魔幻色彩的对象。在上篇文章中说函数内部属性时,还遗留了一个this内部属性没有解释,不过在说this之前,我想先说一说执行环境和作用域的概念。
6、执行环境和作用域
(1)执行环境(execution context):所有的JavaScript代码都运行在一个执行环境中,当控制权转移至JavaScript的可执行代码时,就进入了一个执行环境。活动的执行环境从逻辑上形成了一个栈,全局执行环境永远是这个栈的栈底元素,栈顶元素就是当前正在运行的执行环境。每一个函数都有自己的执行环境,当执行流进入一个函数时,会将这个函数的执行环境压入栈顶,函数执行完之后再将这个执行环境弹出,控制权返回给之前的执行环境。
(2)变量对象(variable object):每一个执行环境都有一个与之对应的变量对象,执行环境中定义的所有变量和函数就是保存在这个变量对象中。这个变量对象是后台实现中的一个对象,我们无法在代码中访问,但是这有助于我们理解执行环境和作用域相关概念。
(3)作用域链(scope chain):当代码在一个执行环境中运行时,会创建由变量对象组成的一个作用域链。这个链的前端,就是当前代码所在环境的变量对象,链的最末端,就是全局环境的变量对象。在一个执行环境中解析标识符时,会在当前执行环境相应的变量对象中搜索,找到就返回,没有找到就沿着作用域链一级一级往上搜索直至全局环境的变量对象,如果一直未找到,就抛出引用异常。
(4)活动对象(activation object):如果一个执行环境是函数执行环境,也将变量对象称为活动对象。活动对象在最开始只包含一个变量,即arguments对象(这个对象在全局环境的变量对象中不存在)。
这四个概念虽然有些抽象,但还是比较自然的,可以结合《JavaScript高级程序设计(第3版)》中的一个例子来细细体会一下:
// 进入到全局作用域,创建全局变量对象 var color = "blue"; function changeColor(){ // 进入到changeColor作用域,创建changeColor相应变量对象 var anotherColor = "red"; function swapColors(color1, color2){ // 进入到swapColors作用域,创建swapColors相应变量对象 var tempColor = anotherColor; anotherColor = color; color = tempColor; /* * swapColors作用域内可以访问的对象有: * 全局变量对象的color,changeColor * changeColor函数相应变量对象的anotherColor、swapColors * swapColors函数相应变量对象的tempColor */ } swapColors('white'); /* * changeColor作用域内可以访问的对象有: * 全局变量对象的color,changeColor * changeColor函数相应变量对象的anotherColor、swapColors */ } changeColor(); /* * 全局作用域内可以访问的对象有: * 全局变量对象的color,changeColor */
这里的整个过程是:
(1)进入全局环境,创建全局变量对象,将全局环境压入栈顶(这里也是栈底)。根据前面的关于声明提升的结论,这里创建全局变量对象可能的一个过程是,先创建全局变量对象,然后处理函数声明设置属性changeColor为相应函数,再处理变量声明设置属性color为undefined。
(2)执行全局环境中的代码。先执行color变量初始化,赋值为'blue',再调用changeColor()函数。
(3)调用changeColor()函数,进入到changeColor函数执行环境,创建这个环境相应的变量对象(也就是活动对象),将这个环境压入栈顶。创建活动对象可能的一个过程是,先创建活动对象,处理内部函数声明设置属性swapColors为相应函数,处理函数参数创建活动对象的属性arguments对象,处理内部变量声明设置属性anotherColor为undefined。
(4)执行changeColor()函数代码。先执行anotherColor初始化为'red',再调用swapColors()函数。
(5)调用swapColors()函数,进入到swapColors函数执行环境,创建相应的变量对象(活动对象),将swapColors执行环境压入栈顶。这里创建活动对象可能的一个过程是,先创建活动对象,处理函数参数,将形式参数作为活动对象的属性并赋值为undefined,创建活动对象的属性arguments对象,并根据实际参数初始化形式参数和arguments对应的值和属性(将属性color1和arguments[0]初始化为'white',由于没有第二个实际参数,所以color2的值为undefined,而arguments的长度只为1了),处理完函数参数之后,再处理函数内部变量声明,将tempColor作为活动对象的属性并赋值为undefined。
(6)执行swapColors()函数代码。先给tempColor初始化赋值,然后实现值交换功能(这里color和anotherColor的值都是沿着作用域链才读取到的)。
(7)swapColors()函数代码执行完之后,返回undefined,将相应的执行环境弹出栈并销毁(注意,这里会销毁执行环境,但是执行环境相应的活动对象并不一定会被销毁),当前执行环境恢复成changeColor()函数的执行环境。随着swapColor()函数执行完并返回,changeColor()也就执行完了,同样返回undefined,并将changeColor()函数的执行环境弹出栈并销毁,当前执行环境恢复成全局环境。整个处理过程结束,全局环境直至页面退出再销毁。
作用域链也解释了为什么函数可以在内部递归调用自身:函数名是函数定义所在执行环境相应变量对象的一个属性,然后在函数内部执行环境中,就可以沿着作用域链向外上溯一层访问函数名指向的函数对象了。如果在函数内部将函数名指向了一个新函数,递归调用时就会不正确了:
function fn(num){ if(1 == num){ return 1; }else{ fn = function(){ return 0; }; return num * fn(num - 1); } } console.info(fn(5));//0
关于作用域和声明提升,再看一个例子:
1 var name = 'linjisong'; 2 function fn(){ 3 console.info(name);//undefined 4 var name = 'oulinhai'; 5 console.info(name);//oulinhai 6 } 7 fn(); 8 console.info(name);//linjisong
这里最不直观的可能是第3行输出undefined,因为在全局中已经定义过name了,不过按照上面解析的步骤去解析一次,就可以得出正确的结果了。另外强调一下,在ECMAScript中只有全局执行环境和函数执行环境,相应的也只有全局作用域和函数作用域,没有块作用域——虽然有块语句。
function fn(){ var fnScope = 'a'; { var blockScope = 'b'; blockScope += fnScope; } console.info(blockScope);//没有块作用域,所以可以在整个函数作用域内访问blockScope console.info(fnScope); } fn();//ba,a console.info(blockScope);//ReferenceError,函数作用域外,不能访问内部定义的变量 console.info(fnScope);//ReferenceError
对于作用域链,还可以使用with、try-catch语句的catch块来延长:
- 使用with(obj){}语句时,将obj对象添加到当前作用域链的最前端。
- 使用try{}catch(error){}语句时,将error对象添加到当前作用域链的最前端。
插了一段较为抽象的概念,希望不至于影响整个阅读的流畅,事实上,我在这里还悄悄的绕过了一个称为“闭包”的概念,关于函数与闭包,在下篇文章中再详细叙述。
7、函数内部对象与this
对于面向对象语言的使用者来说,this实在是再熟悉不过了,不就是指向构造函数新创建的对象吗!不过,在ECMAScript中,且别掉以轻心,事情没有那么简单,虽然在使用new操作符调用函数的情况下,this也的确是指向新创建的对象,但这只是指定this对象值的一种方式而已,还有更多的方式可以指定this对象的值,换句话说,this是动态的,是可以由我们自己自由指定的。
(1)全局环境中的this
在全局环境中,this指向全局对象本身,在浏览器中也就是window,这里也可以把全局环境中的this理解为全局执行环境相应的变量对象,在全局环境中定义的变量和函数都是这个变量对象的属性:
var vo = 'a'; vo2 = 'b'; function fn(){ return 'fn'; } console.info(this === window);//true console.info(this.vo);//a console.info(this.vo2);//b console.info(this.fn());//fn
如果在自定义函数中要引用全局对象,虽然可以直接使用window,但更好的方式则是将全局对象作为参数传入函数,这是在JS库中非常通用的一种方式:
(function(global){ console.info(global === window);//在内部可以使用global代替window了 })(this);
这种方式兼容性更好(ECMAScript的实现中全局对象未必都是window),在压缩时,也可以将global简化为g,而不用使用window了。
(2)函数内部属性this
在函数环境中,this是一个内部属性对象,可以理解成函数对应的活动对象的一个属性,而这个内部属性的值是动态的。那this值是怎么动态确定的呢?
- 使用new调用时,函数也称为构造函数,这个时候函数内部的this被指定为新创建的对象。
function fn(){ var name = 'oulinhai';//函数对应的活动对象的属性 this.name = 'linjisong';//当使用new调用函数时,将this指定为新创建对象,也就是给新创建对象添加属性 } var person = new fn(); console.info(person.name);//linjisong var arr = [fn]; console.info(arr[0]());//undefined
需要注意区分一下函数执行环境中定义的属性(也即活动对象的属性)和this对象的属性,在使用数组元素方式调用函数时,函数内部this指向数组本身,因此上例最后输出undefined。
- 作为一般函数调用时,this指向全局对象。
- 作为对象的方法调用时,this指向调用这个方法的对象。
看下面的例子:
var name = 'oulinhai'; var person = { name:'linjisong', getName:function(){ return this.name; } }; console.info(person.getName());//linjisong var getName = person.getName; console.info(getName());//oulinhai
这里函数对象本身是匿名的,是作为person对象的一个属性,当作为对象属性调用时,this指向了对象,当把这个函数赋给另一个函数然后调用时,是作为一般函数调用的,this指向了全局对象。这个例子充分说明了“函数作为对象的方法调用时内部属性this指向这个调用对象,函数作为一般函数调用时内部属性this指向全局对象”,也说明了this的指定是动态的,是在调用时指定的,而不管函数是单独定义的还是作为对象方法定义的。也正是因为函数作为对象的方法调用时this指向这个调用对象,所以在函数内部返回this时才能够延续调用对象的下一个方法——也就是链式操作(jQuery的一大特色)。
- 使用apply()、call()或bind()调用函数时,this指向第一个参数对象。如果没有传入参数或传入的是null和undefined,this指向全局对象(在ES5的严格模式下会设为null)。如果传入的第一个参数是一个简单类型,会将this设置为相应的简单类型包装对象。
var name = 'linjisong'; function fn(){ return this.name; } var person = { name:'oulinhai', getName:fn }; var person2 = {name:'hujinxing'}; var person3 = {name:'huanglanxue'}; console.info(fn());//linjisong,一般函数调用,内部属性this指向全局对象,因此this.name返回linjisong console.info(person.getName());//oulinhai,作为对象方法调用,this指向这个对象,因此这里返回person.name console.info(fn.apply(person2));//hujinxing,使用apply、call或bind调用函数,执行传入的第一个参数对象,因此返回person2.name console.info(fn.call(person2));//hujinxing var newFn = fn.bind(person3);//ES5中新增方法,会创建一个新函数实例返回,内部this值被指定为传入的参数对象 console.info(newFn());//huanglanxue
上面示例中列出的都是一些常见情况,没有列出第一个参数为null或undefined的情况,有兴趣的朋友可以自行测试。关于this值的确定,在原书中还有一个例子:
var name = 'The Window'; var object = { name : 'My Object', getName:function(){ return this.name; }, getNameFunc:function(){ return function(){ return this.name; } } }; console.info(object.getName());//My Object console.info((object.getName)());//My Object console.info((object.getName = object.getName)());//The Window console.info(object.getNameFunc()());//The Window
第1个是正常输出,第2个(object.getName)与object.getName的效果是相同的,而第3个(object.getName=object.getName)最终返回的是函数对象本身,也就是说第3个会作为一般函数来调用,第4个则先是调用getNameFunc这个方法,返回一个函数,然后再调用这个函数,也是作为一般函数来调用。
8、函数属性和方法
函数是一个对象,因此也可以有自己的属性和方法。不过函数属性和方法与函数内部属性很容易混淆,既然容易混淆,就把它们放一起对照着看,就好比一对双胞胎,不对照着看,不熟悉的人是区分不了的。
先从概念上来区分一下:
(1)函数内部属性:可以理解为函数相应的活动对象的属性,是只能从函数体内部访问的属性,函数每一次被调用,都会被重新指定,具有动态性。
(2)函数属性和方法:这是函数作为对象所具有的特性,只要函数一定义,函数对象就被创建,相应的属性和方法就可以访问,并且除非你在代码中明确赋为另一个值,否则它们的值不会改变,因而具有静态性。有一个例外属性caller,表示调用当前函数的函数,也是在函数被调用时动态指定,在《JavaScript高级程序设计(第3版)》中也因此将caller属性和函数内部属性arguments、this一起讲解,事实上,在ES5的严格模式下,不能对具有动态特性的函数属性caller赋值。
光从概念上区分是非常抽象的,也不是那么容易理解,再把这些属性列在一起比较一下(没有列入一些非标准的属性,如name):
类别 | 名称 | 继承性 | 说明 | 备注 |
函数内部属性 | this | - | 函数据以执行的环境对象 | 和一般面向对象语言有很大区别 |
arguments | - |
表示函数实际参数的类数组对象 arguments本身也有自己的属性:length、callee和caller |
1、length属性表示实际接收到的参数个数 2、callee属性指向函数对象本身,即有: fn.arguments.callee === fn 3、caller属性主要和函数的caller相区分,值永远都是undefined |
|
函数属性 | caller | 否 | 调用当前函数的函数 | 虽然函数一定义就可访问,但是不在函数体内访问时永远为null,在函数体内访问时返回调用当前函数的函数,在全局作用域中调用函数也会返回null |
length | 否 | 函数形式参数的长度 | 就是定义函数时命名的参数个数 | |
prototype | 否 | 函数原型对象 | 原型对象是ECMAScript实现继承的基础 | |
constructor | 是 | 继承自Object,表示创建函数实例的函数,也就是Function() | 值永远是Function,也就是内置的函数Function() | |
函数方法 | apply | 否 | 调用函数自身,以(类)数组方式接受参数 |
这三个方法主要作用是动态绑定函数内部属性this 1、apply和call在绑定之后会马上执行 2、bind在绑定之后可以在需要的时候再调用执行 |
call | 否 | 调用函数自身,以列举方式接受参数 | ||
bind | 否 | 绑定函数作用域,ES5中新增 | ||
toLocalString | 覆盖 |
覆盖了Object类型中的方法,返回函数体 不同浏览器实现返回可能不同,可能返回原始代码,也可能返回去掉注释后的代码 |
||
toString | 覆盖 | |||
valueOf | 覆盖 | |||
hasOwnProperty | 是 | 直接继承自Object类型的方法,用法同Object | ||
propertyIsEnumerable | 是 | |||
isPropertyOf | 是 |
函数属性和方法,除了从Object继承而来的属性和方法,也包括函数本身特有的属性和方法,用的最多的方法自然就是上一小节说的apply()、call(),这两个方法都是用来设置函数内部属性this从而扩展函数作用域的,只不过apply()扩展函数作用域时是以(类)数组方式接受函数的参数,而call()扩展函数作用域时需要将函数参数一一列举出来传递,看下面的例子:
function sum(){ var total = 0, l = arguments.length ; for(; l; l--){ total += arguments[l-1]; } return total; } console.info(sum.apply(null,[1,2,3,4]));//10 console.info(sum.call(null,1,2,3,4));//10
不过需要强调的是:apply和call的主要作用还是在于扩展函数作用域。apply和call在扩展作用域时会马上调用函数,这使得应用中有了很大限制,因此在ES5中新增加了一个bind()函数,这个函数也用于扩展作用域,但是可以不用马上执行函数,它返回一个函数实例,将传入给它的第一个参数作为原函数的作用域。它的一个可能的实现如下:
function bind(scope){ var that = this; return function(){ that.apply(scope, arguments); } }
Function.prototype.bind = bind;
这里涉及了一个闭包的概念,明天再继续。