2022 年 10 月 14 日

函數繫結

當將物件方法傳遞為回呼函式時,例如傳遞給 setTimeout,有一個已知問題:「失去 this」。

在本章中,我們將了解解決此問題的方法。

失去「this」

我們已經看過失去 this 的範例。一旦方法從物件中分開傳遞到某個地方,this 就會遺失。

以下是可能發生在 setTimeout 的情況

let user = {
  firstName: "John",
  sayHi() {
    alert(`Hello, ${this.firstName}!`);
  }
};

setTimeout(user.sayHi, 1000); // Hello, undefined!

正如我們所見,輸出顯示的不是 this.firstName 中的「John」,而是 undefined

這是因為 setTimeout 取得了函式 user.sayHi,而這個函式是從物件中分開取得的。最後一行可以改寫成

let f = user.sayHi;
setTimeout(f, 1000); // lost user context

瀏覽器中的 setTimeout 方法有點特別:它會為函式呼叫設定 this=window(對於 Node.js,this 會變成計時器物件,但這在這裡並不重要)。因此,對於 this.firstName,它會嘗試取得 window.firstName,但這個值並不存在。在其他類似的案例中,this 通常只會變成 undefined

這個任務相當典型,我們想要將物件方法傳遞到其他地方(這裡是排程器),並在那裡呼叫它。如何確保它會在正確的內容中被呼叫?

解決方案 1:包裝器

最簡單的解決方案是使用包裝函式

let user = {
  firstName: "John",
  sayHi() {
    alert(`Hello, ${this.firstName}!`);
  }
};

setTimeout(function() {
  user.sayHi(); // Hello, John!
}, 1000);

現在它可以運作,因為它從外部詞彙環境中接收 user,然後正常呼叫方法。

相同,但更簡短

setTimeout(() => user.sayHi(), 1000); // Hello, John!

看起來很好,但我們的程式碼結構中出現了一個小漏洞。

如果在 setTimeout 觸發之前(有一秒的延遲!)user 的值發生了變化,會怎樣?那麼,它會突然呼叫錯誤的物件!

let user = {
  firstName: "John",
  sayHi() {
    alert(`Hello, ${this.firstName}!`);
  }
};

setTimeout(() => user.sayHi(), 1000);

// ...the value of user changes within 1 second
user = {
  sayHi() { alert("Another user in setTimeout!"); }
};

// Another user in setTimeout!

下一個解決方案可以保證這種情況不會發生。

解決方案 2:繫結

函式提供了一個內建方法 bind,允許修正 this

基本語法是

// more complex syntax will come a little later
let boundFunc = func.bind(context);

func.bind(context) 的結果是一個特殊的類函式「異國物件」,它可以像函式一樣被呼叫,並將呼叫透明地傳遞給 func,設定 this=context

換句話說,呼叫 boundFunc 就如同具有固定 thisfunc

例如,這裡的 funcUser 會傳遞一個呼叫給 func,其中 this=user

let user = {
  firstName: "John"
};

function func() {
  alert(this.firstName);
}

let funcUser = func.bind(user);
funcUser(); // John

這裡 func.bind(user) 作為 func 的「綁定變異」,固定 this=user

所有參數都「原樣」傳遞給原始 func,例如

let user = {
  firstName: "John"
};

function func(phrase) {
  alert(phrase + ', ' + this.firstName);
}

// bind this to user
let funcUser = func.bind(user);

funcUser("Hello"); // Hello, John (argument "Hello" is passed, and this=user)

現在讓我們使用物件方法試試看

let user = {
  firstName: "John",
  sayHi() {
    alert(`Hello, ${this.firstName}!`);
  }
};

let sayHi = user.sayHi.bind(user); // (*)

// can run it without an object
sayHi(); // Hello, John!

setTimeout(sayHi, 1000); // Hello, John!

// even if the value of user changes within 1 second
// sayHi uses the pre-bound value which is reference to the old user object
user = {
  sayHi() { alert("Another user in setTimeout!"); }
};

(*) 行中,我們取得 user.sayHi 方法並將其綁定到 usersayHi 是「綁定」函式,可以單獨呼叫或傳遞給 setTimeout,沒關係,內容會是正確的。

這裡我們可以看到參數「原樣」傳遞,只有 thisbind 固定

let user = {
  firstName: "John",
  say(phrase) {
    alert(`${phrase}, ${this.firstName}!`);
  }
};

let say = user.say.bind(user);

