函数对于任何编程语言都是不可或缺的,在 ES6 出现之前,函数存在诸多问题,最典型的要数 this的指向问题,不同的调用方式、不同的写法,函数内部的 this 也会相应不同。用代码来说明下:

function Sum() {
return this;
}

var newSum = new Sum();

var counter = {
sum: function() {
return this;
},
timer: function() {
setTimeout(function() {
console.log(this);
}, 10);
return this;
}
};

console.log(sum()); // window
console.log(newSum); // sum
console.log(counter.sum()); // counter
console.log(counter.timer()); // counter window

通过上面的代码,你会发现全局函数直接调用 this 会返回 window对象,而全局函数的构造函数则返回它的 实例对象,另外如果你使用对象属性的方式来定义函数,并在这个对象上调用时,则 this 又变成对象本身。但是如果遇到 setInterval 或者 setTimeout 这样的内置函数,其内部的 this 又变成了 window对象。是不是很乱?更不要说还可以使用 apply、call改变this了。

再者,我们在写插件时,为了防止用户不设置相关属性值,通常都会给一些参数设置默认值,但是需要使用 || 或符号,有点麻烦:

function sum(num1, num2) {
num1 = num1 || 1;
num2 = num2 || 2;

return num1 + num2;
}

console.log(sum()); // 3
console.log(sum(5)); // 7
console.log(sum(5, 5)); // 10

倘若函数有多个参数,那就需要把所有的参数以及默认值都写一遍,那岂不是更麻烦?如果能够直接把参数默认值塞到函数名后面的括号里,是不是会更好一些?

接下来,看看 ES6 对函数部分作了哪些调整和改进。

一、默认参数

针对前面提到的参数问题,ES6 新增了 默认参数 的概念,旨在更高效的设置参数默认值,只需要给对应参数赋值即可:

function sum(num1 = 1, num2 = 2) {
return num1 + num2;
}

console.log(sum()); // 3
console.log(sum(5)); // 7
console.log(sum(5, 5)); // 10

如上面代码,sum() 没有传递一个参数,使用两个参数的默认值,而 sum(5) 则只使用了 num2 的默认值,但 sum(5, 5) 则是不用任何一个参数默认值,全部重新定义。

通过设置参数默认值,这也就表明,sum 函数中的两个参数都是可选的。

值得一提的是,如果传入的参数为 null 或者 undefined,则情况会有些不同:

function sum(num1 = 1, num2 = 2) {
return num1 + num2;
}

console.log(sum(null)); // 2
console.log(sum(undefined)); // 3
console.log(sum('')); // "2"

null 会替换掉默认值,它自身被隐式转化为 0,所以结果为 2。

undefined 则直接使用默认值,因此,结果为 3。

另外,空字符串可能会出乎你的所料(你一定觉得它会使用默认值),但实际上,它却替换默认值,所以,结果为 '' + 2,即字符串 "2"

1.1 参数表达式

参数的默认值不仅仅只能用一个静态值来表示,它也可以是一个变量,一个表达式。你只需要把变量或表达式赋值给参数,作为参数的默认值即可。比如,后面的参数默认值可以是前面参数的值:

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

console.log(sum()); // 2
console.log(sum(3)); // 6

当然,这个参数表达式也可以是一个函数:

var a = 1;

function para() {
a += 1;

return a;
}

function sum(num1 = 1, num2 = para()) {
return num1 + num2;
}

console.log(sum(2)); // 4
console.log(sum(2)); // 5
console.log(sum(2)); // 6

或许你会好奇,为什么同样都是打印出 sum(2),但每次结果都是不一样呢?

这里就需要注意,para() 这个表达式(函数)只会在 sum(2) 调用时才会执行。因为 a 是全局变量,para() 内部又每次对 a 进行加 1,从而导致参数 num2 的默认值不同,最终打印出的结果也不同。

1.2 作用域

如果参数是一个变量,那它最终返回的值会受函数的作用域影响。

var num1 = 1;

function sum(num1, num2 = num1) {
return num2;
}

console.log(sum(2)); // 2

函数中参数 num1 的值是来自函数内部,而非全局变量。

如果参数是一个函数,则直接读取全局的值:

var num1 = 1;

function sum(num2 = function() { return num1; }) {
var num1 = 2;
return num2();
}

console.log(sum()); // 1

