理解 JavaScript 中的 this 关键字, call, apply, 和 bind 方法

2021-06-16T18:16:00

在深入研究 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 条规则或指导。

  1. 隐式绑定
  2. 显式绑定
  3. new 绑定
  4. 词法绑定
  5. 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 属性,该属性也包含 namegreet

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 由词法确定。有趣的是它就像你期待的那样,遵循正常的变量查找规则。让我们以之前使用的例子继续。现在,让我们把 languagesgreet 组合到对象中。

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 关键字时,下面是我计算其指向所采取的步骤。

  1. 观察函数在哪被调用。
  2. 点左边有对象吗?如果有,它就是 this 的指向。如果没有,继续 #3。
  3. 是使用 .call, .apply, 或 .bind 调用函数的吗?如果是,它会显式声明 this 的指向。如果没有,继续 #4。
  4. 是使用 new 关键字调用函数的吗?如果是,this 指向 JavaScript 解释器新创建的对象。如果否,继续 #5。
  5. this 在箭头函数中吗?如果是,它的指向应该在词法封闭(父)域中。如果否,继续 #6。
  6. 你处于 strict mode 吗?如果是,this 是 undefined。如果否,继续 #7。
  7. JavaScript 很奇怪。现在 this 指向 window 对象。
本文译自 ui.dev,译者 LOGI
当前页面是本站的「Baidu MIP」版。发表评论请点击:完整版 »