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 使用 this=user 将调用传递给 func

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 库还提供了用于方便批量绑定的函数,例如 lodash 中的 _.bindAll(object, methodNames)

部分函数

到目前为止,我们只讨论了绑定 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 问题 或提交拉取请求,而不是发表评论。
  • 如果你无法理解文章中的某些内容 – 请详细说明。
  • 要插入几行代码,请使用 <code> 标记,对于多行代码,请将其包装在 <pre> 标记中,对于 10 行以上的代码,请使用沙箱 (plnkrjsbincodepen…)