1.3 函数的length

我们都知道,函数有一个length属性,它的值是函数形参的个数,即:

function sum() {}

function sum2(num1, num2) {}

console.log(sum.length); // 0
console.log(sum2.length); // 2

但 ES6 对这个属性有所调整,它的值变成了没有指定参数默认值的形参个数。换句话说就是,如果某个参数已经指定了默认值,那么它就不能被包含在 length 属性中。还是用代码来说明:

function sum(num1) {}

function sum2(num1 = 1) {}

function sum3(num1, num2, num3 = 2) {}

function sum4(num1 = 1, num2, num3) {}

function sum5(num1 = 1, num2 = 2, num3) {}

console.log(sum.length); // 1
console.log(sum2.length); // 0
console.log(sum3.length); // 2
console.log(sum4.length); // 0
console.log(sum5.length); // 0

sum4sum5 的结果可能让你有点意外。它反映了一个特殊情况,即:如果有默认值的参数不在参数列表的最后一位,则函数 length 的值都为 0 。

二、rest参数

之前,我们在处理多位参数时,都是基于 arguments 对象进行操作。但为了更方便的处理不确定的多位参数,ES6 引入了 rest参数 ,它是由三个点加变量名组成,即 ...变量名,表示剩余参数。先来看下它的用法:

function sum(...Args) {
console.log(Args.length);
}

sum(); // 0
sum(1, 2); // 2
sum(1, 2, 3); // 3

或许你会觉得,用 arguments.length 也能正常获取形参长度。的确如此,但是 arguments 只是一个类似数组的对象,而 rest参数 却是一个数组。它可以使用数组的各种方法和属性,这样在函数内部操作时更方便,就拿参数拼接来说吧:

function join(...Args) {
return Args.join('-');
}

console.log(join(1, 2, 3, 4, 5)); // 1-2-3-4-5

使用 rest参数 来操作,秒秒钟就能输出结果。但是,使用 arguments ,势必需要逐个循环,然后再进行字符串拼接。

你也可以用它排序:

function ascendSort(num1, num2) {
return num1 - num2;
}

function descendSrot(num1, num2) {
return num2 - num1;
}

function join(sort, ...Args) {
return Args.sort(sort);
}

console.log(join(ascendSort, 3, 2, 6, 9, -3)); // [-3, 2, 3, 6, 9]
console.log(join(descendSrot, 3, 2, 6, 9, -3)); // [9, 6, 3, 2, -3]

join 函数的第一个参数是 sort,而其他参数就是 ...Args

不过,在使用 rest参数 时,也有一些需要注意的地方。

  • rest参数 必须是参数列表的最后一个
  • rest参数 不包含在函数的length

用代码来说明下:

function sum(...Args, last) {
console.log(Args.length);
}

sum(1, 2, 3); // 报错:Uncaught SyntaxError: Rest parameter must be last formal parameter

function sum1(num1, num2, ...Args) { }

console.log(sum1.length); // 2

三、扩展运算符

与 rest参数 相反的一个概念,是 扩展运算符。扩展运算符 指定一个数组并把这个数组拆分开来逐个作为参数传递给函数。

它也是用三个点加变量名组成,即 ...变量名

先来看下它的一些简单应用:

var arr = [3, 2, 6, 9, -3];

console.log(Math.max(...arr)); // 9
console.log(Math.max(...arr, 10)); // 10
console.log(Math.max(20, ...[3, 50], 10)); // 50

以上代码是求所有数的最大值。无论是只传入 扩展运算符,还是传入 扩展运算符 和其他参数。它都是将所有的数字拆分后,然后进行比较,最后返回最大值的。

用于函数调用:

function sum(num1, num2, num3) {
return num1 + num2 + num3;
}

console.log(sum(...[3, 2, 5])); // 10

用于数组与单项、以及多个数组之间的合并:

console.log([1, 2, ...['a', 'b'], null, 5]); // [1, 2, "a", "b", null, 5]

console.log([...[1, 2, 3], ...['a', 'b', 'c'], ...[null, false, undefined]]);
// [1, 2, 3, "a", "b", "c", null, false, undefined]

四、name属性

对于函数而言,除了之前提到的 length属性 外,它还有 name属性。表示当前函数的名称,这个属性或许对于代码的调试有一定的帮助。

无论你使用哪种方式去定义一个函数,它都会有 name属性,就像下面:

