函数是ECMAScript中最有意思的部分之一,这主要是因为函数实际上是对象。每个函数都是Function类型的实例,而Function也有属性和方法,跟其他引用类型一样。因为函数是对象,所以函数名就是指向函数对象的指针,而且不一定与函数本身紧密绑定。函数通常以函数声明的方式定义,比如:
javascript
function sum (num1, num2) {
return num1 + num2;
}
注意函数定义最后没有加分号。
另一种定义函数的语法是函数表达式。函数表达式与函数声明几乎是等价的:
javascript
let sum = function(num1, num2) {
return num1 + num2;
};
这里,代码定义了一个变量sum并将其初始化为一个函数。注意function关键字后面没有名称,因为不需要。这个函数可以通过变量sum来引用。注意函数定义最后没有加分号。
注意这里的函数末尾是有分号的,与任何变量初始化语句一样。
还有一种定义函数的方式与函数表达式很像,叫作"箭头函数"(arrow function),如下所示:
javascript
let sum = (num1, num2) => {
return num1 + num2;
};
最后一种定义函数的方式是使用Function构造函数。这个构造函数接收任意多个字符串参数,最后一个参数始终会被当成函数体,而之前的参数都是新函数的参数。来看下面的例子:
javascript
let sum = new Function("num1", "num2", "return num1 + num2"); // 不推荐
我们不推荐使用这种语法来定义函数,因为这段代码会被解释两次:第一次是将它当作常规ECMAScript代码,第二次是解释传给构造函数的字符串。这显然会影响性能。不过,把函数想象为对象,把函数名想象为指针是很重要的。而上面这种语法很好地诠释了这些概念。
注意这几种实例化函数对象的方式之间存在微妙但重要的差别,本章后面会讨论。无论如何,通过其中任何一种方式都可以创建函数。
10.1 箭头函数
ECMAScript 6新增了使用胖箭头(=>)语法定义函数表达式的能力。大程度上,箭头函数实例化的函数对象与正式的函数表达式创建的函数对象行为是相同的。任何可以使用函数表达式的地方,都可以使用箭头函数:
javascript
let arrowSum = (a, b) => {
return a + b;
};
let functionExpressionSum = function(a, b) {
return a + b;
};
console.log(arrowSum(5, 8)); // 13
console.log(functionExpressionSum(5, 8)); // 13
箭头函数简洁的语法非常适合嵌入函数的场景:
javascript
let ints = [1, 2, 3];
console.log(ints.map(function(i) { return i + 1; })); // [2, 3, 4]
console.log(ints.map((i) => { return i + 1 })); // [2, 3, 4]
如果只有一个参数,那也可以不用括号。只有没有参数,或者多个参数的情况下,才需要使用括号:
javascript
// 以下两种写法都有效
let double = (x) => { return 2 * x; };
let triple = x => { return 3 * x; };
// 没有参数需要括号
let getRandom = () => { return Math.random(); };
// 多个参数需要括号
let sum = (a, b) => { return a + b; };
// 无效的写法:
let multiply = a, b => { return a * b; };
箭头函数也可以不用大括号,但这样会改变函数的行为。使用大括号就说明包含"函数体",可以在一个函数中包含多条语句,跟常规的函数一样。如果不使用大括号,那么箭头后面就只能有一行代码,比如一个赋值操作,或者一个表达式。而且,省略大括号会隐式返回这行代码的值:
javascript
// 以下两种写法都有效,而且返回相应的值
let double = (x) => { return 2 * x; };
let triple = (x) => 3 * x;
// 可以赋值
let value = {};
let setName = (x) => x.name = "Matt";
setName(value);
console.log(value.name); // "Matt"
// 无效的写法:
let multiply = (a, b) => return a * b;
箭头函数虽然语法简洁,但也有很多场合不适用。箭头函数不能使用arguments、super和new.target,也不能用作构造函数。此外,箭头函数也没有prototype属性。
10.2 函数名
因为函数名就是指向函数的指针,所以它们跟其他包含对象指针的变量具有相同的行为。这意味着一个函数可以有多个名称,如下所示:
javascript
function sum(num1, num2) {
return num1 + num2;
}
console.log(sum(10, 10)); // 20
let anotherSum = sum;
console.log(anotherSum(10, 10)); // 20
sum = null;
console.log(anotherSum(10, 10)); // 20
以上代码定义了一个名为sum()的函数,用于求两个数之和。然后又声明了一个变量anotherSum,并将它的值设置为等于sum。注意,使用不带括号的函数名会访问函数指针,而不会执行函数。此时,anotherSum和sum都指向同一个函数。调用anotherSum()也可以返回结果。把sum设置为null之后,就切断了它与函数之间的关联。而anotherSum()还是可以照常调用,没有问题。
ECMAScript 6的所有函数对象都会暴露一个只读的name属性,其中包含关于函数的信息。多数情况下,这个属性中保存的就是一个函数标识符,或者说是一个字符串化的变量名。即使函数没有名称,也会如实显示成空字符串。如果它是使用Function构造函数创建的,则会标识成"anonymous":
javascript
function foo() {}
let bar = function() {};
let baz = () => {};
console.log(foo.name); // foo
console.log(bar.name); // bar
console.log(baz.name); // baz
console.log((() => {}).name); //(空字符串)
console.log((new Function()).name); // anonymous
如果函数是一个获取函数、设置函数,或者使用bind()实例化,那么标识符前面会加上一个前缀:
javascript
function foo() {}
console.log(foo.bind(null).name); // bound foo
let dog = {
years: 1,
get age() {
return this.years;
},
set age(newAge) {
this.years = newAge;
}
}
let propertyDescriptor = Object.getOwnPropertyDescriptor(dog, 'age');
console.log(propertyDescriptor.get.name); // get age
console.log(propertyDescriptor.set.name); // set age
10.3 理解参数
ECMAScript函数的参数跟大多数其他语言不同。ECMAScript函数既不关心传入的参数个数,也不关心这些参数的数据类型。定义函数时要接收两个参数,并不意味着调用时就传两个参数。你可以传一个、三个,甚至一个也不传,解释器都不会报错。
之所以会这样,主要是因为ECMAScript函数的参数在内部表现为一个数组。函数被调用时总会接收一个数组,但函数并不关心这个数组中包含什么。如果数组中什么也没有,那没问题;如果数组的元素超出了要求,那也没问题。事实上,在使用function关键字定义(非箭头)函数时,可以在函数内部访问arguments对象,从中取得传进来的每个参数值。
arguments对象是一个类数组对象(但不是Array的实例),因此可以使用中括号语法访问其中的元素(第一个参数是arguments[0],第二个参数是arguments[1])。而要确定传进来多少个参数,可以访问arguments.length属性。
在下面的例子中,sayHi()函数的第一个参数叫name:
javascript
function sayHi(name, message) {
console.log("Hello " + name + ", " + message);
}
可以通过arguments[0]取得相同的参数值。因此,把函数重写成不声明参数也可以:
javascript
function sayHi() {
console.log("Hello " + arguments[0] + ", " + arguments[1]);
}
在重写后的代码中,没有命名参数。name和message参数都不见了,但函数照样可以调用。这就表明,ECMAScript函数的参数只是为了方便才写出来的,并不是必须写出来的。与其他语言不同,在ECMAScript中的命名参数不会创建让之后的调用必须匹配的函数签名。这是因为根本不存在验证命名参数的机制。
也可以通过arguments对象的length属性检查传入的参数个数。下面的例子展示了在每调用一个函数时,都会打印出传入的参数个数:
javascript
function howManyArgs() {
console.log(arguments.length);
}
howManyArgs("string", 45); // 2
howManyArgs(); // 0
howManyArgs(12); // 1
这个例子分别打印出2、0和1(按顺序)。既然如此,那么开发者可以想传多少参数就传多少参数。比如:
javascript
function doAdd() {
if (arguments.length === 1) {
console.log(arguments[0] + 10);
} else if (arguments.length === 2) {
console.log(arguments[0] + arguments[1]);
}
}
doAdd(10); // 20
doAdd(30, 20); // 50
这个函数doAdd()在只传一个参数时会加10,在传两个参数时会将它们相加,然后返回。因此doAdd(10)返回20,而doAdd(30,20)返回50。虽然不像真正的函数重载那么明确,但这已经足以弥补ECMAScript在这方面的缺失了。
还有一个必须理解的重要方面,那就是arguments对象可以跟命名参数一起使用,比如:
javascript
function doAdd(num1, num2) {
if (arguments.length === 1) {
console.log(num1 + 10);
} else if (arguments.length === 2) {
console.log(arguments[0] + num2);
}
}
在这个doAdd()函数中,同时使用了两个命名参数和arguments对象。命名参数num1保存着与arugments[0]一样的值,因此使用谁都无所谓。(同样,num2也保存着跟arguments[1]一样的值。)
arguments对象的另一个有意思的地方就是,它的值始终会与对应的命名参数同步。来看下面的例子:
javascript
function doAdd(num1, num2) {
arguments[1] = 10;
console.log(arguments[0] + num2);
}
这个doAdd()函数把第二个参数的值重写为10。因为arguments对象的值会自动同步到对应的命名参数,所以修改arguments[1]也会修改num2的值,因此两者的值都是10。但这并不意味着它们都访问同一个内存地址,它们在内存中还是分开的,只不过会保持同步而已。另外还要记住一点:如果只传了一个参数,然后把arguments[1]设置为某个值,那么这个值并不会反映到第二个命名参数。这是因为arguments对象的长度是根据传入的参数个数,而非定义函数时给出的命名参数个数确定的。
对于命名参数而言,如果调用函数时没有传这个参数,那么它的值就是undefined。这就类似于定义了变量而没有初始化。比如,如果只给doAdd()传了一个参数,那么num2的值就是undefined。
严格模式下,arguments会有一些变化。首先,像前面那样给arguments[1]赋值不会再影响num2的值。就算把arguments[1]设置为10,num2的值仍然还是传入的值。其次,在函数中尝试重写arguments对象会导致语法错误。(代码也不会执行。)
箭头函数中的参数
如果函数是使用箭头语法定义的,那么传给函数的参数将不能使用arguments关键字访问,而只能通过定义的命名参数访问。
javascript
function foo() {
console.log(arguments[0]);
}
foo(5); // 5
let bar = () => {
console.log(arguments[0]);
};
bar(5); // ReferenceError: arguments is not defined
虽然箭头函数中没有arguments对象,但可以在包装函数中把它提供给箭头函数:
javascript
function foo() {
let bar = () => {
console.log(arguments[0]); // 5
};
bar();
}
foo(5);
注意ECMAScript中的所有参数都按值传递的。不可能按引用传递参数。如果把对象作为参数传递,那么传递的值就是这个对象的引用。
10.4 没有重载
ECMAScript函数不能像传统编程那样重载。在其他语言比如Java中,一个函数可以有两个定义,只要签名(接收参数的类型和数量)不同就行。如前所述,ECMAScript函数没有签名,因为参数是由包含零个或多个值的数组表示的。没有函数签名,自然也就没有重载。
如果在ECMAScript中定义了两个同名函数,则后定义的会覆盖先定义的。来看下面的例子:
javascript
function addSomeNumber(num) {
return num + 100;
}
function addSomeNumber(num) {
return num + 200;
}
let result = addSomeNumber(100); // 300
这里,函数addSomeNumber()被定义了两次。第一个版本给参数加100,第二个版本加200。最后一行调用这个函数时,返回了300,因为第二个定义覆盖了第一个定义。
前面也提到过,可以通过检查参数的类型和数量,然后分别执行不同的逻辑来模拟函数重载。
把函数名当成指针也有助于理解为什么ECMAScript没有函数重载。在前面的例子中,定义两个同名的函数显然会导致后定义的重写先定义的。而那个例子几乎跟下面这个是一样的:
javascript
let addSomeNumber = function(num) {
return num + 100;
};
addSomeNumber = function(num) {
return num + 200;
};
let result = addSomeNumber(100); // 300
看这段代码应该更容易理解发生了什么。在创建第二个函数时,变量addSomeNumber被重写成保存第二个函数对象了。
10.5 默认参数值
在ECMAScript5.1及以前,实现默认参数的一种常用方式就是检测某个参数是否等于undefined,如果是则意味着没有传这个参数,那就给它赋一个值:
javascript
function makeKing(name) {
name = (typeof name !== 'undefined') ? name : 'Henry';
return `King ${name} VIII`;
}
console.log(makeKing()); // 'King Henry VIII'
console.log(makeKing('Louis')); // 'King Louis VIII'
ECMAScript 6之后就不用这么麻烦了,因为它支持显式定义默认参数了。下面就是与前面代码等价的ES6写法,只要在函数定义中的参数后面用=就可以为参数赋一个默认值:
javascript
function makeKing(name = 'Henry') {
return `King ${name} VIII`;
}
console.log(makeKing('Louis')); // 'King Louis VIII'
console.log(makeKing()); // 'King Henry VIII'
给参数传undefined相当于没有传值,不过这样可以利用多个独立的默认值:
javascript
function makeKing(name = 'Henry', numerals = 'VIII') {
return `King ${name} ${numerals}`;
}
console.log(makeKing()); // 'King Henry VIII'
console.log(makeKing('Louis')); // 'King Louis VIII'
console.log(makeKing(undefined, 'VI')); // 'King Henry VI'
在使用默认参数时,arguments对象的值不反映参数的默认值,只反映传给函数的参数。当然,跟ES5严格模式一样,修改命名参数也不会影响arguments对象,它始终以调用函数时传入的值为准:
javascript
function makeKing(name = 'Henry') {
name = 'Louis';
return `King ${arguments[0]}`;
}
console.log(makeKing()); // 'King undefined'
console.log(makeKing('Louis')); // 'King Louis'
默认参数值并不限于原始值或对象类型,也可以使用调用函数返回的值:
javascript
let romanNumerals = ['I', 'II', 'III', 'IV', 'V', 'VI'];
let ordinality = 0;
function getNumerals() {
// 每次调用后递增
return romanNumerals[ordinality++];
}
function makeKing(name = 'Henry', numerals = getNumerals()) {
return `King ${name} ${numerals}`;
}
console.log(makeKing()); // 'King Henry I'
console.log(makeKing('Louis', 'XVI')); // 'King Louis XVI'
console.log(makeKing()); // 'King Henry II'
console.log(makeKing()); // 'King Henry III'
函数的默认参数只有在函数被调用时才会求值,不会在函数定义时求值。而且,计算默认值的函数只有在调用函数但未传相应参数时才会被调用。
箭头函数同样也可以这样使用默认参数,只不过在只有一个参数时,就必须使用括号而不能省略了:
javascript
let makeKing = (name = 'Henry') => `King ${name}`;
console.log(makeKing()); // King Henry
默认参数作用域与暂时性死区
因为在求值默认参数时可以定义对象,也可以动态调用函数,所以函数参数肯定是在某个作用域中求值的。
给多个参数定义默认值实际上跟使用let关键字顺序声明变量一样。来看下面的例子:
javascript
function makeKing(name = 'Henry', numerals = 'VIII') {
return `King ${name} ${numerals}`;
}
console.log(makeKing()); // King Henry VIII
这里的默认参数会按照定义它们的顺序依次被初始化。可以依照如下示例想象一下这个过程:
javascript
function makeKing() {
let name = 'Henry';
let numerals = 'VIII';
return `King ${name} ${numerals}`;
}
因为参数是按顺序初始化的,所以后定义默认值的参数可以引用先定义的参数。看下面这个例子:
javascript
function makeKing(name = 'Henry', numerals = name) {
return `King ${name} ${numerals}`;
}
console.log(makeKing()); // King Henry Henry
参数初始化顺序遵循"暂时性死区"规则,即前面定义的参数不能引用后面定义的。像这样就会抛出错误:
javascript
// 调用时不传第一个参数会报错
function makeKing(name = numerals, numerals = 'VIII') {
return `King ${name} ${numerals}`;
}
参数也存在于自己的作用域中,它们不能引用函数体的作用域:
javascript
// 调用时不传第二个参数会报错
function makeKing(name = 'Henry', numerals = defaultNumeral) {
let defaultNumeral = 'VIII';
return `King ${name} ${numerals}`;
}
10.6 参数扩展与收集
ECMAScript 6新增了扩展操作符,使用它可以非常简洁地操作和组合集合数据。扩展操作符最有用的场景就是函数定义中的参数列表,在这里它可以充分利用这门语言的弱类型及参数长度可变的特点。扩展操作符既可以用于调用函数时传参,也可以用于定义函数参数。
10.6.1 扩展参数
在给函数传参时,有时候可能不需要传一个数组,而是要分别传入数组的元素。
假设有如下函数定义,它会将所有传入的参数累加起来:
javascript
let values = [1, 2, 3, 4];
function getSum() {
let sum = 0;
for (let i = 0; i < arguments.length; ++i) {
sum += arguments[i];
}
return sum;
}
这个函数希望将所有加数逐个传进来,然后通过迭代arguments对象来实现累加。如果不使用扩展操作符,想把定义在这个函数这面的数组拆分,那么就得求助于apply()方法:
javascript
console.log(getSum.apply(null, values)); // 10
但在ECMAScript 6中,可以通过扩展操作符极为简洁地实现这种操作。对可迭代对象应用扩展操作符,并将其作为一个参数传入,可以将可迭代对象拆分,并将迭代返回的每个值单独传入。
比如,使用扩展操作符可以将前面例子中的数组像这样直接传给函数:
javascript
console.log(getSum(...values)); // 10
因为数组的长度已知,所以在使用扩展操作符传参的时候,并不妨碍在其前面或后面再传其他的值,包括使用扩展操作符传其他参数:
javascript
console.log(getSum(-1, ...values)); // 9
console.log(getSum(...values, 5)); // 15
console.log(getSum(-1, ...values, 5)); // 14
console.log(getSum(...values, ...[5,6,7])); // 28
对函数中的arguments对象而言,它并不知道扩展操作符的存在,而是按照调用函数时传入的参数接收每一个值:
javascript
let values = [1,2,3,4]
function countArguments() {
console.log(arguments.length);
}
countArguments(-1, ...values); // 5
countArguments(...values, 5); // 5
countArguments(-1, ...values, 5); // 6
countArguments(...values, ...[5,6,7]); // 7
arguments对象只是消费扩展操作符的一种方式。在普通函数和箭头函数中,也可以将扩展操作符用于命名参数,当然同时也可以使用默认参数:
javascript
function getProduct(a, b, c = 1) {
return a * b * c;
}
let getSum = (a, b, c = 0) => {
return a + b + c;
}
console.log(getProduct(...[1,2])); // 2
console.log(getProduct(...[1,2,3])); // 6
console.log(getProduct(...[1,2,3,4])); // 6
console.log(getSum(...[0,1])); // 1
console.log(getSum(...[0,1,2])); // 3
console.log(getSum(...[0,1,2,3])); // 3
10.6.2 收集参数
在构思函数定义时,可以使用扩展操作符把不同长度的独立参数组合为一个数组。这有点类似arguments对象的构造机制,只不过收集参数的结果会得到一个Array实例。
javascript
function getSum(...values) {
// 顺序累加values中的所有值
// 初始值的总和为0
return values.reduce((x, y) => x + y, 0);
}
console.log(getSum(1,2,3)); // 6
收集参数的前面如果还有命名参数,则只会收集其余的参数;如果没有则会得到空数组。因为收集参数的结果可变,所以只能把它作为最后一个参数:
javascript
// 不可以
function getProduct(...values, lastValue) {}
// 可以
function ignoreFirst(firstValue, ...values) {
console.log(values);
}
ignoreFirst(); // []
ignoreFirst(1); // []
ignoreFirst(1,2); // [2]
ignoreFirst(1,2,3); // [2, 3]
箭头函数虽然不支持arguments对象,但支持收集参数的定义方式,因此也可以实现与使用arguments一样的逻辑:
javascript
let getSum = (...values) => {
return values.reduce((x, y) => x + y, 0);
}
console.log(getSum(1,2,3)); // 6
另外,使用收集参数并不影响arguments对象,它仍然反映调用时传给函数的参数:
javascript
function getSum(...values) {
console.log(arguments.length); // 3
console.log(arguments); // [1, 2, 3]
console.log(values); // [1, 2, 3]
}
console.log(getSum(1,2,3));
10.7 函数声明与函数表达式
本章到现在一直没有把函数声明和函数表达式区分得很清楚。事实上,JavaScript引擎在加载数据时对它们是区别对待的。JavaScript引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义。而函数表达式必须等到代码执行到它那一行,才会在执行上下文中生成函数定义。来看下面的例子:
javascript
// 没问题
console.log(sum(10, 10));
function sum(num1, num2) {
return num1 + num2;
}
以上代码可以正常运行,因为函数声明会在任何代码执行之前先被读取并添加到执行上下文。这个过程叫作函数声明提升(function declaration hoisting)。在执行代码时,JavaScript引擎会先执行一遍扫描,把发现的函数声明提升到源代码树的顶部。因此即使函数定义出现在调用它们
的代码之后,引擎也会把函数声明提升到顶部。如果把前面代码中的函数声明改为等价的函数表达式,那么执行的时候就会出错:
javascript
// 会出错
console.log(sum(10, 10));
let sum = function(num1, num2) {
return num1 + num2;
};
上面的代码之所以会出错,是因为这个函数定义包含在一个变量初始化语句中,而不是函数声明中。这意味着代码如果没有执行到加粗的那一行,那么执行上下文中就没有函数的定义,所以上面的代码会出错。这并不是因为使用let而导致的,使用var关键字也会碰到同样的问题:
javascript
console.log(sum(10, 10));
var sum = function(num1, num2) {
return num1 + num2;
};
除了函数什么时候真正有定义这个区别之外,这两种语法是等价的。
注意 在使用函数表达式初始化变量时,也可以给函数一个名称,比如
let sum = function sum() {}。
10.8 函数作为值
因为函数名在ECMAScript中就是变量,所以函数可以用在任何可以使用变量的地方。这意味着不仅可以把函数作为参数传给另一个函数,而且还可以在一个函数中返回另一个函数。来看下面的例子:
javascript
function callSomeFunction(someFunction, someArgument) {
return someFunction(someArgument);
}
这个函数接收两个参数。第一个参数应该是一个函数,第二个参数应该是要传给这个函数的值。任何函数都可以像下面这样作为参数传递:
javascript
function add10(num) {
return num + 10;
}
let result1 = callSomeFunction(add10, 10);
console.log(result1); // 20
function getGreeting(name) {
return "Hello, " + name;
}
let result2 = callSomeFunction(getGreeting, "Nicholas");
console.log(result2); // "Hello, Nicholas"
callSomeFunction()函数是通用的,第一个参数传入的是什么函数都可以,而且它始终返回调用作为第一个参数传入的函数的结果。要注意的是,如果是访问函数而不是调用函数,那就必须不带括号,所以传给callSomeFunction()的必须是add10和getGreeting,而不能是它们的执行结果。
从一个函数中返回另一个函数也是可以的,而且非常有用。例如,假设有一个包含对象的数组,而我们想按照任意对象属性对数组进行排序。为此,可以定义一个sort()方法需要的比较函数,它接收两个参数,即要比较的值。但这个比较函数还需要想办法确定根据哪个属性来排序。这个问题可以通过定义一个根据属性名来创建比较函数的函数来解决。比如:
javascript
function createComparisonFunction(propertyName) {
return function(object1, object2) {
let value1 = object1[propertyName];
let value2 = object2[propertyName];
if (value1 < value2) {
return -1;
} else if (value1 > value2) {
return 1;
} else {
return 0;
}
};
}
这个函数的语法乍一看比较复杂,但实际上就是在一个函数中返回另一个函数,注意那个return操作符。内部函数可以访问propertyName参数,并通过中括号语法取得要比较的对象的相应属性值。取得属性值以后,再按照sort()方法的需要返回比较值就行了。这个函数可以像下面这样使用:
javascript
let data = [
{name: "Zachary", age: 28},
{name: "Nicholas", age: 29}
];
data.sort(createComparisonFunction("name"));
console.log(data[0].name); // Nicholas
data.sort(createComparisonFunction("age"));
console.log(data[0].name); // Zachary
在上面的代码中,数组data中包含两个结构相同的对象。每个对象都有一个name属性和一个age属性。默认情况下,sort()方法要对这两个对象执行toString(),然后再决定它们的顺序,但这样得不到有意义的结果。而通过调用createComparisonFunction("name")来创建一个比较函数,就可以根据每个对象name属性的值来排序,结果name属性值为"Nicholas"、age属性值为29的对象会排在前面。而调用createComparisonFunction("age")则会创建一个根据每个对象age属性的值来排序的比较函数,结果name属性值为"Zachary"、age属性值为28的对象会排在前面。
10.9 函数内部
在ECMAScript 5中,函数内部存在两个特殊的对象:arguments和this。ECMAScript 6又新增了new.target属性。
10.9.1 arguments
arguments对象前面讨论过多次了,它是一个类数组对象,包含调用函数时传入的所有参数。这个对象只有以function关键字定义函数(相对于使用箭头语法创建函数)时才会有。虽然主要用于包含函数参数,但arguments对象其实还有一个callee属性,是一个指向arguments对象所在函数的指针。来看下面这个经典的阶乘函数:
javascript
function factorial(num) {
if (num <= 1) {
return 1;
} else {
return num * factorial(num - 1);
}
}
阶乘函数一般定义成递归调用的,就像上面这个例子一样。只要给函数一个名称,而且这个名称不会变,这样定义就没有问题。但是,这个函数要正确执行就必须保证函数名是factorial,从而导致了紧密耦合。使用arguments.callee就可以让函数逻辑与函数名解耦:
javascript
function factorial(num) {
if (num <= 1) {
return 1;
} else {
return num * arguments.callee(num - 1);
}
}
这个重写之后的factorial()函数已经用arguments.callee代替了之前硬编码的factorial。这意味着无论函数叫什么名称,都可以引用正确的函数。考虑下面的情况:
javascript
let trueFactorial = factorial;
factorial = function() {
return 0;
};
console.log(trueFactorial(5)); // 120
console.log(factorial(5)); // 0
这里,trueFactorial变量被赋值为factorial,实际上把同一个函数的指针又保存到了另一个位置。然后,factorial函数又被重写为一个返回0的函数。如果像factorial()最初的版本那样不使
用arguments.callee,那么像上面这样调用trueFactorial()就会返回0。不过,通过将函数与名称解耦,trueFactorial()就可以正确计算阶乘,而factorial()则只能返回0。
10.9.2 this
另一个特殊的对象是this,它在标准函数和箭头函数中有不同的行为。
在标准函数中,this引用的是把函数当成方法调用的上下文对象,这时候通常称其为this值(在网页的全局上下文中调用函数时,this指向windows)。来看下面的例子:
javascript
window.color = 'red';
let o = {
color: 'blue'
};
function sayColor() {
console.log(this.color);
}
sayColor(); // 'red'
o.sayColor = sayColor;
o.sayColor(); // 'blue'
定义在全局上下文中的函数sayColor()引用了this对象。这个this到底引用哪个对象必须到函数被调用时才能确定。因此这个值在代码执行的过程中可能会变。如果在全局上下文中调用sayColor(),这结果会输出"red",因为this指向window,而this.color相当于window.color。而在把sayColor()赋值给o之后再调用o.sayColor(),this会指向o,即this.color相当于o.color,所以会显示"blue"。
在箭头函数中,this引用的是定义箭头函数的上下文。下面的例子演示了这一点。在对sayColor()的两次调用中,this引用的都是window对象,因为这个箭头函数是在window上下文中定义的:
javascript
window.color = 'red';
let o = {
color: 'blue'
};
let sayColor = () => console.log(this.color);
sayColor(); // 'red'
o.sayColor = sayColor;
o.sayColor(); // 'red'
有读者知道,在事件回调或定时回调中调用某个函数时,this值指向的并非想要的对象。此时将回调函数写成箭头函数就可以解决问题。这是因为箭头函数中的this会保留定义该函数时的上下文:
javascript
function King() {
this.royaltyName = 'Henry';
// this引用King的实例
setTimeout(() => console.log(this.royaltyName), 1000);
}
function Queen() {
this.royaltyName = 'Elizabeth';
// this引用window对象
setTimeout(function() { console.log(this.royaltyName); }, 1000);
}
new King(); // Henry
new Queen(); // undefined
注意函数名只是保存指针的变量。因此全局定义的sayColor()函数和o.sayColor()是同一个函数,只不过执行的上下文不同。
10.9.3 caller
ECMAScript 5也会给函数对象上添加一个属性:caller。虽然ECMAScript 3中并没有定义,但所有浏览器除了早期版本的Opera都支持这个属性。这个属性引用的是调用当前函数的函数,或者如果是在全局作用域中调用的则为null。比如:
javascript
function outer() {
inner();
}
function inner() {
console.log(inner.caller);
}
outer();
以上代码会显示outer()函数的源代码。这是因为ourter()调用了inner(),inner.caller指向outer()。如果要降低耦合度,则可以通过arguments.callee.caller来引用同样的值:
javascript
function outer() {
inner();
}
function inner() {
console.log(arguments.callee.caller);
}
outer();
在严格模式下访问arguments.callee会报错。ECMAScript 5也定义了arguments.caller,但在严格模式下访问它会报错,在非严格模式下则始终是undefined。这是为了分清arguments.caller和函数的caller而故意为之的。而作为对这门语言的安全防护,这些改动也让第三方代码无法检测同一上下文中运行的其他代码。
严格模式下还有一个限制,就是不能给函数的caller属性赋值,否则会导致错误。
10.9.4 new.target
ECMAScript中的函数始终可以作为构造函数实例化一个新对象,也可以作为普通函数被调用。ECMAScript 6新增了检测函数是否使用new关键字调用的new.target属性。如果函数是正常调用的,则new.target的值是undefined;如果是使用new关键字调用的,则new.target将引用被调用的构造函数。
javascript
function King() {
if (!new.target) {
throw 'King must be instantiated using "new"'
}
console.log('King instantiated using "new"');
}
new King(); // King instantiated using "new"
King(); // Error: King must be instantiated using "new"