阅读 1021

一文看穿JavaScript中this的圈圈绕

导文目录


  • 为什么说JavaScript中 this 指针圈圈绕?
  • JavaScript 中 this 绑定作用域的四种情况
    • 先搞清Node环境中和浏览器环境中全局对象的异同
    • 默认绑定
    • 隐式绑定
    • 硬绑定(或者说 显示绑定)
    • new操作符绑定
    • 四种绑定的优先级
  • ES6中引入箭头函数对this的绑定产生了什么影响?
  • 附上前面程序的输出答案

为什么说JavaScript中 this 指针圈圈绕?


相比C++或者Java中的this指针的概念而言,JavaScript中的this指针更为 "灵活" ,C++或Java中的 this在类定义时便成为了一个指向特定类实例的指针,但是JavaScript中的this指针是可以动态绑定的,也就是说是依据上下文环境来指定this到底指向谁。这样的机制给编程带来了很大的灵活性(以及趣味性),但这也导致了在JavaScript编程中若不明白this指针的作用机制而滥用this指针的话,常常会引发一些 "莫名其妙" 的问题。比如说,下面这段程序:

1. let num = 10;
2. console.log(global.num); 
3. global.num = 20;
4. console.log(this.num);
5. console.log(this === global); 
6. function A(){
7.    console.log(this.num++);
8. }
9. let obj = {
10.     num : 30,
11.     B : function(){
12.         console.log(this.num++);
13.         return () => console.log(this.num++);
14.      }
15. }
16. A();
17. let b = obj.B; 
18. b()();  
19. obj.B();   
20. b.apply(obj);
21. new A(); 
22. console.log(global.num); 
复制代码

