函数的扩展

函数参数的默认值

ES6 允许为函数的参数设置默认值,即直接写在参数定义的后面。

1
2
3
4
5
6
7
function log(x, y = 'World') {
console.log(x, y);
}

log('Hello') // Hello World
log('Hello', 'China') // Hello China
log('Hello', '') // Hello
1
2
3
4
5
6
7
function Point(x = 0, y = 0) {
this.x = x;
this.y = y;
}

const p = new Point();
p // { x: 0, y: 0 }

好处:

①阅读代码的人,可以立刻意识到哪些参数是可以省略的,不用查看函数体或文档;

②其次,有利于将来的代码优化,即使未来的版本在对外接口中,彻底拿掉这个参数,也不会导致以前的代码无法运行。

参数变量是默认声明的,所以不能用letconst再次声明。

1
2
3
4
function foo(x = 5) {
let x = 1; // error
const x = 2; // error
}

使用参数默认值时,函数不能有同名参数。

1
2
3
4
5
6
7
8
9
10
// 不报错
function foo(x, x, y) {
// ...
}

// 报错
function foo(x, x, y = 1) {
// ...
}
// SyntaxError: Duplicate parameter name not allowed in this context

参数默认值可以与解构赋值的默认值,结合起来使用。

1
2
3
4
5
6
7
8
function foo({x, y = 5}) {
console.log(x, y);
}

foo({}) // undefined 5
foo({x: 1}) // 1 5
foo({x: 1, y: 2}) // 1 2
foo() // TypeError: Cannot read property 'x' of undefined

上面代码只使用了对象的解构赋值默认值,没有使用函数参数的默认值。只有当函数foo()的参数是一个对象时,变量xy才会通过解构赋值生成。如果函数foo()调用时没提供参数,变量xy就不会生成,从而报错。

通过提供函数参数的默认值,就可以避免这种情况。

1
2
3
4
5
function foo({x, y = 5} = {}) {
console.log(x, y);
}

foo() // undefined 5

下面两种函数写法有什么差别?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 写法一
function m1({x = 0, y = 0} = {}) {
return [x, y];
}

// 写法二
function m2({x, y} = { x: 0, y: 0 }) {
return [x, y];
}

// 函数没有参数的情况
m1() // [0, 0]
m2() // [0, 0]

// x 和 y 都有值的情况
m1({x: 3, y: 8}) // [3, 8]
m2({x: 3, y: 8}) // [3, 8]

// x 有值,y 无值的情况
m1({x: 3}) // [3, 0]
m2({x: 3}) // [3, undefined]

// x 和 y 都无值的情况
m1({}) // [0, 0];
m2({}) // [undefined, undefined]

m1({z: 3}) // [0, 0]
m2({z: 3}) // [undefined, undefined]
  1. 写法一:函数 m1

    1
    2
    3
    function m1({x = 0, y = 0} = {}) {
    return [x, y];
    }

    这个写法使用了解构赋值和默认参数。在没有传递参数时,默认为一个空对象 {},然后再解构这个空对象。这确保了即使没有传递参数,函数也会正常执行并返回带有默认值的数组。

  2. 写法二:函数 m2

    1
    2
    3
    function m2({x, y} = { x: 0, y: 0 }) {
    return [x, y];
    }

    这个写法也使用了解构赋值和默认参数。不同之处在于,它的默认参数是 { x: 0, y: 0 },而不是空对象。因此,在没有传递参数时,默认值就是这个对象,而不是空对象。如果没有传递参数,它仍然会正常执行并返回带有默认值的数组。

在实际使用中,这两种写法的差异可能在于默认参数的选择。写法一使用了空对象,而写法二使用了具有默认值的对象。在某些情况下,你可能更倾向于使用空对象作为默认参数,因为这样可以更灵活地适应传递的参数形式。

参数默认值的位置

1
2
3
4
5
6
7
8
function f(x, y = 5, z) {
return [x, y, z];
}

f() // [undefined, 5, undefined]
f(1) // [1, 5, undefined]
f(1, ,2) // 报错
f(1, undefined, 2) // [1, 5, 2]

上面代码中,有默认值的参数都不是尾参数。这时,无法只省略该参数,而不省略它后面的参数,除非显式输入undefined

如果传入undefined,将触发该参数等于默认值,null则没有这个效果。

1
2
3
4
5
6
function foo(x = 5, y = 6) {
console.log(x, y);
}

foo(undefined, null)
// 5 null

上面代码中,x参数对应undefined,结果触发了默认值,y参数等于null,就没有触发默认值。

函数的 length 属性

指定了默认值以后,函数的length属性,将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length属性将失真。

1
2
3
(function (a) {}).length // 1
(function (a = 5) {}).length // 0
(function (a, b, c = 5) {}).length // 2

因为length属性的含义是,该函数预期传入的参数个数。

如果设置了默认值的参数不是尾参数,那么length属性也不再计入后面的参数了。

1
2
(function (a = 0, b, c) {}).length // 0
(function (a, b = 1, c) {}).length // 1

