【ES6】函数的扩展
1. 函数参数的默认值
在ES6之前不能为函数的参数制定默认值,只能用变通的方法( y = y || '默认值';)。但这种方法在参数的布尔值为false时(如空字符串)会误把默认值赋上。所以对应的还需要加上一个if语句判定是否是undefined来避免这个问题。而ES6允许直接使用“=”来设置默认值。这样做有三个优点:
- 简洁;
- 阅读起来很容易找到哪些参数是可以省略的,而不需要读文档或是看函数体;
- 有利于代码未来的优化,哪怕在未来版本的对外接口中彻底拿掉这个参数,也不会导致以前的代码无法运行。
函数的默认值声明中还有三个需要注意的问题:
✨参数变量是默认声明的,所以不能在函数体内用 let 或 const 重复声明;
✨使用参数默认值时,函数不能有同名参数;
✨参数默认值不是传值的,而是每次都计算表达式的值。也就是说是惰性求值的。
let x = 99; function foo(p = x + 1) { console.log(p); } foo() // 100 x = 100; foo() // 101
与解构赋值结合使用
有时会用到较为复杂的双重默认值的情况。双重默认值指的是:一个是解构赋值的默认值,一个是函数参数的默认值。比如下面这个例子两个函数的区别:
// 设置了解构默认值,同时设置函数参数的默认值是一个空对象 function m1({x = 0, y = 0} = {}) { return [x, y]; } // 没有设置结构默认值,设置函数参数的默认值是x,y都为0的对象 function m2({x, y} = { x: 0, y: 0 }) { return [x, y]; } //运行结果 // 函数没有参数的情况,即undefined m1() // [0, 0] m2() // [0, 0] // x 有值,y 无值的情况 m1({x: 3}) // [3, 0] m2({x: 3}) // [3, undefined] // x 和 y 都无值的情况 m1({}) // [0, 0] (取到了解构赋值的默认值) m2({}) // [undefined, undefined] (没有解构赋值的默认值)
参数默认值的位置和函数的length属性
参数默认值的位置应该放在函数的末尾。因为这样才能起到省略参数的作用。除非显式的将前面的参数置为undefined。
函数的length属性的含义是该函数预期传入的参数个数。也就是说设置了默认值的参数,以及rest属性都不会计入length。需要注意的是,如果设置了默认值的参数不是尾参数的话,在他后面的参数也不会计入length。
作用域及默认值的应用
一旦设置了默认值,函数参数初始化的时候,参数部分会形成一个单独的作用域(与函数体没有关系)。注意这种语法特点在没有设置默认值的时候是不会出现的。
弄清楚作用域问题的重点在于分辨参数究竟指向的哪个作用域的值。下面几个例子用于重点理解这一块:
var x = 1; function f(x, y = x) { console.log(y); } f(2) // 2
由于参数部分是一个单独的作用域,参数声明中的 y = x 指向的是参数部分的x,而不是全局环境的x,所以输出的是2。
let x = 1; function f(y = x) { let x = 2; console.log(y); } f() // 1
参数声明中的 y = x 将 x 的值赋给 y ,由于参数作用域没有找到 x 的值,所以在全局作用域寻找,找到全局作用域中的 x 值为1。如果去掉全局作用域中的 let 声明,将会报错。
不仅仅是赋值,函数也是如此。如果函数的 return 部分在参数作用域中找不到,就在全局作用域中寻找(而不是被函数体内的赋值影响)。
下面是一个更为复杂的例子,涉及到全局、参数、函数内部三个作用域。
var x = 1; function foo(x, y = function() { x = 2; }) { var x = 3; y(); console.log(x); } foo() // 3 x // 1
在这个函数 foo 中,参数作用域中匿名函数 y 指向前一个参数 x 。在函数内部又声明了一个变量 x ,该变量和参数 x 不在同一个作用域,所以不是同一个变量。而后函数 y 执行,对外部(全局)和内部(函数体)的 x 都没有影响。
var x = 1; function foo(x, y = function() { x = 2; }) { x = 3; y(); console.log(x); } foo() // 2 x // 1
将var去除后,内部变量就指向参数 x 了。这是函数 y 执行改变了内部(既是函数体又是参数作用域)的 x 的值,但不影响外部的值。
参数默认值可以用于在缺少参数的时候抛出错误:
function throwIfMissing() { throw new Error('Missing parameter'); } //给函数参数设置默认值为抛出错误的函数,如果没有参数则使用默认值,默认值抛出错误 function foo(mustBeProvided = throwIfMissing()) { return mustBeProvided; } foo() // Error: Missing parameter 由此也可以看出,参数部分的函数是惰性求值的。即在定义时是不执行的,在运行时才执行。
2. rest参数,严格模式和name属性
✨rest参数用于获取函数多余的参数。rest参数搭配的变量是一个数组,将多余的参数放到这个数组中。rest参数后面不能接其他参数,否则会报错。
✨只要参数使用了默认值、解构赋值、或者扩展运算符,就不能显式在函数内部指定严格模式。因为在函数执行时,是先执行函数参数,再执行函数体的。如果在函数体内设定严格模式,而此时参数已经设置了默认值,这显然是不合理的。
✨name属性返回函数的名称。这个属性有一些特殊情况:
- 对于匿名函数,ES5返回空字符串,ES6返回实际的函数名;
- Function构造函数返回的函数实例,name属性的值为 anonymous ;
- bind返回的函数,name属性值会加上 bound 前缀。
3. 箭头函数
基本用法
ES6可以使用箭头符号(⇒)来定义函数。具体使用方法如下:
- 如果不需要参数或有多个参数,用圆括号代表参数部分;
- 代码块部分语句超过一句,则要用大括号包裹起来,并用return语句返回;
- 直接返回对象,要在外面包裹圆括号,避免被错认为代码块;
- 可以结合解构赋值使用,箭头函数可以简化回调函数,可以与rest参数结合使用。
注意点
✨千万要注意箭头函数的this指向问题!!!是指向定义时所在的对象,而不是执行时所在的对象。
function foo() { setTimeout(() => { console.log('id:', this.id); }, 100); } var id = 21; foo.call({ id: 42 }); // id: 42
由于this对象的指向是可变的,但是在箭头函数中他是固定的。在上面的例子中,setTimeout函数是在外层函数foo执行时被定义的(此时id=42),所以他的this指向的是foo(定义的作用域)的id,而不是全局环境的id21(执行的作用域)。
箭头函数的这个特性(this指向固定化)有利于回调函数的封装(这样就不会this变为指向document而导致函数执行错误)。
this指向固定化的本质原因是箭头函数内部没有自己的this,导致内部的this就是外层代码的this。因此,也不能使用call( ),apply( ),bind( )方法改变this的指向。同时它还没有arguments、super、new.target三个变量,所以只能取得外层代码的对应变量。
// ES6 function foo() { setTimeout(() => { console.log('id:', this.id); }, 100); } // ES5 function foo() { **var _this = this; //可以看出箭头函数内部是没有自己的this的** setTimeout(function () { console.log('id:', _this.id); }, 100); }
✨不可以当作构造函数,也就是说,不可以使用new
命令,否则会抛出一个错误。
✨不可以使用arguments
对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。
✨不可以使用yield
命令,因此箭头函数不能用作 Generator 函数。
不适用的场合
基于箭头函数this从“动态”变为“静态”,故也有一些场合不适合使用箭头函数:
-
定义对象的方法,且对象内部包含this
由于对象不构成单独的作用域,所以对象方法中定义的作用域为全局作用域,而不是指向这个对象。
-
需要动态的this
比如需要动态指向被点击的按钮对象 button.addEventListener ,监听函数如果使用回调函数,则会导致this固定指向全局对象。
-
函数复杂有多行
为了增强可读性,不建议使用箭头函数。
4. 尾调用优化
尾调用:某个函数的最后一步是调用另一个函数。
注意点:
- 如果调用函数之后还有赋值操作(如let y = g(x); return y;),不属于尾调用;
- 尾调用不一定要在函数的最后一行,只要是最后一步操作就可以(如if语句的结尾)。
尾调用优化
函数会在内存中形成一个“调用记录”,又叫**“调用帧”**(call frame),保存调用位置和内部变量等信息。如果函数A中调用了函数B,那么函数A之上还会形成一个函数B的调用帧,直到B运行结束并将结果返回A,B的调用帧才会消失。所有的调用帧形成了一个“调用栈”(call stack)。
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。
只保留内层函数的调用帧,就叫做“尾调用优化”。其意义就是每次调用只有一层调用帧,大大节约了内存。目前只有 Safari 浏览器支持尾调用优化。
尾递归
如果尾调用自身,就称为尾递归。递归非常消耗内存,需要同时保存很多个调用帧,容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,故不会发生栈溢出。
//阶乘的常规写法 function factorial(n) { if (n === 1) return 1; return n * factorial(n - 1); } //阶乘的尾递归写法 function factorial(n, total) { if (n === 1) return total; return factorial(n - 1, n * total); }
ES6 第一次明确规定,所有 ECMAScript 的实现,都必须部署“尾调用优化”。
递归函数的改写
为了实现尾递归,需要改写递归函数,使得最后一步是调用自身。实现的方法就是把所有用到的内部变量改写为函数的参数。如上面尾递归的阶乘函数。但改写后出现了一个问题,就是代码的可读性变差了。这里提供了两种解决办法:
-
通过一个正常的函数调用尾递归函数(或使用柯里化)
function tailFactorial(n, total) { //参数total使得可读性降低 if (n === 1) return total; return tailFactorial(n - 1, n * total); } //1. 使用一个正常的函数来调用尾递归 function factorial(n) { return tailFactorial(n, 1); } //2. 柯里化:将多参数的函数转化为单参数的函数 function currying(fn, n) { return function (m) { return fn.call(this, m, n); }; } const factorial = currying(tailFactorial, 1);
-
采用函数默认值
function factorial(n, total = 1) { //total有默认值,只需要输入n一个参数 if (n === 1) return total; return factorial(n - 1, n * total); }
ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。正常模式之下,只能自己实现尾调用优化。
尾递归优化的实现
核心思想:用循环替代递归。
-
蹦床函数(trampoline)
function trampoline(f) { while (f && f instanceof Function) { f = f(); } return f; } function sum(x, y) { if (y > 0) { return sum.bind(null, x + 1, y - 1); } else { return x; } }
在蹦床函数中,只要f执行后返回一个函数他就继续执行。这里把在函数中调用函数转化为了执行函数返回的函数。也就是将“递归”转换为了“循环”。但缺点是需要改写原函数,将调用改为返回自身的另一个版本。
-
tco()函数
function tco(f) { var value; var active = false; var accumulated = []; return function accumulator() { 2 accumulated.push(arguments); //accumulator的实参就是sum的实参, 2 //也就是说把[1, 100000]传入了accumulated 4 //虽然没能进入if语句,但sum(也就是accumulator)执行了第一句push, 4 //所以实参被修改成了变化后的实参,while循环继续, 4 //也就是说f.apply每执行一次就会往accumulate中push进新的实参,直到运行结束 if (!active) { active = true; while (accumulated.length) { 3 value = f.apply(this, accumulated.shift()); //执行了sum函数, 3 //判断y(>0)的值后返回一个sum 3 //sum的参数发生了改变(x+1, y-1),执行sum相当于执行accumulator 3 //但由于还在while循环里,所以执行这个被返回的sum得到undefined,被赋给value } active = false; 5 return value; //直到外层匿名函数f(sum)返回x,value才被返回出来 } }; } var sum = tco(function(x, y) { 1 //将tco函数的返回值赋给sum, 1 //tco函数的返回值是accumulator函数, 1 //也就是说执行sum(1, 100000)实际上是在执行accumulator(1, 100000) if (y > 0) { return sum(x + 1, y - 1) } else { return x } }); sum(1, 100000) // 100001
上面代码中,tco函数是尾递归优化的实现,它的奥妙就在于状态变量active。默认情况下,这个变量是不激活的。一旦进入尾递归优化的过程,这个变量就激活了。然后,每一轮递归sum返回的都是undefined,所以就避免了递归执行;而accumulated数组存放每一轮sum执行的参数,总是有值的,这就保证了accumulator函数内部的while循环总是会执行。这样就很巧妙地将“递归”改成了“循环”,而后一轮的参数会取代前一轮的参数,保证了调用栈只有一层。
5. 函数参数的尾逗号、toString( )及catch参数的省略
ES2017 允许函数的最后一个参数有尾逗号(trailing comma)。
toString()方法返回函数代码本身,以前会省略注释和空格。修改后的toString()方法,明确要求返回一模一样的原始代码。
JavaScript 语言的try...catch结构,以前明确要求catch命令后面必须跟参数,接受try代码块抛出的错误对象。ES2019 做出了改变,允许catch语句省略参数。