第十章(一)
本章内容:
- 函数表达式、函数声明及箭头函数
- 默认参数及扩展操作符
- 使用函数实现递归
- 使用闭包实现私有变量
函数实际上是对象。每个函数都是Function
类型的实例,而Function
也有属性和方法,跟其他引用类型一样。因为函数是对象,所以函数名就是指向函数对象的指针,而且不一定与函数本身紧密绑定。函数通常以函数声明的方式定义,比如:
function sum (num1, num2) {
return num1 + num2;
}
注意函数定义最后没有加分号。
另一种定义函数的语法是函数表达式。函数表达式与函数声明几乎是等价的:
let sum = function(num1, num2) {
return num1 + num2;
};
最后一种定义函数的方式是使用Function 构造函数。这个构造函数接收任意多个字符串参数,最后一个参数始终会被当成函数体,而之前的参数都是新函数的参数。来看下面的例子:
let sum = new Function("num1", "num2", "return num1 + num2"); // 不推荐
10.1 箭头函数
ECMAScript 6 新增了使用胖箭头(=>)语法定义函数表达式的能力。很大程度上,箭头函数实例化的函数对象与正式的函数表达式创建的函数对象行为是相同的。任何可以使用函数表达式的地方,都可以使用箭头函数。
箭头函数简洁的语法非常适合嵌入函数的场景:
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]
即箭头函数由一个接收参数的括号,一个指向函数体的箭头和一个函数体组成。
如果只有一个参数,那也可以不用括号。只有没有参数,或者多个参数的情况下,才需要使用括号,以下为示例:
// 以下两种写法都有效
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; };
箭头函数也可以不用大括号,但箭头后面只能有一行代码,如果有大括号就可以像函数体一样有多条语句。省略大括号时, 会隐式地返回这行代码的值,即不需要手动return;使用大括号则需要return才能返回值。
// 以下两种写法都有效,而且返回相应的值
let double = (x) => { return 2 * x; };
let triple = (x) => 3 * x;
array1 = [1, 2, 3, 4];
// 不用大括号隐式地返回
console.log(array1.map( i => i*2 )); // [2, 4, 6, 8]
// 使用大括号必须要return获取返回值
console.log(array1.map( i => { i * 2 } )); // [undefined, undefined, undefined, undefined]
箭头函数虽然语法简洁,但也有很多场合不适用。箭头函数不能使用arguments、super 和new.target,也不能用作构造函数。此外,箭头函数也没有prototype 属性。
10.2 函数名
因为函数名就是指向函数的指针,所以它们跟其他包含对象指针的变量具有相同的行为。这意味着一个函数可以有多个名称。
注意,使用不带括号的函数名会访问函数指针,而不会执行函数。
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
如上所示,通过不使用括号的赋值,令anotherSum指向了sum函数。sum原本指向函数存在的地址,指针赋值null,不会影响原本的函数。
ECMAScript 6 的所有函数对象都会暴露一个只读的name
属性,其中包含关于函数的信息。多数情况下,这个属性中保存的就是一个函数标识符,或者说是一个字符串化的变量名。即使函数没有名称,也会如实显示成空字符串。如果它是使用Function 构造函数创建的,则会标识成"anonymous":
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()实例化,那么标识符前面会加上一个前缀
let dog = {
years: 1,
get age() {
return this.years;
},
set age(newAge) {
this.years = newAge;
}
}
console.log(propertyDescriptor.get.name); // get age
console.log(propertyDescriptor.set.name); // set age
10.3 理解参数
ECMAScript 函数既不关心传入的参数个数,也不关心这些参数的数据类型。定义函数时要接收两个参数,并不意味着调用时就传两个参数。你可以传一个、三个,甚至一个也不传,解释器都不会报错。
之所以会这样,主要是因为ECMAScript 函数的参数在内部表现为一个数组。函数被调用时总会接收一个数组,但函数并不关心这个数组中包含什么。如果数组中什么也没有,那没问题;如果数组的元素超出了要求,那也没问题。
在使用function 关键字定义(非箭头)函数时,可以在函数内部访问arguments
对象,从中取得传进来的每个参数值。
arguments
对象是一个类数组对象(但不是Array 的实例),因此可以使用中括号语法访问其中的元素(第一个参数是arguments[0],第二个参数是arguments[1])。而要确定传进来多少个参数,可以访问arguments.length
属性。
在下面的例子中,sayHi()函数的第一个参数叫name:
function sayHi(name, message) {
console.log("Hello " + name + ", " + message);
}
可以通过arguments[0]取得相同的参数值。因此,把函数重写成不声明参数也可以:
function sayHi() {
console.log("Hello " + arguments[0] + ", " + arguments[1]);
}
在重写后的代码中,没有命名参数。name 和message 参数都不见了,但函数照样可以调用。这就表明,ECMAScript 函数的参数只是为了方便才写出来的,并不是必须写出来的。
注意arguments是由传入的参数构成的数组,超出索引的会返回undefined。
还有一个必须理解的重要方面,那就是arguments 对象可以跟命名参数一起使用,比如:
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 对象的另一个有意思的地方就是,它的值始终会与对应的命名参数同步。
function doAdd(num1) {
arguments[0] = 10;
console.log(num1);
}
doAdd(99) // 10
如上所示,对arguments对象的值的修改会自动同步到对应的命名参数。同样,内部修改命名参数也会同步到arguments对象上。
function doAdd(num1) {
num1 = 10;
console.log(arguments[0]);
}
doAdd(99) // 10
这并不意味着它们都访问同一个内存地址,它们在内存中还是分开的,只不过会保持同步而已。并且多次修改每次都会自动同步:
function doAdd(num1) {
num1 = 10;
console.log(arguments[0]);
arguments[0] = 99;
console.log(num1);
}
doAdd(1)
// 10
// 99
另外还要记住一点:如果只传了一个参数,然后把arguments[1]设置为某个值,那么这个值并不会反映到第二个命名参数。这是因为arguments 对象的长度是根据传入的参数个数,而非定义函数时给出的命名参数个数确定的。
function doAdd(num1, num2) {
num2 = 10;
console.log(num1 + num2); // 76
console.log(arguments[1]); // undefined
console.log(arguments[0] + arguments[1]); // NaN
}
doAdd(66)
如上所示,对于没有传入的值,即使对该参数后续修改添加了值,也不会改变arguments对象中该位置处的undefined值。
对于命名参数而言,如果调用函数时没有传这个参数,那么它的值就是undefined。这就类似于定义了变量而没有初始化。比如,如果只给doAdd()传了一个参数,那么num2 的值就是undefined。
严格模式下,arguments 会有一些变化。首先,像前面那样给arguments[1]赋值不会再影响num2的值。就算把arguments[1]设置为10,num2 的值仍然还是传入的值。其次,在函数中尝试重写 arguments对象会导致语法错误。(代码也不会执行。)
如果函数是使用箭头语法定义的,那么传给函数的参数将不能使用arguments 关键字访问,而只能通过定义的命名参数访问。
注意 ECMAScript 中的所有参数都按值传递的。不可能按引用传递参数。如果把对象作为参数传递,那么传递的值就是这个对象的引用。
10.4 没有重载
ECMAScript 函数不能像传统编程那样重载。即同名函数会覆盖,后定义的覆盖会覆盖先定义的。
function addSomeNumber(num) {
return num + 100;
}
function addSomeNumber(num) {
return num + 200;
}
let result = addSomeNumber(100); // 300
如上所示,返回的结果是后定义函数的结果,把函数名当成指针也有助于理解为什么ECMAScript 没有函数重载。在前面的例子中,定义两个同名的函数显然会导致后定义的重写先定义的。而那个例子几乎跟下面这个是一样的:
let addSomeNumber = function(num) {
return num + 100;
};
addSomeNumber = function(num) {
return num + 200;
};
let result = addSomeNumber(100); // 300
10.5 默认参数值
ECMAScript 6支持显式定义默认参数,只要在函数定义中的参数后面用=就可以为参数赋一个默认值:
function makeKing(name = 'Henry') {
return `King ${name} VIII`;
}
console.log(makeKing('Louis')); // 'King Louis VIII'
console.log(makeKing()); // 'King Henry VIII'
在使用默认参数时,arguments 对象的值不反映参数的默认值,只反映传给函数的参数。
function makeKing(name = 'Henry') {
name = 'Louis';
return `King ${arguments[0]}`;
}
console.log(makeKing()); // 'King undefined'
默认参数值并不限于原始值或对象类型,也可以使用调用函数返回的值
function makeKing(name = 'Henry', numerals = getNumerals())
return `King ${name} ${numerals}`;
}
函数的默认参数只有在函数被调用时才会求值,不会在函数定义时求值。即函数定义时,不会赋默认值,调用时才会赋值。
而且,计算默认值的函数只有在调用函数但未传相应参数时才会被调用。
即默认值为A函数返回值时,不会直接运行A函数,而是在调用函数且没有传递参数时,才会调用A函数计算默认值。
默认参数作用域与暂时性死区
因为在求值默认参数时可以定义对象,也可以动态调用函数,所以函数参数肯定是在某个作用域中求值的。
给多个参数定义默认值实际上跟使用let 关键字顺序声明变量一样。
默认参数会按照定义它们的顺序依次被初始化,所以后定义默认值的参数可以引用先定义的参数。
function makeKing(name = 'Henry', numerals = name) {
return `King ${name} ${numerals}`;
}
console.log(makeKing()); // King Henry Henry
参数初始化顺序遵循“暂时性死区”规则,即前面定义的参数不能引用后面定义的。像这样就会抛出错误:
// 调用时不传第一个参数会报错
function makeKing(name = numerals, numerals = 'VIII') {
return `King ${name} ${numerals}`;
}
参数也存在于自己的作用域中,它们不能引用函数体的作用域(既不能调用函数体内的变量):
// 调用时不传第二个参数会报错
function makeKing(name = 'Henry', numerals = defaultNumeral) {
let defaultNumeral = 'VIII';
return `King ${name} ${numerals}`;
}
10.6 参数扩展与收集
ECMAScript 6 新增了扩展操作符,使用它可以非常简洁地操作和组合集合数据。扩展操作符最有用的场景就是函数定义中的参数列表,在这里它可以充分利用这门语言的弱类型及参数长度可变的特点。扩展操作符既可以用于调用函数时传参,也可以用于定义函数参数。
10.6.1 扩展参数
对可迭代对象应用扩展操作符,并将其作为一个参数传入,可以将可迭代对象拆分,并将迭代返回的每个值单独传入。
let values = [1, 2, 3, 4];
function getSum() {
let sum = 0;
for (let i = 0; i < arguments.length; ++i) {
sum += arguments[i];
}
return sum;
}
对于上面这种希望将参数单独传入的情况,使用扩展操作符将数组拆分并单独传入:
console.log(getSum(...values)); // 10
扩展操作符:...
放在可迭代对象前面,将其拆分并迭代返回其中的每个值:
console.log(...[1,2,3]) // 1 2 3
注意上面是依次返回123而不是一次返回整个数组。
对函数中的arguments
对象而言,它并不知道扩展操作符的存在,而是按照调用函数时传入的参数接收每一个值。
在普通函数和箭头函数中,也可以将扩展操作符用于命名参数,当然同时也可以使用默认参数:
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 实例。
function getSum(...values) {
// 顺序累加values 中的所有值
// 初始值的总和为0
return values
}
console.log(getSum(1,2,3)); // [1, 2, 3]
即与扩展相反,通过在函数定义处使用扩展操作符,将接收到的参数合并为一个数组形式进行处理。
收集参数的前面如果还有命名参数,则只会收集其余的参数;如果没有则会得到空数组。因为收集参数的结果可变,所以只能把它作为最后一个参数:
// 不可以
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 一样的逻辑:
let getSum = (...values) => {
return values.reduce((x, y) => x + y, 0);
}
console.log(getSum(1,2,3)); // 6
另外,使用收集参数并不影响arguments 对象,它仍然反映调用时传给函数的参数。
10.7 函数声明与函数表达式
函数声明:
function sayHi() {
alert( "Hello" );
}
函数表达式:
let sayHi = function() {
alert( "Hello" );
};
JavaScript 引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义。而函数表达式必须等到代码执行到它那一行,才会在执行上下文中生成函数定义。
因此,函数声明具有函数声明提升这个特点,即函数声明会在任何代码执行之前先被读取并添加到执行上下文。在执行代码时,JavaScript 引擎会先执行一遍扫描,把发现的函数声明提升到源代码树的顶部。因此即使函数定义出现在调用它们的代码之后,引擎也会把函数声明提升到顶部。
例,下面的代码可以正常运行(调用写在了声明的前面):
// 没问题
console.log(sum(10, 10));
function sum(num1, num2) {
return num1 + num2;
}
而对于函数表达式,这种写法会出错:
// 会出错
console.log(sum(10, 10));
let sum = function(num1, num2) {
return num1 + num2;
};
使用var关键字也会同样出错:
console.log(sum(10, 10));
var sum = function(num1, num2) {
return num1 + num2;
};
因为这个函数定义包含在一个变量初始化语句中,而不是函数声明中。
这意味着代码如果没有执行到加粗的那一行,那么执行上下文中就没有函数的定义,所以上面的代码会出错。
除了函数什么时候真正有定义这个区别之外,这两种语法(函数声明和函数表达式)是等价的。
10.8 函数作为值
因为函数名在ECMAScript 中就是变量,所以函数可以用在任何可以使用变量的地方。这意味着不仅可以把函数作为参数传给另一个函数,而且还可以在一个函数中返回另一个函数。
function callSomeFunction(someFunction, someArgument) {
return someFunction(someArgument);
}
这个函数接收两个参数。第一个参数应该是一个函数,第二个参数应该是要传给这个函数的值。
注意这种使用的时候,函数不能加括号,表示是访问而不是调用。
10.9 函数内部
在ECMAScript 5 中,函数内部存在两个特殊的对象:arguments 和this。ECMAScript 6 又新增了new.target 属性。
10.9.1 arguments
arguments 对象是一个类数组对象,包含调用函数时传入的所有参数。
这个对象只有以function 关键字定义函数(相对于使用箭头语法创建函数)时才会有。
虽然主要用于包含函数参数,但arguments 对象其实还有一个callee 属性,是一个指向arguments 对象所在函数的指针。
使用这个属性可以在自身的函数名发生改变时,正确调用函数。比如递归过程中需要调用自己这个函数,但是当函数名变化时,会发生错误,使用这个属性就可以无视函数名变化正确调用自己。
10.9.2 this
在标准函数中,this 引用的是把函数当成方法调用的上下文对象,这时候通常称其为this 值(在网页的全局上下文中调用函数时,this 指向windows)。
例:
window.color = 'red';
let o = {
color: 'blue'
};
function sayColor() {
console.log(this.color);
}
sayColor(); // 'red'
// 为o对象添加这个方法
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 上下文中定义的:
window.color = 'red';
let o = {
color: 'blue'
};
let sayColor = () => console.log(this.color);
sayColor(); // 'red'
o.sayColor = sayColor;
o.sayColor(); // 'red'
有读者知道,在事件回调或定时回调中调用某个函数时,this 值指向的并非想要的对象。此时将回调函数写成箭头函数就可以解决问题。这是因为箭头函数中的this 会保留定义该函数时的上下文:
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
这个属性引用的是调用当前函数的函数,或者如果是在全局作用域中调用的则为null。
function outer() {
inner();
}
function inner() {
console.log(inner.caller);
}
outer();
以上代码会显示outer()函数的源代码。这是因为ourter()调用了inner(),inner.caller
指向outer()。如果要降低耦合度(更清晰),则可以通过arguments.callee.caller 来引用同样的值:
function outer() {
inner();
}
function inner() {
console.log(arguments.callee.caller);
}
outer();
10.9.4 new.target
ECMAScript 中的函数始终可以作为构造函数实例化一个新对象,也可以作为普通函数被调用。
ECMAScript 6 新增了检测函数是否使用new 关键字调用的new.target 属性。如果函数是正常调用的,则new.target 的值是undefined;如果是使用new 关键字调用的,则new.target 将引用被调用的构造函数。
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"
10.10 函数属性与方法
ECMAScript 中的函数是对象,因此有属性和方法。每个函数都有两个属性:length
和prototype
。其中,length
属性保存函数定义的命名参数的个数(注意是命名参数,arguments.length
保存的是接收参数的个数)
prototype(指向原型对象) 是保存引用类型所有实例方法的地方,这意味着toString()、valueOf()等方法实际上都保存在prototype 上,进而由所有实例共享。这个属性在自定义类型时特别重要。
函数还有两个方法:apply()和call()。这两个方法都会以指定的this 值来调用函数,即会设置调用函数时函数体内this 对象的值。
apply()
方法
apply()
方法接收两个参数:函数内this 的值和一个参数数组。第二个参数可以是Array 的实例,但也可以是arguments 对象。
function sum(num1, num2) {
return num1 + num2;
}
function callSum1(num1, num2) {
return sum.apply(this, arguments); // 传入arguments 对象
}
function callSum2(num1, num2) {
return sum.apply(this, [num1, num2]); // 传入数组
}
console.log(callSum1(10, 10)); // 20
console.log(callSum2(10, 10)); // 20
在这个例子中,callSum1()会调用sum()函数,将this 作为函数体内的this 值(这里等于window,因为是在全局作用域中调用的)传入,同时还传入了arguments 对象。callSum2()也会调用sum()函数,但会传入参数的数组。这两个函数都会执行并返回正确的结果。
call()
方法
call()方法与apply()的作用一样,只是传参的形式不同。第一个参数跟apply()一样,也是this值,而剩下的要传给被调用函数的参数则是逐个传递的。换句话说,通过call()向函数传参时,必须将参数一个一个地列出来:
function sum(num1, num2) {
return num1 + num2;
}
function callSum(num1, num2) {
return sum.call(this, num1, num2);
}
console.log(callSum(10, 10)); // 20
apply()和call()真正强大的地方并不是给函数传参,而是控制函数调用上下文即函数体内this值的能力。
window.color = 'red';
let o = {
color: 'blue'
};
function sayColor() {
console.log(this.color);
}
sayColor(); // red
sayColor.call(this); // red
sayColor.call(window); // red
sayColor.call(o); // blue
如上所示,sayColor()是一个全局函数,如果在全局作用域中调用它,那么会显示"red"。这是因为this.color 会求值为window.color。
如果在全局作用域中显式调用sayColor.call(this)或者sayColor.call(window),则同样都会显示"red"。而在使用sayColor.call(o)把函数的执行上下文即this 切换为对象o 之后,结果就变成了显示"blue"了。
使用call()或apply()的好处是可以将任意对象设置为任意函数的作用域,这样对象可以不用关心方法。在前面例子最初的版本中,为切换上下文需要先把sayColor()直接赋值为o 的属性,然后再调用。而在这个修改后的版本中,就不需要这一步操作了。
ECMAScript 5 出于同样的目的定义了一个新方法:bind()
。bind()
方法会创建一个新的函数实例,其this
值会被绑定到传给bind()
的对象。比如:
window.color = 'red';
var o = {
color: 'blue'
};
function sayColor() {
console.log(this.color);
}
let objectSayColor = sayColor.bind(o);
objectSayColor(); // blue
这里,在sayColor()上调用bind()并传入对象o 创建了一个新函数objectSayColor()。
objectSayColor()中的this 值被设置为o,因此直接调用这个函数,即使是在全局作用域中调用,也会返回字符串"blue"。