say("Hello"); // Hello, John! ("Hello" argument is passed to say)
say("Bye"); // Bye, John! ("Bye" is passed to say)
便利方法:bindAll

如果一個物件有許多方法,而且我們計畫積極傳遞它,那麼我們可以在迴圈中將它們全部綁定

for (let key in user) {
  if (typeof user[key] == 'function') {
    user[key] = user[key].bind(user);
  }
}

JavaScript 函式庫也提供用於方便大量綁定的函式,例如 _.bindAll(object, methodNames) 在 lodash 中。

部分函式

到目前為止,我們只討論過綁定 this。讓我們更進一步。

我們不僅可以綁定 this,還可以綁定參數。這很少見,但有時會很方便。

bind 的完整語法

let bound = func.bind(context, [arg1], [arg2], ...);

它允許將內容綁定為 this,並作為函式的起始參數。

例如,我們有一個乘法函式 mul(a, b)

function mul(a, b) {
  return a * b;
}

讓我們使用 bind 在其基礎上建立一個函式 double

function mul(a, b) {
  return a * b;
}

let double = mul.bind(null, 2);

alert( double(3) ); // = mul(2, 3) = 6
alert( double(4) ); // = mul(2, 4) = 8
alert( double(5) ); // = mul(2, 5) = 10

呼叫 mul.bind(null, 2) 會建立一個新函式 double,它會傳遞呼叫給 mul,並將 null 固定為內容,2 固定為第一個參數。其他參數「原樣」傳遞。

這稱為 部分函式應用,我們透過固定現有函式的某些參數來建立一個新函式。

請注意,我們實際上沒有在這裡使用 this。但 bind 需要它,所以我們必須輸入一些東西,例如 null

以下程式碼中的 triple 函式將值加倍

function mul(a, b) {
  return a * b;
}

let triple = mul.bind(null, 3);

alert( triple(3) ); // = mul(3, 3) = 9
alert( triple(4) ); // = mul(3, 4) = 12
alert( triple(5) ); // = mul(3, 5) = 15

我們通常為什麼要建立部分函式?

好處是我們可以建立一個具有可讀名稱(doubletriple)的獨立函式。我們可以使用它,而不必每次都提供第一個參數,因為它已透過 bind 固定。

在其他情況下,當我們有一個非常通用的函式,並希望有一個較不通用的變異以方便使用時,部分應用會很有用。

例如,我們有一個函式 send(from, to, text)。然後,在 user 物件中,我們可能希望使用它的部分變異:sendTo(to, text),它從目前的使用者傳送。

在沒有內容的情況下進行部分應用

如果我們想要修正一些參數,但不想修正內容 this 呢?例如,對於物件方法。

原生 bind 不允許這樣做。我們不能只省略內容並跳到參數。

幸運的是,一個只用於繫結參數的函式 partial 可以輕鬆實作。

像這樣

function partial(func, ...argsBound) {
  return function(...args) { // (*)
    return func.call(this, ...argsBound, ...args);
  }
}

// Usage:
let user = {
  firstName: "John",
  say(time, phrase) {
    alert(`[${time}] ${this.firstName}: ${phrase}!`);
  }
};

// add a partial method with fixed time
user.sayNow = partial(user.say, new Date().getHours() + ':' + new Date().getMinutes());

user.sayNow("Hello");
// Something like:
// [10:00] John: Hello!

