函数参数的默认值
基本用法
在ES6之前,不能直接为函数的参数指定默认值,于是只能使用变通的方法。在函数内部手动检测参数是否有值,不过这样的方法有时并不保险。
ES6允许直接写上默认值。
function log(x, y = 'World') {
console.log(x, y);
}
log('Hello') // Hello World
log('Hello', 'China') // Hello China
log('Hello', '') // Hello
这样写不仅简化了写法,更加可靠,而且还有两个好处,只要一看函数的参数表,马上就知道哪些参数是必须的,哪些不用传也行,不传时默认值是什么。将来包装这个函数或写对外接口时,可以直接剪掉这个参数,也不会有什么问题。
与解构赋值默认值结合使用
如果只使用解构赋值的默认值,那就意味着还是必须要传一个参数才行。
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
这里如果传入的参数不是一个对象,那么解构赋值就不会执行,x,y也就不会生成。如果同时使用函数参数的默认值,就可以不传参数了。
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() // undefined, 5
参数默认值的位置
通常情况下,定义了默认值的参数,应该是函数的尾参数。因为这样比较容易看出来,到底省略了哪些参数。如果非尾部的参数设置默认值,实际上这个参数是没法省略的。
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]
函数的length属性
指定了默认值以后,函数的length属性,将返回没有指定默认值的参数个数。
(function (a) {}).length // 1
(function (a = 5) {}).length // 0
(function (a, b, c = 5) {}).length // 2
这是因为length属性的含义是,该函数预期传入的参数个数。某个参数指定默认值以后,预期传入的参数个数就不包括这个参数了。同理,rest参数也不会计入length属性。
(function(...args) {}).length // 0
如果设置了默认值的参数不是尾参数,那么length属性也不再计入后面的参数了。
(function (a = 0, b, c) {}).length // 0
(function (a, b = 1, c) {}).length // 1
作用域
如果参数默认值是一个变量,则该变量所处的作用域,与其他变量的作用域规则是一样的,即先是当前函数的作用域,然后才是全局作用域。
内部的x已经生成:
var x = 1;
function f(x, y = x) {
console.log(y);
}
f(2) // 2
内部的x并未生成:
let x = 1;
function f(y = x) {
let x = 2;
console.log(y);
}
f() // 1
如果给y赋值时x并不存在则会报错:
function f(y = x) {
let x = 2;
console.log(y);
}
f() // ReferenceError: x is not defined
由于函数的参数其实也是隐式的使用let声明的,所以下面这样写会触发暂时性死区,是读不到外面作用域中的x的:
var x4 = 1;
function f4(x4 = x4) {
console.log(x4);
}
f4(); // ReferenceError: x is not defined
如果参数的默认值是一个函数,该函数的作用域是其声明时所在的作用域:
let foo = 'outer';
function bar(func = x => foo) {
let foo = 'inner';
console.log(func()); // outer
}
bar();
上面代码中,函数bar的参数func的默认值是一个匿名函数,返回值为变量foo。这个匿名函数声明时,bar函数的作用域还没有形成,所以匿名函数里面的foo指向外层作用域的foo,输出outer。
这里使用babel转码的时候会转为这样:
var foo = 'outer';
function bar() {
var func = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : function (x) {
return foo;
};
var foo = 'inner';
console.log(func());
}
bar();
这时输出的就是inner了,因为func是在函数里定义的。不知道这个问题会怎么解决。
应用
利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误。
function throwIfMissing() {
throw new Error('Missing parameter');
}
function foo1(mustBeProvided = throwIfMissing()) {
return mustBeProvided;
}
foo1();
rest参数
ES6引入rest参数(形式为“…变量名”),用于获取函数的多余参数,这样就不需要使用arguments对象了。rest参数搭配的变量是一个数组,该变量将多余的参数放入数组中。
function add(...values) {
let sum = 0;
for (var val of values) {
sum += val;
}
return sum;
}
console.log(add(2, 5, 3)); // 10
与arguments不同,这里的value真的是一个数组,所以数组特有的方法都可以用于这个变量。注意,rest参数之后不能再有其他参数(即只能是最后一个参数),否则会报错。函数的length属性,不包括rest参数。
拓展运算符
扩展运算符(spread)是三个点(…)。它好比rest参数的逆运算,将一个数组转为用逗号分隔的参数序列。该运算符主要用于函数调用。
function push(array, ...items) {
array.push(...items);
}
扩展运算符与正常的函数参数可以结合使用。
function f(v, w, x, y, z) {
console.log(v+","+w+","+x+","+y+","+z);
}
var args = [0, 1];
f(-1, ...args, 2, ...[3]);
应用
由于扩展运算符可以展开数组,所以不再需要apply方法,将数组转为函数的参数了。
// ES5的写法
Math.max.apply(null, [14, 3, 77])
// ES6的写法
Math.max(...[14, 3, 77])
还有一些其他的应用:
var arr1 = ['a', 'b'];
var arr2 = ['c'];
var arr3 = ['d', 'e'];
// ES6的合并数组
[...arr1, ...arr2, ...arr3];
//与解构赋值结合使用
const [first, ...rest] = [1, 2, 3, 4, 5];
first // 1
rest // [2, 3, 4, 5]
const [first, ...rest] = [];
first // undefined
rest // []:
const [first, ...rest] = ["foo"];
first // "foo"
rest // []
对于字符串,拓展运算符可以将其转化为真正的数组,并且可以正确的识别32位的Unicode字符。因此,正确返回字符串长度的函数,可以像下面这样写。
function length(str) {
return [...str].length;
}
length('x\uD83D\uDE80y') // 3
凡是涉及到操作32位Unicode字符的函数,都有这个问题。因此,最好都用扩展运算符改写。
let str = 'x\uD83D\uDE80y';
str.split('').reverse().join('')
// 'y\uDE80\uD83Dx'
[...str].reverse().join('')
// 'y\uD83D\uDE80x'
任何Iterator接口的对象,都可以用扩展运算符转为真正的数组。
var nodeList = document.querySelectorAll('div');
var array = [...nodeList];
name属性
var func1 = function () {};
// ES5
func1.name // ""
// ES6
func1.name // "func1"
const bar = function baz() {};
// ES5
bar.name // "baz"
// ES6
bar.name // "baz"
比较特殊的是bind返回的函数:
function foo() {};
foo.bind({}).name // "bound foo"
(function(){}).bind({}).name // "bound "
箭头函数
基本用法:
var f = v => v;
//等同于
var f = function(v) {
return v;
};
var f = () => 5;
// 等同于
var f = function () { return 5 };
var sum = (num1, num2) => num1 + num2;
// 等同于
var sum = function(num1, num2) {
return num1 + num2;
};
由于{}代表着代码块,要直接返回对象的函数要使用({})的形式。
var getTempItem = id => ({ id: id, name: "Temp" });
与变量解构同时使用:
const full = ({ first, last }) => first + ' ' + last;
// 等同于
function full(person) {
return person.first + ' ' + person.last;
}
利用这个特性可以简化回调函数:
// 正常函数写法
[1,2,3].map(function (x) {
return x * x;
});
// 箭头函数写法
[1,2,3].map(x => x * x);
几个比较巧妙的用法:
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]]
注意事项
函数体内的this对象就是定义时所在的对象,而不是使用时所在的对象。
一般来说,this对象的指向是可变的,比如使用call或函数实际执行时的环境发生了变化。但是箭头函数并不是这样的,它的this对象在定义函数时就已经确定了,不会发生改变。
var s2 = 0;
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('s2in: ', timer.s2), 3100);
setTimeout(() => console.log('s2out: ', s2), 3100);
// s1: 3
// s2in: 0
// s2out: 3
这里setInterval执行时是处于全局作用域的,普通函数的this这时是全局变量,可以看到timer的s2一直没有被更新,因为执行时this是全局变量,但是timer的s1是一直被更新的,因为这个函数中的this一开始就被绑定在了timer。
箭头函数中this的固定并不是因为箭头函数内部有绑定this的机制,实际原因是箭头函数根本没有自己的this,导致内部的this就是外层代码块的this。
如果我们看一下babel对箭头函数的处理就会更加清楚:
// ES6
function foo() {
setTimeout(() => {
console.log('id:', this.id);
}, 100);
}
// ES5
function foo() {
var _this = this;
setTimeout(function () {
console.log('id:', _this.id);
}, 100);
}
箭头函数里根本就没有this。
所以当箭头函数嵌套的时候,所有的this都是最外层箭头函数所在作用域中的this:
function foo() {
return () => {
return () => {
return () => {
console.log('id:', this.id);
};
};
};
}
var f = foo.call({id: 1});
var t1 = f.call({id: 2})()(); // id: 1
var t2 = f().call({id: 3})(); // id: 1
var t3 = f()().call({id: 4}); // id: 1
由于没有自己的this,箭头函数自然也就不能用做构造函数,也就不能用call()、apply()、bind()这些方法去改变this的指向。
arguments、super、new.target在箭头函数中也是不存在的,指向外层函数的对应变量
除此之外,箭头函数还不可以使用yield命令,因此箭头函数不能用作Generator函数。
嵌套箭头函数
function insert(value) {
return {into: function (array) {
return {after: function (afterValue) {
array.splice(array.indexOf(afterValue) + 1, 0, value);
return array;
}};
}};
}
insert(2).into([1, 3]).after(1); //[1, 2, 3]
使用箭头函数改写以后:
let insert = (value) => ({into: (array) => ({after: (afterValue) => {
array.splice(array.indexOf(afterValue) + 1, 0, value);
return array;
}})});
insert(2).into([1, 3]).after(1); //[1, 2, 3]
const pipeline = (...funcs) =>
val => funcs.reduce((a, b) => b(a), val);
const plus1 = a => a + 1;
const mult2 = a => a * 2;
const addThenMult = pipeline(plus1, mult2);
addThenMult(5)
函数绑定
函数绑定运算符是并排的两个双冒号(::),双冒号左边是一个对象,右边是一个函数。该运算符会自动将左边的对象,作为上下文环境(即this对象),绑定到右边的函数上面。
foo::bar;
// 等同于
bar.bind(foo);
foo::bar(...arguments);
// 等同于
bar.apply(foo, arguments);
var method = obj::obj.foo;
// 等同于
var method = ::obj.foo;
尾调用优化
尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。
这种情况就属于尾调用:
function f(x){
return g(x);
}
function f(x) {
if (x > 0) {
return m(x)
}
return n(x);
}
以下3种情况都不是尾调用:
// 情况一
function f(x){
let y = g(x);
return y;
}
// 情况二
function f(x){
return g(x) + 1;
}
// 情况三
function f(x){
g(x);
}
函数调用会在内存形成一个“调用记录”,又称“调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用帧上方,还会形成一个B的调用帧。等到B运行结束,将结果返回到A,B的调用帧才会消失。如果函数B内部还调用函数C,那就还有一个C的调用帧,以此类推。所有的调用帧,就形成一个“调用栈”(call stack)。
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。这就叫做“尾调用优化”(Tail call optimization),即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用帧只有一项,这将大大节省内存。这就是“尾调用优化”的意义。
尾递归
递归非常耗费内存,因为需要同时保存成千上百个调用帧,很容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生“栈溢出”错误。
比如说著名的fibonacci数列计算,如果使用非尾递归:
function Fibonacci (n) {
if ( n <= 1 ) {return 1};
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
Fibonacci(10); // 89
// Fibonacci(100)
// Fibonacci(500)
// 堆栈溢出了
换一种想法,从头开始计算,使用尾递归:
function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {
if( n <= 1 ) {return ac2};
return Fibonacci2 (n - 1, ac2, ac1 + ac2);
}
Fibonacci2(100) // 573147844013817200000
Fibonacci2(1000) // 7.0330367711422765e+208
Fibonacci2(10000) // Infinity
这样调用栈就不会溢出了。
严格模式
ES6的尾调用优化只在严格模式下开启,正常模式是无效的。
这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈。
func.arguments:返回调用时函数的参数。
func.caller:返回调用当前函数的那个函数。
尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。
尾递归优化的实现
对于不支持尾调用优化的环境,我们也可以自己实现尾调用优化,主要的思想就是将嵌套的调用转换为循环
function tco(f) {
var value;
var active = false;
var accumulated = [];
return function accumulator() {
accumulated.push(arguments);
if (!active) {
active = true;
while (accumulated.length) {
value = f.apply(this, accumulated.shift());
}
active = false;
return value;
}
};
}
var sum = tco(function(x, y) {
if (y > 0) {
return sum(x + 1, y - 1)
}
else {
return x
}
});
sum(1, 100000)
// 100001