函数的扩展
一、函数参数的默认值
ES6允许为函数的参数设置默认值,即直接写在参数定义的后面。
function log(x, y = 'World') { console.log(x, y); } log('Hello') // Hello World log('Hello', 'China') // Hello China log('Hello', '') // Hello
参数变量是默认声明的,所以不能用let
或const
再次声明。
function foo(x = 5) { let x = 1; // error const x = 2; // error }
1.1 与解构赋值默认值结合使用
参数默认值可以与解构赋值的默认值,结合起来使用。
function fetch(url, { body = '', method = 'GET', headers = {} }) { console.log(method); } fetch('http://example.com', {}) // "GET" fetch('http://example.com') // 报错
上面代码中,如果函数fetch
的第二个参数是一个对象,就可以为它的三个属性设置默认值。
上面的写法不能省略第二个参数,如果结合函数参数的默认值,就可以省略第二个参数。这时,就出现了双重默认值。
function fetch(url, { method = 'GET' } = {}) { console.log(method); } fetch('http://example.com') // "GET"
上面代码中,函数fetch
没有第二个参数时,函数参数的默认值就会生效,然后才是解构赋值的默认值生效,变量method
才会取到默认值GET
。
下面两种写法区别:
// 写法一 设置解构赋值的默认值,参数的默认值 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.2 参数默认值的位置
通常情况下,定义了默认值的参数,应该是函数的尾参数。因为这样比较容易看出来,到底省略了哪些参数。如果非尾部的参数设置默认值,实际上这个参数是没法省略的。
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]
1.3 函数的length属性
length
属性的含义是,该函数预期传入的参数个数。某个参数指定默认值以后,预期传入的参数个数就不包括这个参数了。因此,指定了默认值以后,函数的length
属性,将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length
属性将失真。同理,rest参数也不会计入length
属性。
(function (a) {}).length // 1 (function (a = 5) {}).length // 0 (function (a, b, c = 5) {}).length // 2 (function(...args) {}).length // 0
如果设置了默认值的参数不是尾参数,那么length
属性也不再计入后面的参数了。
(function (a = 0, b, c) {}).length // 0 (function (a, b = 1, c) {}).length // 1
1.4 作用域
1)参数默认值是一个变量
如果参数默认值是一个变量,则该变量所处的作用域,与其他变量的作用域规则是一样的,即先是当前函数的作用域,然后才是全局作用域。
var x = 1; function f(x, y = x) { console.log(y); } f(2) // 2
参数y
的默认值等于x
。调用时,由于函数作用域内部的变量x
已经生成,所以y
等于参数x
,而不是全局变量x
。
如果调用时,函数作用域内部的变量x
没有生成,结果就会不一样。
let x = 1; function f(y = x) { let x = 2; console.log(y); } f() // 1
函数调用时,y
的默认值变量x
尚未在函数内部生成,所以x
指向全局变量。
如果此时,全局变量x
不存在,就会报错。
function f(y = x) { let x = 2; console.log(y); } f() // ReferenceError: x is not defined
下面这种写法也会报错
var x = 1; function foo(x = x) { // ... } foo() // ReferenceError: x is not defined
上面代码中,函数foo
的参数x
的默认值也是x
。这时,默认值x
的作用域是函数作用域,而不是全局作用域。由于在函数作用域中,存在变量x
,但是默认值在x
赋值之前先执行了,所以这时属于暂时性死区,任何对x
的操作都会报错。
2)参数默认值为函数
如果参数的默认值是一个函数,该函数的作用域是其声明时所在的作用域。
如果函数A的参数默认值是函数B,那么由于函数的作用域是其声明时所在的作用域,函数B的作用域就不是函数A,而是全局作用域。
let foo = 'outer'; function bar(func = x => foo) { let foo = 'inner'; console.log(func()); // outer } bar();
上面代码中,函数bar
的参数func
的默认值是一个匿名函数,返回值为变量foo
。这个匿名函数声明时,bar
函数的作用域还没有形成,所以匿名函数里面的foo
指向外层作用域的foo
,输出outer
。
下面是一个更复杂的例子。
var x = 1; function foo(x, y = function() { x = 2; }) { var x = 3; y(); console.log(x); } foo() // 3
上面代码中,函数foo
的参数y
的默认值是一个匿名函数。函数foo
调用时,它的参数x
的值为undefined
,所以y
函数内部的x
一开始是undefined
,后来被重新赋值2
。但是,函数foo
内部重新声明了一个x
,值为3
,这两个x
是不一样的,互相不产生影响,因此最后输出3
。
如果将var x = 3
的var
去除,两个x
就是一样的,最后输出的就是2
。
var x = 1; function foo(x, y = function() { x = 2; }) { x = 3; y(); console.log(x); } foo() // 2
详见:http://www.tuicool.com/articles/RjQ3Qn
1.5 应用
利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误。
function throwIfMissing() { throw new Error('Missing parameter'); } function foo(mustBeProvided = throwIfMissing()) { return mustBeProvided; } foo() // Error: Missing parameter
上面代码的foo
函数,如果调用的时候没有参数,就会调用默认值throwIfMissing
函数,从而抛出一个错误。
从上面代码还可以看到,参数mustBeProvided
的默认值等于throwIfMissing
函数的运行结果(即函数名之后有一对圆括号),这表明参数的默认值不是在定义时执行,而是在运行时执行(即如果参数已经赋值,默认值中的函数就不会运行),这与python语言不一样。
另外,可以将参数默认值设为undefined
,表明这个参数是可以省略的。
function foo(optional = undefined) { ··· }
二、rest参数
ES6引入rest参数(形式为“...变量名”),用于获取函数的多余参数,这样就不需要使用arguments对象了。rest参数搭配的变量是一个数组,该变量将多余的参数放入数组中。rest参数中的变量代表一个数组,所以数组特有的方法都可以用于这个变量。rest参数之后不能再有其他参数(即只能是最后一个参数),否则会报错。函数的length属性,不包括rest参数。
function add(...values) { let sum = 0; for (var val of values) { sum += val; } return sum; } add(2, 5, 3) // 10
上面代码的add函数是一个求和函数,利用rest参数,可以向该函数传入任意数目的参数。
下面是一个rest参数代替arguments变量的例子。
// arguments变量的写法 function sortNumbers() { return Array.prototype.slice.call(arguments).sort(); } // rest参数的写法 const sortNumbers = (...numbers) => numbers.sort();
// 报错 function f(a, ...b, c) { // ... }
三、扩展运算符
扩展运算符(spread)是三个点(...
)。它好比rest参数的逆运算,将一个数组转为用逗号分隔的参数序列。
console.log(...[1, 2, 3]) // 1 2 3 console.log(1, ...[2, 3, 4], 5) // 1 2 3 4 5 [...document.querySelectorAll('div')] // [<div>, <div>, <div>]
该运算符主要用于函数调用。
function push(array, ...items) { array.push(...items); } function add(x, y) { return x + y; } var numbers = [4, 38]; add(...numbers) // 42
上面代码中,array.push(...items)
和add(...numbers)
这两行,都是函数的调用,它们的都使用了扩展运算符。该运算符将一个数组,变为参数序列。
扩展运算符与正常的函数参数可以结合使用,非常灵活。
function f(v, w, x, y, z) { } var args = [0, 1]; f(-1, ...args, 2, ...[3]);
3.1 替代数组的apply方法
由于扩展运算符可以展开数组,所以不再需要apply
方法,将数组转为函数的参数了。
// ES5的写法 function f(x, y, z) { // ... } var args = [0, 1, 2]; f.apply(null, args); // ES6的写法 function f(x, y, z) { // ... } var args = [0, 1, 2]; f(...args);
另一个例子是通过push
函数,将一个数组添加到另一个数组的尾部。
// ES5的写法 var arr1 = [0, 1, 2]; var arr2 = [3, 4, 5]; Array.prototype.push.apply(arr1, arr2); // ES6的写法 var arr1 = [0, 1, 2]; var arr2 = [3, 4, 5]; arr1.push(...arr2);
上面代码的ES5写法中,push
方法的参数不能是数组,所以只好通过apply
方法变通使用push
方法。有了扩展运算符,就可以直接将数组传入push
方法。
3.2 扩展运算符的应用
3.2.1 合并数组
var arr1 = ['a', 'b']; var arr2 = ['c']; var arr3 = ['d', 'e']; // ES5的合并数组 arr1.concat(arr2, arr3); // [ 'a', 'b', 'c', 'd', 'e' ] // ES6的合并数组 [...arr1, ...arr2, ...arr3] // [ 'a', 'b', 'c', 'd', 'e' ]
3.2.2 与解构赋值结合
如果将扩展运算符用于数组赋值,只能放在参数的最后一位,否则会报错。
const [first, ...rest] = [1, 2, 3, 4, 5]; first // 1 rest // [2, 3, 4, 5] const [...butLast, last] = [1, 2, 3, 4, 5]; // 报错 const [first, ...middle, last] = [1, 2, 3, 4, 5]; // 报错
3.2.3 函数返回值
JavaScript的函数只能返回一个值,如果需要返回多个值,只能返回数组或对象。扩展运算符提供了解决这个问题的一种变通方法。
var dateFields = readDateFields(database); var d = new Date(...dateFields);
3.2.4 字符串
扩展运算符还可以将字符串转为真正的数组,并且能够正确识别32位的Unicode字符。
[...'hello'] // [ "h", "e", "l", "l", "o" ] 'x\uD83D\uDE80y'.length // 4 [...'x\uD83D\uDE80y'].length // 3
凡是涉及到操作32位Unicode字符的函数,都有这个问题。因此,最好都用扩展运算符改写。
3.2.5 实现了Iterator接口对象
任何Iterator接口的对象,都可以用扩展运算符转为真正的数组。
var nodeList = document.querySelectorAll('div'); var array = [...nodeList];
对于那些没有部署Iterator接口的类似数组的对象,扩展运算符就无法将其转为真正的数组。
let arrayLike = { '0': 'a', '1': 'b', '2': 'c', length: 3 }; // TypeError: Cannot spread non-iterable object. let arr = [...arrayLike];
arrayLike
是一个类似数组的对象,但是没有部署Iterator接口,扩展运算符就会报错。这时,可以改为使用Array.from
方法将arrayLike
转为真正的数组。
四、严格模式
只要函数参数使用了默认值、解构赋值、或者扩展运算符,那么函数内部就不能显式设定为严格模式,否则会报错。
// 报错 function doSomething(a, b = a) { 'use strict'; // code } // 报错 const doSomething = function ({a, b}) { 'use strict'; // code }; // 报错 const doSomething = (...a) => { 'use strict'; // code }; const obj = { // 报错 doSomething({a, b}) { 'use strict'; // code } };
两种方法可以规避这种限制。第一种是设定全局性的严格模式,这是合法的。
'use strict'; function doSomething(a, b = a) { // code }
第二种是把函数包在一个无参数的立即执行函数里面。
const doSomething = (function () { 'use strict'; return function(value = 42) { return value; }; }());
五、name属性
函数的name
属性,返回该函数的函数名。如果将一个匿名函数赋值给一个变量,ES5的name
属性,会返回空字符串,而ES6的name
属性会返回实际的函数名。
var func1 = function () {}; // ES5 func1.name // "" // ES6 func1.name // "func1"
如果将一个具名函数赋值给一个变量,则ES5和ES6的name
属性都返回这个具名函数原本的名字。
const bar = function baz() {}; // ES5 bar.name // "baz" // ES6 bar.name // "baz"
Function
构造函数返回的函数实例,name
属性的值为“anonymous”。
(new Function).name // "anonymous"
bind
返回的函数,name
属性值会加上“bound ”前缀。
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; };
如果箭头函数的代码块部分多于一条语句,就要使用大括号将它们括起来,并且使用return
语句返回。
var sum = (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)函数体内的this
对象,就是定义时所在的对象,而不是使用时所在的对象。
(2)不可以当作构造函数,也就是说,不可以使用new
命令,否则会抛出一个错误。
(3)不可以使用arguments
对象,该对象在函数体内不存在。如果要用,可以用Rest参数代替。
(4)不可以使用yield
命令,因此箭头函数不能用作Generator函数。
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
一次都没更新。箭头函数可以让this
指向固定化,这种特性很有利于封装回调函数。下面是一个例子,DOM事件的回调函数封装在一个对象里面。
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
就是外层代码块的this
。正是因为它没有this
,所以也就不能用作构造函数。除了this
,以下三个变量在箭头函数之中也是不存在的,指向外层函数的对应变量:arguments
、super
、new.target
。
function foo() { setTimeout(() => { console.log('args:', arguments); }, 100); } foo(2, 4, 6, 8) // args: [2, 4, 6, 8]
上面代码中,箭头函数内部的变量arguments
,其实是函数foo
的arguments
变量。
另外,由于箭头函数没有自己的this
,所以当然也就不能用call()
、apply()
、bind()
这些方法去改变this
的指向。
(function() { return [ (() => this.x).bind({ x: 'inner' })() ]; }).call({ x: 'outer' }); // ['outer']
上面代码中,箭头函数没有自己的this
,所以bind
方法无效,内部的this
指向外部的this
。
嵌套的箭头函数
箭头函数内部,还可以再使用箭头函数。
七、函数绑定
箭头函数可以绑定this
对象,大大减少了显式绑定this
对象的写法(call
、apply
、bind
)。但是,箭头函数并不适用于所有场合,所以ES7提出了“函数绑定”(function bind)运算符,用来取代call
、apply
、bind
调用。虽然该语法还是ES7的一个提案,但是Babel转码器已经支持。
函数绑定运算符是并排的两个双冒号(::),双冒号左边是一个对象,右边是一个函数。该运算符会自动将左边的对象,作为上下文环境(即this对象),绑定到右边的函数上面。
foo::bar; // 等同于 bar.bind(foo); foo::bar(...arguments); // 等同于 bar.apply(foo, arguments); const hasOwnProperty = Object.prototype.hasOwnProperty; function hasOwn(obj, key) { return obj::hasOwnProperty(key); }
如果双冒号左边为空,右边是一个对象的方法,则等于将该方法绑定在该对象上面。
var method = obj::obj.foo; // 等同于 var method = ::obj.foo; let log = ::console.log; // 等同于 var log = console.log.bind(console);
由于双冒号运算符返回的还是原对象,因此可以采用链式写法。
// 例一 import { map, takeWhile, forEach } from "iterlib"; getPlayers() ::map(x => x.character()) ::takeWhile(x => x.strength > 100) ::forEach(x => console.log(x)); // 例二 let { find, html } = jake; document.querySelectorAll("div.myClass") ::find("p") ::html("hahaha");