JS箭頭函數

2022-03-15 19:00:13
在 ES6 中,增加了一種新的函數定義方式——箭頭函數(=>)。其基本語法如下所示:
// ES6語法
const foo = v => v;
// 等同於傳統語法
var foo = function (v) {
    return v;
};
最直觀的表現是在編寫上省去了 function 關鍵字,函數引數和普通的函數引數一樣,函數體會被一個大括號括起來。
const fn = (num1, num2) => {
    return num1 + num2;
};
如果函數的引數只有一個,則可以省略小括號;如果函數體只有一行,則可以省略大括號和 return 關鍵字。
[1, 2, 3].map(r => r * 2);  // [2, 4, 6]
// 等同於
[1, 2, 3].map(function(r) {
    return r * 2;
});
接下來詳細講解箭頭函數的特點。

箭頭函數的特點

語法簡潔

箭頭函數帶給人最直觀的感受就是可以使用更加簡潔的語法、較少的程式碼量來完成和普通函數一樣的功能。

求一個陣列各項的和,可以簡寫為如下所示的程式碼:
[1, 2, 3, 4].reduce((x, y) => x + y, 0); // 10
陣列中的元素按照從小到大順序排序,可以簡寫為如下所示的程式碼:
[1, 4, 6, 3, 2].sort((x, y) => x - y) //[1, 2, 3, 4, 6]
過濾出陣列中大於 3 的數位,可以簡寫為如下所示的程式碼:
[1, 2, 5, 6, 3].filter(x => x > 3); // [ 5, 6 ]

不繫結 this

在箭頭函數中,this 指向的是定義時所在的物件,而不是使用時所在的物件。

這裡我們通過 setTimeout() 函數和 setInterval() 函數來看看普通函數和箭頭函數的差別。
function Timer() {
    this.s1 = 0;
    this.s2 = 0;
    // 箭頭函數
    setInterval(() => this.s1++, 1000);
    // 普通函數
    setInterval(function () {
        this.s2++;
    }, 1000);
}

let timer = new Timer();

setTimeout(() => console.log('s1: ', timer.s1), 3100); // 3.1秒後輸出s1: 3
setTimeout(() => console.log('s2: ', timer.s2), 3100); // 3.1秒後輸出s2: 0
在上面的範例中,我們宣告了一個 Timer() 函數,增加了兩個範例屬性 s1 和 s2,然後使用 setInterval() 函數去執行 s1 和 s2 的遞加操作,唯一的區別是一個使用普通函數,另一個使用箭頭函數,在最後的結果中會發現 s1 和 s2 輸出的值是不一樣的。

這是為什麼呢?原因如下所述:

在生成 Timer 的範例 timer 後,通過 setTimeout() 函數在 3.1 秒後輸出 timer 的 s1 變數,此時 setInterval() 函數已經執行了 3 次,由於 this.s1++ 是處在箭頭函數中的,這裡的 this 就指向 timer,此時 timer.s1 值為“3”。

而 this.s2++ 是處在普通函數中的,這裡的 this 指向的是全域性物件 window,實際上相當於 window.s2++,結果是 window.s2 = 3,而在最後一行的輸出結果中, timer.s2 仍然為“0”。

在上文中,我們有講到“this 指向的是定義時所在的物件”。從嚴格意義上講,箭頭函數中不會建立自己的 this,而是會從自己作用域鏈的上一層繼承 this。

我們可以通過下面這個範例來理解:
const Person = {
    'name': 'kingx',
    'age': 18,
    'sayHello': function () {
        setTimeout(() => {
            console.log('我叫' + this.name + ',我今年' + this.age + '歲!')
        }, 1000);
    }
};
Person.sayHello(); // 我叫kingx,我今年18歲!

const Person2 = {
    'name': 'little bear',
    'age': 18,
    'sayHello': () => {
        setTimeout(() => {
            console.log('我叫' + this.name + ',我今年' + this.age + '歲!')
        }, 1000);
    }
};
Person2.sayHello(); // 我叫undefined,我今年undefined歲!
上面兩段程式碼的唯一區別是在 sayHello() 函數的定義時,第一段是通過 function 關鍵字定義的,而第二段是通過箭頭函數定義的。

在第一段程式碼中,sayHello() 函數通過 function 關鍵字進行定義,在執行 Person.sayHello() 函數時,sayHello() 函數中的 this 會指向函數的呼叫體,即 Person 本身;

