深入 call、apply、bind、箭頭函式以及柯里化

前端瓶子君發表於2019-09-24

引言

JS系列暫定 27 篇,從基礎,到原型,到非同步,到設計模式,到架構模式等。

本篇是JS系列中第 5 篇,文章主講 JS 中 call 、 applybind 、箭頭函式以及柯里化,著重介紹它們之間的區別、對比使用,深入瞭解 call 、 applybind

一、Function.prototype.call()

call() 方法呼叫一個函式, 其具有一個指定的 this 值和多個引數(引數的列表)。

func.call(thisArg, arg1, arg2, ...)
複製程式碼

它執行 func,提供的第一個引數 thisArg 作為 this,後面的作為引數。

1. func 與 func.call

先看一個例子:

func(1, 2, 3);
func.call(obj, 1, 2, 3)
複製程式碼

他們都呼叫的是 func,引數是 12 和 3

唯一的區別是 func.call 也將 this 設定為 obj

需要注意的是,設定的 thisArg 值並不一定是該函式執行時真正的 this 值,如果這個函式處於非嚴格模式下,則指定為 nullundefinedthis 值會自動指向全域性物件(瀏覽器中就是 window 物件),同時值為原始值(數字,字串,布林值)的 this 會指向該原始值的自動包裝物件。

2. func.call 繫結上下文

例如,在下面的程式碼中,我們在物件的上下文中呼叫 sayWord.call(bottle) 執行 sayWord ,並 bottle 傳遞為 sayWordthis

function sayWord() {
  var talk = [this.name, 'say', this.word].join(' ');
  console.log(talk);
}

var bottle = {
  name: 'bottle', 
  word: 'hello'
};

// 使用 call 將 bottle 傳遞為 sayWord 的 this
sayWord.call(bottle); 
// bottle say hello
複製程式碼

3. 使用 func.call 時未指定 this

非嚴格模式
// 非嚴格模式下
var bottle = 'bottle'
function say(){
   // 注意:非嚴格模式下,this 為 window
   console.log('name is %s',this.bottle)
}

say.call()
// name is bottle
複製程式碼
嚴格模式
// 嚴格模式下
'use strict'
var bottle = 'bottle'
function say(){
   // 注意:在嚴格模式下 this 為 undefined
   console.log('name is %s',this.bottle)
}

say.call()
// Uncaught TypeError: Cannot read property 'bottle' of undefined
複製程式碼

4. call 在 JS 繼承中的使用: 構造繼承

基本思想:在子型別的建構函式內部呼叫父型別建構函式。

注意:函式只不過是在特定環境中執行程式碼的物件,所以這裡使用 apply/call 來實現。

使用父類的建構函式來增強子類例項,等於是複製父類的例項屬性給子類(沒用到原型)

// 父類
function SuperType (name) {
  this.name = name; // 父類屬性
}
SuperType.prototype.sayName = function () { // 父類原型方法
  return this.name;
};

// 子類
function SubType () {
  // 呼叫 SuperType 建構函式
  // 在子類建構函式中,向父類建構函式傳參
  SuperType.call(this, 'SuperType'); 
  // 為了保證子父類的建構函式不會重寫子類的屬性,需要在呼叫父類建構函式後,定義子類的屬性
  this.subName = "SubType"; 
  // 子類屬性
};

// 子類例項
let instance = new SubType(); 
// 執行子類建構函式,並在子類建構函式中執行父類建構函式,this繫結到子類
複製程式碼

5. 解決 var 作用域問題

var bottle = [
  {name: 'an', age: '24'},
  {name: 'anGe', age: '12'}
];

for (var i = 0; i < bottle.length; i++) {
  // 匿名函式
  (function (i) { 
    setTimeout(() => {
      // this 指向了 bottle[i]
      console.log('#' + i  + ' ' + this.name + ': ' + this.age); 
    }, 1000)
  }).call(bottle[i], i);
  // 呼叫 call 方法,同時解決了 var 作用域問題
}
複製程式碼

