箭頭函式

ES6篇 - 箭頭函式

本章的目標是對箭頭函式提供一些較為全面性的介紹,除了基本的語法之外,也補充了很多React搭配使用時的實例,此外也提供撰寫的風格建議。當然,箭頭函式並不光只是語法簡短而已,它有一些與原來JavaScript中函式不同的設計,你可以把它當成是原本(傳統)的函式的改進版本。

註: 本文章同步放置於Github庫的這裡

根據網路上統計的統計資料,箭頭函式(Arrow Functions)是ES6標準中,是最受歡迎的其中一種ES6新特性。它會受歡迎的原因是好處多多,只要注意在某些情況下不要用過頭就行了。有什麼好處呢?大致上有以下幾點:

  • 語法簡單,少打很多字元

  • 可以讓程式碼的可閱讀性提高

  • 可以綁定詞法上的this

註: 上述的統計可以參考2alityponyfoo這兩個網站的相關統計資料。

註: 本文大部份的基本語法內容是來自我電子書中的箭頭函式章節。

語法介紹

箭頭函式的語法如下,出自箭頭函數(MDN)

([param] [, param]) => {
   statements
}

param => expression

簡單的說明如下:

  • 符號是肥箭頭符號(=>) (註: "->"是瘦箭頭)

  • 基本上是"函式表達式(FE)的簡短寫法"

一個簡單的範例是:

const func = (x) => x + 1

相當於

const func = function (x) { return x + 1 }

所以你可以少打很多英文字元與一些標點符號之類的,函式是個匿名的函式。基本上的使用如下說明:

  • 花括號({})是有意義的,如果函式有多行語句(表達式)時就要使用花括號,花括號中的return回傳值語句要自己寫。例如 () => {}

  • 只有單一個傳入參數時,可以不需要左邊(前面)作為傳入參數使用的圓括號(())符號,例如 x => x*x

因此,初學者最容易搞混的是下面這個例子,因為有花括號({})與沒有是兩碼子事:

const funcA = x => x + 1
const funcB = x => { x + 1 }

funcA(1) //2
funcB(1) //undefined

當沒使用花括號({})時,代表要會使用自動有return的作用,所以它也只能用在單一行的表達式的時候使用。使用花括號({})則是可以加入多行的語句,不過return不會自動加,有需要你要自己加上,沒加這個函式最後等於return undefined

註: 表達式與語句仍然有一些差異,像throw "Error2"是一個只有單一行的語句,但它並不能用於箭頭函式中無花括號的情況。

註: JS語言中函式的設計,必有回傳值,沒寫相當於回傳undefined

第二個會容易造成混亂與誤解的是,在肥箭頭符號(=>)的後面可以直接換行,下面指的是單行回傳表達式的情況,雖然不會造成錯誤但很難閱讀,所以不建議這樣寫。像下面這幾個的例子都是合法語法:

// !! 不建議這樣寫 !!
const funcA = x =>
x + 1

// !! 不建議這樣寫 !!
const funcB =
x =>
x + 1

在這裡容易造成誤解,理由是肥箭頭符號(=>)的後面可以用換行,但"前面"不能直接接到換行,這個會造成編譯器無法編譯,瀏覽器也無法執行。我想主要原因是,畢竟這個符號是等號(=)與大於符號(>)組合而成的一個新符號,等號還有其他的用途。放在語句的前面應該就是等號的作用,再加上一個大於符號會造成語法錯誤。不管如何,別亂寫語法就對了,按照一般的你所看到的正常語法來寫就對了。

通常要換行或是寫成多行的語句時,則是要配合使用花括號或圓括號,但它們兩者在實際上的意義不同。在使用某些函式庫例如React時,你會看到在箭頭後面使用圓括號(())而非花括號({}),會使用圓括號(())會有兩個情況,第一個是仍然是有自動加retrun回傳的作用,但這個只有單一行的表達式在撰寫時區分為多行來寫,第二個是回傳值是個物件的類型值,因為物件的字面文字定義同樣也是用花括號({}),為了明顯區分出是物件的字面文字,所以用圓括號框住,這一點要相當注意。