作用域

一旦设置了参数的默认值,函数进行声明初始化时,参数会形成一个单独的作用域(context)。等到初始化结束,这个作用域就会消失。

1
2
3
4
5
6
7
8
let x = 1;

function f(y = x) {
let x = 2;
console.log(y);
}

f() // 1

上面代码中,函数f调用时,参数y = x形成一个单独的作用域。这个作用域里面,变量x本身没有定义,所以指向外层的全局变量x。函数调用时,函数体内部的局部变量x影响不到默认值变量x

如果参数的默认值是一个函数,该函数的作用域也遵守这个规则。请看下面的例子。

1
2
3
4
5
6
7
8
let foo = 'outer';

function bar(func = () => foo) {
let foo = 'inner';
console.log(func());
}

bar(); // outer

上面代码中,函数bar的参数func的默认值是一个匿名函数,返回值为变量foo。函数参数形成的单独作用域里面,并没有定义变量foo,所以foo指向外层的全局变量foo,因此输出outer

应用

利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误。

1
2
3
4
5
6
7
8
9
10
function throwIfMissing() {
throw new Error('Missing parameter');
}

function foo(mustBeProvided = throwIfMissing()) {
return mustBeProvided;
}

foo()
// Error: Missing parameter

上面代码的foo函数,如果调用的时候没有参数,就会调用默认值throwIfMissing函数,从而抛出一个错误。

另外,可以将参数默认值设为undefined,表明这个参数是可以省略的。

1
function foo(optional = undefined) { ··· }

rest 参数(…数组)

ES6 引入 rest 参数(形式为...变量名),用于获取函数的多余参数,这样就不需要使用arguments对象了。

rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。

1
2
3
4
5
6
7
8
9
10
11
function add(...values) {
let sum = 0;

for (var val of values) {
sum += val;
}

return sum;
}

add(2, 5, 3) // 10

上面代码的add函数是一个求和函数,利用 rest 参数,可以向该函数传入任意数目的参数。

1
2
3
4
5
6
7
// arguments变量的写法
function sortNumbers() {
return Array.from(arguments).sort();
}

// rest参数的写法
const sortNumbers = (...numbers) => numbers.sort();

上面代码的两种写法,比较后可以发现,rest 参数的写法更自然也更简洁。

arguments对象不是数组,而是一个类似数组的对象。所以为了使用数组的方法,必须使用Array.from先将其转为数组。rest 参数就不存在这个问题,它就是一个真正的数组,数组特有的方法都可以使用。

rest 参数之后不能再有其他参数(即只能是最后一个参数),否则会报错。

1
2
3
4
// 报错
function f(a, ...b, c) {
// ...
}

函数的length属性,不包括 rest 参数。

1
2
3
(function(a) {}).length  // 1
(function(...a) {}).length // 0
(function(a, ...b) {}).length // 1

name 属性

函数的name属性,返回该函数的函数名。

1
2
function foo() {}
foo.name // "foo"

Function构造函数返回的函数实例,name属性的值为anonymous

1
(new Function).name // "anonymous"

bind返回的函数,name属性值会加上bound前缀。

1
2
3
4
function foo() {};
foo.bind({}).name // "bound foo"

(function(){}).bind({}).name // "bound "

箭头函数

基本用法

1
2
3
4
5
6
var f = v => v;

// 等同于
var f = function (v) {
return v;
};

如果箭头函数不需要参数或需要多个参数,就使用一个圆括号代表参数部分。

1
2
3
4
5
6
7
8
9
var f = () => 5;
// 等同于
var f = function () { return 5 };

var sum = (num1, num2) => num1 + num2;
// 等同于
var sum = function(num1, num2) {
return num1 + num2;
};

如果箭头函数的代码块部分多于一条语句,就要使用大括号将它们括起来,并且使用return语句返回。

1
var sum = (num1, num2) => { return num1 + num2; }

由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号,否则会报错。

1
2
3
4
5
// 报错
let getTempItem = id => { id: id, name: "Temp" };

// 不报错
let getTempItem = id => ({ id: id, name: "Temp" });

箭头函数可以与变量解构结合使用。

1
2
3
4
5
6
const full = ({ first, last }) => first + ' ' + last;

// 等同于
function full(person) {
return person.first + ' ' + person.last;
}

下面是 rest 参数与箭头函数结合的例子。

1
2
3
4
5
6
7
8
9
const numbers = (...nums) => nums;

numbers(1, 2, 3, 4, 5)
// [1,2,3,4,5]

const headAndTail = (head, ...tail) => [head, tail];

headAndTail(1, 2, 3, 4, 5)
// [1,[2,3,4,5]]

使用注意点

(1)箭头函数没有自己的this对象(详见下文)。

(2)不可以当作构造函数,也就是说,不可以对箭头函数使用new命令,否则会抛出一个错误。

(3)不可以使用arguments对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。

(4)不可以使用yield命令,因此箭头函数不能用作 Generator 函数。