function sum1() {}

var sum2 = function() {};

console.log(sum1.name); // sum1

// ES5
console.log(sum2.name); // ''

// ES6
console.log(sum2.name); // sum2

值得注意的是,如果是用变量声明的匿名函数,那么它返回的函数名会有所不同。在支持ES6的浏览器(chrome54 测)返回的是 变量名,而ES5浏览器(firefox49 测)则返回 空字符串 。

倘若你用变量声明的是一个具名函数,那么无论是否支持ES6,都会返回具名函数的名称。

var sum = function spe() {};

console.log(sum.name); // spe

由于函数的变种(写法)很多,所以可能会出现一些特殊的name属性,比如,对象上定义的方法:

var number = {
add: function() {}
};

// ES5
console.log(number.add.name); // ''

// ES6
console.log(number.add.name); // add

比如,函数上调用 bind() 方法后,返回的name值是带 bound 前缀的:

function sum() {}

console.log(sum.bind().name); // bound sum

再比如,构造函数实例的name值是 anonymous

function sum() {}

console.log((new Function()).name); // anonymous

五、箭头函数

ES6 中新增了一种函数的写法,即 箭头函数。箭头函数就是个简写形式的函数表达式,如它的名称所描述的,它用一个 => 符号来定义一个函数。

先来看下它的语法:

// 多个参数
(param1, param2, …, paramN) => { statements }
(param1, param2, …, paramN) => expression

// 一个参数时,圆括号是可选的
(singleParam) => { statements }
singleParam => { statements }

// 无参数时,左边需要空的圆括号:
() => { statements }

由这几段语法可以知道,箭头函数式主要由三部分组成。首先是 函数的参数、后面跟着 => 符号,最后是 函数体。

从上面的代码也可以看出,箭头函数的不同写法,关键在于参数的个数。

当函数有参数多个时,左边必须是多个参数包含在一个圆括号中:

var sum = (num1, num2) => num1 + num2;

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

当函数只有一个参数时,左边的圆括号可以省略(也可以加上):

var increase = num => num + 1;

// 等价于
var increase = function(num) {
return num + 1;
};

当函数没有一个参数时,则左边必须使用空的圆括号:

var getThis = () => this;

// 等价于
var getThis = function() {
return this;
};

除了参数以外,右边的函数体写法也有可能不同。如果你的函数体内有零个或多个表达式,你必须使用花括号把它们包含起来。

像定义一个空函数,你可以这样:

var noop = () => {};

// 等价于
var noop = function() {};

又比如,函数体内多个表达式:

var sqrtRoot = (num1, num2) => {
var temp = num1*num1 + num2*num2;
return Math.sqrt(temp);
};

console.log(sqrtRoot(3, 4)); // 5

如果你函数返回的是一个对象,你不能把对象直接写在 => 符号后面,这样会导致报错。你要么使用 一对圆括号 把返回的对象包含起来,要么使用 花括号 再包一层:

var getObj1 = () => {name: 'yix'};

var getObj2 = () => ({name: 'yix'});

var getObj3 = () => {
return {
name: 'yix'
};
};

console.log(getObj1()); // undefined
console.log(getObj2()); // {name: "yix"}
console.log(getObj3()); // {name: "yix"}

即: 如果 => 符号后面是 () 圆括号,则表示函数执行后返回圆括号的内容。 如果 => 符号后面是 {} 花括号,则表示函数执行后会运行花括号里的内容。

值得注意的是,虽然箭头函数很好用,但是它和传统的函数还是有很多的区别。

5.1 不绑定 this

由文章开头我们知道,this 在函数调用中可以说是千变万化,它返回的值取决它的执行环境(context)。但是在箭头函数却不会有这样的问题,它返回的就是定义时所在的对象。

不使用箭头函数:

function Increase() {
this.num = 1;

setTimeout(function() {
this.num++;
console.log(this); // window
console.log(this.num); // NaN
}, 500);
}

var p = new Increase();

使用箭头函数:

function Increase() {
this.num = 1;

setTimeout(() => {
this.num++;
console.log(this); // increase
console.log(this.num); // 2
}, 500);
}

var p = new Increase();

在上面的代码中,不使用箭头函数,setTimeout 函数内部的this默认是 window,但全局中根本没有 num 这个变量,于是就变成了 window.num + 1 ,最后得到的结果是 NaN。

