手写面试题三:深入理解 js this 绑定

364 阅读12分钟

转载请注明原文链接。原文链接

手写面试题系列是我为了准备当下和以后的面试而编写的文章系列,当然对于前端小伙伴也有帮助。我建议读完之后,自己动手敲代码或者手写一遍才能更好地掌握。

参考文献:深入理解 js this 绑定

一、什么是this?

首先,我给出结论。

  1. this是一个属性,它总是指向一个对象(非严格模式);
  2. 全局环境(全局上下文)中,this指向的是全局对象,浏览器中它指向window对象,node环境中它指向global对象;
  3. 函数环境(函数内部)中,this的值取决于函数被调用的方式
  4. this 不能在执行期间被赋值;

根据上面的结论,读者应该很好地理解了第一点和第二点,对于第三点可能抱有疑问。第三点也是本文的关键,同时也是实际工作中、面试中重点会设计到的。

二、函数中的this

首先,先思考下下面的代码的输出情况:

var person = {
 name: "承太郎",
 getName: function() {
  console.log(this.name);
 },
};

person.getName(); // 输出?

var person1 = person.getName();
person1(); // 输出?

要很好地理解this到底指向什么,就不得不提到this的绑定规则,掌握了以下4种绑定规则,就可以掌握函数调用时this的指向。

1. 默认绑定

看下下面的例子:

function foo(){
    var a = 1 ;
    console.log(this.a);  
}
var a = 10;
foo(); // 10

这里的例子就是典型的默认绑定,直接调用时,this指向了window。所以this.a实际上就是window.a,而在函数外部执行的代码var a = 10;实际上也把a设置到了window对象上。所以,最后输出的结果为10。 换个角度来说,可以认为foo()实际上调用的是window.foo(),而foo函数内部的this指向的是window,所以this.a指的是window.a,也就是10。

简单理解就是:像 这种直接使用而不带任何修饰的函数调用 ,就 默认且只能 应用 默认绑定;默认绑定一般绑定到window上,严格模式下绑定到undefined

把上面的例子改造一下,再思考一下输出会是怎样的: 注意:把下面的代码复制到浏览器控制台运行是,请刷新页面,避免之前运行的代码造成的影响。

function foo(){
    var a = 1 ;
    console.log(this.a);  
}
let a = 10; // var 改成 let
foo(); // 输出?

相信部分读者应该能得出结论,输出的是undefined。我们知道运行foo()时,this.a实际访问的是window.a,但是运行代码let a = 10; 时并不会把a赋值到window上(let和const声明的变量不会同时赋值到window上,var声明或者不声明的变量会挂在到window上),所以window.a并不存在。

换成严格模式呢?

"use strict";
function foo(){
    var a = 1 ;
    console.log(this.a);  
}
var a = 10;
foo(); 

继续按照上面的思路分析。首先严格模式下this指向的是undefined,所以this.a实际上访问的是undefined.a,undefined上不存在a,所以会报错。

2. 隐性 绑定

再看下这个例子:

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

obj.foo();            // ?

输出结果是:undefined、10。

结合上面的理解,我们知道foo()运行时,相当于运行了window.foo(),函数内部的this指向的是window,而 window上并没有属性a,所以结果为undefined。

obj.foo()呢?我们结合结果推理一下,就能知道,obj.foo()运行时,函数内部的this指向的是obj对象,所以this.a实际上指的是obj.a。我们可以在上面的例子中加上一些代码,验证我们的推理。

再看下这个例子:

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

obj.foo();            // 10

obj.a = 20;
obj.foo();            // ?

按照猜想,函数内部的this.a指向的是obj.a,这时候给obj.a赋值为20,所以obj.foo()输出的结果也是20。实际运行,结果也符合我们的猜想。

上面的例子中obj.foo()就是隐性绑定。函数中this的指向了函数的上级。如果是类似于a.b.c.foo()的链式关系呢,此时的是指向它的直接上级c还是上级a?

同样可以举例去求证:

function foo() {
 console.log(this.a);
}
var obj = {
    a : 10,
    b: {
        a: 100,
        foo: foo,
    },
    foo : foo
}
foo();                // ?

obj.foo();            // ?
obj.b.foo();          // ?

输出结果是:undefined、10、100。

这里,做一个简单的推理来分析下这段代码。经过前面例子的分析,我们知道obj.foo()运行时,函数foo内部的this指向的是obj,所以this.a就是obj.a,输出10。 而obj.b.foo()运行时,输出的结果是100,正好对应obj.b内部的变量a的值。也就是说,函数内部的this.a === obj.b.a,所以这时,this指向的是obj.b

可以看到。对于链式关系的函数调用,函数内部的this指向的是它的直接上级。

结合默认绑定和隐性绑定,我们可以的出结论:

  1. 函数运行时,函数内部的this指向的是调用它对象,也就是说a.foo()内部的this指向的是a,而foo()内部的this指向的是window;
  2. 链式关系的函数调用时,函数内部的this指向的是函数的直接上级;
