函数本身就是一段JavaScript代码,定义一次但可能被调用任意次。如果函数挂载在一个对象上,作为对象的一个属性,通常这种函数被称作对象的方法。用于初始化一个新创建的对象的函数被称作构造函数。
相对于其他面向对象语言,在JavaScript中的函数是特殊的,函数即是对象。JavaScript可以把函数赋值给变量,或者作为参数传递给其他函数,甚至可以给它们设置属性等。
JavaScript的函数可以嵌套在其他函数中定义,这样定义的函数就可以访问它们外层函数中的任何变量。这也就是所谓的“闭包”,它可以给JavaScript带来强劲的编程能力。
1.函数定义
函数使用function
关键字定义,有函数语句
和函数表达式
两种定义方式。
//一.函数语句类:
//打印对象所有属性名称和值。
function printprops(obj) {
for (var key in obj) {
console.log(key + ":" + obj[key]);
}
}
//计算阶乘的递归函数,函数名称将成为函数内部的一个局部变量。
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n);
}
//二.函数表达式类:
//计算n的平方的函数表达式。这里将一个函数赋给一个变量。
var square = function (x) { return x * x; }
//兔子数列。函数表达式也可以包含名称,方便递归。
var foo = function foo(n) {
if (n <= 1) return 1;
else foo(n - 1) + foo(n - 2);
}
//数组元素升序排列。函数表达式也能作为参数传递给其他函数。
var data = [5, 3, 7, 2, 1];
data.sort(function (a, b) { return a - b; });
//函数表达式有时定义后立即调用。
var tensquared = (function (x) { return x * x; }(10));
函数命名
函数名称要求简洁、描述性强,因为这样可以极大改善代码的可读性,方便别人维护代码;函数名称通常是动词或以动词开头的词组。通常来说,函数名编写有两种约定:
- 一种约定是函数名第一个单词首字母小写,后续单词首字母大写,就像
likeThis()
; - 当函数名包含多个单词时,另一种约定是用下划线来分割单词,就像
like_this()
。
项目中编写方法名时尽量选择一种保持代码风格一致。还有,对于一些私有函数(不作为公用API的一部分),这种函数通常以一条下划线作为前辍。
2.函数调用
函数声明后需要通过调用才能被执行。JavaScript中通常有4种方式来调用函数:
- 作为普通函数;
- 作为对象方法;
- 作为构造函数;
- 通过它们的
call()
和apply()
方法间接调用。
下面就通过一些具体示例来演示上述4中函数的调用方式。
1.对于普通函数,通过调用表达式就可直接调用,这种方式很直接也很常见。
//定义一个普通函数。
var strict = function () { return !this; }; //检测当前运行环境是否为严格模式。
//通过函数名直接调用。
console.log(strict());
注:根据ES3和非严格的ES5对普通函数调用的规定,调用上下文(this
)是全局对象;在严格模式下,调用上下文则是undefined。
2.通常,保存在对象属性里的JavaScript函数被称作“方法”。
//定义一个对象直接量。
var calc = {
a: null,
b: null,
add: function () { //将函数保存在对象属性中。
return this.a + this.b;
}
};
//通过对象名调用方法。
calc.a = 1, calc.b = 2;
console.log(calc.add());
注:对象方法中的调用上下文(this
)不同于普通函数中的上下文。这里this
指代当前对象。
方法链:当方法返回值是一个对象,那么这个对象还可以再调用它的方法。每次调用的结果都是另外一个表达式的组成部分,这种方法调用方式最终会形成一个序列,也被称为“方法链”。所以,在自己设计API的时候,当方法并不需要返回值时,最好直接返回
this
。这样以后使用API就可以进行“链式调用”风格的编程。
需要注意的是,this是一个关键字,Javascript语法不允许给它赋值。再者,关键字this
没有作用域的限制,嵌套的函数不会从外层调用它的函数中继承this
。也就是说,如果嵌套函数作为方法调用,其this
指向为调用它的对象。如果嵌套函数作为函数调用,其this
值不是全局对象就是undefined
。下面通过一段代码来具体说明。
var o = {
m: function () { //对象中的方法
var self = this; //将this的值保存在一个变量中
console.log(this === o); //输出true,表明this就是这个引用对象o
f(); //调用嵌套函数f()
function f() { //定义一个嵌套函数(**普通函数,非对象方法)
console.log(this === o); //输出false,this的值为全局对象或undefined
console.log(self === o); //输出true,变量self指外部函数的this值
}
}
}
3.如果函数或者防方法调用之前带有关键字new
,它就构成构造函数调用。构造函数调用会创建一个新的对象,构造函数通常不使用return
,函数体执行完毕它会显示返回。还有,创建的对象继承自构造函数的prototype
属性,构造函数中使用this
关键字来引用这个新创建的对象。
//与普通函数一样的定义方式。
function Person(name, age) {
this.name = name;
this.age = age;
this.say = function () {
console.log("My name is " + this.name + ", I am " + this.age + " years old.");
}
}
//用关键字new调用构造函数,实例化对象。
var obj = new Person("Lamb", "21");
obj.say();//调用对象方法。
4.我们知道Javascript中的函数也是对象,所以函数对象也是可以包含方法的,其中call()
和apply()
两个方法可以用来间接地调用函数,这两个方法都可以显式指定调用函数里面的调用上下文this
。
//定义一个打印函数。
function print() {
if (this.text) {
alert(this.text);
} else {
alert("undefined");
}
}
//call方法间接调用方法,并指定其调用上下文。
print.call({ text: "hello" });
关于call()
和apply()
两个方法的用法以及区别下面详细讨论。
3.函数的实参和形参
JavaScript中的函数定义不需要指定函数形参的类型,调用函数时也不检查传入形参的个数。这样,同时也会留下两个疑问给我们:
- 当调用函数时的实参个数和声明的形参个数不匹配的时候如何处理;
- 如何显式测试函数实参的类型,以避免非法的实参传入函数。
下面就简单介绍JavaScript是如何对上述两个问题做出处理的。
可选参数
当调用函数的时候传入的实参比函数定义时指定的形参个数要少,剩下的形参都将设置为undefined
。一般来说,为了保持函数较好的适应性,都会给省略的参数设置一个合理的默认值。
function getPropertyNames(obj,/*optional*/arr) {
arr=arr||[];
for (var property in obj) { arr.push(property); }
return arr;
}
需要注意的是,当使用这种可选实参来实现函数时,需要将可选实参放在实参列表的最后。一般来书,函数定义中使用注释/*optional*/
来强调形参是可选的。
实参对象
当调用函数时传入的参数个数超过了原本函数定义的形参个数,那么方法中可以通过实参对象来获取,标识符arguments
是指向实参对象的引用。实参对象是一个类数组对象,可以通过数字下标来访问传入函数的实参值。实参对象有一个重要的用处,就是让函数可以操作任意数量的实参,请看下面的例子:
//返回传入实参的最大值。
function max(/* ... */) {
var max = Number.NEGATIVE_INFINITY; //该值代表负无穷大。
for (var i = 0; i < arguments.length; i++) {
if (arguments[i] > max) {
max = arguments[i];
}
}
return max;
}
//调用。
var largest = max(10, 45, 66, 35, 21); //=>66
还有重要的一点,如果函数中修改arguments[]
元素,同样会影响对应的实参变量。
除以上之外,实参对象还包含了两个属性callee
和caller
:
callee
是ECMAScript标准规范的,它指代当前正在执行的函数。caller
是非标准属性但是大多数浏览器都支持,它指代当前正在执行函数的函数。
//callee可以用来递归匿名函数。
var sum = function (x) {
if (x <= 1) return 1;
return x + arguments.callee(x - 1);
}
//调用函数b,方法a中打印结果为函数b。
var a = function () {
alert(a.caller);
}
var b = function () {
a();
}
注意,在ECMAScript 5严格模式下,对这两个属性进行读写会产生一个类型错误。
实参类型
声明JavaScript函数时形参不需要指定类型,在形参传入函数体之前也不会做任何类型检查,但是JavaScript在必要的时候会进行类型转换,例如:
function mult(a, b) {
return a * b;
}
function conn(x, y) {
return x + y;
}
console.log(mult(3, "2")); //字符串类型自动转为数字类型,输出结果:6
console.log(conn(3, "2")); //数字类型自动转为字符串类型,输出结果:"32"
上述的两种类型存在隐式转换关系所以JS可以自动转换,但是还存在其他情况:比如,一个方法期望它第一个实参为数组,传入一个非数组的值就可能引发问题,这时就应当在函数体中添加实参类型检查逻辑。
4.作为值的函数
开篇提到过,在JavaScript中函数不仅是一种语法,函数即是对象,简单归纳函数具有的几种性质:
1.函数可以被赋值给一个变量;
function square(x) { return x * x; }
var s = square; //现在s和square指代同一个函数对象
square(5); //=>25
s(5); //=>25
2.函数可以保存在对象的属性或数组元素中;
var array = [function (x) { return x * x; }, 20];
array[0](array[1]); //=>400
3.函数可以作为参数传入另外一个函数;
//这里定义一些简单函数。
function add(x, y) { return x + y; }
function subtract(x, y) { return x - y; }
function multipty(x, y) { return x * y; }
function divide(x, y) { return x / y; }
//这里函数以上面某个函数做参数。
function operate(operator, num1, num2) {
return operator(num1, num2);
}
//调用函数计算(4*5)-(2+3)的值。
var result = operate(subtract, operate(multipty, 4, 5), operate(add, 2, 3));
console.log(result); //=>15
4.函数可以设置属性。
//初始化函数对象的计数器属性。
uniqueInteger.counter = 0;
//先返回计数器的值,然后计数器自增1。
function uniqueInteger() {
return uniqueInteger.counter+=1;
}
当函数需要一个“静态”变量来在调用时保持某个值不变,最方便的方式就是给函数定义属性,而不是定义全局变量,因为定义全局变量会让命名空间变的杂乱无章。
5.作为命名空间的函数
函数中声明的变量只在函数内部是有定义,不在任何函数内声明的变量是全局变量,它在JavaScript代码中的任何地方都是有定义的。JavaScript中没有办法声明只在一个代码块内可见的变量的。基于这个原因,常常需要定义一个函数用作临时的命名空间,在这个命名空间内定义的变量都不会污染到全局变量。
//该函数就可看作一个命名空间。
function mymodule() {
//该函数下的变量都变成了“mymodule”空间下的局部变量,不会污染全局变量。
}
//最后需要调用命名空间函数。
mymodule();
上段代码还是会暴露出一个全局变量:mymodule
函数。更为常见的写法是,直接定义一个匿名函数,并在单个表达式中调用它:
//将上面mymodule()函数重写成匿名函数,结束定义并立即调用它。
(function () {
//模块代码。
}());
6.闭包
闭包是JavaScript中的一个难点。在理解闭包之前先要明白变量作用域
和函数作用域链
两个概念。
-
变量作用域:无非就是两种,全局变量和局部变量。全局变量拥有全局作用域,在任何地方都是有定义的。局部变量一般是指在函数内部定义的变量,它们只在函数内部有定义。
-
函数作用域链:我们知道JavaScript函数是可以嵌套的,子函数对象会一级一级地向上寻找所有父函数对象的变量。所以,父函数对象的所有变量,对子函数对象都是可见的,反之则不成立。需要知道的一点是,函数作用域链是在定义函数的时候创建的。
关于“闭包”的概念书本上定义很具体,但是也很抽象,很难理解。简单的理解,“闭包”就是定义在一个函数内部的函数(这么说并不准确,应该说闭包是函数的作用域)。
var scope = "global scope"; //全局变量
function checkscope() {
var scope = "local scope"; //局部变量
function f() { return scope; } //在作用域中返回这个值
return f();
}
checkscope(); //=>"local scope"
上面一段代码就就实现了一个简单的闭包,函数f()
就是闭包。根据输出结果,可以看出闭包可以保存外层函数局部变量,通过闭包可以把函数内的变量暴露在全局作用域下。
闭包有什么作用呢?下面一段代码是上文利用函数属性定义的一个计数器函数,其实它存在一个问题:恶意代码可以修改counter
属性值,从而让uniqueInteger
函数计数出错。
//初始化函数对象的计数器属性。
uniqueInteger.counter = 0;
//先返回计数器的值,然后计数器自增1。
function uniqueInteger() {
return uniqueInteger.counter+=1;
}
闭包可捕捉到单个函数调用的局部变量,并将这些局部变量用作私有状态,故我们可以利用闭包的特性来重写uniqueInteger
函数。
//利用闭包重写。
var uniqueInteger = (function () { //定义函数并立即调用
var counter = 0; //函数的私有状态
return function () {
return counter += 1;
};
})();
//调用。
uniqueInteger(); //=>1
uniqueInteger(); //=>2
uniqueInteger(); //=>3
当外部函数返回后,其他任何代码都无法访问counter
变量,只有内部的函数才能访问。根据输出结果可以看出,闭包会使得函数中的变量都被保存在内存中,内存消耗大,所以要合理使用闭包。
像counter
一样的私有变量在多个嵌套函数中都可以访问到它,因为这多个嵌套函数都共享同一个作用域链,看下面一段代码:
function counter() {
var n = 0;
return {
count: function () { return n += 1; },
reset: function () { n = 0; }
};
}
var c = counter(), d = counter(); //创建两个计时器
c.count(); //=>0
d.count(); //=>0 能看出它们互不干扰
c.reset(); //reset和count方法共享状态
c.count(); //=>0 因为重置了计数器c
d.count(); //=>1 而没有重置计数器d
书写闭包的时候还需注意一件事,this
是JavaScript的关键字,而不是变量。因为闭包内的函数只能访问闭包内的变量,所以this
必须要赋给that
才能引用。绑定arguments
的问题与之类似。
var name = "The Window";
var object = {
name: "My Object",
getName: function () {
var that = this;
return function () {
return that.name;
};
}
};
console.log(object.getName()()); //=>"My Object"
到这里如果你还不明白我在说什么,这里推荐两篇前辈们写的关于“闭包”的文章。
阮一峰,学习Javascript闭包(Closure)
russj,JavaScript 闭包的理解
7.函数属性、方法和构造函数
前文已经介绍过,在JavaScript中函数也是对象,它也可以像普通对象一样拥有属性和方法。
length属性
在函数体里,arguments.length
表示传入函数的实参的个数。而函数本身的length
属性表示的则是“形参”,也就是在函数调用时期望传入函数的实参个数。
function check(args) {
var actual = args.length; //参数的真实个数
var expected = args.callee.length; //期望的实参个数
if (actual!=expected) { //如果不同则抛出异常
throw Error("Expected "+ expected+"args;got "+ actual);
}
}
function f(x,y,z) {
check(arguments); //检查实参和形参个数是否一致。
return x + y + z;
}
prototype属性
每个函数都包含prototype
属性,这个属性指向一个对象的引用,这个对象也就是原型对象。当将函数用作构造函数的时候,新创建的对象会从原型对象上继承属性。
call()方法和apply()方法
上文提到,这两个方法可以用来间接调用函数。call()
和apply()
的第一个实参表示要调用函数的母对象,它是调用上下文,在函数内通过this
来引用母对象。假如要想把函数func()
以对象obj
方法的形式来调用,可以这样:
func.call(obj);
func.apply(obj);
call()
和apply()
的区别之处是,第一个实参(调用上下文)之后的所有实参传入的方式不同。
func.call(obj, 1, 2); //实参可以为任意数量
func.apply(obj, [1, 2]); //实参都放在了一个数组中
下面看一个有意思的函数,他能将一个对象的方法替换为一个新方法。这个新方法“包裹”了原始方法,实现了AOP。
//调用原始方法之前和之后记录日志消息
function trace(o, m) {
var original = o[m]; //在闭包中保存原始方法
o[m] = function () { //定义新方法
console.log(new Date(), "Entering:", m); //输出日志消息
var result = original.apply(o, arguments); //调用原始方法
console.log(new Date(), "Exiting:", m); //输出日志消息
return result; //返回结果
}
}
这种动态修改已有方法的做法,也被称作“猴子补丁(monkey-patching)”。
bind()方法
bind()方法是ES5中新增的方法,这个方法的主要作用是将函数绑定至某个对象。该方法会返回一个新的函数,调用这个新的函数会将原始函数当作传入对象的方法来调用。
function func(y) { return this.x + y; } //待绑定的函数
var o = { x: 1 }; //将要绑定的对象
var f = func.bind(o);//通过调用f()来调用o.func()
f(2); //=>3
ES3中可以通过下面的代码来实现bind()
方法:
if (!Function.prototype.bind) {
Function.prototype.bind = function (o /* , args */) {
//将this和arguments保存在变量中,以便在嵌套函数中使用。
var self = this, boundArgs = arguments;
//bind()方法返回的是一个函数。
return function () {
//创建一个参数列表,将传入bind()的第二个及后续的实参都传入这个函数。
var args = [], i;
for (var i = 1; i < boundArgs.length; i++) {
args.push(boundArgs[i]);
}
for (var i = 0; i < arguments.length; i++) {
args.push(boundArgs[i]);
}
//现在将self作为o的方法来调用,传入这些实参。
return self.apply(o,args);
}
}
}
Function()构造函数
定义函数时需要使用function
关键字,但是函数还可以通过Function()
构造函数来定义。Function()
构造函数可以传入任意数量字符串实参,最后一个实参字符串表示函数体,每两条语句之间也需要用分号分隔。
var f = Function("x", "y", "return x*y;");
//等价于下面的函数
var f = function f(x, y) { return x * y; }
关于Function()
构造函数需要注意以下几点:
Function()
构造函数允许Javascript在运行时动态创建并编译函数;- 每次调用
Function()
构造函数都会解析函数体并创建新的函数。如果将其放在循环代码块中执行,执行效率会受到影响; -
最重要的一点,它所创建的函数并不是使用词法作用域,相反,函数体代码的编译总是会在顶层函数执行。比如下面代码所示:
var scope = "global scope"; function checkscope() { var scope = "local scope"; return Function("return scope;"); //无法捕获局部作用域 } checkscope(); //=>"global scope"
Function()
构造函数可以看作是在全局作用域中执行的eval()
,在实际开发中很少见到。
8.函数式编程
JavaScript中可以像操控对象一样操控函数,也就是说可以在JavaScript中应用函数式编程技术。
使用函数处理数组
假设有一个数组,数组元素都是数字,我们想要计算这些元素的平均值和标准差。可以利用map()
和reduce()
等数组方法来实现,符合函数式编程风格。
//首先定义两个简单的函数。
var sum = function (x, y) { return x + y; }
var square = function (x) { return x * x }
//将上面的函数和数组方法配合使用计算出平均数和标准差。
var data = [1, 1, 3, 5, 5];
var mean = data.reduce(sum) / data.length;
var deviations = data.map(function (x) { return x - mean; });
var stddev = Math.sqrt(deviations.map(square).reduce(sum) / (data.length - 1));
高阶函数
所谓高阶函数就是函数操作函数,它接收一个或多个函数作为参数,并返回一个新的函数。
//返回传入函数func返回值的逻辑非。
function not(func) {
return function () {
var result = func.apply(this, arguments);
return !result;
};
}
//判断传入参数a是否为偶数。
var even = function (x) {
return x % 2 === 0;
}
var odd = not(even); //odd为新的函数,所做的事和even()相反。
[1, 1, 3, 5, 5].every(odd); //=>true 每个元素都是奇数。
这里是一个更常见的例子,它接收两个函数f()
和g()
,并返回一个新的函数用以计算f(g())
。
//返回一个新的函数,计算f(g(...))。
function compose(f, g) {
return function () {
//需要给f()传入一个参数,所以使用f()的call()方法。
//需要给g()传入很多参数,所以使用g()的apply()方法。
return f.call(this, g.apply(this, arguments));
}
}
var square = function (x) { return x * x; }
var sum = function (x, y) { return x + y; }
var squareofsum = compose(square, sum);
squareofsum(2, 3); //=>25
记忆
能将上次计算的结果缓存起来,在函数式编程当中,这种缓存技巧叫做“记忆”。下面的代码展示了一个高阶函数,memorize()
接收一个函数作为实参,并返回带有记忆能力的函数。
//返回f()的带有记忆功能的版本。
function memorize(f) {
//将值保存在闭包中。
var cache = {};
return function () {
//将实参转换为字符串形式,并将其用做缓存的键。
var key = arguments.length + Array.prototype.join.call(arguments, ",");
if (key in cache) {
return cache[key];
} else {
return cache[key] = f.apply(this, arguments);
}
}
}
memorize()
所返回的函数将它的实参数组转换成字符串,并将字符串用做缓存对象的属性名。如果缓存中存在这个值,则直接返回它,否则调用既定的函数对实参进行计算,将计算结果缓存起来并保存。下面代码展示了如何使用memorize()
:
//返回两个整数的最大公约数。
function gcd(a, b) {
var temp;
if (a < b) { //确保 a >= b
temp = b;
b = a;
a = temp;
}
while (b != 0) { //这里是求最大公约数的欧几里德算法
temp = b;
b = a % b;
a = temp;
}
return a;
}
var gcdmemo = memorize(gcd);
gcdmemo(85, 187);
//当写一个递归函数时,往往需要实现记忆功能。
var factorial = memorize(function (n) {
return (n <= 1) ? 1 : n * factorial(n - 1);
});
factorial(5); //=>120
9.参考与扩展
本篇内容源自我对《JavaScript权威指南》第8章 函数 章节的阅读总结和代码实践。总结的比较粗糙,你也可通过原著或MDN更深入了解函数。