呼叫 partial(func[, arg1, arg2...]) 的結果是一個包裝器 (*),它會呼叫 func,並帶有

  • 與它取得相同的 this(對於 user.sayNow 呼叫,它是 user
  • 然後給它 ...argsBound – 來自 partial 呼叫的參數("10:00"
  • 然後給它 ...args – 傳遞給包裝器的參數("Hello"

使用擴充語法這麼做很容易,對吧?

另外,還有一個來自 lodash 函式庫的現成實作 _.partial

摘要

方法 func.bind(context, ...args) 會傳回函式 func 的「繫結變體」,它會修正內容 this 和第一個參數(如果已提供)。

我們通常會套用 bind 來修正物件方法的 this,以便我們可以將它傳遞到某個地方。例如,傳遞到 setTimeout

當我們修正現有函式的某些參數時,產生的(較不通用)函式稱為部分套用部分

當我們不想重複使用相同的參數時,部分會很方便。例如,如果我們有一個 send(from, to) 函式,而 from 對於我們的任務應該總是相同的,我們可以取得一個部分並繼續使用它。

任務

重要性:5

輸出會是什麼?

function f() {
  alert( this ); // ?
}

let user = {
  g: f.bind(null)
};

user.g();

答案:null

function f() {
  alert( this ); // null
}

let user = {
  g: f.bind(null)
};

user.g();

繫結函式的內容是硬性固定的。根本沒有辦法進一步更改它。

因此,即使我們執行 user.g(),原始函式也會以 this=null 呼叫。

重要性:5

我們能透過額外的繫結來更改 this 嗎?

輸出會是什麼?

function f() {
  alert(this.name);
}

f = f.bind( {name: "John"} ).bind( {name: "Ann" } );

f();

答案:John

function f() {
  alert(this.name);
}

f = f.bind( {name: "John"} ).bind( {name: "Pete"} );

f(); // John

f.bind(...) 傳回的特殊 繫結函式 物件只會在建立時記住內容(和提供的參數)。

函式無法重新繫結。

重要性:5

函式的屬性中有一個值。它會在 bind 之後改變嗎?為什麼會或不會?

function sayHi() {
  alert( this.name );
}
sayHi.test = 5;

let bound = sayHi.bind({
  name: "John"
});

alert( bound.test ); // what will be the output? why?

答案:undefined

bind 的結果是另一個物件。它沒有 test 屬性。

重要性:5

在以下程式碼中呼叫 askPassword() 應該檢查密碼,然後根據答案呼叫 user.loginOk/loginFail

但它會導致錯誤。為什麼?

修正反白行,讓所有功能正確運作(其他行不要變更)。

function askPassword(ok, fail) {
  let password = prompt("Password?", '');
  if (password == "rockstar") ok();
  else fail();
}

let user = {
  name: 'John',

  loginOk() {
    alert(`${this.name} logged in`);
  },

  loginFail() {
    alert(`${this.name} failed to log in`);
  },

};

askPassword(user.loginOk, user.loginFail);

錯誤發生是因為 ask 取得函式 loginOk/loginFail 沒有物件。

當它呼叫它們時,它們自然假設 this=undefined

讓我們 bind 內容

function askPassword(ok, fail) {
  let password = prompt("Password?", '');
  if (password == "rockstar") ok();
  else fail();
}

let user = {
  name: 'John',

  loginOk() {
    alert(`${this.name} logged in`);
  },

  loginFail() {
    alert(`${this.name} failed to log in`);
  },

};

askPassword(user.loginOk.bind(user), user.loginFail.bind(user));

現在它運作了。

另一種解決方案可能是

//...
askPassword(() => user.loginOk(), () => user.loginFail());

通常那樣也行,而且看起來不錯。

不過在更複雜的情況下,那會稍微不可靠一點,因為 user 變數可能在呼叫 askPassword 之後,但在訪客回答並呼叫 () => user.loginOk() 之前變更。

重要性:5

這項任務是 修正失去「this」的函式 的一個稍微複雜的變體。

user 物件已修改。現在它沒有兩個函式 loginOk/loginFail,而是一個單一函式 user.login(true/false)

我們應該在以下程式碼中傳遞什麼給 askPassword,讓它呼叫 user.login(true) 作為 ok,並呼叫 user.login(false) 作為 fail

function askPassword(ok, fail) {
  let password = prompt("Password?", '');
  if (password == "rockstar") ok();
  else fail();
}

let user = {
  name: 'John',

  login(result) {
    alert( this.name + (result ? ' logged in' : ' failed to log in') );
  }
};

askPassword(?, ?); // ?

你的變更應該只修改反白片段。

  1. 使用包裝函式,或使用箭頭函式簡潔表達

    askPassword(() => user.login(true), () => user.login(false));

    現在它從外部變數取得 user,並以正常方式執行它。

  2. 或從 user.login 建立一個使用 user 作為內容並具有正確第一個引數的部分函式

    askPassword(user.login.bind(user, true), user.login.bind(user, false));
教學地圖

留言

留言前請先閱讀這段…
  • 如果你有建議要如何改進 - 請 提交 GitHub 問題 或提交 pull 要求,而不是留言。
  • 如果你無法理解文章中的某個部分 - 請詳細說明。
  • 要插入幾行程式碼,請使用 <code> 標籤,要插入多行程式碼,請將它們包在 <pre> 標籤中,要插入超過 10 行程式碼,請使用沙盒 (plnkrjsbincodepen…)