理解 JavaScript 中的 this 关键字, call, apply, 和 bind 方法
在深入研究 JavaScript 中 this
关键字的细节前,回头看看它起初为何存在是很重要的。this
允许你在不同上下文中重用函数。换句话说,调用函数或方法时,它允许你决定哪个对象应该被关注。接下来讨论的一切都基于此思想,我们想在不同上下文或对象上重用函数或方法。
我们的首要关注点是如何分辨 this
关键字的指向。当试图回答该问题时,你需要问自己的第一也是最重要的问题是 “函数在哪里被调用?”。分辨 this
关键字指向的唯一方式是观察使用 this
的函数在哪儿被调用。
要说明这一点,让我们看一个你已经熟悉的示例,有一个 greet
函数,它接收一个 name,发出一条提示。
function greet (name) {
alert(`Hello, my name is ${name}`)
}
如果我问你 greet
会提示什么,你会如何回答?只给出函数定义,无法得知问题答案。要知道 name
是什么,你必须观察函数的调用点。
greet('Tyler')
相同思路被用来查找 this
关键字的指向。你甚至可以把 this
看成一个常规函数参数 —— 它将根据调用的方式做改变。
既然知道了辨别 this
指向的第一步是观察函数在哪儿被调用,下一步是什么呢?为了完成接下来的步骤,让我们建立 5 条规则或指导。
- 隐式绑定
- 显式绑定
- new 绑定
- 词法绑定
- window 绑定
隐式绑定
记住,我们的目标是观察使用 this
关键字的函数,并分辨出 this
指向什么。第一也是最常用的规则叫 隐式绑定
。它在 80% 的情况下都能告诉你 this
的指向。
我们有以下对象。
const user = {
name: 'Tyler',
age: 27,
greet() {
alert(`Hello, my name is ${this.name}`)
}
}
现在,要调用 user
对象上的 greet
方法,你会使用点表示法。
user.greet()
这为我们带来了隐式绑定规则的核心点。为了分辨出 this
关键字的指向,首先,看函数调用处左边的点。如果点存在,找到点左边的对象,它就是 this
关键字的指向。
上例中,user
位于点左边,这意味着 this
指向 user
对象。所以在 greet
方法内部,JavaScript 解释器就像把 this
变成了 user
。
greet() {
// alert(`Hello, my name is ${this.name}`)
alert(`Hello, my name is ${user.name}`) // Tyler
}
让我们再看一个类似但稍微高级点的例子。现在除了 name
, age
, 和 greet
属性,让我们赋予 user 对象一个 mother
属性,该属性也包含 name
和 greet
。
const user = {
name: 'Tyler',
age: 27,
greet() {
alert(`Hello, my name is ${this.name}`)
},
mother: {
name: 'Stacey',
greet() {
alert(`Hello, my name is ${this.name}`)
}
}
}
现在问题变成,以下每个调用分别提示什么?
user.greet()
user.mother.greet()
每当试图解答 this
指向什么时,我们需要观察调用处点的左边。第一个调用,user
位于点左边,所以 this
指向 user
。第二个调用,mother
位于点左边,this
指向 mother
。
user.greet() // Tyler
user.mother.greet() // Stacey
就像之前提到的,大约 80% 的情形,都会存在一个点左边的对象。这就是分辨 this
关键字指向时,你应该采取的首要步骤是观察点的左边的原因。但是,如果不存在点呢?这为我们带来了下一规则。
显式绑定
现在,greet
不是 user
对象的一个方法,而是一个独立函数。
function greet () {
alert(`Hello, my name is ${this.name}`)
}
const user = {
name: 'Tyler',
age: 27,
}
我们知道,要解答 this
关键字的指向,我们首先应该观察函数的调用点。现在的需求是如何能在调用 greet
时,让 this
关键字指向 user
对象。我们不能还像之前那样使用 user.greet()
,因为 user
没有 greet
方法。在 JavaScript 中,每个函数都包含一个名为 call
的方法,它就是为了完成上述功能。
[tip type="info"]
"call" 是每个函数上的方法,它允许你调用时指定它上下文。
[/tip]
记住以上要点,我们使用下面的代码调用 greet
,让它处于 user
上下文中。
greet.call(user)
重复一遍,call
属性存在于每个函数,它的第一个参数是函数调用上下文(或焦点对象)。换句话说,你传递的第一个参数就是函数内部 this
关键字的指向。
这就是规则 #2(显式绑定)的基础,因为我们显式(使用 .call
)指定 this
关键字的指向。
现在我们稍微修改下 greet
函数。如果我们还想传递一些参数怎么办呢?假定除了名字,我们还想提示他会的语言。就像下面这样
function greet (l1, l2, l3) {
alert(
`Hello, my name is ${this.name} and I know ${l1}, ${l2}, and ${l3}`
)
}
现在使用 .call
向被调函数传递参数,你可以在第一个上下文参数后,一个接着一个地跟上函数的参数。
function greet (l1, l2, l3) {
alert(
`Hello, my name is ${this.name} and I know ${l1}, ${l2}, and ${l3}`
)
}
const user = {
name: 'Tyler',
age: 27,
}
const languages = ['JavaScript', 'Ruby', 'Python']
greet.call(user, languages[0], languages[1], languages[2])
这能工作并展示使用 .call
调用函数时,如何向它传递参数。然而,你可能已经注意到,从 languages
数组一个个地拿元素作为参数有点烦人。如果我们可以把整个数组当作第二个参数传递,JavaScript 会为我们展开就好了。好消息是,这就是 .apply
的功能。.apply
几乎等同于 .call
,不同之处在于,你可以传递单个数组,它会把数组展开作为函数的参数。
所以使用 .apply
,我们的代码可以改成下面这样,其它地方都保持相同。
const languages = ['JavaScript', 'Ruby', 'Python']
// greet.call(user, languages[0], languages[1], languages[2])
greet.apply(user, languages)
迄今为止,在 “显式绑定” 规则下,我们学习了 .call
和 .apply
,它们都允许你调用函数时,指定内部 this
关键字的指向。该规则的最后一部分是 .bind
,它几乎与 .call
相同,但不同于后者立即调用函数,前者会返回一个函数,你可以在之后调用它。所以如果我们回看之前的代码,使用 .bind
,它会像这样
function greet (l1, l2, l3) {
alert(
`Hello, my name is ${this.name} and I know ${l1}, ${l2}, and ${l3}`
)
}
const user = {
name: 'Tyler',
age: 27,
}
const languages = ['JavaScript', 'Ruby', 'Python']
const newFn = greet.bind(user, languages[0], languages[1], languages[2])
newFn() // alerts "Hello, my name is Tyler and I know JavaScript, Ruby, and Python"
new 绑定
计算 this
关键字指向的第三条规则叫 new
绑定。如果你不熟悉 JavaScript 中的 new
关键字,每当使用它调用函数时,在底层,JavaScript 解释器都会为你创建崭新对象,把它作为 this
。所以,自然地,如果函数使用 new
调用,this
关键字就指向解释器创建的新对象。
function User (name, age) {
/*
Under the hood, JavaScript creates a new object
called `this` which delegates to the User's prototype
on failed lookups. If a function is called with the
new keyword, then it's this new object that interpreter
created that the this keyword is referencing.
*/
this.name = name
this.age = age
}
const me = new User('Tyler', 27)
词法绑定
此刻,我们到达了第四条规则,并且你可能有些困惑。这很正常,JavaScript 中的 this
关键字确实比它应该的更复杂。下面是个好消息,本条规则非常直观。
你很可能听说或使用过箭头函数。它是 ES6 的新语法,允许你以更简洁的格式编写函数。
friends.map((friend) => friend.name)
除了简洁,当谈到 this
关键字时,它还更直观。不同于常规函数,箭头函数没有自己的 this
。相反,this
由词法确定。有趣的是它就像你期待的那样,遵循正常的变量查找规则。让我们以之前使用的例子继续。现在,让我们把 languages
和 greet
组合到对象中。
const user = {
name: 'Tyler',
age: 27,
languages: ['JavaScript', 'Ruby', 'Python'],
greet() {}
}
之前我们假定 languages
数组的长度总是 3。这样做是为了能够使用 l1
, l2
, 和 l3
这样的硬编码变量。现在让我们使 greet
更智能一点,假定 languages
可以是任意长度。为了做到这点,我们使用 .reduce
创建字符串。
const user = {
name: 'Tyler',
age: 27,
languages: ['JavaScript', 'Ruby', 'Python'],
greet() {
const hello = `Hello, my name is ${this.name} and I know`
const langs = this.languages.reduce(function (str, lang, i) {
if (i === this.languages.length - 1) {
return `${str} and ${lang}.`
}
return `${str} ${lang},`
}, "")
alert(hello + langs)
}
}
代码很多但最终结果相同。调用 user.greet()
时,我们期待看见 Hello, my name is Tyler and I know JavaScript, Ruby, and Python.
。遗憾的是,出现了一个错误。你能找到吗?复制上面的代码到终端运行。你会注意到它抛出了 Uncaught TypeError: Cannot read property 'length' of undefined
。数一下,我们唯一使用 .length
的地方是第 9 行,所以我们知道错误在那。
if (i === this.languages.length - 1) {}
根据错误,this.languages
是 undefined。让我们运用步骤清晰地找出 this
关键字指向的原因,它不是期望的 user
。首先,我们需要观察函数在哪儿调用。等等?函数被传给了 .reduce
,所以我们没有思路了。我们永远不能真正看到匿名函数调用,因为 JavaScript 在 .reduce
的实现中完成了它。这就是问题所在。我们需要指定传给 .reduce
的函数在 user
上下文中调用。那样 this.languages
将指向 user.languages
。就如上面学到的,我们可以使用 .bind
。
const user = {
name: 'Tyler',
age: 27,
languages: ['JavaScript', 'Ruby', 'Python'],
greet() {
const hello = `Hello, my name is ${this.name} and I know`
const langs = this.languages.reduce(function (str, lang, i) {
if (i === this.languages.length - 1) {
return `${str} and ${lang}.`
}
return `${str} ${lang},`
}.bind(this), "")
alert(hello + langs)
}
}
所以我们看到 .bind
解决了该问题,但如果用箭头函数会怎么样呢?之前我说过,使用箭头函数,this
由词法决定。有趣的是它就像你期待的那样,遵循正常的变量查找规则。
以上代码中,只是遵循你的自然直觉,匿名函数中的 this
会指向什么。对我来说,它会指向 user
。没有必要只因必须为 .reduce
传递函数而创建新的上下文。这种直觉带来了箭头函数常常被忽视的价值。如果我们重写以上代码,不做其它修改只是使用匿名箭头函数取代匿名 function 声明,一切就都正常了。
const user = {
name: 'Tyler',
age: 27,
languages: ['JavaScript', 'Ruby', 'Python'],
greet() {
const hello = `Hello, my name is ${this.name} and I know`
const langs = this.languages.reduce((str, lang, i) => {
if (i === this.languages.length - 1) {
return `${str} and ${lang}.`
}
return `${str} ${lang},`
}, "")
alert(hello + langs)
}
}
重复一遍原因,使用箭头函数时,this
由词法确定。箭头函数没有自己的 this
。相反,就像变量查找,JavaScript 解释器会查询封闭(父)作用域确定 this
的指向。
window 绑定
最后是后备情况 —— window 绑定。假如我们有如下代码
function sayAge () {
console.log(`My age is ${this.age}`)
}
const user = {
name: 'Tyler',
age: 27
}
正如之前所述,想在 user
上下文上调用 sayAge
,你可以使用 .call
, .apply
, 或 .bind
。如果我们什么都不用,只是像通常那样调用它会发生什么呢?
sayAge() // My age is undefined
不出意外,你得到的是 My age is undefined
,因为 this.age
没有被定义。这儿情况有点奇怪。真实情形是因为左边没有点,也没有使用 .call
, .apply
, 或 .bind
,亦或 new
关键字,JavaScript 默认 this
指向 window
对象。这意味着如果我们向 window
对象添加 age
属性,再次调用 sayAge
时,this.age
就不再是 undefined 而是 window 对象的 age
属性。不信我?执行以下代码,
window.age = 27
function sayAge () {
console.log(`My age is ${this.age}`)
}
非常棒,是吗?这就是为何第 5 条规则是 window 绑定
。如果不满足其它任何规则,JavaScript 会默认 this
指向 window
对象。
[tip type="info"]
自从 ES5,如果你开启了 "strict mode",JavaScript 会做正确的事,不是默认指向 window 对象,它会让 this
保持 undefined。
[/tip]
'use strict'
window.age = 27
function sayAge () {
console.log(`My age is ${this.age}`)
}
sayAge() // TypeError: Cannot read property 'age' of undefined
所以运用所有规则,每当看到函数中的 this
关键字时,下面是我计算其指向所采取的步骤。
- 观察函数在哪被调用。
- 点左边有对象吗?如果有,它就是
this
的指向。如果没有,继续 #3。 - 是使用
.call
,.apply
, 或.bind
调用函数的吗?如果是,它会显式声明this
的指向。如果没有,继续 #4。 - 是使用
new
关键字调用函数的吗?如果是,this
指向 JavaScript 解释器新创建的对象。如果否,继续 #5。 this
在箭头函数中吗?如果是,它的指向应该在词法封闭(父)域中。如果否,继续 #6。- 你处于
strict mode
吗?如果是,this
是 undefined。如果否,继续 #7。 - JavaScript 很奇怪。现在
this
指向window
对象。
本文译自 ui.dev,译者 LOGI