Javascript 函數(shù)綁定

2023-02-17 10:51 更新

當(dāng)將對象方法作為回調(diào)進行傳遞,例如傳遞給 ?setTimeout?,這兒會存在一個常見的問題:“丟失 ?this?”。

在本章中,我們會學(xué)習(xí)如何去解決這個問題。

丟失 “this”

我們已經(jīng)看到了丟失 this 的例子。一旦方法被傳遞到與對象分開的某個地方 —— this 就丟失。

下面是使用 setTimeout 時 this 是如何丟失的:

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

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

正如我們所看到的,輸出沒有像 this.firstName 那樣顯示 “John”,而顯示了 undefined

這是因為 setTimeout 獲取到了函數(shù) user.sayHi,但它和對象分離開了。最后一行可以被重寫為:

let f = user.sayHi;
setTimeout(f, 1000); // 丟失了 user 上下文

瀏覽器中的 setTimeout 方法有些特殊:它為函數(shù)調(diào)用設(shè)定了 this=window(對于 Node.js,this 則會變?yōu)橛嫊r器(timer)對象,但在這兒并不重要)。所以對于 this.firstName,它其實試圖獲取的是 window.firstName,這個變量并不存在。在其他類似的情況下,通常 this 會變?yōu)?nbsp;undefined。

這個需求很典型 —— 我們想將一個對象方法傳遞到別的地方(這里 —— 傳遞到調(diào)度程序),然后在該位置調(diào)用它。如何確保在正確的上下文中調(diào)用它?

解決方案 1:包裝器

最簡單的解決方案是使用一個包裝函數(shù):

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

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

現(xiàn)在它可以正常工作了,因為它從外部詞法環(huán)境中獲取到了 user,就可以正常地調(diào)用方法了。

相同的功能,但是更簡短:

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

看起來不錯,但是我們的代碼結(jié)構(gòu)中出現(xiàn)了一個小漏洞。

如果在 setTimeout 觸發(fā)之前(有一秒的延遲?。?code>user 的值改變了怎么辦?那么,突然間,它將調(diào)用錯誤的對象!

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

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

// ……user 的值在不到 1 秒的時間內(nèi)發(fā)生了改變
user = {
  sayHi() { alert("Another user in setTimeout!"); }
};

// Another user in setTimeout!

下一個解決方案保證了這樣的事情不會發(fā)生。

解決方案 2:bind

函數(shù)提供了一個內(nèi)建方法 bind,它可以綁定 ?this?。

基本的語法是:

// 稍后將會有更復(fù)雜的語法
let boundFunc = func.bind(context);

func.bind(context) 的結(jié)果是一個特殊的類似于函數(shù)的“外來對象(exotic object)”,它可以像函數(shù)一樣被調(diào)用,并且透明地(transparently)將調(diào)用傳遞給 func 并設(shè)定 this=context。

換句話說,boundFunc 調(diào)用就像綁定了 this 的 func

舉個例子,這里的 funcUser 將調(diào)用傳遞給了 func 同時 this=user

let user = {
  firstName: "John"
};

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

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

這里的 func.bind(user) 作為 func 的“綁定的(bound)變體”,綁定了 this=user。

所有的參數(shù)(arguments)都被“原樣”傳遞給了初始的 func,例如:

let user = {
  firstName: "John"
};

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

// 將 this 綁定到 user
let funcUser = func.bind(user);

funcUser("Hello"); // Hello, John(參數(shù) "Hello" 被傳遞,并且 this=user)

現(xiàn)在我們來嘗試一個對象方法:

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

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

// 可以在沒有對象(譯注:與對象分離)的情況下運行它
sayHi(); // Hello, John!

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

// 即使 user 的值在不到 1 秒內(nèi)發(fā)生了改變
// sayHi 還是會使用預(yù)先綁定(pre-bound)的值,該值是對舊的 user 對象的引用
user = {
  sayHi() { alert("Another user in setTimeout!"); }
};

在 (*) 行,我們?nèi)×朔椒?nbsp;user.sayHi 并將其綁定到 user。sayHi 是一個“綁定后(bound)”的方法,它可以被單獨調(diào)用,也可以被傳遞給 setTimeout —— 都沒關(guān)系,函數(shù)上下文都會是正確的。