3. 显示绑定

所谓显示绑定就是通过内置的call()、apply()、bind()方法来主动改变函数中this的指向。

这里,我不会去讨论call()、apply()、bind()的用法,具体用法参看官方文档:

  1. Function.prototype.call()
  2. Function.prototype.apply()
  3. Function.prototype.bind()

call()的用法为例子:

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

var obj = {
    a : 10            
};
foo.call(obj);        // 10

上面的例子中,如果直接运行foo()的话,函数内部的this指向的是window,使用foo.call(obj)改变了foo()函数内部this的指向,让this指向了对象obj,所以this.a等价于obj.a,输出结果为10。

4. new 绑定

在解释new绑定之前,先简单解释下new。

我们知道new关键字的常用用法是:new constructor[([arguments])]

new关键字会进行如下的操作:

  1. 创建一个空的简单JavaScript对象(即{});
  2. 为步骤1新创建的对象添加属性__proto__,将该属性链接至构造函数的原型对象 ;
  3. 将步骤1新创建的对象作为this的上下文 ;
  4. 如果该函数没有返回对象,则返回this。

再来谈谈new绑定,也就是使用new关键字实例化对象后,创建的对象中的this指向什么? 举例说明:

function foo(){
    this.a = 10;
    console.log(this);
}
foo();                    // window对象
console.log(window.a);    // 10   默认绑定

var obj = new foo();      // foo{ a : 10 }  创建的新对象的默认名为函数名
                          // 然后等价于 foo { a : 10 };  var obj = foo;
console.log(obj.a);       // 10    new绑定

在此之前,结合一下上面的例子来解释下new关键字进行的几步操作。

首先var obj = new foo(); 这行代码中,new关键字做了什么?

  1. 创建了一个空对象{},最终赋值给了变量obj(实际上是对象创建完毕之后才赋值给的变量obj,这样解释便于理解);
  2. 给空对象(可以理解为obj)添加__proto__(隐式原型),并把__proto__指向构造函数foo()的原型对象(foo.prototype)(也就是说,obj.__proto__ === foo.prototype,这意味着obj可以通过原型链使用foo.prototype上的属性);
  3. obj作为函数foo()this的上下文,也就是说函数foo()中的this指向的是obj,那么this.a就是obj.a
  4. 如果函数foo()主动返回一个对象,那么就将第三部的this进行覆盖,把这个对象指向obj(具体请看下面的例子);
function foo(){
    this.a = 10;
    console.log(this);
    return {
   		a: 100
    }
}
var obj = new foo();
console.log(obj.a); //输出:100

按照原型链,访问obj上不存在的属性时,会沿着原型链去寻找。这意味,下面的代码时成立的。

function foo(){
    this.a = 10;
    // return {
   	//	 a: 100
    // }
}
var obj = new foo();
console.log(obj.a); //输出:10
console.log(obj.b); // 输出: undefined
foo.prototype.b = 200;
console.log(obj.b); // 输出: 200

可以看到,上面的代码中在没有运行foo.prototype.b = 200;之前,obj.b的值为undefined,在运行之后值为200。验证了,obj.__proto__ === foo.prototype,obj上没有属性b就会沿着原型链到foo的原型对象(foo.prototype)上寻找属性b。

细心的同学可能会发现,我把return {a: 100 }给注释起来了。因为不注释的话,就相当于把{a: 100 }赋值给了obj。那么obj的隐式原型(__proto__)其实和对象{a: 100 }的隐式原型时一样的。我们可以换个写法验证一下。

const objA = {a: 100};
function foo(){
    this.a = 10;
	return objA;
}
var obj = new foo();
var obj1 = new foo();
console.log(obj === obj1); // true

在上面的例子中,每次new foo()都会返回对象objA,并分别赋值给了变量objobj1。但实际上,objobj1都指向了objA,其实是相等的。 如果函数foo()最终返回的是{a: 100}而不是objA,那么objobj1实际指向的实例化时返回的{a: 100}。但是,2次实例化中创建了2次{a: 100},所以这个时候obj不等于obj1

如果读者可以理解上面的解释,那么new执行的操作就很好理解了,简单来说就是:

new关键字会创建一个空对象({}),然后让this指向这个空对象,继而给this的属性赋值也就是给这个空对象赋值(如上面例子中的this.a = 10;就是给空对象添加了一个属性a,并给a赋值为10),再然后就是把赋值后的对象的__proto__指向构造器的原型对象(上面的例子中就是函数foo().prototype);如果函数最终返回了一个对象,那么前面创建的空对象并通过this给空对象赋值的操作都不会是最终返回的对象,最终返回的是函数return的那个对象。这意味着,上面的例子中实例化函数foo是没有意义的。

解释了那么多new关键字所做的事情,读者也一定能理解所谓的new 绑定就是实例化后的对象从被实例化的函数的this上“继承”了this的属性。

