说起来 ECMAScript 中什么有意思,我想那莫过于函数了——而有意思的根源,则在于函数实际上是对象。每个函数都是 Function 类型的实例,而且都与其他引用类型一样具有属性和方法。——《JavaScript高级程序设计(第3版)》(以下简称《J3》)
在《浅析JavaScript的对象系统》中我们从对象的角度对JS中的函数进行过简要的描述,我们知道了函数是一种对象类型之一,函数有属性和方法;不存在所谓的全局函数,任何一个函数包括你自定义的所有函数其实都是“挂”在某个对象上的方法。。。不过,函数有意思的地方可远不止于此。下面就一起来看看这些有意思的点。
- 函数的声明
定义一个函数有多种形式,常见的为函数声明形式和函数表达式形式。
函数声明形式和大多数其他语言差不多:
function f(v1, v2){
//do something
}
关键字(function
)、函数名(f
)、参数列表((v1, v2)
)、函数体({//do something}
)。嗯差不多就这样,不过少了public
之类的访问修饰符还有函数的返回类型。对于前者,JavaScript中并没有访问修饰符的概念,变量或函数的访问控制是由JavaScript的作用域链和执行环境这一机制控制的,对此可以看一下这篇文章《JavaScript的执行环境和作用域链》以加深理解。对于后者,因为:
ECMAScript 中的函数在定义时不必指定是否返回值。实际上,任何函数在任何时候都可以通过 return 语句后跟要返回的值来实现返回值。——《J3》
这就是说,既然任何ECMAScript函数都可以在任何时候返回任何值,那么你就不可能定义函数的返回类型了,因为你无法确定它返回的会是数值、字符串还是对象或者其他类型。
对于返回值,这里再补充一点,没有return
具体值或者只有一句return;
的函数实际上都返回undefined
值。
函数表达式形式则比较有意思了:
var f = function(v1, v2){
//do something
};
很明显,这其实只是一条赋值语句,只不过等号右边的值是一个函数而已。这个变量f
保存了对函数的引用,在使用时,和通过函数声明形式定义的函数没有任何差别,一样是传入参数即可:f(3, 7)
。
这两种定义函数的形式虽然在使用时没什么差别,但是需要注意的是,因为后者(函数表达式)说白了就是一条赋值语句,因此在尚未执行这句语句时,你是无法调用它的,因为未定义;而前者(函数声明)则可以在这个函数的前面正常调用它,因为解析器会率先读取函数声明,存在一个“函数声明提升”的过程。
- 参数与重载
ECMAScript 函数的参数与大多数其他语言中函数的参数有所不同。ECMAScript 函数不介意传递进来多少个参数,也不在乎传进来的参数是什么数据类型。——《J3》
参数的数据类型没有限制这点就不必说了,对于参数个数而言,如果一个函数的参数列表有2个参数,你可以按照期望传2个参数,你也可以只传1个参数,你甚至可以传0个或者2个以上的参数,而这些都不会导致解析器报错。为什么JS中的函数对参数可以如此纵容呢?原因在于:
之所以会这样,原因是 ECMAScript中的参数在内部是用一个数组来表示的。函数接收到的始终都是这个数组,而不关心数组中包含哪些参数(如果有参数的话)。如果这个数组中不包含任何元素,无所谓;如果包含多个元素,也没有问题。——《J3》
所以你明白了,我们定义的参数列表其实是形式上的,只是为了方便在函数内部操作而定义的一些命名而已。真正保存着你传入的参数的东西,就是上面所说的那个“数组”——arguments
。不过,arguments
其实并不是数组,而是函数内部一个特殊的对象,但是它使用起来很像数组,因为你可以使用方括号来取每一项的值——按序对应你传入的参数,例如arguments[0]
就表示你传入的第一个参数。还有,你可以通过arguments.length
来确定实际传了多少个参数。请看下面两段代码,其作用和效果是完全一样的:
var sum(num1, num2){
return num1 + num2; //通过命名参数(参数列表)执行内部操作
}
console.log(sum(1, 2)); //3
var sum(){
return arguments[0] + arguments[1]; //通过arguments对象执行内部操作
}
console.log(sum(1, 2)); //3
所以:
这个事实说明了 ECMAScript 函数的一个重要特点:命名的参数只提供便利,但不是必需的。——《J3》
说到这里,其实有一点就可以明确了,那就是:JavaScript函数没有重载!为什么这么说?我们知道,要实现两个函数重载,除了要求这两个函数的函数名一致外,要么使得这两个函数的参数个数不同,要么使得参数类型不完全一致。而上面已经说了,JS函数的参数实际上是由一个包含零或多个值的arguments
对象来表示的,并不存在什么参数个数、参数类型的差别。因此,JavaScript函数没有重载!
从JS函数参数的特点中我们可以直接否决掉函数重载,现在我们再从另一个角度来否决一次。请看下面代码:
function add(num1, num2){
return num1 + num2;
}
function add(value){
return value + 100;
}
console.log(add(1, 2)); //101
结果不是3而是执行了第二个函数返回了101,很明显,并没有进行重载。事实上,上面那个add()
函数的代码无论如何都不会被执行,原因在于函数名其实只是对函数的一个引用,是一个指针,因此对于同一命名,后面的始终都会覆盖掉前面的。
由于函数名仅仅是指向函数的指针,因此函数名与包含对象指针的其他变量没有什么不同。——《J3》
也就是说,上面这段代码中的add
先是指向第一个函数,后又指向第二个函数,相当于第一个被覆盖掉了。这种情况下,第一个add()
函数可以直接整个抹除掉了,写了和没写一样,永远也访问不到。
以上分别从函数参数和引用类型两个角度判了JavaScript函数重载死刑。那就真的一点办法都没有了吗?如果我一定要实现类似重载的效果呢?办法总是有的。前面说过,arguments.length
可以确定实际传入的参数个数,那我们就可以利用它来做文章了。请看下面代码:
function add(){
if(arguments.length === 1){
return arguments[0] + 100;
} else if(arguments.length === 2){
return arguments[0] + arguments[1];
}
}
console.log(add(1, 2)); //3
console.log(add(1)); //101
这样不就挺好地模拟了重载了吗?的确,通过arguments.length
来做判断,我们确实可以做出类似重载的效果。不过需要明确的一点是:这只是模拟重载,JavaScript函数没有重载!
- 作为值的函数
因为 ECMAScript 中的函数名本身就是变量,所以函数也可以作为值来使用。也就是说,不仅可以像传递参数一样把一个函数传递给另一个函数,而且可以将一个函数作为另一个函数的结果返回。——《J3》
请看代码:
function callSomeFunction(someFunction, someArgument){
return someFunction(someArgument);
}
function sayHello(name){
alert('Hello ' + name);
}
callSomeFunction(sayHello, 'leo'); //'Hello leo'(注意此处的参数为函数指针,即函数名sayHello,而不是sayHello())
- arguments与this
-
arguments
arguments
在上面探讨函数的参数与重载的时候已经大致了解过了。我们知道: -
arguments
是函数内部一个特殊的对象,存储着所有参数的值; - 任何一个函数都有其唯一对应的一个
arguments
; -
arguments
本身作为一个对象,其表现却类似数组,可以像数组一样通过索引取值; -
arguments
对象的存在是JS函数没有重载的一个重要原因,但我们却可以通过arguments.length
模拟函数重载;
除了这些,这里再补充一点:arguments
对象还拥有一个属性callee
,它是一个指针,指向拥有这个arguments
对象的那个函数。有时候,我们可以巧妙地使用arguments.callee
来优化代码。
-
this
对于this
,有趣的则更多了。当你在一个函数中使用this
时,应该先明确几点: -
this
是JS中的一个关键字; -
this
是在函数运行时生成的一个特殊的内部对象; -
this
实际上是一个指针,指向调用该函数的那个对象;
什么叫做“调用该函数的那个对象”?请看以下代码及相关注释:
<script>
//在Global环境下调用该函数,this指向全局环境
var x = 0;
function test(){
this.x = 1;
}
test();
console.log(x); //1(说明this指向全局对象Global)
</script>
<script>
//函数作为某个对象的方法进行调用,this指向该对象
var x = 1;
function test(){
console.log(this.x);
}
var obj = {};
obj.x = 0;
obj.f = test;
obj.f(); //0(说明this指向obj)
</script>
<script>
//函数作为构造函数进行调用,this指向new出的那个对象
var x = 0;
function test(){
this.x = 1;
}
var obj = new test();
console.log(obj.x); //1(说明this指向obj)
</script>
以上3段代码分别是this
的常见使用场景。其实我们知道,前两种使用场景的本质是一致的,第一种看起来是在直接调用test()
函数,实际上和第二种一样都是在调用对象方法,只不过这个对象是看不见的全局变量罢了。
结合代码稍加分析我们便能理解上面讲的“this
实际上是一个指针,指向调用该函数的那个对象”这句话的含义了:
-
this
的值(即它的指向)在函数调用前是无法确定的; -
this
的值可以总结为:谁调用我,我就指向谁;
这个理解只能说大体上是这么回事,但是JavaScript中的这个this
说简单就这么简单,可深究起来你会发现远不止如此,这也是为什么很多JavaScript程序员对this
有所困惑的原因。这里推荐一篇总结的不错的博客《彻底理解js中this的指向,不必硬背》。
首先必须要说的是,this的指向在函数定义的时候是确定不了的,只有函数执行的时候才能确定this到底指向谁,实际上this的最终指向是那个调用它的对象(这句话有些问题,后面会解释为什么会有问题,虽然网上大部分的文章都是这样说的,虽然在很多情况下那样去理解不会出什么问题,但是实际上那样理解是不准确的,所以在你理解this的时候会有种琢磨不透的感觉)——《彻底理解js中this的指向,不必硬背》
- 身为对象
- 属性
-
caller
caller
是函数对象的一个属性,它是一个指针,指向调用该函数的那个函数。不要把它和callee
混淆了,后者是函数内部的arguments
对象的一个属性。请理解一下下面的代码:
-
function fn(){
console.log(fn === arguments.callee); //true
console.log(fn.caller === arguments.callee.caller); //true
}
-
length
函数对象的length
属性表示的是这个函数希望接收的参数的个数。注意是“希望接收”,而不是“实际接收”,也就是说length
的值取决于函数定义时括号中的参数个数,而不是实际传入了几个参数。请看下面测试:
function fn(a, b, c){
console.log(arguments.length); //2(arguments.length表示实际接收的参数个数)
console.log(fn.length); //3(始终都会返回3,取决于定义时参数列表中参数的个数)
}
fn(1, 2);
-
prototype
原型,是一个指针,指向该函数的原型对象。函数的prototype
属性是一个需要大书特书的东西,它和整个JavaScript语言的结构、实现和特性都息息相关。这里只是明确其作为函数对象的一个属性先提一下,之后会在相关文章中作详细的探讨。 - 方法
apply()
call()
bind()
这三个方法都是函数对象所特有的。
先介绍前两个,其作用都是实现在特定的作用域中调用函数。
apply()
和call()
的异同:
先看apply()
。apply()
方法接收两个参数,一个是在其中运行函数的作用域对象,另一个是参数数组(可以是arguments
对象,也可以是一个Array
数组)。请看示例:
function sum(num1, num2){
return num1 + num2;
}
function callSum1(num1, num2){
return sum.apply(this, num1, num2);
}
function callSum2(num1, num2){
return sum.apply(this, [num1, num2]);
}
console.log(callSum1(1, 2)); //3
console.log(callSum2(1, 2)); //3
再来看看call()
。
call()方法与 apply()方法的作用相同,它们的区别仅在于接收参数的方式不同。对于 call() 方法而言,第一个参数是 this 值没有变化,变化的是其余参数都直接传递给函数。换句话说,在使用 call() 方法时,传递给函数的参数必须逐个列举出来。——《J3》
请看示例:
function sum(sum1, sum2){
return sum1 + sum2;
}
function callSum(num1, num2){
return sum.call(this, sum1, sum2);
}
console.log(callSum(1, 2)); //3
apply()
和call()
这两个方法的作用是完全一样的,区别只在于两者接收参数的方式不同。
上面理清了apply()
和call()
这两个方法的异同,可从示例代码中看不出它们的实际作用,下面就来看看它们真正的用武之地。
apply()
和call()
的作用:
window.color = 'red';
var o = { color: 'blue' };
function sayColor(){
console.log(this.color);
}
sayColor(); //'red'
sayColor.call(this); //'red'
sayColor.call(o); //'blue'
当运行sayColor.call(o)
时,sayColor()
的作用域被设定为o
,因此返回结果为blue
。你应该看出来了,apply()
和call()
的真正作用,在于扩充函数的作用域,而
使用 call()(或 apply())来扩充作用域的大好处,就是对象不需要与方法有任何耦合关系。——《J3》
这一点是很有意义的,因为它可以让你写出更加灵活、漂亮的代码。
最后,别忘了还有一个bind()
。和apply()
与call()
在某个环境中直接调用并执行函数不同,bind()
方法会返回一个函数实例,并将传入的对象绑定到该函数实例内部的this
。请看测试:
window.color = 'red';
var o = { color: 'blue' };
function sayColor(){
console.log(this.color);
}
var newSayColor = sayColor.bind(o); //创建一个sayColor的实例并将其this值绑定到o
newSayColor(); //'blue'
函数作为对象,除了上面介绍的这三个特有的方法之外,当然还有从Object
继承而来的toString()
、valueOf()
等方法,不过这几个继承而来的方法对函数对象而言意义不大,因为它们都只是单纯地返回函数的代码。