這里我們能夠看到參數(shù)(arguments)都被“原樣”傳遞了,只是 this 被 bind 綁定了:

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

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

say("Hello"); // Hello, John!(參數(shù) "Hello" 被傳遞給了 say)
say("Bye"); // Bye, John!(參數(shù) "Bye" 被傳遞給了 say)

便捷方法:?bindAll?

如果一個對象有很多方法,并且我們都打算將它們都傳遞出去,那么我們可以在一個循環(huán)中完成所有方法的綁定:

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

JavaScript 庫還提供了方便批量綁定的函數(shù),例如 lodash 中的 _.bindAll(object, methodNames)。

偏函數(shù)(Partial functions)

到現(xiàn)在為止,我們只在談?wù)摻壎?nbsp;this。讓我們再深入一步。

我們不僅可以綁定 this,還可以綁定參數(shù)(arguments)。雖然很少這么做,但有時它可以派上用場。

bind 的完整語法如下:

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

它允許將上下文綁定為 this,以及綁定函數(shù)的起始參數(shù)。

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

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

讓我們使用 bind 在該函數(shù)基礎(chǔ)上創(chuàng)建一個 double 函數(shù):

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) 的調(diào)用創(chuàng)建了一個新函數(shù) double,它將調(diào)用傳遞到 mul,將 null 綁定為上下文,并將 2 綁定為第一個參數(shù)。并且,參數(shù)(arguments)均被“原樣”傳遞。

它被稱為 偏函數(shù)應(yīng)用程序(partial function application) —— 我們通過綁定先有函數(shù)的一些參數(shù)來創(chuàng)建一個新函數(shù)。

請注意,這里我們實際上沒有用到 this。但是 bind 需要它,所以我們必須傳入 null 之類的東西。

下面這段代碼中的 triple 函數(shù)將值乘了三倍:

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

為什么我們通常會創(chuàng)建一個偏函數(shù)?

好處是我們可以創(chuàng)建一個具有可讀性高的名字(double,triple)的獨立函數(shù)。我們可以使用它,并且不必每次都提供一個參數(shù),因為參數(shù)是被綁定了的。

另一方面,當(dāng)我們有一個非常通用的函數(shù),并希望有一個通用型更低的該函數(shù)的變體時,偏函數(shù)會非常有用。

例如,我們有一個函數(shù) send(from, to, text)。然后,在一個 user 對象的內(nèi)部,我們可能希望對它使用 send 的偏函數(shù)變體:從當(dāng)前 user 發(fā)送 sendTo(to, text)

在沒有上下文情況下的 partial

當(dāng)我們想綁定一些參數(shù)(arguments),但是這里沒有上下文 this,應(yīng)該怎么辦?例如,對于一個對象方法。

原生的 bind 不允許這種情況。我們不可以省略上下文直接跳到參數(shù)(arguments)。

幸運的是,僅綁定參數(shù)(arguments)的函數(shù) partial 比較容易實現(xiàn)。

像這樣:

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

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

// 添加一個帶有綁定時間的 partial 方法
user.sayNow = partial(user.say, new Date().getHours() + ':' + new Date().getMinutes());

user.sayNow("Hello");
// 類似于這樣的一些內(nèi)容:
// [10:00] John: Hello!

partial(func[, arg1, arg2...]) 調(diào)用的結(jié)果是一個包裝器 (*),它調(diào)用 func 并具有以下內(nèi)容:

  • 與它獲得的函數(shù)具有相同的 ?this?(對于 ?user.sayNow? 調(diào)用來說,它是 ?user?)
  • 然后給它 ?...argsBound? —— 來自于 ?partial? 調(diào)用的參數(shù)(?"10:00"?)
  • 然后給它 ?...args? —— 給包裝器的參數(shù)(?"Hello"?)

