共 2670 字,讀完需 5 分鐘。編譯自 Dmitri Pavlutin 的文章,對原文內容做了精簡和程式碼風格優化。ES6 中引入的箭頭函式可以讓我們寫出更簡潔的程式碼,但是部分場景下使用箭頭函式會帶來嚴重的問題,有哪些場景?會導致什麼問題?該怎麼解決,容我慢慢道來。
能見證每天在用的程式語言不斷演化是一件讓人非常興奮的事情,從錯誤中學習、探索更好的語言實現、創造新的語言特性是推動程式語言版本迭代的動力。JS 近幾年的變化就是最好的例子, 以 ES6 引入的箭頭函式(arrow functions)、class 等特性為代表,把 JS 的易用性推到了新的高度。
關於 ES6 中的箭頭函式,網上有很多文章解釋其作用和語法,如果你剛開始接觸 ES6,可以從這裡開始。任何事物都具有兩面性,語言的新特性常常被誤解、濫用,比如箭頭函式的使用就存在很多誤區。接下來,筆者會通過例項介紹該避免使用箭頭函式的場景,以及在這些場景下該如何使用函式表示式(function expressions)、函式宣告或者方法簡寫(shorthand method)來保障程式碼正確性和可讀性。
1. 定義物件方法
JS 中物件方法的定義方式是在物件上定義一個指向函式的屬性,當方法被呼叫的時候,方法內的 this 就會指向方法所屬的物件。
1.1 定義字面量方法
因為箭頭函式的語法很簡潔,可能不少同學會忍不住用它來定義字面量方法,比如下面的例子 JS Bin:
1 2 3 4 5 6 7 8 9 10 11 12 |
const calculator = { array: [1, 2, 3], sum: () => { console.log(this === window); // => true return this.array.reduce((result, item) => result + item); } }; console.log(this === window); // => true // Throws "TypeError: Cannot read property 'reduce' of undefined" calculator.sum(); |
calculator.sum 使用箭頭函式來定義,但是呼叫的時候會丟擲 TypeError,因為執行時 this.array 是未定義的,呼叫 calculator.sum 的時候,執行上下文裡面的 this 仍然指向的是 window,原因是箭頭函式把函式上下文繫結到了 window 上,this.array 等價於 window.array,顯然後者是未定義的。
解決的辦法是,使用函式表示式或者方法簡寫(ES6 中已經支援)來定義方法,這樣能確保 this 是在執行時是由包含它的上下文決定的,修正後的程式碼如下 JS Bin:
1 2 3 4 5 6 7 8 |
const calculator = { array: [1, 2, 3], sum() { console.log(this === calculator); // => true return this.array.reduce((result, item) => result + item); } }; calculator.sum(); // => 6 |
這樣 calculator.sum 就變成了普通函式,執行時 this 就指向 calculator 物件,自然能得到正確的計算結果。
1.2 定義原型方法
同樣的規則適用於原型方法(prototype method)的定義,使用箭頭函式會導致執行時的執行上下文錯誤,比如下面的例子 JS Bin:
1 2 3 4 5 6 7 8 9 10 11 |
function Cat(name) { this.name = name; } Cat.prototype.sayCatName = () => { console.log(this === window); // => true return this.name; }; const cat = new Cat('Mew'); cat.sayCatName(); // => undefined |
使用傳統的函式表示式就能解決問題 JS Bin:
1 2 3 4 5 6 7 8 9 10 11 |
function Cat(name) { this.name = name; } Cat.prototype.sayCatName = function () { console.log(this === cat); // => true return this.name; }; const cat = new Cat('Mew'); cat.sayCatName(); // => 'Mew' |
sayCatName 變成普通函式之後,被呼叫時的執行上下文就會指向新建立的 cat 例項。
2. 定義事件回撥函式
this 是 JS 中很強大的特性,可以通過多種方式改變函式執行上下文,JS 內部也有幾種不同的預設上下文指向,但普適的規則是在誰上面呼叫函式 this 就指向誰,這樣程式碼理解起來也很自然,讀起來就像在說,某個物件上正在發生某件事情。
但是,箭頭函式在宣告的時候就繫結了執行上下文,要動態改變上下文是不可能的,在需要動態上下文的時候它的弊端就凸顯出來。比如在客戶端程式設計中常見的 DOM 事件回撥函式(event listenner)繫結,觸發回撥函式時 this 指向當前發生事件的 DOM 節點,而動態上下文這個時候就非常有用,比如下面這段程式碼試圖使用箭頭函式來作事件回撥函式 JS Bin:
1 2 3 4 5 |
const button = document.getElementById('myButton'); button.addEventListener('click', () => { console.log(this === window); // => true this.innerHTML = 'Clicked button'; }); |
在全域性上下文下定義的箭頭函式執行時 this 會指向 window,當單擊事件發生時,瀏覽器會嘗試用 button 作為上下文來執行事件回撥函式,但是箭頭函式預定義的上下文是不能被修改的,這樣 this.innerHTML 就等價於 window.innerHTML,而後者是沒有任何意義的。
使用函式表示式就可以在執行時動態的改變 this,修正後的程式碼 JS Bin:
1 2 3 4 5 |
const button = document.getElementById('myButton'); button.addEventListener('click', function() { console.log(this === button); // => true this.innerHTML = 'Clicked button'; }); |
當使用者單擊按鈕時,事件回撥函式中的 this 實際指向 button,這樣的 this.innerHTML = ‘Clicked button’ 就能按照預期修改按鈕中的文字。
3. 定義建構函式
建構函式中的 this 指向新建立的物件,當執行 new Car() 的時候,建構函式 Car 的上下文就是新建立的物件,也就是說 this instanceof Car === true。顯然,箭頭函式是不能用來做建構函式, 實際上 JS 會禁止你這麼做,如果你這麼做了,它就會丟擲異常。
換句話說,箭頭建構函式的執行並沒有任何意義,並且是有歧義的。比如,當我們執行下面的程式碼 JS Bin:
1 2 3 4 5 |
const Message = (text) => { this.text = text; }; // Throws "TypeError: Message is not a constructor" const helloMessage = new Message('Hello World!'); |
構造新的 Message 例項時,JS 引擎拋了錯誤,因為 Message 不是建構函式。在筆者看來,相比舊的 JS 引擎在出錯時悄悄失敗的設計,ES6 在出錯時給出具體錯誤訊息是非常不錯的實踐。可以通過使用函式表示式或者函式宣告 來宣告建構函式修復上面的例子 JS Bin:
1 2 3 4 5 |
const Message = function(text) { this.text = text; }; const helloMessage = new Message('Hello World!'); console.log(helloMessage.text); // => 'Hello World!' |
4. 追求過短的程式碼
箭頭函式允許你省略引數兩邊的括號、函式體的花括號、甚至 return 關鍵詞,這對編寫更簡短的程式碼非常有幫助。這讓我想起大學計算機老師給學生留過的有趣作業:看誰能使用 C 語言編寫出最短的函式來計算字串的長度,這對學習和探索新語言特性是個不錯的法子。但是,在實際的軟體工程中,程式碼寫完之後會被很多工程師閱讀,真正的 write once, read many times,在程式碼可讀性方面,最短的程式碼可能並不總是最好的。一定程度上,壓縮了太多邏輯的簡短程式碼,閱讀起來就沒有那麼直觀,比如下面的例子 JS Bin:
1 2 3 4 |
const multiply = (a, b) => b === undefined ? b => a * b : a * b; const double = multiply(2); double(3); // => 6 multiply(2, 3); // => 6 |
multiply 函式會返回兩個數字的乘積或者返回一個可以繼續呼叫的固定了一個引數的函式。程式碼看起來很簡短,但大多數人第一眼看上去可能無法立即搞清楚它幹了什麼,怎麼讓這段程式碼可讀性更高呢?有很多辦法,可以在箭頭函式中加上括號、條件判斷、返回語句,或者使用普通的函式 JS Bin:
1 2 3 4 5 6 7 8 9 10 11 12 |
function multiply(a, b) { if (b === undefined) { return function (b) { return a * b; } } return a * b; } const double = multiply(2); double(3); // => 6 multiply(2, 3); // => 6 |
為了讓程式碼可讀性更高,在簡短和囉嗦之間把握好平衡是非常有必要的。
5. 總結
箭頭函式無疑是 ES6 帶來的重大改進,在正確的場合使用箭頭函式能讓程式碼變的簡潔、短小,但某些方面的優勢在另外一些方面可能就變成了劣勢,在需要動態上下文的場景中使用箭頭函式你要格外的小心,這些場景包括:定義物件方法、定義原型方法、定義建構函式、定義事件回撥函式。