ES6函數的拓展
1、函數參數的默認值
ES6 之前,不能直接為函數的參數指定默認值,只能采用變通的方法。
function log(x, y) { y = y || ‘World‘; console.log(x, y); } log(‘Hello‘) // Hello World log(‘Hello‘, ‘China‘) // Hello China log(‘Hello‘, ‘‘) // Hello World
上面代碼檢查函數log
的參數y
有沒有賦值,如果沒有,則指定默認值為World
。這種寫法的缺點在於,如果參數y
賦值了,但是對應的布爾值為false
,則該賦值不起作用。就像上面代碼的最後一行,參數y
為了避免這個問題,通常需要先判斷一下參數y
是否被賦值,如果沒有,再等於默認值。
if (typeof y === ‘undefined‘) { y = ‘World‘; }
ES6 允許為函數的參數設置默認值,即直接寫在參數定義的後面。
function log(x, y = ‘World‘) { console.log(x, y); } log(‘Hello‘) // Hello World log(‘Hello‘, ‘China‘) // Hello China log(‘Hello‘, ‘‘) // Hello
function Point(x = 0, y = 0) {this.x = x; this.y = y; } const p = new Point(); p // { x: 0, y: 0 }
參數變量是默認聲明的,所以不能用let
或const
再次聲明。
function foo(x = 5) { let x = 1; // error const x = 2; // error }
使用參數默認值時,函數不能有同名參數。
// 不報錯 function foo(x, x, y) { // ... } // 報錯 function foo(x, x, y = 1) { // ... } // SyntaxError: Duplicate parameter name not allowed in this context
一個容易忽略的地方是,參數默認值不是傳值的,而是每次都重新計算默認值表達式的值。也就是說,參數默認值是惰性求值的。
let x = 99; function foo(p = x + 1) { console.log(p); } foo() // 100 x = 100; foo() // 101
與解構賦值默認值結合使用
參數默認值可以與解構賦值的默認值,結合起來使用。
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
的參數是一個對象時,變量x
和y
才會通過解構賦值生成。如果函數foo
調用時沒提供參數,變量x
和y
就不會生成,從而報錯。通過提供函數參數的默認值,就可以避免這種情況。
如果沒有提供參數,函數foo
的參數默認為一個空對象。
function foo({x, y = 5} = {}) { console.log(x, y); } foo() // undefined 5
如果函數fetch
的第二個參數是一個對象,就可以為它的三個屬性設置默認值。這種寫法不能省略第二個參數,如果結合函數參數的默認值,就可以省略第二個參數。這時,就出現了雙重默認值。
function fetch(url, { body = ‘‘, method = ‘GET‘, headers = {} }) { console.log(method); } fetch(‘http://example.com‘, {}) // "GET" fetch(‘http://example.com‘) // 報錯
上面代碼中,函數fetch
沒有第二個參數時,函數參數的默認值就會生效,然後才是解構賦值的默認值生效,變量method
才會取到默認值GET
。
function fetch(url, { body = ‘‘, method = ‘GET‘, headers = {} } = {}) { console.log(method); } fetch(‘http://example.com‘) // "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]
參數默認值的位置
通常情況下,定義了默認值的參數,應該是函數的尾參數。因為這樣比較容易看出來,到底省略了哪些參數。如果非尾部的參數設置默認值,實際上這個參數是沒法省略的。
// 例一 function f(x = 1, y) { return [x, y]; } f() // [1, undefined] f(2) // [2, undefined]) f(, 1) // 報錯 f(undefined, 1) // [1, 1] // 例二 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
。
函數的 length 屬性
指定了默認值以後,函數的length
屬性,將返回沒有指定默認值的參數個數。也就是說,指定了默認值後,length
屬性將失真。
(function (a) {}).length // 1 (function (a = 5) {}).length // 0 (function (a, b, c = 5) {}).length // 2
這是因為length
屬性的含義是,該函數預期傳入的參數個數。某個參數指定默認值以後,預期傳入的參數個數就不包括這個參數了。同理,後文的 rest 參數也不會計入length
屬性。
上面代碼中,參數y
的默認值等於變量x
。調用函數f
時,參數形成一個單獨的作用域。在這個作用域裏面,默認值變量x
指向第一個參數x
,而不是全局變量x
,所以輸出是2
。
var x = 1; function f(x, y = x) { console.log(y); } f(2) // 2
函數f
調用時,參數y = x
形成一個單獨的作用域。這個作用域裏面,變量x
本身沒有定義,所以指向外層的全局變量x
。函數調用時,函數體內部的局部變量x
影響不到默認值變量x
。
let x = 1; function f(y = x) { let x = 2; console.log(y); } f() // 1
如果此時,全局變量x
不存在,就會報錯。
function f(y = x) { let x = 2; console.log(y); } f() // ReferenceError: x is not defined
下面這樣寫,也會報錯。
上面代碼中,參數x = x
形成一個單獨作用域。實際執行的是let x = x
,由於暫時性死區的原因,這行代碼會報錯”x 未定義“。
var x = 1; function foo(x = x) { // ... } foo() // ReferenceError: x is not defined
如果參數的默認值是一個函數,該函數的作用域也遵守這個規則。
函數bar
的參數func
的默認值是一個匿名函數,返回值為變量foo
。函數參數形成的單獨作用域裏面,並沒有定義變量foo
,所以foo
指向外層的全局變量foo
,因此輸出outer
。
let foo = ‘outer‘; function bar(func = () => foo) { let foo = ‘inner‘; console.log(func()); } bar(); // outer
如果寫成下面這樣,就會報錯。
function bar(func = () => foo) { let foo = ‘inner‘; console.log(func()); } bar() // ReferenceError: foo is not defined
函數foo
的參數形成一個單獨作用域。這個作用域裏面,首先聲明了變量x
,然後聲明了變量y
,y
的默認值是一個匿名函數。這個匿名函數內部的變量x
,指向同一個作用域的第一個參數x
。函數foo
內部又聲明了一個內部變量x
,該變量與第一個參數x
由於不是同一個作用域,所以不是同一個變量,因此執行y
後,內部變量x
和外部全局變量x
的值都沒變。
var x = 1; function foo(x, y = function() { x = 2; }) { var x = 3; y(); console.log(x); } foo() // 3 x // 1
如果將var x = 3
的var
去除,函數foo
的內部變量x
就指向第一個參數x
,與匿名函數內部的x
是一致的,所以最後輸出的就是2
,而外層的全局變量x
依然不受影響。
var x = 1; function foo(x, y = function() { x = 2; }) { x = 3; y(); console.log(x); } foo() // 2 x // 1
rest 參數
ES6 引入 rest 參數(形式為...變量名
),用於獲取函數的多余參數,這樣就不需要使用arguments
對象了。rest 參數搭配的變量是一個數組,該變量將多余的參數放入數組中。
function add(...values) { let sum = 0; for (var val of values) { sum += val; } return sum; } add(2, 5, 3) // 10
下面是一個 rest 參數代替arguments
變量的例子。
// arguments變量的寫法 function sortNumbers() { return Array.prototype.slice.call(arguments).sort(); } // rest參數的寫法 const sortNumbers = (...numbers) => numbers.sort();
arguments
對象不是數組,而是一個類似數組的對象。所以為了使用數組的方法,必須使用Array.prototype.slice.call
先將其轉為數組。rest 參數就不存在這個問題,它就是一個真正的數組,數組特有的方法都可以使用。
利用 rest 參數改寫數組push
方法的例子。
function push(array, ...items) { items.forEach(function(item) { array.push(item); console.log(item); }); } var a = []; push(a, 1, 2, 3)
註意,rest 參數之後不能再有其他參數(即只能是最後一個參數),否則會報錯。
// 報錯 function f(a, ...b, c) { // ... }
函數的length
屬性,不包括 rest 參數。
(function(a) {}).length // 1 (function(...a) {}).length // 0 (function(a, ...b) {}).length // 1
嚴格模式
從 ES5 開始,函數內部可以設定為嚴格模式。
function doSomething(a, b) { ‘use strict‘; // code }
ES2016 做了一點修改,規定只要函數參數使用了默認值、解構賦值、或者擴展運算符,那麽函數內部就不能顯式設定為嚴格模式,否則會報錯。
這樣規定的原因是,函數內部的嚴格模式,同時適用於函數體和函數參數。但是,函數執行的時候,先執行函數參數,然後再執行函數體。這樣就有一個不合理的地方,只有從函數體之中,才能知道參數是否應該以嚴格模式執行,但是參數卻應該先於函數體執行。
兩種方法可以規避這種限制。第一種是設定全局性的嚴格模式,這是合法的。
‘use strict‘; function doSomething(a, b = a) { // code }
第二種是把函數包在一個無參數的立即執行函數裏面。
const doSomething = (function () { ‘use strict‘; return function(value = 42) { return value; }; }());
name 屬性
函數的name
屬性,返回該函數的函數名。
function foo() {} foo.name // "foo"
這個屬性早就被瀏覽器廣泛支持,但是直到 ES6,才將其寫入了標準。
需要註意的是,ES6 對這個屬性的行為做出了一些修改。如果將一個匿名函數賦值給一個變量,ES5 的name
屬性,會返回空字符串,而 ES6 的name
屬性會返回實際的函數名。
var f = function () {}; // ES5 f.name // "" // ES6 f.name // "f"
箭頭函數
ES6 允許使用“箭頭”(=>
)定義函數。
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; }
由於大括號被解釋為代碼塊,所以如果箭頭函數直接返回一個對象,必須在對象外面加上括號,否則會報錯。
// 報錯 let getTempItem = id => { id: id, name: "Temp" }; // 不報錯 let getTempItem = id => ({ id: id, name: "Temp" });
箭頭函數可以與變量解構結合使用
const full = ({ first, last }) => first + ‘ ‘ + last; // 等同於 function full(person) { return person.first + ‘ ‘ + person.last; }
箭頭函數使得表達更加簡潔。
const isEven = n => n % 2 == 0;
const square = n => n * n;
箭頭函數的一個用處是簡化回調函數。
// 正常函數寫法 [1,2,3].map(function (x) { return x * x; }); // 箭頭函數寫法 [1,2,3].map(x => x * x);
另一個例子是
// 正常函數寫法 var result = values.sort(function (a, b) { return a - b; }); // 箭頭函數寫法 var result = values.sort((a, b) => a - b);
下面是 rest 參數與箭頭函數結合的例子。
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
對象的指向是可變的,但是在箭頭函數中,它是固定的。
setTimeout
的參數是一個箭頭函數,這個箭頭函數的定義生效是在foo
函數生成時,而它的真正執行要等到 100 毫秒後。
箭頭函數導致this
總是指向函數定義生效時所在的對象(本例是{id: 42}
),所以輸出的是42
。
foo.call({id:42})call()將foo函數裏面的this 指向了{id:42}這個對象
箭頭函數可以讓setTimeout
裏面的this
,綁定定義時所在的作用域,而不是指向運行時所在的作用域。
function foo() { setTimeout(() => { console.log(‘id:‘, this.id); }, 100); } var id = 21; foo.call({ id: 42 }); // id: 42
如果是普通函數,執行時this
應該指向全局對象window
,這時應該輸出21
。
foo.call({id:42})等價於 foo({id:42})
function foo() { setTimeout(function(){ console.log(‘id:‘, this.id); },100); } var id = 21; foo.call({ id: 42 }); //21
Timer
函數內部設置了兩個定時器,分別使用了箭頭函數和普通函數。前者的this
綁定定義時所在的作用域(即Timer
函數),後者的this
指向運行時所在的作用域(即全局對象)。所以,3100 毫秒之後,timer.s1
被更新了 3 次,而timer.s2
一次都沒更新。
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
this
指向的固定化,並不是因為箭頭函數內部有綁定this
的機制,實際原因是箭頭函數根本沒有自己的this
,導致內部的this
就是外層代碼塊的this
。正是因為它沒有this
,所以也就不能用作構造函數。
// 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
,就是函數foo
的this
,所以t1
、t2
、t3
都輸出同樣的結果。因為所有的內層函數都是箭頭函數,都沒有自己的this
,它們的this
其實都是最外層foo
函數的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
,以下三個變量在箭頭函數之中也是不存在的,指向外層函數的對應變量:arguments
、super
、new.target
箭頭函數內部的變量arguments
,其實是函數foo
的arguments
變量。
function foo() { setTimeout(() => { console.log(‘args:‘, arguments); }, 100); } foo(2, 4, 6, 8) // args: [2, 4, 6, 8]
由於箭頭函數沒有自己的this
,所以當然也就不能用call()
、apply()
、bind()
這些方法去改變this
的指向。
雙冒號運算符
箭頭函數可以綁定this
對象,大大減少了顯式綁定this
對象的寫法(call
、apply
、bind
)。但是,箭頭函數並不適用於所有場合,所以現在有一個提案,提出了“函數綁定”(function bind)運算符,用來取代call
、apply
、bind
調用。
函數綁定運算符是並排的兩個冒號(::
),雙冒號左邊是一個對象,右邊是一個函數。該運算符會自動將左邊的對象,作為上下文環境(即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);
尾調用優化
什麽是尾調用
尾調用(Tail Call)是函數式編程的一個重要概念,本身非常簡單,一句話就能說清楚,就是指某個函數的最後一步是調用另一個函數。
function f(x){ return g(x); }
以下三種情況,都不屬於尾調用。
情況一是調用函數g
之後,還有賦值操作,所以不屬於尾調用,即使語義完全一樣。情況二也屬於調用後還有操作,即使寫在一行內。
// 情況一 function f(x){ let y = g(x); return y; } // 情況二 function f(x){ return g(x) + 1; } // 情況三 function f(x){ g(x); }
情況三等同於下面的代碼。
function f(x){ g(x); return undefined; }
尾調用不一定出現在函數尾部,只要是最後一步操作即可。
function f(x) { if (x > 0) { return m(x) } return n(x); }
函數m
和n
都屬於尾調用,因為它們都是函數f
的最後一步操作。
尾遞歸
函數調用自身,稱為遞歸。如果尾調用自身,就稱為尾遞歸。
遞歸非常耗費內存,因為需要同時保存成千上百個調用幀,很容易發生“棧溢出”錯誤(stack overflow)。但對於尾遞歸來說,由於只存在一個調用幀,所以永遠不會發生“棧溢出”錯誤。
ES6函數的拓展