它没有自己的this对象,内部的this就是定义时上层作用域中的this。也就是说,箭头函数内部的this指向是固定的,相比之下,普通函数的this指向是可变的。

1
2
3
4
5
6
7
8
9
10
function foo() {
setTimeout(() => {
console.log('id:', this.id);
}, 100);
}

var id = 21;

foo.call({ id: 42 });
// id: 42

上面代码中,setTimeout()的参数是一个箭头函数,这个箭头函数的定义生效是在foo函数生成时,而它的真正执行要等到 100 毫秒后。如果是普通函数,执行时this应该指向全局对象window,这时应该输出21。但是,箭头函数导致this总是指向函数定义生效时所在的对象(本例是{id: 42}),所以打印出来的是42

下面例子是回调函数分别为箭头函数和普通函数,对比它们内部的this指向。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Timer() {
this.s1 = 0;
this.s2 = 0;
// 箭头函数
setInterval(() => this.s1++, 1000);
// 普通函数
setInterval(function () {
this.s2++;
}, 1000);
}

var timer = new Timer();

setTimeout(() => console.log('s1: ', timer.s1), 3100);
setTimeout(() => console.log('s2: ', timer.s2), 3100);
// s1: 3
// s2: 0

上面代码中,Timer函数内部设置了两个定时器,分别使用了箭头函数和普通函数。前者的this绑定定义时所在的作用域(即Timer函数),后者的this指向运行时所在的作用域(即全局对象)。所以,3100 毫秒之后,timer.s1被更新了 3 次,而timer.s2一次都没更新。

下面是一个例子,DOM 事件的回调函数封装在一个对象里面。

1
2
3
4
5
6
7
8
9
10
11
12
var handler = {
id: '123456',

init: function() {
document.addEventListener('click',
event => this.doSomething(event.type), false);
},

doSomething: function(type) {
console.log('Handling ' + type + ' for ' + this.id);
}
};

上面代码的init()方法中,使用了箭头函数,这导致这个箭头函数里面的this,总是指向handler对象。如果回调函数是普通函数,那么运行this.doSomething()这一行会报错,因为此时this指向document对象。

总之,箭头函数根本没有自己的this,导致内部的this就是外层代码块的this。正是因为它没有this,所以也就不能用作构造函数。

以下三个变量在箭头函数之中也是不存在的,指向外层函数的对应变量:argumentssupernew.target

由于箭头函数没有自己的this,所以当然也就不能用call()apply()bind()这些方法去改变this的指向。

不适用场合

由于箭头函数使得this从“动态”变成“静态”,下面两个场合不应该使用箭头函数。

第一个场合是定义对象的方法,且该方法内部包括this

1
2
3
4
5
6
const cat = {
lives: 9,
jumps: () => {
this.lives--;
}
}

上面代码中,cat.jumps()方法是一个箭头函数,这是错误的。调用cat.jumps()时,如果是普通函数,该方法内部的this指向cat;如果写成上面那样的箭头函数,使得this指向全局对象,因此不会得到预期结果。这是因为对象不构成单独的作用域,导致jumps箭头函数定义时的作用域就是全局作用域。

对象的属性建议使用传统的写法定义,不要用箭头函数定义。

第二个场合是需要动态this的时候,也不应使用箭头函数。

1
2
3
4
var button = document.getElementById('press');
button.addEventListener('click', () => {
this.classList.toggle('on');
});

上面代码运行时,点击按钮会报错,因为button的监听函数是一个箭头函数,导致里面的this就是全局对象。如果改成普通函数,this就会动态指向被点击的按钮对象。

尾调用优化

尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。

1
2
3
function f(x){
return g(x);
}

上面代码中,函数f的最后一步是调用函数g,这就叫尾调用。

以下三种情况,都不属于尾调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 情况一
function f(x){
let y = g(x);
return y;
}

// 情况二
function f(x){
return g(x) + 1;
}

// 情况三
function f(x){
g(x);
}

上面代码中,情况一是调用函数g之后,还有赋值操作,所以不属于尾调用,即使语义完全一样。情况二也属于调用后还有操作,即使写在一行内。情况三等同于下面的代码。

1
2
3
4
function f(x){
g(x);
return undefined;
}

尾调用不一定出现在函数尾部,只要是最后一步操作即可。

1
2
3
4
5
6
function f(x) {
if (x > 0) {
return m(x)
}
return n(x);
}

函数调用自身,称为递归。如果尾调用自身,就称为尾递归。

1
2
3
4
5
6
function factorial(n) {
if (n === 1) return 1;
return n * factorial(n - 1);
}

factorial(5) // 120

“尾调用优化”对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格

ES6 中只要使用尾递归,就不会发生栈溢出(或者层层递归造成的超时),相对节省内存。

catch 命令的参数省略

以前明确要求catch命令后面必须跟参数,接受try代码块抛出的错误对象。

1
2
3
4
5
try {
// ...
} catch (err) {
// 处理错误
}

ES6 允许catch语句省略参数。

1
2
3
4
5
try {
// ...
} catch {
// ...
}