this的绑定规则详解


单纯的把 this 理解为指向函数自身是错误的,this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用

绑定规则

默认绑定

首先要介绍的是最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则。

function foo() {
  console.log(this.a)
}
var a = 2
foo() // 2
  1. 我们可以看到当调用 foo() 时,this.a 被解析成了全局变量 a。为什么?因为在本例中,函数调用时应用了 this 的默认绑定,因此 this 指向全局对象
  2. 那么我们怎么知道这里应用了默认绑定呢?可以通过分析调用位置来看看 foo() 是如何调 用的。在代码中,foo() 是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其他规则
  3. 这条规则也解释了上面 count 的代码中,为什么函数里面的this指向了window,因为 foo属于独立函数调用的,触发了默认绑定,从而指向全局window。(浏览器中全局是 window对象,node 中是空对象{})
  4. 属于默认绑定规则的还有:
    • 函数调用链(一个函数又调用另外一个函数)
    • 将函数作为参数,传入到另一个函数中

(扩展:如果使用严格模式(strict mode),则不能将全局对象用于默认绑定,因此 this 会绑定到 undefined:)

结论:默认绑定的 this,都指向全局。

隐式绑定

一般的对象调用

这一条需要考虑的规则是调用位置是否有上下文对象,或者说是通过某个对象发起的函数调用

function foo() {
  console.log(this.a)
}

const obj = {
  a: 2,
  foo: foo
}

// 通过 obj 对象调用 foo 函数
obj.foo() // 2
  1. 调用位置会使用 obj 上下文来引用函数,因此你可以说函数被调用时 obj 对象“拥 有”或者“包含”它。
  2. foo() 被调用时,它的前面确实加上了对 obj 的引用。当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。因为调用 foo()this 被绑定到 obj 上,因此 this.aobj.a 是一样的。

对象属性引用链

对象属性引用链中只有上一层或者说最后一层在调用位置中起作用。举例来说:

function foo() {
  console.log(this.a)
}

var obj2 = {
  a: 2,
  foo: foo
}

var obj1 = {
  a: 1,
  obj2: obj2
}

obj1.obj2.foo() // 2

最终 this 指向的是 obj2

隐式丢失

一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把 this 绑定到全局对象或者 undefined 上(取决于是否是严格模式)

第一种情况:将对象里的函数赋值给一个变量

function foo() {
  console.log(this.a)
}

var obj = {
  a: 2,
  foo: foo
}

var bar = obj.foo // 函数别名!

var a = 'global' // a 是全局对象的属性
bar() // "global"

虽然 barobj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,因此此时的 bar() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定

第二种情况:传入回调函数时

function foo() {
  console.log(this.a)
}

function doFoo(fn) {
  // fn 其实引用的是 foo
  fn() // <-- 调用位置!
}

var obj = {
  a: 2,
  foo: foo
}

var a = 'global'  // a 是全局对象的属性
doFoo(obj.foo)    // "global"

参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一 个例子一样。

结论:隐式绑定的 this,指向调用函数的上下文对象。

显示绑定

使用 call(…) 和 apply(…)

如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么做呢? 可以使用函数的 call(..)apply(..) 方法

  • JavaScript 提供的绝大多数函数以及你自 己创建的所有函数都可以使用 call(..)apply(..) 方法。

这两个方法是如何工作的呢?它们的第一个参数是一个对象,是给 this 准备的,接着在调用函数时将其绑定到 this。因为你可以直接指定 this 的绑定对象,因此我们称之为显式绑定。 思考以下代码:

function foo() {
  console.log(this.a)
}

var obj = {
  a: 2
}

foo.call(obj) // 2
  1. 通过 foo.call(..),我们可以在调用 foo 时强制把它的 this 绑定到 obj 上。
  2. 如果你传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作 this 的绑定对 象,这个原始值会被转换成它的对象形式(也就是 new String(…)、new Boolean(…) 或者 new Number(…))。这通常被称为“装箱”。

从 this 绑定的角度来说,call(…) 和 apply(…) 是一样的,它们的区别体现在参数上:第一个参数是相同的,后面的参数,call为参数列表,apply为数组,他们内部的实现原理也不难理解,详细请看以下两个手写方法

硬绑定(一个函数总是显示的绑定到一个对象上)

由于硬绑定是一种非常常用的模式,所以 ES5 提供了内置的方法 Function.prototype.bind, 它的用法如下

function foo(num) {
  console.log(this.a, num)
  return this.a + num
}

var obj = {
  a: 2
}

// 调用 bind() 方法,返回一个函数
var bar = foo.bind(obj)

var b = bar(3) // 2 3

console.log(b) // 5

调用 bind(...) 方法,会返回一个新函数,那么这个新函数的 this,永远指向我们传入的obj对象

注意:bind(...) 方法不会立即执行。

关于 bind 方法的简单实现,可以前往:手写 bind 方法,超级详细 ⚡

API调用的 “上下文(内置函数)

第三方库的许多函数,以及 JavaScript 语言和宿主环境中许多新的内置函数,都提供了一个可选的参数,通常被称为“上下文”(context),其作用和 bind(..) 一样,确保你的回调函数使用指定的 this。例如:

(1)数组方法 forEach()

function foo(el) {
  console.log(el, this.id)
}

var obj = {
  id: 'bin'
};

[1, 2, 3].forEach(foo, obj)

// 输出:
// 1 bin 
// 2 bin 
// 3 bin
  • 调用 foo(…) 时把 this 绑定到 obj 身上

(2)setTimeout()

setTimeout(function() {
  console.log(this); // window
}, 1000);
  • 在使用 setTimeout 时会传入一个回调函数,而这个回调函数中的this一般指向 window,这个和 setTimeout 源码的内部调用有关,这个不再展开赘述