但在使用了箭头函数后,它可以让 this 不绑定运行的环境,而是把 this 指向了定义所在的环境。因此可以找到 num 的值为 1,递增后的结果为 2 。

5.2 不绑定 arguments

在普通函数中,我们可以轻松的访问到 参数对象-arguments,但是你却不能在箭头函数里访问它,因为箭头函数没有 arguments对象:

var sum1 = function(num1, num2) {
return arguments;
};

var sum2 = (num1, num2) => {
return arguments;
};

console.log(sum1(1, 2)); // [1, 2]
console.log(sum2(1, 2)); // arguments is not defined

但如果箭头函数是在普通函数里访问 arguments对象,则又能正常访问,因为此时访问到的 arguments对象 是普通函数的,而非箭头函数的:

var sum3 = function(num1, num2) {
return () => arguments;
};

console.log(sum3(1, 2)()); // [1, 2]

5.3 new 操作符

箭头函数不能用作构造器,若尝试用 new 来构造一个函数,则会报错:

var Sum = (num1, num2) => num1 + num2;

var total = new Sum(1, 2); // sum is not a constructor

总结起来,箭头函数有这么几个特点:

  • 没有 thisargumentsnew.target 的绑定
  • 不能用 new 来构造函数。若强行使用,则会抛出一个错误
  • 没有 prototype。构造函数都没有了,要 prototype 干嘛
  • 函数内部没有 arguments 对象。你可以用 Rest参数 代替它
  • 不能有同名的参数

六、尾调用优化

首先,必须说明的是,尾调用优化 是 ES6 针对一些性能消耗比较高的代码(比如递归函数)而引入的js引擎优化,它只是一种概念,一个优化手段,而并非是代码或者语法的某种特殊写法。

其他语言早就已经有 尾调用优化 的概念和应用了,我相信,ES6 肯定是借鉴它们的。

要明白什么是尾调用优化,你当然首先需要知道 尾调用。

所谓的 尾调用,就是一个函数在另外一个函数内部的最后一行进行调用。换言之,尾调用是发生在当一个函数尾部返回另一个函数调用时。比如:

function sum() {
return add();
}

如果最后返回的调用函数是本身,则这种调用,简称 尾递归。如:

function factorial(num) {
if (num <= 1) {
return 1;
}

return num * factorial(num - 1);
}

但是,以下的情况并不是尾调用:

function sum() {
return add() + 1;
}


function factorial(num) {
if (num <= 1) {
return 1;
}

var total = num * factorial(num - 1);

return total;
}

sum 函数里最后一行虽然返回了一个函数调用,但是它是先做了加性操作符运算,然后再返回的,所以它不算。而 factorial 函数最后一行返回的根本不是直接调用的函数,只是达到同等的效果而已,因此它也不能算尾调用。

虽然普通函数也能用到尾调用优化,但为了更深入的阐述尾调用优化的作用,我们就来说说最具有代表性的尾递归,阶层求值:

function f(n) {
if (n === 1) {
return 1;
}
return n * f(n - 1);
}

当我们运行 f(5) 时,会执行下列过程:

f(5)

5 * f(4)

5 * 4 * f(3)

5 * 4 * 3 * f(2)

5 * 4 * 3 * 2 * f(1)

5 * 4 * 3 * 2 * 1

这种递归的写法并不能使用尾调用优化,因为 f 函数的最后行为不是返回单纯的函数调用。而且因为需要引用前面的关系,就会加入一层一层的堆栈帧,当 n 很大时,很有可能导致堆栈溢出。

我们把上面的函数改动一下:

function f2(n, m) {
var m = m || 1;
if (n === 1) {
return m;
}
return f2(n - 1, m * n);
}

此时,f2 最后返回了一个函数调用,并且使用参数 m 保存了每次计算后的值。运行 f2(5) 它就转化为:

f2(5)

f2(4, 1 * 5)

f2(3, 1 * 5 * 4)

f2(2, 1 * 5 * 4 * 3)

f2(1, 1 * 5 * 4 * 3 * 2)

1 * 5 * 4 * 3 * 2

这样,就变成了一个接一个、无需引用前面的堆栈帧,并且是相互独立的函数调用了。

最后,值得注意的是,你必须在严格模式下,尾调用优化才能生效!