this 到底指向哪里

1,210 阅读8分钟

this 算是 JS 中最复杂的机制之一了,很多时候我们明明觉得,我已经理解了这个关键字,但是实际用来的时候,还是有时候不明白这个 this 到底指向哪里,今天我们就来捋一捋,希望能对你有帮助。

两个误解

其实刚开始学的时候,我也有过疑惑,这个关键字叫 this,那顾名思义,是不是意味着 this 指向自身呢?其实对 this的误解比较常见的有以下两种。

误解一:指向函数自身

初学者刚接触的时候,可能会觉得 this 就是指向函数本身,这就犯了一个错,太执着于 this 的字面含义了。

我们来看看下面这个例子:

例 1:

function foo(num) {
  console.log( "foo: " + num ); // 记录 foo 被调用的次数
  this.count++; 
  console.log(this); // Window
}
foo.count = 0;
var i;
for (i=0; i<10; i++) {
  if (i > 5) {
    foo( i ); 
  }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9

console.log('count:', foo.count)
// count: 0

在这个 foo 函数中有一个属性 count,我们期望 foo 每次被调用时,count 都能+1,以此来记录 foo 函数调用的次数。

但我们看到,结果并不像预期的那样。可以看到 foo 函数被调用了 4 次,但是 foo.count 却依旧为 0 ,这足以证明 this 指向函数自身是错误的。

但是 foo 上确实有 count 属性呀,那为什么this.count++没有产生预期的效果呢?这是因为这个 this 是指向 window 的,这样的操作在无意间创建了一个全局变量 count,而它的值是 NaN,自然无法修改到 foo 中的 count 属性。

误解二:指向函数的作用域

还有一个误解则是 this 指向了函数的作用域。但事实上,在任何情况下, this 都不会指向函数自身的作用域。

我们再来看另一个例子

例 2:

function foo() {
  var a = 2;
  this.bar(); 
}
function bar() {
  console.log('this.a:', this.a ); // this.a: undefined
}
foo();

咋一看,感觉输入的 a 好像就是 2呀,其实不然,这段代码想联通 foobar 之间的作用域,从而能使 bar 中也能访问到 foo 中定义的 a,但事实上是不行的,不要把 this作用域混为一谈。

四条规则

分析出 this 指向哪里也没有那么难,this 的指向其实是遵循以下四条规则的,碰到实际情况,只需要将这四条规则往里一套就可以了。

请注意,this 是在运行时绑定,而非定义时绑定,运行的上下文不同,this 的指向也会不同,可以简单理解为:谁最后调用了的, this 就指向谁

规则一:默认绑定

默认绑定可以认为是一条最基础的规则,在其他规则无法匹配的情况下,就会匹配到这条规则。

可以认为,在函数调用时,前面没有某个对象在调用,那基本就是默认绑定了,当然像使用 bind 这种去强行改变 this 指向的是例外。

我们可以看看下面这个简单的例子。

例 3:

function foo() {
  console.log(this); // Window
  console.log( this.a ); // 2
} 
var a = 2;
foo(); 

在调用 foo 函数的时候,this.a 采用了变量 a 的值,这是因为 foo 在被调用时,并没有谁在调用 foo,默认为全局对象 Window 在调用,即 Window.foo(), 可以看到其中的 this 也是指向 Window 的,而 var 定义的变量 a,也是挂在全局对象 Window 上面的,所以 this.a 就被解析成了变量 a

当然这是在非严格模式下,在严格模式下这个 this 可不是指向 Window 哦,而是 undefined

这里提一嘴,如果 alet/const 定义的,那 a 就不是挂载在 Window 上了哦,而是在块级作用域中,那 this.a 就是 undefined 了。

规则二:隐式绑定

隐式绑定也很好理解,就是函数被某个对象调用了,那么 this 就是绑定到这个对象上。如果链式调用的话,那么只认最后调用的那个对象。

我们再来举个例子

例4:

function foo() {
  console.log( this );  // {a: 2, foo: ƒ}
  console.log( this.a ); // 2
}
var obj = {
  a: 2,
  foo: foo 
};
obj.foo();

这里 foo 是被 obj 调用的,那么函数中的上下文就被绑定到了 obj 上了,即 this 指向了 obj 了。所以 this.a 就是 2 了。

我们再来看看另一个例子。

例5:

function foo() {
  console.log( this ); // {a: 42, foo: ƒ}
  console.log( this.a ); // 42
}
var obj2 = { 
  a: 42,
  foo: foo 
};
var obj1 = {
  a: 2,
  obj2: obj2
};
obj1.obj2.foo(); 

这就是一个链式调用,可以看到 foo 函数最后还是被 obj2 调用的,所以 foo 内部的 this 也指向了 obj2,谨记,this 只认最后调用它的那个对象。

我们来看一下一种迷惑性很强的情况

例6:

function foo() {
  console.log( this.a ); // "oops, global"
}
var obj = {
  a: 2,
  foo: foo 
};
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a 是全局对象的属性 
bar(); 

这里 foo 函数是 obj 的一个属性,然后给了 foo 函数一个别名 bar,再直接调用 bar 函数,咋一看,这不是妥妥的隐式调用吗,为什么 a 输出的却是 oops, global 呢?

还记得我们之前说过的默认绑定吗?这里只是将 foo 函数的引用赋值给了 bar,但是并没有调用哦,在调用的那一刻,前面并没有任何对象在调用,所以这就是匹配到了隐式绑定的规则了, this 指向的是 Window

规则三:显示绑定

通过 call 或者是 apply 等方法,可以强行的将 this 指向某个对象。

例7:

function foo() {
  console.log( this.a ); // 2
}
var obj = { a:2 };
foo.call( obj );

在这个例子中,在通过 foo.call( obj )强行将 foo 中的 this 指向 obj,而无须像隐式绑定一样,obj 必须包含 foo 属性才行。

规则四:new 绑定

我们先来看看 new 的时候究竟干了些什么

  1. 创建一个全新的对象
  2. 新对象的原型对象指向构造函数的原型属性
  3. 将 this 指向这个新对象
  4. 如果构造函数返回了一个对象,就将该返回值返回,如果返回值不是对象,就将创建的新对象返回

这里也简单列一下 new 的实现方式

 function _new() {
   var o = new Object() // 创建一个新的对象
   let [constructor, ...oArgs] = [...arguments] // 传入参数第一个是构造函数,后面的是构造函数的参数
   o.__proto__ = constructor.prototype   // 执行构造函数 将构造函数的原型赋给 实例对象的__proto__这样构造函数的属性就全都给实例了
   let resultObject = constructor.apply(o,oArgs) // 将构造函数的this指向创建的对象
   if (resultObject && typeof resultObject == Object || typeof resultObject == "function") {
       // 如果构造函数的执行结果返回的是一个 对象 那么就返回这个对象
       return resultObject
  }
   // 如果构造函数返回的是正常我们常见的那种(不是对象), 那么返回这个新创建的对象
   return o
 }

可以看出,其实 new 内部也是利用 call 或者是 apply 等方式,将 this 指向创建的新对象。

例8:

function foo(a) {
  this.a = a; 
}
var bar = new foo(2); 
console.log( bar.a ); // 2

关于隐式绑定 this 丢失的问题

这里再详细讲一下例 6 吧,当时我第一次看到的时候也是困惑不已。

function foo() {
  console.log( this.a ); // "oops, global"
}
var obj = {
  a: 2,
  foo: foo 
};
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a 是全局对象的属性 
bar(); 

再看一个变种

例 9:

function foo() {
  console.log( this.a ); 
}
function doFoo(fn) {
  // fn 其实引用的是 foo 
  fn(); 
}
var obj = {
  a: 2,
  foo: foo 
};
var a = "oops, global"; // a 是全局对象的属性 
doFoo( obj.foo ); // "oops, global"

在例 6 中,给了 obj.foo 一个别名 bar ,然后再去执行 bar(),执行的结果还是 this 指向了全局变量 Window,从而输出的结果是 oops, global

而在例 9 中,是将 obj.foo作为一个参数传入了 doFoo 中,再执行 doFoo( obj.foo ),但是结果也是一样的。因为参数的传递就是一种隐式的赋值,和例 6 中的 var bar = obj.foo是一个道理。

这其实就是出现了 this 丢失的问题,this 并没有指向包含这个函数 foo 的对象 obj,从而应用了默认规则,将 this 绑定到了全局对象 Window 或者是 undefined (严格模式) 上。

还不理解的话可以看一看 ruanyifeng 大佬的这篇 《JavaScript 的 this 原理》,这里我也大概讲述一下。

在这个示例中,将对象{foo: 5 } 赋值给了变量 obj,但是大家都知道, obj 只是一个地址,指向了这个{foo: 5 } 的内存地址。

原始的对象以字典结构保存,每一个属性名都对应一个属性描述对象

属性描述对象长这样:

{
  foo: {
    [[value]]: 5
    [[writable]]: true
    [[enumerable]]: true
    [[configurable]]: true
  }
}

这是值为基本类型的情况,那如果 foo 的值是函数呢,那就会只这样的了。

var obj = { foo: function () {} };

function 是存在一个单独的地址里,所以它可以在不同的上下文执行。

回到例 6,执行var bar = obj.foo的时候,bar 其实也是一个指向 foo 函数的内存地址,bar() 是在全局的环境执行,所以输出的 this.a 也就是 oops, global

这样理解是不是就清晰很多了。

参考资料

《JavaScript 的 this 原理》

《你不知道的JavaScript》