使用 spread 可以很容易實現(xiàn)這些操作,對吧?

此外,還有來自 lodash 庫的現(xiàn)成的 _.partial 實現(xiàn)。

總結(jié)

方法 func.bind(context, ...args) 返回函數(shù) func 的“綁定的(bound)變體”,它綁定了上下文 this 和第一個參數(shù)(如果給定了)。

通常我們應(yīng)用 bind 來綁定對象方法的 this,這樣我們就可以把它們傳遞到其他地方使用。例如,傳遞給 setTimeout。

當(dāng)我們綁定一個現(xiàn)有的函數(shù)的某些參數(shù)時,綁定后的(不太通用的)函數(shù)被稱為 partially applied 或 partial。

當(dāng)我們不想一遍又一遍地重復(fù)相同的參數(shù)時,partial 非常有用。就像我們有一個 send(from, to) 函數(shù),并且對于我們的任務(wù)來說,from 應(yīng)該總是一樣的,那么我們就可以搞一個 partial 并使用它。

任務(wù)


作為方法的綁定函數(shù)

重要程度: 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();

綁定函數(shù)的上下文是硬綁定(hard-fixed)的。沒有辦法再修改它。

所以即使我們執(zhí)行 user.g(),源方法調(diào)用時還是 this=null。


二次 bind

重要程度: 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(...) 返回的外來(exotic)綁定函數(shù) 對象僅在創(chuàng)建的時候記憶上下文(以及參數(shù),如果提供了的話)。

一個函數(shù)不能被重綁定(re-bound)。


bind 后的函數(shù)屬性

重要程度: 5

函數(shù)的屬性中有一個值。?bind? 之后它會改變嗎?為什么,闡述一下?

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

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

alert( bound.test ); // 輸出將會是什么?為什么?

解決方案

答案:undefined。

bind 的結(jié)果是另一個對象。它并沒有 test 屬性。


修復(fù)丟失了 "this" 的函數(shù)

重要程度: 5

下面代碼中對 ?askPassword()? 的調(diào)用將會檢查 password,然后基于結(jié)果調(diào)用 ?user.loginOk/loginFail?。

但是它導(dǎo)致了一個錯誤。為什么?

修改最后一行,以使所有內(nèi)容都能正常工作(其它行不用修改)。

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);

解決方案

發(fā)生了錯誤是因為 ask 獲得的是沒有綁定對象的 loginOk/loginFail 函數(shù)。

當(dāng) ask 調(diào)用這兩個函數(shù)時,它們自然會認(rèn)定 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));

現(xiàn)在它能正常工作了。

另一個可替換解決方案是:

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

通常這也能正常工作,也看起來挺好的。

但是可能會在更復(fù)雜的場景下失效,例如變量 user 在調(diào)用 askPassword 之后但在訪問者應(yīng)答和調(diào)用 () => user.loginOk() 之前被修改。


偏函數(shù)在登錄中的應(yīng)用

重要程度: 5

這個任務(wù)是比 修復(fù)丟失了 "this" 的函數(shù) 略微復(fù)雜的變體。

user 對象被修改了?,F(xiàn)在不是兩個函數(shù) loginOk/loginFail,現(xiàn)在只有一個函數(shù) user.login(true/false)

在下面的代碼中,我們應(yīng)該向 askPassword 傳入什么參數(shù),以使得 user.login(true) 結(jié)果是 ok,user.login(fasle) 結(jié)果是 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. 使用包裝(wapper)函數(shù),箭頭函數(shù)很簡潔:
  2. askPassword(() => user.login(true), () => user.login(false));

    現(xiàn)在它從外部變量中獲得了 user,然后以常規(guī)方式運行它。

  3. 或者從 user.login 創(chuàng)建一個偏函數(shù),該函數(shù)使用 user 作為上下文,并具有正確的第一個參數(shù):
  4. askPassword(user.login.bind(user, true), user.login.bind(user, false));


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號