在呼叫 setTimeout() 函數時,由於其函數體部分是通過箭頭函數定義的,內部的 this 會繼承至父作用域的 this,因此 setTimeout() 函數內部的 this 會指向 Person,從而輸出結果“我叫kingx,我今年18歲!”。

在第二段程式碼中,sayHello() 函數通過箭頭函數定義,在執行 Person2.sayHello() 函數時,sayHello() 函數中的 this 會指向外層作用域,而 Person2 的父作用域就是全域性作用域 window;

在呼叫 setTimeout() 函數時,由於其函數體部分是通過箭頭函數定義的,內部的 this 會繼承至 sayHello() 函數所在的作用域的 this,即 window,而 window 上並沒有定義 name 和 age 屬性,因此輸出結果“我叫undefined,我今年undefined歲!”。

從這裡的範例可以看出,物件函數使用箭頭函數是不合適的。

不支援 call() 函數與 apply() 函數的特性

我們都知道通過呼叫 call() 函數與 apply() 函數可以改變一個函數的執行主體,即改變被呼叫函數中 this 的指向。但是箭頭函數卻不能達到這一點,因為箭頭函數並沒有自己的 this,而是繼承父作用域中的 this。

也就是說,在呼叫 call() 函數和 apply() 函數時,如果被呼叫函數是一個箭頭函數,則不會改變箭頭函數中 this 的指向。
let adder = {
    base : 1,
    
    add : function(a) {
      var f = v => v + this.base;
      return f(a);
  },
  
    addThruCall: function(a) {
      var f = v => v + this.base;
          var b = {
        base : 2
      }; 
      return f.call(b, a);
  }
    };
 
console.log(adder.add(1));         // 2
console.log(adder.addThruCall(1)); // 2
在上面的範例中,執行 adder.add(1) 時,add() 函數內部通過箭頭函數的形式定義了 f() 函數,f() 函數中的 this 會繼承至父作用域,即 adder,那麼 this.base = 1,因此執行 adder.add(1) 相當於執行 1 + 1 的操作,結果輸出“2”。

執行 adder.addThruCall(1) 時,addThruCall() 函數內部通過箭頭函數定義了 f() 函數,其中的 this 指向了 adder。雖然在返回結果時,通過 call() 函數呼叫了 f() 函數,但是並不會改變 f() 函數中 this 的指向,this 仍然指向 adder,而且會接收引數 a,因此執行 adder.addThruCall(1) 相當於執行 1 + 1 的操作,結果輸出“2”。

因此在使用 call() 函數和 apply() 函數呼叫箭頭函數時,需要謹慎。

不繫結 arguments

在普通的 function() 函數中,我們可以通過 arguments 物件來獲取到實際傳入的引數值,但是在箭頭函數中,我們卻無法做到這一點。
const fn = () => {
    console.log(arguments);
};
fn(1, 2); // Uncaught ReferenceError: arguments is not defined
通過上面的程式碼可以看出,在瀏覽器環境下,在箭頭函數中使用 arguments 時,會丟擲異常。

因為無法在箭頭函數中使用 arguments,同樣也就無法使用 caller 和 callee 屬性。

雖然我們無法通過 arguments 來獲取實參,但是我們可以藉助 rest 運運算元(...)來達到這個目的。
const fn = (...args) => {
    console.log(args);
};
fn(1, 2); // [1, 2]

支援巢狀

箭頭函數支援巢狀的寫法,假如我們需要實現這樣一個場景:

有一個引數會以管道的形式經過兩個函數處理,第一個函數處理完的輸出將作為第二個函數的輸入,兩個函數運算完後輸出最後的結果。
const pipeline = (...funcs) =>
    val => funcs.reduce((a, b) => b(a), val);
const plus1 = a => a + 1;
const mult2 = a => a * 2;
const addThenMult = pipeline(plus1, mult2);
addThenMult(5);  // 12
在上面的範例中,我們先看第 5 行程式碼,這裡呼叫了 pipeline() 函數,並傳入 plus1 和 mult2 兩個引數,返回的是一個函數,在函數中使用 reduce() 函數先後呼叫傳入的兩個處理常式。

在執行第 6 行程式碼時,pipeline() 函數中的 val 為 5,在第一次執行 reduce() 函數時,a 為 5,b 為 plus1() 函數,實際相當於執行 5 + 1 = 6,並返回了計算結果。

在第二次執行 reduce() 函數時,a 為上一次返回的結果 6,b 為 mult2() 函數,實際相當於執行 6×2 = 12,因此最後輸出“12”。