前面的話
函數是所有編程語言的重要組成部分,在ES6出現前,JS的函數語法一直沒有太大的變化,從而遺留了很多問題,導致實現一些基本的功能經常要編寫很多代碼。ES6大力度地更新了函數特性,在ES5的基礎上進行了許多改進,使用JS編程可以更少出錯,同時也更加靈活。本文將詳細介紹ES6函數擴展
形參默認值
Javascript函數有一個特別的地方,無論在函數定義中聲明了多少形參,都可以傳入任意數量的參數,也可以在定義函數時添加針對參數數量的處理邏輯,當已定義的形參無對應的傳入參數時為其指定一個默認值
【ES5模擬】
在ES5中,一般地,通過下列方式創建函數並為參數設置默認值
function makeRequest(url, timeout, callback) { timeout = timeout || 2000; callback = callback || function() {}; // 函數的剩余部分 }
在這個示例中,timeout和callback為可選參數,如果不傳入相應的參數系統會給它們賦予一個默認值。在含有邏輯或操作符的表達式中,前一個操作數的值為false時,總會返回后一個值。對於函數的命名參數,如果不顯式傳值,則其值默認為undefined
因此我們經常使用邏輯或操作符來為缺失的參數提供默認值
然而這個方法也有缺陷,如果我們想給makeRequest函數的第二個形參timeout傳入值0,即使這個值是合法的,也會被視為一個false值,並最終將timeout賦值為2000
在這種情況下,更安全的選擇是通過typeof檢查參數類型,如下所示
function makeRequest(url, timeout, callback) { timeout = (typeof timeout !== "undefined") ? timeout : 2000; callback = (typeof callback !== "undefined") ? callback : function() {}; // 函數的剩余部分 }
雖然這種方法更安全,但依然為實現一個基本需求而書寫了額外的代碼。它代表了一種常見的模式,而流行的 JS 庫中都充斥着類似的模式進行默認補全
【ES6默認參數】
ES6簡化了為形參提供默認值的過程,如果沒為參數傳入值則為其提供一個初始值
function makeRequest(url, timeout = 2000, callback = function() {}) { // 函數的剩余部分 }
在這個函數中,只有第一個參數被認為總是要為其傳入值的,其他兩個參數都有默認值,而且不需要添加任何校驗值是否缺失的代碼,所以函數代碼比較簡潔
如果調用make Request()方法時傳入3個參數,則不使用默認值
// 使用默認的 timeout 與 callback makeRequest("/foo"); // 使用默認的 callback makeRequest("/foo", 500); // 不使用默認值 makeRequest("/foo", 500, function(body) { doSomething(body); });
【觸發默認值】
聲明函數時,可以為任意參數指定默認值,在已指定默認值的參數后可以繼續聲明無默認值參數
function makeRequest(url, timeout = 2000, callback) { console.log(url); console.log(timeout); console.log(callback); }
在這種情況下,只有當不為第二個參數傳入值或主動為第二個參數傳入undefined時才會使用timeout的默認值
[注意]如果傳入undefined
,將觸發該參數等於默認值,null
則沒有這個效果
function makeRequest(url, timeout = 2000, callback) { console.log(timeout); } makeRequest("/foo");//2000 makeRequest("/foo", undefined);//2000 makeRequest("/foo", null);//null makeRequest("/foo", 100);//100
上面代碼中,timeout參數對應undefined
,結果觸發了默認值,y
參數等於null
,就沒有觸發默認值
使用參數默認值時,函數不能有同名參數
// SyntaxError: Duplicate parameter name not allowed in this context function foo(x, x, y = 1) { // ... }
另外,一個容易忽略的地方是,參數默認值不是傳值的,而是每次都重新計算默認值表達式的值。也就是說,參數默認值是惰性求值的
let x = 99; function foo(p = x + 1) { console.log(p); } foo() // 100 x = 100; foo() // 101
上面代碼中,參數p
的默認值是x+1
。這時,每次調用函數foo
,都會重新計算x+1
,而不是默認p
等於100
【length屬性】
指定了默認值以后,函數的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
【arguments】
當使用默認參數值時,arguments對象的行為與以往不同。在ES5非嚴格模式下,函數命名參數的變化會體現在arguments對象中
function mixArgs(first, second) { console.log(first === arguments[0]);//true console.log(second === arguments[1]);//true first = "c"; second = "d"; console.log(first === arguments[0]);//true console.log(second === arguments[1]);//true } mixArgs("a", "b");
在非嚴格模式下,命名參數的變化會同步更新到arguments對象中,所以當first和second被賦予新值時,arguments[0]和arguments[1]相應更新,最終所有===全等比較的結果為true
然而,在ES5的嚴格模式下,取消了arguments對象的這個令人感到困惑的行為,無論參數如何變化,arguments對象不再隨之改變
function mixArgs(first, second) { "use strict"; console.log(first === arguments[0]);//true console.log(second === arguments[1]);//true first = "c"; second = "d" console.log(first === arguments[0]);//false console.log(second === arguments[1]);//false } mixArgs("a", "b");
這一次更改 first 與 second 就不會再影響 arguments 對象,因此輸出結果符合通常的期望
在ES6中,如果一個函數使用了默認參數值,則無論是否顯式定義了嚴格模式,arguments對象的行為都將與ES5嚴格模式下保持一致。默認參數值的存在使得arguments對象保持與命名參數分離,這個微妙的細節將影響使用arguments對象的方式
// 非嚴格模式 function mixArgs(first, second = "b") { console.log(first);//a console.log(second);//b console.log(arguments.length);//1 console.log(arguments[0]);//a console.log(arguments[1]);//undefined first = 'aa'; arguments[1] = 'b'; console.log(first);//aa console.log(second);//b console.log(arguments.length);//1 console.log(arguments[0]);//a console.log(arguments[1]);//b } mixArgs("a");
在這個示例中,只給mixArgs()方法傳入一個參數,arguments. Iength 的值為 1, arguments[1] 的值為 undefined, first與arguments[0]全等,改變first和second並不會影響arguments對象
【默認參數表達式】
關於默認參數值,最有趣的特性可能是非原始值傳參了。可以通過函數執行來得到默認參數的值
function getValue() { return 5; } function add(first, second = getValue()) { return first + second; } console.log(add(1, 1)); // 2 console.log(add(1)); // 6
在這段代碼中,如果不傳入最后一個參數,就會調用getvalue()函數來得到正確的默認值。切記,初次解析函數聲明時不會調用getvalue()方法,只有當調用add()函數且不傳入第二個參數時才會調用
let value = 5; function getValue() { return value++; } function add(first, second = getValue()) { return first + second; } console.log(add(1, 1)); // 2 console.log(add(1)); // 6 console.log(add(1)); // 7
在此示例中,變量value的初始值為5,每次調用getvalue()時加1。第一次調用add(1)返回6,第二次調用add(1)返回7,因為變量value已經被加了1。因為只要調用add()函數就有可能求second的默認值,所以任何時候都可以改變那個值
正因為默認參數是在函數調用時求值,所以可以使用先定義的參數作為后定義參數的默認值
function add(first, second = first) { return first + second; } console.log(add(1, 1)); // 2 console.log(add(1)); // 2
在上面這段代碼中,參數second的默認值為參數first的值,如果只傳入一個參數,則兩個參數的值相同,從而add(1,1)返回2,add(1)也返回2
function getValue(value) { return value + 5; } function add(first, second = getValue(first)) { return first + second; } console.log(add(1, 1)); // 2 console.log(add(1)); // 7
在上面這個示例中,聲明second=getvalue(first),所以盡管add(1,1)仍然返回2,但是add(1)返回的是(1+6)也就是7
在引用參數默認值的時候,只允許引用前面參數的值,即先定義的參數不能訪問后定義的參數
function add(first = second, second) { return first + second; } console.log(add(1, 1)); // 2 console.log(add(undefined, 1)); // 拋出錯誤
調用add(undefined,1)會拋出錯誤,因為second比first晚定義,因此其不能作為first的默認值
【臨時死區】
在介紹塊級作用域時提到過臨時死區TDZ,其實默認參數也有同樣的臨時死區,在這里的參數不可訪問。與let聲明類似,定義參數時會為每個參數創建一個新的標識符綁定,該綁定在初始化之前不可被引用,如果試圖訪問會導致程序拋出錯誤。當調用函數時,會通過傳入的值或參數的默認值初始化該參數
function getValue(value) { return value + 5; } function add(first, second = getValue(first)) { return first + second; } console.log(add(1, 1)); // 2 console.log(add(1)); // 7
調用add(1,1)和add(1)時實際上相當於執行以下代碼來創建first和second參數值
// JS 調用 add(1, 1) 可表示為 let first = 1; let second = 1; // JS 調用 add(1) 可表示為 let first = 1; let second = getValue(first);
當初次執行函數add()時,first和second被添加到一個專屬於函數參數的臨時死區(與let的行為類似)。由於初始化second時first已經被初始化,所以它可以訪問first的值,但是反過來就錯了
function add(first = second, second) { return first + second; } console.log(add(1, 1)); // 2 console.log(add(undefined, 1)); // 拋出錯誤
在這個示例中,調用add(1,1)和add(undefined,1)相當於在引擎的背后做了如下事情
// JS 調用 add(1, 1) 可表示為 let first = 1; let second = 1; // JS 調用 add(1) 可表示為 let first = second; let second = 1;
在這個示例中,調用add(undefined,1)函數,因為當first初始化時second尚未初始化,所以會導致程序拋出錯誤,此時second尚處於臨時死區中,所有引用臨時死區中綁定的行為都會報錯
【形參與自由變量】
下列代碼中,y是形參,需要考慮臨時死區的問題;而x是自由變量,不需要考慮。所以調用函數時,由於未傳入參數,執行y=x,x是自由變量,通過作用域鏈,在全局作用域找到x=1,並賦值給y,於是y取值1
let x = 1; function f(y = x) {} f() // 1
下列代碼中,x和y是形參,需要考慮臨時死區的問題。因為沒有自由變量,所以不考慮作用域鏈尋值的問題。調用函數時,由於未傳入參數,執行y=x,由於x正處於臨時死區內,所有引用臨時死區中綁定的行為都會報錯
let x = 1; function f(y = x,x) {} f()// ReferenceError: x is not defined
類似地,下列代碼也報錯
let x = 1; function foo(x = x) {} foo() // ReferenceError: x is not defined
不定參數
無論函數已定義的命名參數有多少,都不限制調用時傳入的實際參數數量,調用時總是可以傳入任意數量的參數。當傳入更少數量的參數時,默認參數值的特性可以有效簡化函數聲明的代碼;當傳入更多數量的參數時,ES6同樣也提供了更好的方案。
【ES5】
早先,Javascript提供arguments對象來檢查函數的所有參數,從而不必定義每一個要用的參數。盡管arguments對象檢査在大多數情況下運行良好,但是實際使用起來卻有些笨重
function pick(object) { let result = Object.create(null); // 從第二個參數開始處理 for (let i = 1, len = arguments.length; i < len; i++) { result[arguments[i]] = object[arguments[i]]; } return result; } let book = { title: "ES6", author: "huochai", year: 2017 }; let bookData = pick(book, "author", "year"); console.log(bookData.author); // "huochai" console.log(bookData.year); // 2017
這個函數模仿了Underscore.js庫中的pick()方法,返回一個給定對象的副本,包含原始對象屬性的特定子集。在這個示例中只定義了一個參數,第一個參數傳入的是被復制屬性的源對象,其他參數為被復制屬性的名稱
關於pick()函數應該注意這樣幾件事情:首先,並不容易發現這個函數可以接受任意數量的參數,當然,可以定義更多的參數,但是怎么也達不到要求;其次,因為第一個參數為命名參數且已被使用,要查找需要拷貝的屬性名稱時,不得不從索引1而不是索引0開始遍歷arguments對象
【ES6】
在ES6中,通過引入不定參數(rest parameters)的特性可以解決這些問題,不定參數也稱為剩余參數或rest參數
在函數的命名參數前添加三個點(...)就表明這是一個不定參數,該參數為一個數組,包含着自它之后傳入的所有參數,通過這個數組名即可逐一訪問里面的參數
function pick(object, ...keys) { let result = Object.create(null); for (let i = 0, len = keys.length; i < len; i++) { result[keys[i]] = object[keys[i]]; } return result; }
在這個函數中,不定參數keys包含的是object之后傳入的所有參數,而arguments對象包含的則是所有傳入的參數,包括object。這樣一來,就可以放心地遍歷keys對象了。這種方法還有另一個好處,只需看一眼函數就可以知道該函數可以處理的參數數量
【使用限制】
不定參數有兩條使用限制
1、每個函數最多只能聲明一個不定參數,而且一定要放在所有參數的末尾
// 語法錯誤:不能在剩余參數后使用具名參數 function pick(object, ...keys, last) { let result = Object.create(null); for (let i = 0, len = keys.length; i < len; i++) { result[keys[i]] = object[keys[i]]; } return result; }
2、不定參數不能在對象字面量的 setter 屬性中使用
let object = { // 語法錯誤:不能在 setter 中使用剩余參數 set name(...value) { // 一些操作 } };
之所以存在這條限制,是因為對象字面量setter的參數有且只能有一個。而在不定參數的定義中,參數的數量可以無限多,所以在當前上下文中不允許使用不定參數
【arguments】
不定參數的設計初衷是代替JS的arguments對象。起初,在ES4草案中,arguments對象被移除並添加了不定參數的特性,從而可以傳入不限數量的參數。但是ES4從未被標准化,這個想法被擱置下來,直到重新引入了ES6標准,唯一的區別是arguments對象依然存在
function checkArgs(n,...args) { console.log(args.length);//2 console.log(arguments.length);//3 console.log(args);//['b','c'] console.log(arguments);//['a','b','c'] } checkArgs("a", "b", "c");
【應用】
不定參數中的變量代表一個數組,所以數組特有的方法都可以用於這個變量
// arguments變量的寫法 function sortNumbers() { return Array.prototype.slice.call(arguments).sort(); } // 不定參數的寫法 const sortNumbers = (...numbers) => numbers.sort();
上面代碼的兩種寫法,比較后可以發現,不定參數的寫法更自然也更簡潔
展開運算符
在所有的新功能中,與不定參數最相似的是展開運算符。不定參數可以指定多個各自獨立的參數,並通過整合后的數組來訪問;而展開運算符可以指定一個數組,將它們打散后作為各自獨立的參數傳入函數。JS內建的Math.max()方法可以接受任意數量的參數並返回值最大的那一個
let value1 = 25, value2 = 50; console.log(Math.max(value1, value2)); // 50
如上例所示,如果只處理兩個值,那么Math.max()非常簡單易用。傳入兩個值后返回更大的那一個。但是如果想從一個數組中挑選出最大的那個值應該怎么做呢?Math.max()方法不允許傳入數組,所以在ES5中,可能需要手動實現從數組中遍歷取值,或者使用apply()方法
let values = [25, 50, 75, 100] console.log(Math.max.apply(Math, values)); // 100
這個解決方案確實可行,但卻讓人很難看懂代碼的真正意圖
使用ES6中的展開運算符可以簡化上述示例,向Math.max()方法傳入一個數組,再在數組前添加不定參數中使用的...符號,就無須再調用apply()方法了。JS引擎讀取這段程序后會將參數數組分割為各自獨立的參數並依次傳入
let values = [25, 50, 75, 100] // 等價於 console.log(Math.max(25, 50, 75, 100)); console.log(Math.max(...values)); // 100
使用apply()方法需要手動指定this的綁定,如果使用展開運算符可以使這種簡單的數學運算看起來更加簡潔
可以將展開運算符與其他正常傳入的參數混合使用。假設限定Math.max()返回的最小值為0,可以單獨傳入限定值,其他的參數仍然使用展開運算符得到
let values = [-25, -50, -75, -100] console.log(Math.max(...values, 0)); // 0
在這個示例中,Math.max()函數先用展開運算符傳入數組中的值,又傳入了參數0
展開運算符可以簡化使用數組給函數傳參的編碼過程,在大多數使用apply()方法的情況下展開運算符可能是一個更合適的方案
嚴格模式
從 ES5 開始,函數內部可以設定為嚴格模式
function doSomething(a, b) { 'use strict'; // code }
ES7做了一點修改,規定只要函數參數使用了默認值、解構賦值、或者擴展運算符,那么函數內部就不能顯式設定為嚴格模式,否則會報錯
// 報錯 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 } };
這樣規定的原因是,函數內部的嚴格模式,同時適用於函數體和函數參數。但是,函數執行的時候,先執行函數參數,然后再執行函數體。這樣就有一個不合理的地方,只有從函數體之中,才能知道參數是否應該以嚴格模式執行,但是參數卻應該先於函數體執行
// 報錯 function doSomething(value = 070) { 'use strict'; return value; }
上面代碼中,參數value
的默認值是八進制數070
,但是嚴格模式下不能用前綴0
表示八進制,所以應該報錯。但是實際上,JS引擎會先成功執行value = 070
,然后進入函數體內部,發現需要用嚴格模式執行,這時才會報錯
雖然可以先解析函數體代碼,再執行參數代碼,但是這樣無疑就增加了復雜性。因此,標准索性禁止了這種用法,只要參數使用了默認值、解構賦值、或者擴展運算符,就不能顯式指定嚴格模式。
兩種方法可以規避這種限制:
1、設定全局性的嚴格模式
'use strict'; function doSomething(a, b = a) { // code }
2、把函數包在一個無參數的立即執行函數里面
const doSomething = (function () { 'use strict'; return function(value = 42) { return value; }; }());
構造函數
Function構造函數是JS語法中很少被用到的一部分,通常我們用它來動態創建新的函數。這種構造函數接受字符串形式的參數,分別為函數參數及函數體
var add = new Function("first", "second", "return first + second");
console.log(add(1, 1)); // 2
ES6增強了Function構造函數的功能,支持在創建函數時定義默認參數和不定參數。唯一需要做的是在參數名后添加一個等號及一個默認值
var add = new Function("first", "second = first","return first + second"); console.log(add(1, 1)); // 2 console.log(add(1)); // 2
在這個示例中,調用add(1)時只傳入一個參數,參數second被賦值為first的值。這種語法與不使用Function聲明函數很像
定義不定參數,只需在最后一個參數前添加...
var pickFirst = new Function("...args", "return args[0]");
console.log(pickFirst(1, 2)); // 1
在這段創建函數的代碼中,只定義了一個不定參數,函數返回傳入的第一個參數。對於Function構造函數,新增的默認參數和不定參數這兩個特性使其具備了與聲明式創建函數相同的能力
參數尾逗號
ES8允許函數的最后一個參數有尾逗號(trailing comma)。
此前,函數定義和調用時,都不允許最后一個參數后面出現逗號
function clownsEverywhere( param1, param2 ) { /* ... */ } clownsEverywhere( 'foo', 'bar' );
上面代碼中,如果在param2
或bar
后面加一個逗號,就會報錯。
如果像上面這樣,將參數寫成多行(即每個參數占據一行),以后修改代碼的時候,想為函數clownsEverywhere
添加第三個參數,或者調整參數的次序,就勢必要在原來最后一個參數后面添加一個逗號。這對於版本管理系統來說,就會顯示添加逗號的那一行也發生了變動。這看上去有點冗余,因此新的語法允許定義和調用時,尾部直接有一個逗號
function clownsEverywhere( param1, param2, ) { /* ... */ } clownsEverywhere( 'foo', 'bar', );
這樣的規定使得函數參數與數組和對象的尾逗號規則保持一致了
name屬性
由於在JS中有多種定義函數的方式,因而辨別函數就是一項具有挑戰性的任務。此外,匿名函數表達式的廣泛使用更是加大了調試的難度,開發者們經常要追蹤難以解讀的棧記錄。為了解決這些問題,ES6為所有函數新增了name屬性
ES6中所有的函數的name屬性都有一個合適的值
function doSomething() { // ... } var doAnotherThing = function() { // ... }; console.log(doSomething.name); // "doSomething" console.log(doAnotherThing.name); // "doAnotherThing"
在這段代碼中,dosomething()函數的name屬性值為"dosomething",對應着聲明時的函數名稱;匿名函數表達式doAnotherThing()的name屬性值為"doAnotherThing",對應着被賦值為該匿名函數的變量的名稱
【特殊情況】
盡管確定函數聲明和函數表達式的名稱很容易,ES6還是做了更多的改進來確保所有函數都有合適的名稱
var doSomething = function doSomethingElse() { // ... }; var person = { get firstName() { return "huochai" }, sayName: function() { console.log(this.name); } } console.log(doSomething.name); // "doSomethingElse" console.log(person.sayName.name); // "sayName" var descriptor = Object.getOwnPropertyDescriptor(person, "firstName"); console.log(descriptor.get.name); // "get firstName"
在這個示例中,dosomething.name的值為"dosomethingElse",是由於函數表達式有一個名字,這個名字比函數本身被賦值的變量的權重高
person.sayName()的name屬性的值為"sayName",因為其值取自對象字面量。與之類似,person.firstName實際上是一個getter函數,所以它的名稱為"get firstName",setter函數的名稱中當然也有前綴"set"
還有另外兩個有關函數名稱的特例:通過bind()函數創建的函數,其名稱將帶有"bound"前綴;通過Function構造函數創建的函數,其名稱將帶有前綴"anonymous"
var doSomething = function() { // ... }; console.log(doSomething.bind().name); // "bound doSomething" console.log((new Function()).name); // "anonymous"
綁定函數的name屬性總是由被綁定函數的name屬性及字符串前綴"bound"組成,所以綁定函數dosomething()的name屬性值為"bound dosomething"
[注意]函數name屬性的值不一定引用同名變量,它只是協助調試用的額外信息,所以不能使用name屬性的值來獲取對於函數的引用
判斷調用
ES5中的函數結合new使用,函數內的this值將指向一個新對象,函數最終會返回這個新對象
function Person(name) { this.name = name; } var person = new Person("huochai"); var notAPerson = Person("huochai"); console.log(person); // "[Object object]" console.log(notAPerson); // "undefined"
給notAperson變量賦值時,沒有通過new關鍵字來調用person(),最終返回undefined(如果在非嚴格模式下,還會在全局對象中設置一個name屬性)。只有通過new關鍵字調用person()時才能體現其能力,就像常見的JS程序中顯示的那樣
而在ES6中,函數混亂的雙重身份終於將有一些改變
JS函數有兩個不同的內部方法:[[Call]]和[[Construct]]
當通過new關鍵字調用函數時,執行的是[[construct]]函數,它負責創建一個通常被稱作實例的新對象,然后再執行函數體,將this綁定到實例上
如果不通過new關鍵字調用函數,則執行[[call]]函數,從而直接執行代碼中的函數體
具有[[construct]]方法的函數被統稱為構造函數
[注意]不是所有函數都有[[construct]]方法,因此不是所有函數都可以通過new來調用
【ES5判斷函數被調用】
在ES5中,如果想確定一個函數是否通過new關鍵字被調用,或者說,判斷該函數是否作為構造函數被調用,最常用的方式是使用instanceof操作符
function Person(name) { if (this instanceof Person) { this.name = name; // 使用 new } else { throw new Error("You must use new with Person.") } } var person = new Person("huochai"); var notAPerson = Person("huochai"); // 拋出錯誤
在這段代碼中,首先檢查this的值,看它是否為構造函數的實例,如果是,則繼續正常執行。如果不是,則拋出錯誤。由於[[construct]]方法會創建一個person的新實例,並將this綁定到新實例上,通常來講這樣做是正確的
但這個方法也不完全可靠,因為有一種不依賴new關鍵字的方法也可以將this綁定到person的實例上
function Person(name) { if (this instanceof Person) { this.name = name; // 使用 new } else { throw new Error("You must use new with Person.") } } var person = new Person("huochai"); var notAPerson = Person.call(person, "huochai"); // 不報錯
調用person.call()時將變量person傳入作為第一個參數,相當於在person函數里將this設為了person實例。對於函數本身,無法區分是通過person.call()(或者是person.apply())還是new關鍵字調用得到的person的實例
【元屬性new.target】
為了解決判斷函數是否通過new關鍵字調用的問題,ES6引入了new.target這個元屬性。元屬性是指非對象的屬性,其可以提供非對象目標的補充信息(例如new)。當調用函數的[[construct]]方法時,new.target被賦值為new操作符的目標,通常是新創建對象實例,也就是函數體內this的構造函數;如果調用[[call]]方法,則new.target的值為undefined
有了這個元屬性,可以通過檢查new.target是否被定義過,檢測一個函數是否是通過new關鍵字調用的
function Person(name) { if (typeof new.target !== "undefined") { this.name = name; // 使用 new } else { throw new Error("You must use new with Person.") } } var person = new Person("huochai"); var notAPerson = Person.call(person, "match"); // 出錯!
也可以檢查new.target是否被某個特定構造函數所調用
function Person(name) { if (new.target === Person) { this.name = name; // 使用 new } else { throw new Error("You must use new with Person.") } } function AnotherPerson(name) { Person.call(this, name); } var person = new Person("huochai"); var anotherPerson = new AnotherPerson("huochai"); // 出錯!
在這段代碼中,如果要讓程序正確運行,new.target一定是person。當調用 new Anotherperson("huochai") 時, 真正的調用Person. call(this,name)沒有使用new關鍵字,因此new.target的值為undefined會拋出錯誤
[注意]在函數外使用new.target是一個語法錯誤
塊級函數
在ES3中,在代碼塊中聲明一個函數(即塊級函數)嚴格來說應當是一個語法錯誤, 但所有的瀏覽器都支持該語法。不幸的是,每個瀏覽器對這個特性的支持都稍有不同,所以最好不要在代碼塊中聲明函數,更好的選擇是使用函數表達式
為了遏制這種不兼容行為, ES5的嚴格模式為代碼塊內部的函數聲明引入了一個錯誤
"use strict"; if (true) { // 在 ES5 會拋出語法錯誤, ES6 則不會 function doSomething() { // ... } }
在ES5中,代碼會拋出語法錯誤。而在ES6中,會將dosomething()函數視為一個塊級聲明,從而可以在定義該函數的代碼塊內訪問和調用它
"use strict"; if (true) { console.log(typeof doSomething); // "function" function doSomething() { // ... } doSomething(); } console.log(typeof doSomething); // "undefined"
在定義函數的代碼塊內,塊級函數會被提升至頂部,所以typeof dosomething的值為"function",這也佐證了,即使在函數定義的位置前調用它,還是能返回正確結果。但是一旦if語句代碼塊結束執行,dosomething()函數將不再存在
【使用場景】
塊級函數與let函數表達式類似,一旦執行過程流出了代碼塊,函數定義立即被移除。二者的區別是,在該代碼塊中,塊級函數會被提升至塊的頂部,而用let定義的函數表達式不會被提升
"use strict"; if (true) { console.log(typeof doSomething); // 拋出錯誤 let doSomething = function () { // ... } doSomething(); } console.log(typeof doSomething);
在這段代碼中,當執行到typeof dosomething時,由於此時尚未執行let聲明語句,dosomething()還在當前塊作用域的臨時死區中,因此程序被迫中斷執行
因此,如果需要函數提升至代碼塊頂部,則選擇塊級函數;如果不需要,則選擇let表達式
【非嚴格模式】
在ES6中,即使處於非嚴格模式下,也可以聲明塊級函數,但其行為與嚴格模式下稍有不同。這些函數不再提升到代碼塊的頂部,而是提升到外圍函數或全局作用域的頂部
// ES6 behavior if (true) { console.log(typeof doSomething); // "function" function doSomething() { // ... } doSomething(); } console.log(typeof doSomething); // "function"
在這個示例中,dosomething()函數被提升至全局作用域,所以在if代碼塊外也可以訪問到。ES6將這個行為標准化了,移除了之前存在於各瀏覽器間不兼容的行為,所以所有ES6的運行時環境都將執行這一標准
箭頭函數
在ES6中,箭頭函數是其中最有趣的新增特性。顧名思義,箭頭函數是一種使用箭頭(=>)定義函數的新語法,但是它與傳統的JS函數有些許不同,主要集中在以下方面
1、沒有this、super、arguments和new.target
綁定箭頭函數中的this、super、arguments和new.target這些值由外圍最近一層非箭頭函數決定
2、不能通過new關鍵字調用
箭頭函數沒有[[construct]]方法,不能被用作構造函數,如果通過new關鍵字調用箭頭函數,程序拋出錯誤
3、沒有原型
由於不可以通過new關鍵字調用箭頭函數,因而沒有構建原型的需求,所以箭頭函數不存在prototype這個屬性
4、不可以改變this綁定
函數內部的this值不可被改變,在函數的生命周期內始終保持一致
5、不支持arguments對象
箭頭函數沒有arguments綁定,必須通過命名參數和不定參數這兩種形式訪問函數的參數
6、不支持重復的命名參數
無論在嚴格還是非嚴格模式下,箭頭函數都不支持重復的命名參數;而在傳統函數的規定中,只有在嚴格模式下才不能有重復的命名參數
在箭頭函數內,其余的差異主要是減少錯誤以及理清模糊不清的地方。這樣一來,JS引擎就可以更好地優化箭頭函數的執行過程
這些差異的產生有如下幾個原因
1、最重要的是,this綁定是JS程序中一個常見的錯誤來源,在函數內很容易對this的值失去控制,其經常導致程序出現意想不到的行為,箭頭函數消除了這方面的煩惱
2、如果限制箭頭函數的this值,簡化代碼執行的過程,則JS引擎可以更輕松地優化這些操作,而常規函數往往同時會作為構造函數使用或者以其他方式對其進行修改
[注意]箭頭函數同樣也有一個name屬性,這與其他函數的規則相同
【語法】
箭頭函數的語法多變,根據實際的使用場景有多種形式。所有變種都由函數參數、箭頭、函數體組成,根據使用的需求,參數和函數體可以分別采取多種不同的形式
var reflect = value => value; // 有效等價於: var reflect = function(value) { return value; };
當箭頭函數只有一個參數時,可以直接寫參數名,箭頭緊隨其后,箭頭右側的表達式被求值后便立即返回。即使沒有顯式的返回語句,這個箭頭函數也可以返回傳入的第一個參數
如果要傳入兩個或兩個以上的參數,要在參數的兩側添加一對小括號
var sum = (num1, num2) => num1 + num2; // 有效等價於: var sum = function(num1, num2) { return num1 + num2; };
這里的sum()函數接受兩個參數,將它們簡單相加后返回最終結果,它與reflect()函數唯一的不同是,它的參數被包裹在小括號中,並且用逗號進行分隔(類似傳統函數)
如果函數沒有參數,也要在聲明的時候寫一組沒有內容的小括號
var getName = () => "huochai"; // 有效等價於: var getName = function() { return "huochai"; };
如果希望為函數編寫由多個表達式組成的更傳統的函數體,那么需要用花括號包裹函數體,並顯式地定義一個返回值
var sum = (num1, num2) => { return num1 + num2; }; // 有效等價於: var sum = function(num1, num2) { return num1 + num2; };
除了arguments對象不可用以外,某種程度上都可以將花括號里的代碼視作傳統的函數體定義
如果想創建一個空函數,需要寫一對沒有內容的花括號
var doNothing = () => {}; // 有效等價於: var doNothing = function() {};
花括號代表函數體的部分,但是如果想在箭頭函數外返回一個對象字面量,則需要將該字面量包裹在小括號里
var getTempItem = id => ({ id: id, name: "Temp" }); // 有效等價於: var getTempItem = function(id) { return { id: id, name: "Temp" }; };
將對象字面量包裹在小括號中是為了將其與函數體區分開來
【IIFE】
JS函數的一個流行的使用方式是創建立即執行函數表達式(IIFE),可以定義一個匿名函數並立即調用,自始至終不保存對該函數的引用。當創建一個與其他程序隔離的作用域時,這種模式非常方便
let person = function(name) { return { getName: function() { return name; } }; }("huochai"); console.log(person.getName()); // "huochai"
在這段代碼中,IIFE通過getName()方法創建了一個新對象,將參數name作為該對象的一個私有成員返回給函數的調用者
只要將箭頭函數包裹在小括號里,就可以用它實現相同的功能
let person = ((name) => { return { getName: function() { return name; } }; })("huochai"); console.log(person.getName()); // "huochai"
[注意]小括號只包裹箭頭函數定義,沒有包含("huochai"),這一點與正常函數有所不同,由正常函數定義的立即執行函數表達式既可以用小括號包裹函數體,也可以額外包裹函數調用的部分
【this】
函數內的this綁定是JS中最常出現錯誤的因素,函數內的this值可以根據函數調用的上下文而改變,這有可能錯誤地影響其他對象
var PageHandler = { id: "123456", init: function() { document.addEventListener("click", function(event) { this.doSomething(event.type); // 錯誤 }, false); }, doSomething: function(type) { console.log("Handling " + type + " for " + this.id); } };
在這段代碼中,對象pageHandler的設計初衷是用來處理頁面上的交互,通過調用init()方法設置交互,依次分配事件處理程序來調用this.dosomething()。然而,這段代碼並沒有如預期的正常運行
實際上,因為this綁定的是事件目標對象的引用(在這段代碼中引用的是document),而沒有綁定pageHandler,且由於this.dosonething()在目標document中不存在,所以無法正常執行,嘗試運行這段代碼只會使程序在觸發事件處理程序時拋出錯誤
可以使用bind()方法顯式地將this綁定到pageHandler函數上來修正這個問題
var PageHandler = { id: "123456", init: function() { document.addEventListener("click", (function(event) { this.doSomething(event.type); // 錯誤 }).bind(this), false); }, doSomething: function(type) { console.log("Handling " + type + " for " + this.id); } };
現在代碼如預期的運行,但可能看起來仍然有點奇怪。調用bind(this)后,事實上創建了一個新函數,它的this被綁定到當前的this,也就是page Handler
可以通過一個更好的方式來修正這段代碼:使用箭頭函數
箭頭函數中沒有this綁定,必須通過查找作用城鏈來決定其值。如果箭頭函數被非箭頭函數包含,則this綁定的是最近一層非箭頭函數的this;否則,this的值會被設置為undefined
var PageHandler = { id: "123456", init: function() { document.addEventListener("click", event => this.doSomething(event.type), false); }, doSomething: function(type) { console.log("Handling " + type + " for " + this.id); } };
這個示例中的事件處理程序是一個調用了this.doSomething()的箭頭函數,此處的this與init()函數里的this一致,所以此版本代碼的運行結果與使用bind(this)一致。雖然dosomething()方法不返回值,但是它仍是函數體內唯一的一條執行語句,所以不必用花括號將它包裹起來
箭頭函數缺少正常函數所擁有的prototype屬性,它的設計初衷是即用即棄,所以不能用它來定義新的類型。如果嘗試通過new關鍵字調用一個箭頭函數,會導致程序拋出錯誤
var MyType = () => {}, object = new MyType(); // 錯誤:不能對箭頭函數使用 'new'
在這段代碼中,MyType是一個沒有[[Construct]]方法的箭頭函數,所以不能正常執行new MyType()。也正因為箭頭函數不能與new關鍵字混用,所以JS引擎可以進一步優化它們的行為。同樣,箭頭函數中的this值取決於該函數外部非箭頭函數的this值,且不能通過call()、apply()或bind()方法來改變this的值
【數組】
箭頭函數的語法簡潔,非常適用於數組處理。如果想給數組排序,通常需要寫一個自定義的比較器
var result = values.sort(function(a, b) { return a - b; });
只想實現一個簡單功能,但這些代碼實在太多了。用箭頭函數簡化如下
var result = values.sort((a, b) => a - b);
諸如sort()、map()及reduce()這些可以接受回調函數的數組方法,都可以通過箭頭函數語法簡化編碼過程並減少編碼量
// 正常函數寫法 [1,2,3].map(function (x) { return x * x; }); // 箭頭函數寫法 [1,2,3].map(x => x * x);
【arguments】
箭頭函數沒有自己的arguments對象,且未來無論函數在哪個上下文中執行,箭頭函數始終可以訪問外圍函數的arguments對象
function createArrowFunctionReturningFirstArg() { return () => arguments[0]; } var arrowFunction = createArrowFunctionReturningFirstArg(5); console.log(arrowFunction()); // 5
在createArrowFunctionReturningFirstArg()中,箭頭函數引用了外圍函數傳入的第一個參數arguments[0],也就是后續執行過程中傳入的數字5。即使函數箭頭此時已不再處於創建它的函數的作用域中,卻依然可以訪問當時的arguments對象,這是arguments標識符的作用域鏈解決方案所規定的
【辨識方法】
盡管箭頭函數與傳統函數的語法不同,但它同樣可以被識別出來
var comparator = (a, b) => a - b; console.log(typeof comparator); // "function" console.log(comparator instanceof Function); // true
同樣地,仍然可以在箭頭函數上調用call()、apply()及bind()方法,但與其他函數不同的是,箭頭函數的this值不會受這些方法的影響
var sum = (num1, num2) => num1 + num2; console.log(sum.call(null, 1, 2)); // 3 console.log(sum.apply(null, [1, 2])); // 3 var boundSum = sum.bind(null, 1, 2); console.log(boundSum()); // 3
包括回調函數在內所有使用匿名函數表達式的地方都適合用箭頭函數來改寫
【函數柯里化】
柯里化是一種把接受多個參數的函數變換成接受一個單一參數的函數,並且返回(接受余下的參數而且返回結果的)新函數的技術
如果使用ES5的語法來寫,如下所示
function add(x){ return function(y){ return y + x; }; } var addTwo = add(2); addTwo(3); // => 5 add(10)(11); // => 21
使用ES6的語法來寫,如下所示
var add = (x) => (y) => x+y
一般來說,出現連續地箭頭函數調用的情況,就是在使用函數柯里化的技術
尾調用優化
ES6關於函數最有趣的變化可能是尾調用系統的引擎優化。尾調用指的是函數作為另一個函數的最后一條語句被調用
function doSomething() { return doSomethingElse(); // 尾調用 }
尾調用之所以與其他調用不同,就在於它的特殊的調用位置
我們知道,函數調用會在內存形成一個“調用記錄”,又稱“調用幀”(call frame),保存調用位置和內部變量等信息。如果在函數A
的內部調用函數B
,那么在A
的調用幀上方,還會形成一個B
的調用幀。等到B
運行結束,將結果返回到A
,B
的調用幀才會消失。如果函數B
內部還調用函數C
,那就還有一個C
的調用幀,以此類推。所有的調用幀,就形成一個“調用棧”(call stack)
尾調用由於是函數的最后一步操作,所以不需要保留外層函數的調用幀,因為調用位置、內部變量等信息都不會再用到了,只要直接用內層函數的調用幀,取代外層函數的調用幀就可以了
尾調用優化(Tail call optimization),即只保留內層函數的調用幀。如果所有函數都是尾調用,那么完全可以做到每次執行時,調用幀只有一項,這將大大節省內存
ES6縮減了嚴格模式下尾調用棧的大小(非嚴格模式下不受影響),如果滿足以下條件,尾調用不再創建新的棧幀,而是清除並重用當前棧幀
1、尾調用不訪問當前棧幀的變量(也就是說函數不是一個閉包)
2、在函數內部,尾調用是最后一條語句
3、尾調用的結果作為函數值返回
以下這段示例代碼滿足上述的三個條件,可以被JS引擎自動優化
"use strict"; function doSomething() { // 被優化 return doSomethingElse(); }
在這個函數中,尾調用doSomethingElse()的結果立即返回,不調用任何局部作用域變量。如果做一個小改動,不返回最終結果,那么引擎就無法優化當前函數
"use strict"; function doSomething() { // 未被優化:缺少 return doSomethingElse(); }
同樣地,如果定義了一個函數,在尾調用返回后執行其他操作,則函數也無法得到優化
"use strict"; function doSomething() { // 未被優化:在返回之后還要執行加法 return 1 + doSomethingElse(); }
如果把函數調用的結果存儲在一個變量里,最后再返回這個變量,則可能導致引擎無法優化
"use strict"; function doSomething() { // 未被優化:調用並不在尾部 var result = doSomethingElse(); return result; }
可能最難避免的情況是閉包的使用,它可以訪問作用域中所有變量,因而導致尾調用優化失效
"use strict"; function doSomething() { var num = 1, func = () => num; // 未被優化:此函數是閉包 return func(); }
在示例中,閉包func()可以訪問局部變量num,即使調用func()后立即返回結果,也無法對代碼進行優化
【應用】
實際上,尾調用的優化發生在引擎背后,除非嘗試優化一個函數,否則無須思考此類問題。遞歸函數是其最主要的應用場景,此時尾調用優化的效果最顯著
function factorial(n) { if (n <= 1) { return 1; } else { // 未被優化:在返回之后還要執行乘法 return n * factorial(n - 1); } }
由於在遞歸調用前執行了乘法操作,因而當前版本的階乘函數無法被引擎優化。如果n是一個非常大的數,則調用棧的尺寸就會不斷增長並存在最終導致棧溢出的潛在風險
優化這個函數,首先要確保乘法不會在函數調用后執行,可以通過默認參數來將乘法操作移出return語句,結果函數可以攜帶着臨時結果進入到下一個迭代中
function factorial(n, p = 1) { if (n <= 1) { return 1 * p; } else { let result = n * p; // 被優化 return factorial(n - 1, result); } }
在這個重寫后的factorial()函數中,第一個參數p的默認值為1,用它來保存乘法結果,下一次迭代中可以取出它用於計算,不再需要額外的函數調用。當n大於1時,先執行一輪乘法計算,然后將結果傳給第二次factorial()調用的參數。現在,ES6引擎就可以優化遞歸調用了
寫遞歸函數時,最好得用尾遞歸優化的特性,如果遞歸函數的計算量足夠大,則尾遞歸優化可以大幅提升程序的性能
另一個常見的事例是Fibonacci數列
function Fibonacci (n) { if ( n <= 1 ) {return 1}; return Fibonacci(n - 1) + Fibonacci(n - 2); } Fibonacci(10) // 89 Fibonacci(100) // 堆棧溢出 Fibonacci(500) // 堆棧溢出
尾遞歸優化過的 Fibonacci 數列實現如下
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 是如此,第一次明確規定,所有 ECMAScript 的實現,都必須部署“尾調用優化”。這就是說,ES6 中只要使用尾遞歸,就不會發生棧溢出,相對節省內存