5. this绑定优先级

new 绑定 > 显示绑定 > 隐式绑定 > 默认绑定

三、面试题解析

第一题

var x = 10;
var obj = {
    x: 20,
    f: function(){
        console.log(this.x);        // ?
        var foo = function(){ 
            console.log(this.x);    
            }
        foo();                      // ?
    }
};
obj.f();

答案是:20 10

第一个this.x就是上面所说的隐式绑定this指向的就是调用函数f()的对象obj,所以this.x就是obj.x,所以输出20。 第二个this.x就是默认绑定,第二个this是再内部函数foo()调用的时候输出的,foo()的前面没有任何对象,也就是说foo()中的this指向的是window,所以this.x就是window.x,所以输出10。

第二题

var x = 10;
var obj = {
    x: 20,
    f: function(){ console.log(this.x); }
};
var bar = obj.f;
var obj2 = {
    x: 30,
    f: obj.f
}
obj.f();
bar();
obj2.f();

答案:20 10 30

这一题和上面一样。obj.f()运行时,函数f()中的this指向了obj,所以this.x等价于obj.x,输出20; bar()运行时,等价于window.bar(),所以this指向window,所以this.x等价于window.x,输出10; obj2.f()运行时,this指向obj2,所以this.x等价于obj2.x,输出30。

第三题

function foo() {
    getName = function () { console.log (1); };
    return this;
}
foo.getName = function () { console.log(2);};
foo.prototype.getName = function () { console.log(3);};
var getName = function () { console.log(4);};
function getName () { console.log(5);}
 
foo.getName();                // ?
getName();                    // ?
foo().getName();              // ?
getName();                    // ?
new foo.getName();            // ?
new foo().getName();          // ?
new new foo().getName();      // ?

这一题还是笔者在实际面试中遇到过的真题,而且当时确实不懂挂掉了。 答案是:2 4 1 1 2 3 3

foo.getName()很好解释,运行的第二个函数foo.getName,所以输出2。

getName(),这里的考点在于函数声明会得到提升,所以尽管function getName () { console.log(5);}foo.prototype.getName = function () { console.log(3);};后面,先执行的是函数声明,后执行的是函数表达式,所以var getName = function () { console.log(4);};会覆盖后面的函数声明,所以输出4。

foo().getName()foo()中的this就是window,所以foo().getName()就是window.getName(),但别忘了函数内部执行了getName = function () { console.log (1); };等价于window.getName = function () { console.log (1); };,所以这个时候全局环境中的getName已经被覆盖了。所以输出1。

getName(); ,由于上一步的代码覆盖了全局的getName,所以此时也输出1。

new foo.getName();,我们得知道new关键字,最终得实例化的是一个函数,所以这里new关键词实例化的是函数foo.getName(),所以输出2。

new foo().getName();,不同于上一个,这里new关键字先实例化了foo(),然后实例化后的对象调用了函数getName();我们知道实例化后的对象上并没有函数getName(),所以就往foo.prototype上找,也就是foo.prototype.getName,所以输出3。

new new foo().getName(),仔细分析一下,可以把代码拆分成两步,var obj = new foo(); new obj.getName();,我们知道new foo()返回的是一个普通对象,普通对象是不能像函数一样加上()运行的也不能通过new关键字实例化,所以最终一定是我拆分的2步;这里最终也输出3,不同于上一步是运行了getName(),这一步是实例化了getName(),都是foo.prototype.getName()

四、箭头函数的this绑定

箭头函数不会创建自己的this,它只会从自己的作用域链的上一层继承this。

举例说明:

var foo = () => {
	console.log(this)
}
foo(); // window对象

回顾一下我们最初的例子,

var person = {
 name: "承太郎",
 getName: function() {
  console.log(this.name);
 },
};

person.getName(); // 输出?

var person1 = person.getName();
person1(); // 输出?

现在我们知道,第一个输出的是"承太郎",第二个是undefined。

我们尝试着使用箭头函数来改造一下这个例子。

var person = {
 name: "承太郎",
 getName: function() {
	return () => {
		console.log(this.name);
	};
 },
};

person.getName()(); // 输出“承太郎”

var person1 = person.getName();
person1(); // 输出“承太郎”

对比一下,我们在函数getName内部返回了一个箭头函数,然后person1()就输出了预期的结果“承太郎”。结合上面的官方解释,箭头函数会从自己的作用域链的上一层继承this。所以,改造后的代码中,箭头函数内部的this会继承上一层的person对象上的this,所以此时this.name就是person.name

五、总结

  1. 全局环境的this指向window;
  2. 函数内部的this默认指向全局环境的this,也就是window;
  3. a.foo()类似调用函数时,foo()内部的this指向a,a.b.foo()中的this指向b;
  4. new关键字实例化时,会创建一个新对象,this会指向这个新创建的对象,所以实例化后的对象;
  5. 箭头函数内部没有this,但它会继承上一级作用域中的this。