React中因為使用了JSX語法,所以有可能你會看到一大串的JSX語法最外圍都會加上圓括號(()),例如下面的範例,我這裡只有寫一小串不過通常是一大串:

const HelloWorld = (props) => (
  <div>
    <h1>{props.text}</h1>
  </div>
)

圓括號在JavaScript中的用途你應該有看過了一些,用於函式呼叫定義,if之類的語句中的表達式,以及它是一個群組運算符(Grouping operator),可以明確地控制運算求值的優先次序,例如((a + b) * c)。在這裡的用法,則是形成一種未完結的語句,也就是撰寫時多行實際是單一整行的語句,這是由於按照JS中的ASI(Automatic Semicolon Insertion, 自動插入分號)機制,當讀到圓括號的開頭符號(()會被認為這個語句尚未完成,並不會自動插入分號(;)來作語句的結束,一直到看到圓括號的結尾符號()),才會整個算作一個語句。這與使用花括號({})作為區塊語句的語法,或是函式的傳入參數寫成多行(下面的範例中就有這樣寫)有點類似,但意義不同。但用在這裡的情況仍然是箭頭函式有自動回傳的情況,也就是仍然屬於單一行的表達式。

不過,JSX語法畢竟只是一種簡寫語法,上面的範例透過babel編譯器後,它的程式碼相於下面這樣:

"use strict";

var HelloWorld = function HelloWorld(props) {
  return React.createElement(
    "div",
    null,
    React.createElement(
      "h1",
      null,
      props.text
    )
  );
};