你能列出最终所有的输出吗?你可以先尝试着写一下,不要复制到VSCode中运行哦~ ,手动写出答案!写完先看一下最后面的答案,看你是否写对了。如果写对了说明你已经基本掌握了JavaScript中this指针的机制 (PS:设定这里运行环境是node环境 ;如果没有写对,那看完本文相信就可以对this有一个基本清楚的认识了。

相信你肯定忍不住去看了答案了,或许答案看起来杂乱无章,这也是为什么this作为JavaScript中最复杂的机制之一经常被拿到面试中去考察JS的功底。以下内容可能需要花费8-10分钟时间,但是会让读者你受益匪浅的,你的疑问也可以在下面的内容中得到解答!

JavaScript 中 this 绑定作用域的四种情况


先搞清Node环境中和浏览器环境中全局对象的异同

在讲解this绑定作用域的四种情况之前,我们先要弄清楚一个问题。Node环境中的全局作用域和浏览器环境下的全局作用域有什么不同?

这个问题很重要,因为这个异同,会导致同样的代码在Node环境和浏览器环境下的表现不尽相同。就比如我们这里要讲的this指针的指向会因为环境不同而不同。这个不同体现在以下三点:

  • 浏览器的全局作用域的全局对象是window ; 而Node中这个"等价"的全局对象是global
  • 浏览器环境下全局作用域下的this指向的就是window对象; 但是Node环境下全局作用域中的thisglobal是分离的,this指针指向一个空对象
  • 浏览器环境下全局作用域中声明的变量会被认为是全局对象window的属性;但是Node下全局作用域下的声明的变量不属于global

由此,你便可知,上面代码中1-5的输出了,就像下面这样:

undefined  // 1 
undefined  // 2
false      // 3
复制代码

为了方便讲解,我给每个输出编了号,我们依次来看:

  1. 第一个undefined是因为Node的全局作用域上的变量并不会作为global的属性,此时global.num尚未赋值,所以是undefined
  2. 第二个undefined是因为Node中全局作用域中的this并不指向global,所以此时this.num尚未赋值,所以也是undefined
  3. 第三个false也更加应证了 2 中的结论,Node中全局作用域的thisglobal风马牛不相及

【PS】上面我一直强调是全局作用域下的this ,为什么呢?因为Node中在子作用域中的this的行为和浏览器中是相仿的,基本一致

默认绑定

下面我们来讲解JavaScript中this绑定作用域的四种情况。

先来说第一种——默认绑定 ,我们可以这样理解 默认绑定 ,this指针在作用域内没有认领的对象时就会默认绑定到全局作用域的全局对象中去,在Node中就是会绑定到global对象上去。这是一个很形象的说法,虽然不严谨但是好理解,我们看下面这几个例子,来说明什么情况下,this没有对象认领。

global.name = 'javascript';
(function A(){
    this.name += 'this';
    console.log(this.name);//输出 javascriptthis
})();
console.log(global.name);//输出 javascriptthis
复制代码

在函数A的作用域内,this并没有可以依靠的对象,所以this指针便开启默认绑定模式,此时指向的是global

这里我们有必要明确一个概念,有别于JavaScript中"一切皆为对象"的概念,虽然A确实是一个Function类型的对象 , 下面的例子可证明确实如此

function A(){}
console.log(A instanceof Function); //输出 true
console.log(A instanceof Object);   //输出 true
复制代码

但是function A(){}只是一个函数的声明,并没有实例对象的产生,而this是需要依托于一个存在的实例对象 , 如果使用new A()则便有了实例对象,this也就有了依托,有关new操作符绑定在后面说。

明白了这一点,我们来看一个更为复杂的例子:

global.name = 'javascript';
(function A(){
    this.name += 'this';
    return function(){
        console.log(this.name);//输出 javascriptthis
    }
})()();
console.log(global.name);//输出 javascriptthis
复制代码

这个例子中函数A返回了一个匿名函数也可以叫闭包,我们发现this照样绑定在了global上。这个例子是想告诉读者,默认绑定和作用域层级没有关系,只要是在作用域内this找不到认领的实例对象,那就会启用默认绑定。

由此,你是不是可以知道开篇的例子中 6,7,8,16行的输出结果了?

20  //这里是后置自增运算,所以先输出后加一
复制代码

隐式绑定

隐式绑定顾名思义没有显式的表明this的指向,但是已经绑定的某个实例对象上去了。举个简单的例子,这个用法其实是我们最常用的:

global.name = 'javascript' ;
let obj = {
    name : 'obj',
    A    : function(){
        this.name += 'this';
        console.log(this.name); 
    }
}
obj.A();//输出 objthis
console.log(global.name);//输出 javascript
复制代码

这个例子中函数A的作用域内,this总算是有对象认领了,这个对象就是obj,所以this.name指向的就是obj中的name ,这种情况就叫做隐式绑定

隐式绑定虽然是我们最常用的,也是相对好理解的一种绑定方式,但是确是四种绑定中最坑的一种,为什么呢?因为,这种情况下this一不小心就会找不到认领她的对象了,我们称之为"丢失"。而在"丢失"的情况下,this的指向会启用默认绑定。我们看下面的例子;

global.name = 'javascript' ;
let obj = {
    name : 'obj',
    A    : function(){
        this.name += 'this';
        console.log(this.name)
    },
    B    : function(f){
        this.name += 'this';
        f();
    },
    C    : function(){
      setTimeout(function(){
          console.log(this.name);
      },1000);
    }
}
let a = obj.A;              // 1
a();                        // 2
obj.B(function(){           // 3
    console.log(this.name); // 4
});                         // 5
obj.C();                    // 6
console.log(global.name);   // 7
复制代码

这里列出了三种"丢失"的情况:

  1. 1-2行中obj的A函数赋值给了a,然后调用a(),这时候函数的执行上下文发生了变化,相当于是全局作用域下的一个函数的执行,所以承接我们上面所说,此时启用了默认绑定
  2. 3-5行中给obj.B传递一个Function参数,并且在Bf()执行,这相当于一个B中的立即执行函数,此时在this所在作用域找不到认领的对象,于是启用默认绑定
  3. 6行是最有意思的一行,为什么呢?因为这一行在Node环境浏览器环境下的结果是不一样的,按照常理来说,回调函数中的this同样会因为丢失而启用默认绑定,在浏览器环境下确实如此。但是在node中事情好像没那么简单,我们先看看输出的结果,在做分析
javascriptthis // 1-2行执行结果
javascriptthis // 3-5行执行结果
javascriptthis // 7行执行结果
undefined      // 6行执行结果
复制代码

你会发现有一个值很扎眼,没错,就是undefined,那为什么setTimeout()回调中的this没有启用默认绑定呢?这里根据这篇博客做了一个猜想 : NodeJS 回调函数中的this ,我建议你看一看这篇博客

亦如fs.open()回调一样,setTimeout()函数会先初始化自己,那么此时回调函数作用域上就是存在实例对象了,只是这个对象我们看不到而已,所以此时this.name并未初始化,所以输出undefined。为此我做了一个实验来证明,setTimeout()this指向不等于global

function A(){
    console.log(this === global);
}
A();  //输出  true
setTimeout(function(){
    console.log(global === this);
},1000);  // 输出  false
复制代码

由此,我们可以知道,开篇例子中18,19行的输出便是:

21 // 隐式绑定丢失
22 // 箭头函数绑定上级作用域this指针,这个后面会讲
30 //隐式绑定
复制代码

硬绑定(显式绑定)

接下来要讲的是硬绑定 , 这个比较简单,是通过JS的三个常用API来显式的实现绑定特定作用域,这三个API为

  • apply
  • call
  • bind

这三个API之间的关系非本篇关键,可以自行了解,本篇以apply为例

我们知道JS函数的一大特点就是有 定义时上下文运行时上下文 以及 上下文可变 的概念,而apply就是帮助我们改变函数运行时上下文的环境,这种通过API显式指定某个函数执行上下文环境的绑定方式就是 硬绑定

我们来看下面这个例子:

global.name = 'global';
function A(){
    console.log(this.name);
}
let obj = {
    name : 'obj'
}
A.apply(obj); //输出 obj
A.apply(global); //输出 global
复制代码

对,你应该懂了,什么叫硬绑定。就是不管函数你定义在哪里,这样使用了我这个API,你就可以为所欲为,绑定到任意作用域的对象上去,哪怕是global都不带怕的,硬核API !!!

由此,你也可以得到开篇例子中20行输出结果应该是:

31  //obj.name在此之前被加了一次1,所以这里是31
复制代码

new操作符绑定

最后一种绑定方式是new操作符绑定,这个也是JS中最常用的用法之一了,简单来说就是通过new操作符来实例化一个对象的过程中发生了this指针的绑定,这个过程是不可见的,是后台帮你完成了这一绑定过程。具体是什么过程呢?这里我们就已开篇的例子为例吧

function A(){
    console.log(this.num++);
}
new A(); //输出为 NaN
复制代码

NaN是JSNumber对象上的一个静态属性,意如其名"not a number",表示不是数字。这里new A()实例化了一个对象,此时在A的作用域里就用对象认领this指针了,所以此时this指向实例化对象,但是这个对象中num属性并没有初始化,因此是undefined,而undefined非数字却使用了++运算,因此最终输出了NaN

四种绑定的优先级

既然this的绑定有四种机制,那必定会出现机制冲突的情况,没关系,其实从上面的讲解中你应该已经能隐约感觉到这四种机制是有优先级存在的。比如,在new操作符绑定的时候,就是因为new绑定优先级高于默认绑定,所以this指针指向的是新实例化的对象而不是全局对象global。这里给出这四种绑定的优先级 :

 new 操作符绑定   >    硬绑定   >    隐式绑定   >    默认绑定

这个关系还是挺明显的,故不作例子阐述了。

ES6中引入箭头函数对this的绑定产生了什么影响?


快要结束了,再坚持一下,最后有必要说明以下ES6中的箭头函数对于this指针绑定的影响,ES6中引入箭头函数是为了更优雅的书写函数,对于那些简单的函数我们使用箭头函数代替原来的函数写法可以大大简化代码量而且看上去更加整洁优雅。也正是因为箭头函数的设计是为了简洁优雅,所以箭头函数除了简化代码表示以外,还简化了函数的行为。

  • 箭头函数不能声明的方式定义,只能通过函数表达式
  • 箭头函数不能通过new来实例化对象
  • 也是因为上面的原因,箭头函数中并没有自己的this指针,这不代表不能使用this,箭头函数中的this是继承自父级作用域上的this,也就是说箭头函数中的this绑定的是父级作用域内this所指向的对象

举个例子来讲:

name = 'global';
this.name = 'this';
let obj = {
    name : 'obj',
    A    : function(){
        ()=>{
            console.log(this.name)
        }
    },
    B    :() => {
        console.log(this.name)
    }
}
obj.A(); //输出 obj
obj.B(); //输出 this 
复制代码

这里或许obj.B()输出让你疑惑,其实我们开篇也讲了,全局作用域下thisglobal风马牛不相及,所以这里对应到父级作用域中this对应的对象就是this本身或者export

由此,开篇示例中18行的输出便可知晓了 :

21 // b() 所输出
22 // (b())()所输出
复制代码

这里有些绕,之所以最终this绑定到了global上,是分了两步

  • 首先,因为是箭头函数,所以this继承父级this绑定到了obj
  • 因为隐式调用的"丢失",导致父级this默认绑定到了global

附上前面程序的输出答案

undefined
undefined
false
20
21
22
30
31
NaN
23
复制代码

总算是写完了,写作的过程,笔者收获也很大,就比如Node中回调函数的this指向问题我也没有想到,是通过实验才印证Node中回调函数中this指向的是自身实例化的对象,这个工作同样不可见,后台完成了,就像new一样。 希望读者也可以得到收获!

下面是我的微信公众号,如果觉得本篇文章你收获很大,可以关注我的微信公众号,我会同步文章,这样可以RSS订阅方便阅读,感谢支持!