列印結果:

#0 an: 24
#1 anGe: 12
複製程式碼

在上面例中的 for 迴圈體內,我們建立了一個匿名函式,然後通過呼叫該函式的 call 方法,將每個陣列元素作為指定的 this 值立即執行了那個匿名函式。這個立即執行的匿名函式的作用是列印出 bottle[i] 物件在陣列中的正確索引號。

二、Function.prototype.apply()

apply() 方法呼叫一個具有給定 this 值的函式,以及作為一個陣列(或[類似陣列物件)提供的引數。

func.apply(thisArg, [argsArray])
複製程式碼

它執行 func 設定 this = context 並使用類陣列物件 args 作為引數列表。

例如,這兩個呼叫幾乎相同:

func(1, 2, 3);
func.apply(context, [1, 2, 3])
複製程式碼

兩個都執行 func 給定的引數是 1,2,3。但是 apply 也設定了 this = context

call 和 apply 之間唯一的語法區別是 call 接受一個引數列表,而 apply 則接受帶有一個類陣列物件。

需要注意:Chrome 14 以及 Internet Explorer 9 仍然不接受類陣列物件。如果傳入類陣列物件,它們會丟擲異常。

1. call、apply 與 擴充套件運算子

我們已經知道了JS 基礎之: var、let、const、解構、展開、函式 一章中的擴充套件運算子 ...,它可以將陣列(或任何可迭代的)作為引數列表傳遞。因此,如果我們將它與 call 一起使用,就可以實現與 apply 幾乎相同的功能。

這兩個呼叫結果幾乎相同:

let args = [1, 2, 3];

func.call(context, ...args); // 使用 spread 運算子將陣列作為引數列表傳遞
func.apply(context, args);   // 與使用 call 相同
複製程式碼

如果我們仔細觀察,那麼 callapply 的使用會有一些細微的差別。

  • 擴充套件運算子 ... 允許將 可迭代的 引數列表 作為列表傳遞給 call
  • apply 只接受 類陣列一樣的 引數列表

2. apply 函式轉移

apply 最重要的用途之一是將呼叫傳遞給另一個函式,如下所示:

let wrapper = function() {
  return anotherFunction.apply(this, arguments);
};
複製程式碼

wrapper 通過 anotherFunction.apply 獲得了上下文 thisanotherFunction 的引數並返回其結果。

當外部程式碼呼叫這樣的 wrapper 時,它與原始函式的呼叫無法區分。

3. apply 連線陣列

array.push.apply 將陣列新增到另一陣列上:

var array = ['a', 'b']
var elements = [0, 1, 2]
array.push.apply(array, elements)
console.info(array) // ["a", "b", 0, 1, 2]
複製程式碼

4. apply 來連結構造器

Function.prototype.constructor = function (aArgs) {
  var oNew = Object.create(this.prototype);
  this.apply(oNew, aArgs);
  return oNew;
};
複製程式碼

5. apply 和內建函式

/* 找出陣列中最大/小的數字 */
let numbers = [5, 6, 2, 3, 7]
/* 應用(apply) Math.min/Math.max 內建函式完成 */

let max = Math.max.apply(null, numbers) 
/* 基本等同於 Math.max(numbers[0], ...) 或 Math.max(5, 6, ..) */

let min = Math.min.apply(null, numbers)

console.log('max: ', max)
// max: 7
console.log('min: ', min)
// min: 2
複製程式碼

它相當於:

/* 程式碼對比: 用簡單迴圈完成 */
let numbers = [5, 6, 2, 3, 7]
let max = -Infinity, min = +Infinity
for (var i = 0; i < numbers.length; i++) {
  if (numbers[i] > max)
    max = numbers[i]
  if (numbers[i] < min) 
    min = numbers[i]
}

console.log('max: ', max)
// max: 7
console.log('min: ', min)
// min: 2
複製程式碼

但是:如果用上面的方式呼叫 apply,會有超出 JavaScript 引擎的引數長度限制的風險。更糟糕的是其他引擎會直接限制傳入到方法的引數個數,導致引數丟失。

所以,當資料量較大時

function minOfArray(arr) {
  var min = Infinity
  var QUANTUM = 32768 // JavaScript 核心中已經做了硬編碼  引數個數限制在65536

  for (var i = 0, len = arr.length; i < len; i += QUANTUM) {
    var submin = Math.min.apply(null, arr.slice(i, Math.min(i + QUANTUM, len)))
    min = Math.min(submin, min)
  }
  return min
}
var min = minOfArray([5, 6, 2, 3, 7])
// max 同樣也是如此
複製程式碼

三、Function.prototype.bind()

JavaScript 新手經常犯的一個錯誤是將一個方法從物件中拿出來,然後再呼叫,希望方法中的 this 是原來的物件(比如在回撥中傳入這個方法)。如果不做特殊處理的話,一般 this 就丟失了。

例如:

let bottle = {
  nickname: "bottle",
  sayHello() {
    console.log(`Hello, ${this.nickname}!`)
  },
  sayHi(){
    setTimeout(function(){
      console.log('Hello, ', this.nickname)
    }, 1000)
  }
};

// 問題一
bottle.sayHi();
// Hello, undefined!

// 問題二
setTimeout(bottle.sayHello, 1000); 
// Hello, undefined!
複製程式碼

問題一的 this.nickname 是 undefined ,原因是 this 指向是在執行函式時確定的,而不是定義函式時候確定的,再因為 sayHi 中 setTimeout 在全域性環境下執行,所以 this 指向 setTimeout 的上下文:window。

問題二的 this.nickname 是 undefined ,是因為 setTimeout 僅僅只是獲取函式 bottle.sayHello 作為 setTimeout 回撥函式,this 和 bottle 物件分離了。

問題二可以寫為:

// 在這種情況下,this 指向全域性作用域
let func = bottle.sayHello;
setTimeout(func, 1000); 
// 使用者上下文丟失
// 瀏覽器上,訪問的實際上是 Window 上下文
複製程式碼

那麼怎麼解決這兩個問題喃?

解決方案一: 快取 this 與包裝

首先通過快取 this 解決問題一 bottle.sayHi();

let bottle = {
  nickname: "bottle",
  sayHello() {
    console.log(`Hello, ${this.nickname}!`)
  },
  sayHi(){
    var _this = this // 快取this
    setTimeout(function(){
      console.log('Hello, ', _this.nickname)
    }, 1000)
  }
};

bottle.sayHi();
// Hello,  bottle
複製程式碼

那問題二 setTimeout(bottle.sayHello, 1000); 喃?

let bottle = {
  nickname: "bottle",
  sayHello() {
    console.log(`Hello, ${this.nickname}!`);
  }
};

// 加一個包裝層
setTimeout(() => {
  bottle.sayHello()
}, 1000); 
// Hello, bottle!
複製程式碼

這樣看似解決了問題二,但如果我們在 setTimeout 非同步觸發之前更新 bottle 值又會怎麼樣呢?

var bottle = {
  nickname: "bottle",
  sayHello() {
    console.log(`Hello, ${this.nickname}!`);
  }
};

setTimeout(() => {
  bottle.sayHello()
}, 1000); 

// 更新 bottle
bottle = {
  nickname: "haha",
  sayHello() {
    console.log(`Hi, ${this.nickname}!`)
  }
};
// Hi, haha!
複製程式碼

bottle.sayHello() 最終列印為 Hi, haha! ,那麼怎麼解決這種事情發生喃?

解決方案二: bind

bind() 最簡單的用法是建立一個新繫結函式,當這個新繫結函式被呼叫時,this 鍵值為其提供的值,其引數列表前幾項值為建立時指定的引數序列,繫結函式與被調函式具有相同的函式體(ES5中)。

let bottle = {
  nickname: "bottle",
  sayHello() {
    console.log(`Hello, ${this.nickname}!`);
  }
};

// 未繫結,“this” 指向全域性作用域
let sayHello = bottle.sayHello
console.log(sayHello())
// Hello, undefined!

// 繫結
let bindSayHello = sayHello.bind(bottle)
// 建立一個新函式,將 this 繫結到 bottle 物件
console.log(bindSayHello())
// Hello, bottle!
複製程式碼

所以,從原來的函式和原來的物件建立一個繫結函式,則能很漂亮地解決上面兩個問題:

let bottle = {
  nickname: "bottle",
  sayHello() {
    console.log(`Hello, ${this.nickname}!`);
  },
  sayHi(){
    // 使用 bind
    setTimeout(function(){
      console.log('Hello, ', this.nickname)
    }.bind(this), 1000)
    
    // 或箭頭函式
    setTimeout(() => {
      console.log('Hello, ', this.nickname)
    }, 1000)
  }
};

// 問題一:完美解決
bottle.sayHi()
// Hello,  bottle
// Hello,  bottle

let sayHello = bottle.sayHello.bind(bottle); // (*)

sayHello(); 
// Hello, bottle!

// 問題二:完美解決
setTimeout(sayHello, 1000); 
// Hello, bottle!

// 更新 bottle
bottle = {
  nickname: "haha",
  sayHello() {
    console.log(`Hi, ${this.nickname}!`)
  }
};
複製程式碼

問題一,可以通過 bind 或箭頭函式完美解決。

最終更新 bottle 後, setTimeout(sayHello, 1000); 列印依然是 Hello, bottle!, 問題二完美解決!

1. bind 與 new

再看一個例子:

this.nickname = 'window'
let bottle = {
  nickname: 'bottle'
}
function sayHello() {
  console.log('Hello, ', this.nickname)
}

let bindBottle = sayHello.bind(bottle) // this 指向 bottle
console.log(bindBottle()) 
// Hello,  bottle

console.log(new bindBottle())  // this 指向 sayHello {}
// Hello,  undefined
複製程式碼

上面例子中,執行結果 this.nickname 輸出為 undefined ,這不是全域性 nickname , 也不是 bottle 物件中的 nickname ,這說明 bind 的 this 物件失效了,new 的實現中生成一個新的物件,這個時候的 this 指向的是 sayHello

注意 :繫結函式也可以使用 new 運算子構造:這樣做就好像已經構造了目標函式一樣。提供的 this 值將被忽略,而前置引數將提供給模擬函式。

2. 二次 bind

function sayHello() {
  console.log('Hello, ', this.nickname)
}

sayHello = sayHello.bind( {nickname: "Bottle"} ).bind( {nickname: "AnGe" } );
sayHello();
// Hello,  Bottle
複製程式碼

輸出依然是 Hello, Bottle ,這是因為 func.bind(...) 返回的外來的繫結函式物件僅在建立的時候記憶上下文(如果提供了引數)。

一個函式不能作為重複繫結。

2. 偏函式

當我們確定一個函式的一些引數時,返回的函式(更加特定)被稱為偏函式。我們可以使用 bind 來獲取偏函式:

function list() {
  return Array.prototype.slice.call(arguments);
}

var list1 = list(1, 2, 3); // [1, 2, 3]

var leadingThirtysevenList = list.bind(undefined, 37);
var list2 = leadingThirtysevenList(); // [37]
var list3 = leadingThirtysevenList(1, 2, 3); // [37, 1, 2, 3]
複製程式碼

當我們不想一遍又一遍重複相同的引數時,偏函式很方便。

3. 作為建構函式使用的繫結函式

function Bottle(nickname) {
  this.nickname = nickname;
}
Bottle.prototype.sayHello = function() { 
  console.log('Hello, ', this.nickname)
};

let bottle = new Bottle('bottle');
let BindBottle = Bottle.bind(null, 'bindBottle');

let b1 = new BindBottle('b1');
b1 instanceof Bottle; // true
b1 instanceof BindBottle; // true
new Bottle('bottle1') instanceof BindBottle; // true

b1.sayHello()
// Hello,  bindBottle
複製程式碼

四、柯里化

在電腦科學中,柯里化(Currying)是把接受多個引數的函式變換成接受一個單一引數(最初函式的第一個引數)的函式,並且返回接受餘下的引數且返回結果的新函式的技術。這個技術由 Christopher Strachey 以邏輯學家 Haskell Curry 命名的,儘管它是 Moses Schnfinkel 和 Gottlob Frege 發明的。

var add = function(x) {
  return function(y) {
    return x + y;
  };
};

var increment = add(1);
var addTen = add(10);

increment(2);
// 3

addTen(2);
// 12

add(1)(2);
// 3
複製程式碼

這裡定義了一個 add 函式,它接受一個引數並返回一個新的函式。呼叫 add 之後,返回的函式就通過閉包的方式記住了 add 的第一個引數。所以說 bind 本身也是閉包的一種使用場景。

柯里化是將 f(a,b,c) 可以被以 f(a)(b)(c) 的形式被呼叫的轉化。JavaScript 實現版本通常保留函式被正常呼叫和在引數數量不夠的情況下返回偏函式這兩個特性。

五、擴充套件:箭頭函式

1. 沒有 this

let bottle = {
  nickname: "bottle",
  sayHi(){
    setTimeout(function(){
      console.log('Hello, ', this.nickname)
    }, 1000)
    
    // 或箭頭函式
    setTimeout(() => {
      console.log('Hi, ', this.nickname)
    }, 1000)
  }
};

bottle.sayHi()
// Hello,  undefined
// Hi,  bottle
複製程式碼

報錯是因為 Hello, undefined 是因為執行時 this=WindowWindow.nicknameundefined

但箭頭函式就沒事,因為箭頭函式沒有 this。在外部上下文中,this 的查詢與普通變數搜尋完全相同。this 指向定義時的環境。

2. 不可 new 例項化

不具有 this 自然意味著另一個限制:箭頭函式不能用作建構函式。他們不能用 new 呼叫。

3. 箭頭函式 vs bind

箭頭函式 => 和正常函式通過 .bind(this) 呼叫有一個微妙的區別:

  • .bind(this) 建立該函式的 “繫結版本”。
  • 箭頭函式 => 不會建立任何繫結。該函式根本沒有 this。在外部上下文中,this 的查詢與普通變數搜尋完全相同。

4. 沒有 arguments 物件

箭頭函式也沒有 arguments 變數。

因為我們需要用當前的 thisarguments 轉發一個呼叫,所有這對於裝飾者來說非常好。

例如,defer(f, ms) 得到一個函式,並返回一個包裝函式,以 毫秒 為單位延遲呼叫:

function defer(f, ms) {
  return function() {
    setTimeout(() => f.apply(this, arguments), ms)
  };
}

function sayHi(who) {
  alert('Hello, ' + who);
}

let sayHiDeferred = defer(sayHi, 2000);
sayHiDeferred("John"); // 2 秒後列印 Hello, John
複製程式碼

沒有箭頭功能的情況如下所示:

function defer(f, ms) {
  return function(...args) {
    let ctx = this;
    setTimeout(function() {
      return f.apply(ctx, args);
    }, ms);
  };
}
複製程式碼

在這裡,我們必須建立額外的變數 argsctx,以便 setTimeout 內部的函式可以接收它們。

5. 總結

  • this 指向定義時的環境
  • 不可 new 例項化
  • this 不可變
  • 沒有 arguments 物件

六、參考

裝飾和轉發,call/apply

七、系列文章

想看更過系列文章,點選前往 github 部落格主頁

八、走在最後

1. ❤️玩得開心,不斷學習,並始終保持編碼。??

2. 如有任何問題或更獨特的見解,歡迎評論或直接聯絡瓶子君(公眾號回覆 123 即可)!??

3. ?歡迎關注:前端瓶子君,每日更新!?

前端瓶子君

相關文章