要補充一點的是,雖然與箭頭函式無關,但在React中也很常見到的一種JSX的寫法,就是使用圓括號開頭符號(()接在return關鍵字的後面,尤其是在render函式中,例如像下面這樣的例子:

class HelloWorld extends React.Component {
    render() {
        return (
          <div>
            <h1>{props.text}</h1>
          </div>
        )
    }
}

它的功能也是類似上面所說的,形成一個未完結的語句,讓JS認為這行語句尚未結束,而不會作ASI的來結束語句。如果return後面沒有加上圓括號的開頭符號((),ASI會起作用然後會幫你自動加上分號(;),這將會造成語法錯誤或是不預期的結果。這一點要特別的小心注意。

註: 以上關於ASI(Automatic Semicolon Insertion, 自動插入分號)的機制,可以參考這篇我寫的部落格文章

this值的綁定

箭頭函式可以取代某些原有使用self = this.bind(this)的情況,它可以在詞法上綁定this變數。但要視情況而定,而不是每種情況都一定可以用箭頭函式來取代。下面來簡單說明一下。

箭頭函式為什麼可以綁定this

一開頭要說的是,箭頭函式並不是完全只是原本JS中函式的縮寫語法而已,它與原本的函式內部設計有些不同,稱得上是改進過的函式。

箭頭函式與原本(傳統)的函式的不同之處,以下這兩點簡單說明:

  • 沒有定義本地端綁定(local bindings)的以下幾個變數,也就是說這些變數將由lexical(詞法的)綁定: arguments, super, this, new.target

  • 不能作為建構函式(constructor)使用,不能使用new

函式在JS中的功用很多,除了像一般的其他程式語言中的函式那樣使用外,函式也可以作為建構函式使用,這是JS中設計出來的四不像的以類別為基礎的物件導向語法,不得不說它又是一個容易造成混亂的設計,這邊我就不再多描述。

函式區塊中還有另一個隱藏的自動生成物件 - arguments。它是一個討論區中的常客,這個物件因為是隱藏的機制,除了是個"偽"陣列的物件,而且它與傳入參數有互相參照的作用,初學者如果用了它就像踏到坑裡面去,不是很容易能掌握它而且會常常出現問題。如果是以我的看法,"不要使用它"就是最佳的回答。連JavaScript的創造者Brendan Eich都認為在ES6之後,這個物件應該逐漸要退出舞台,不需要再使用它。

在ES6之後,JS引進了一種新式的設計,稱為Lexical this(詞法的this),下面簡單的說明一下。

一般而言,在JS中的預設this值,就是呼叫這個函式的物件,例如是o.m(),除非m是一個綁定方法(一般指bind這個方法),不然this預設就是o。在ES5嚴格模式的作用下,一般的在詞法中函式呼叫(f())的this預設都會是undefined,而不是window或全域物件。但如果預設的this值是undefined時,它也沒什麼太大的功用。

註: 上面這一段是摘譯Brendan Eich的部落格的。

Lexical this(詞法的this)是一種預設this的自動機制,這裡所謂的詞法(Lexical)一般指的是在函式區塊中,這因為在函式呼叫時才會產生this。也就是說Lexical this(詞法的this)是在函式中使用一些特定的語法時,這些語法就會從週邊的作用域(函式或全域作用域)中,捕捉this值,作為自己的預設this。雖然有可能你還搞不清楚,這是在講什麼,但是這的確會非常的有用處。

箭頭函式的預設this就是用Lexical this(詞法的this),它雖然是函式,但它並沒有像原本(傳統)的函式的設計,在被呼叫時以呼叫它的物件作為預設this值,或是在全域呼叫時以window或全域物件作為預設this(或是嚴格模式下是undefined)。它不是這樣設計的,它是從詞法的作用域中捕捉this值,作為自己的預設this值。

註: 不要把this與scope搞混了,是不一樣的東西。你可以參考這裡書中的this的內容。

註: 雖然說詞法環境(LexicalEnvironment)我們指的就是作用域,而ES6中也有區塊作用域(block scope),但因為這裡討論到的是this,會影響到Lexical this(詞法的this)的只有函式或全域的作用域,其他的區域作用域(例如if, for等等)並沒有影響到。

上面講了一堆,實際看例子就會很容易理解,這是一個在函式中使用箭頭函式的例子:

const obj = { a:1 }

function func() {

  setTimeout( () => {
    // 這裡`this`會以詞法上的func中為預設
    console.log(this.a)
  }, 2000)
}

func.call(obj)

setTimeout中的箭頭函式,可以有效地捕抓到外層的func區塊中的this值,作為自己的預設this。所以當使用obj呼叫func時。箭頭函式中的this.a可以正確地得到1

看起來沒啥大不了,比對一下如果用一般的函式定義會如何?下面是一般的函式定義方式:

const obj = { a: 1 }

function func() {

  setTimeout( function() {
    // 這裡`this`,會是這個callback的自己本地(local)綁定值
    // this為window物件(嚴格模式為undefined)
    // this.a必是undefined
    console.log(this.a)
  }, 2000)
}

func.call(obj)

在以往這個有名的常見問題中,解決方式有很多種,要不就是要開發者自己作"捕捉"外層函式this的動作,或是用bind方法自己綁定,下面這是其中一種用"捕捉"外層函式this的解決方式:

const obj = { a: 1 }

function func(){

  // 這裡用一個常數that先捕捉到this,讓它到作用域鏈上
  const that = this

  setTimeout(
    function(){
      // 這裡可以存取得到that
      console.log(that)
    }, 2000)
}

func.call(obj) //Object {a: 1}

註: 上面的程式碼中的that是隨便取的名稱,有人喜歡用self_self_that都一樣的用途。不要誤以為它是個關鍵字。

類似的問題非常多,你可以從setTimeout的例子就可以看到,這種用了callback(回調, 回呼)作為傳入參數的方法,在JS中多到一個程度,語法也是很常見,可見在真實的應用情況,如果能像箭頭函式能作週邊的捕抓this值,作為自己的預設this,可以得到多大的方便與減少多少的濳在問題。

當然,箭頭函式的Lexical this(詞法的this)作用在大部的情況都可以運作得很好,但它也有在某些下不適合使用的情況,因為Lexical this(詞法的this)一旦綁定過了,就無法再覆蓋,即使是用new關鍵字也不行。不過,這部份可能會比較進階,你可以參考You Don't Know JS: this & Object Prototypes這篇文章中的內容。也因為如此,有些情況下你不應該使用箭頭函式,在下面的內容中會說明。

總結來說,這一章的關於this在原本(傳統)的函式與箭頭函式是不同之處如下:

  • 傳統函式: this值是動態的,由呼叫這個函式的擁有者物件(Owner)決定

  • 箭頭函式: this是lexical(詞法的)作用域決定,也就是由週邊的作用域所決定

不可使用箭頭函式的情況

以下這幾個範例都是與this值有關,所以說如果你的箭頭函式裡有用到this值的確要特別注意,並不是不能用而是要小心它的行為是不是你要的結果。

以下的每個範例都只能用一般的函式定義方式,"不可"使用箭頭函式。

註: 本節的內容大部份是參考自When 'not' to use arrow functions

用物件字面文字定義物件時,物件中的方法

因為箭頭函式會以物件在定義時的捕捉到的週邊this為預設this,也就是window或全域物件(或是在嚴格模式的undefined)。所以會造成是存取不到物件中的array屬性值。

const calculate = {
  array: [1, 2, 3],
  sum: () => {
    return this.array.reduce((result, item) => result + item)
  }
}

//錯誤: TypeError: Cannot read property 'array' of undefined
calculate.sum()

在物件的prototype屬性中定義的方法

這種情況與上面一點類似,箭頭函式的this值這時會是window或全域物件(或是在嚴格模式的undefined)。

function MyCat(name) {
  this.catName = name
}

MyCat.prototype.sayCatName = () => {
  return this.catName
}

cat = new MyCat('Mew')

cat.sayCatName() // undefined

DOM事件處理的監聽者(事件處理函式)

箭頭函式的this值,相當於window或全域物件(或是在嚴格模式的undefined)。這裡的this值如果用一般函式定義的寫法,正確應該是要對應到被監聽DOM元素本身。

const button = document.getElementById('myButton')

button.addEventListener('click', () => {
  this.innerHTML = 'Clicked button'
})

建構函式

箭頭函式沒有constructor這個設計(原本的函式中有),直接使用new運算符時會拋出例外產生錯誤。

const Message = (text) => {
  this.text = text
}

// 錯誤 Throws "TypeError: Message is not a constructor"

const helloMessage = new Message('Hello World!');

其他注意的限制或陷阱

  • 函式物件中的callapplybind三個方法,無法"覆蓋"箭頭函式中的this值。

  • 箭頭函式沒有原本(傳統)的函式有的隱藏arguments物件。

  • 箭頭函式不能當作generators使用,使用yield會產生錯誤。

  • 在一些函式庫像jQuery、underscore函式庫有些有使用callback(回調, 回呼)的API中不一定可以用。

撰寫風格建議

  • callback(回調, 回呼)優先使用箭頭函式。 (Airbnb 8.1, Google 5.5.3)

  • 雖然箭頭函式的左邊(傳入參數)只有一個時可以省略圓括號(()),但建議你還是不論幾個都用圓括號框起來。(Google 5.5.3, eslint: arrow-parens)

  • 避免合併使用箭頭函式與其他的比較運算符(>=, <=),這會造成閱讀上不使與混亂。(Airbnb 8.5)

  • 肥箭頭符號的前後要加一個空格,不要黏在一起。另外,不要直接在符號前後換行。(前面不行,後面要用圓括號或花括號,上面有說明) (eslint: arrow-spacing)

結論

本章對於箭頭函式的用法作了一翻全面性的說明,語法上很簡單,但其中比較不同的是對箭頭函式中的Lexical this(詞法的this)作了一些簡單的說明,相信你看過之後,應該會對箭頭函式有更進一步的認識。這個ES6新的函式語法,改善了許多長久以來在函式中使用上的this綁定問題。在之後的類別章節中,我們會再見到箭頭函式的用法。

Last updated