结论:显式绑定的 this,指向我们指定的绑定对象。

new 绑定

在 JavaScript 中,普通函数可以使用 new 操作符去调用,此时的普通函数则被称为 “构造函数”。没错,凡是由 new 操作符调用的函数,都称为 “构造函数”

使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。

  1. 在内存中创建一个新对象。
  2. 这个新对象内部的[[Prototype]] 特性被赋值为构造函数的 prototype 属性。
  3. 构造函数内部的this 被赋值为这个新对象(即this 指向新对象)。
  4. 执行构造函数内部的代码(给新对象添加属性)。
  5. 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
function foo(a) {
  this.a = a
}

var bar = new foo(2)

console.log(bar.a) // 2

使用 new 来调用 foo(..) 时,我们会构造一个新对象并把它绑定到 foo(..) 调用中的 this 上。new 是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定

结论:new 绑定的 this,都指向通过 new 调用的函数的实例对象(就是该函数)

绑定规则的优先级

现在我们已经了解了函数调用中 this 绑定的四条规则,你需要做的就是找到函数的调用位置并判断应当应用哪条规则。但是,如果某个调用位置可以应用多条规则呢?所以就需要有绑定规则的优先级。

它们之间的优先级关系为:

默认绑定 < 隐式绑定 < 显示绑定(bind) < new绑定

这里提前列出优先级,想看详细代码解析的可以往下看,也可以直接拖到最后面的例题部分

默认绑定的优先级最低

毫无疑问,默认规则的优先级是最低的,因为存在其他规则时,就会通过其他规则的方式来绑定this

隐式绑定和显式绑定的优先级比较

测试一下即可知道,有以下代码:

function foo() {
  console.log(this.a)
}

var obj1 = {
  a: 1,
  foo: foo
}

var obj2 = {
  a: 2,
  foo: foo
}

// 同时使用隐式绑定和显示绑定
obj1.foo.call(obj2) // 2

可以看到,输出的结果为 2,说明 foo 函数内 this 指向的是 obj2,而 obj2 是通过显示绑定调用的,所以:显示绑定的优先级更高

隐式绑定和 new 绑定的优先级比较

有以下测试代码:

function foo() {
  console.log(this);
}

var obj = {
  title: "juejin",
  foo: foo
}

// 同时使用隐式绑定和new绑定
new obj.foo(); // foo对象

最后 foo 函数输出的 this 为 foo 对象,说明new绑定优先级更高(否则应该输出 obj 对象),所以:new 绑定的优先级更高

new 绑定和显示绑定的优先级比较

最后,new 绑定和显式绑定谁的优先级更高呢?

new绑定和call、apply是不允许同时使用的,只能和 bind 相比较,如下:

function foo() {
  console.log(this)
}

var obj = {
  title: "juejin"
}

var foo = new foo.call(obj);  // 直接报错

但是 new 绑定可以和 bind 方法返回后的函数一起使用

function foo() {
  console.log(this);
}

var obj = {
  title: "juejin"
}

var bar = foo.bind(obj);

var foo = new bar(); // foo 对象, 说明使用的是new绑定

最后 foo 函数输出的 this 为 foo 对象,说明new绑定优先级更高(否则应该输出 obj 对象),所以:new 绑定的优先级更高

优先级结论:默认绑定 < 隐式绑定 < 显示绑定(bind)< new绑定

判断this

现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的 顺序来进行判断:

  1. 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。 var bar = new foo()
  2. 函数是否通过 call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是 指定的对象。 var bar = foo.call(obj2)
  3. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上 下文对象。 var bar = obj1.foo()
  4. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定 到全局对象。 var bar = foo()

就是这样。对于正常的函数调用来说,理解了这些知识你就可以明白 this 的绑定原理了。 不过……凡事总有例外

绑定例外

规则总有例外,这里也一样。在某些场景下 this 的绑定行为会出乎意料,你认为应当应用其他绑定规则时,实际上应用的可能是默认绑定规则。

箭头函数

箭头函数不使用 this 的四种标准规则,而是根据外层(函数或者全局)作用域来决定 this。

忽略的this

如果你把 null 或者 undefined 作为 this 的绑定对象传入 call、apply 或者 bind,这些值在调用时会被忽略,实际应用的是默认绑定规则:

function foo() {
  console.log(this.a)
}

var a = 2

foo.call(null) // 2
foo.call(undefined) // 2
foo.bind(null)();

最后输出的结果都是 2,说明 this 指向的是全局 window

间接引用

另一个需要注意的是,你有可能(有意或者无意地)创建一个函数的间接引用,在这种情况下,调用这个函数会应用默认绑定规则。 间接引用最容易在赋值时发生:

function foo() {
  console.log(this.a)
}
var a = 2
var o = { a: 3, foo: foo }
var p = { a: 4 }

o.foo(); // 3

// 函数赋值
(p.foo = o.foo)()  // 2

赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,因此调用位置是 foo() 属于独立函数调用,而不是 p.foo() 或者 o.foo()。根据我们之前说过的,这里会应用默认绑定。

总结

如果要判断一个运行中函数的 this 绑定,就需要找到这个函数的直接调用位置。找到之后就可以顺序应用下面这四条规则来判断 this 的绑定对象。

  1. 由 new 调用?绑定到新创建的对象。
  2. 由 call 或者 apply(或者 bind)调用?绑定到指定的对象。
  3. 由上下文对象调用?绑定到那个上下文对象。
  4. 默认:在严格模式下绑定到 undefined,否则绑定到全局对象。

文章作者: QT-7274
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 QT-7274 !
评论
  目录