深入剖析this之后,面试官:想不到你会手写call( )!--JS基础篇(六)

267 阅读10分钟

写在前面

吃透JavaScript:掌握构造函数与原型链的深层奥秘--JS基础篇(五) - 掘金 (juejin.cn)

this是JavaScript中的一个关键字,代表了函数执行时的上下文对象。在讨论this时往往是指函数体内的this,但是其不仅仅局限于函数体内,在全局上下文和对象的成员方法内也都有this的身影,接下来我们就来看看this到底是何方神圣。

this的引入

起初,为了让对象中的函数有能力访问对象中自己的属性,this就出现了。换句话说,如果函数调用的形式是:obj.foo()this就指代obj这个对象。

function getThis(){
    return this;
}

const obj = {
    length : 12,
    size : 50
}
const obj2 = {
    length : 14,
    size : 55
}

obj.getThis = getThis;
obj2.getThis = getThis;

console.log(obj.getThis);
console.log(obj2.getThis)
//结果
/*
{ length: 12, size: 50, getThis: [Function: getThis] }
{ length: 14, size: 55, getThis: [Function: getThis] }
*/

这种机制注定了this不是一个简单的变量,this的值也不是人为可操作的,而是根据函数的调用方式动态确定的。根据结果可以看到,虽然获取this时被调用的是同一个函数,但是this指代的却是两个不同的对象。故,this的值不是属于以this所在函数getThis()为属性的对象,而是调用此函数的对象,因为其是动态的。

这一点我们可以通过对象所在的原型链查看。

const obj3 = {
    length : 16,
    size : 60,
    __proto__ : obj1
}
console.log(obj.getThis());
//{ length: 16, size: 60, getThis: [Function: getThis] }

上面我将obj3的隐式原型属性指向了obj1,这使得obj3能够继承getThis()方法,调用该方法获得指向自身的this。

对于原型链的知识有不懂的朋友可以看我上一篇文章。

  • 注意:上述obj.getThis = getThis;不应该写成obj.getThis = getThis();这里获取的是getThis()函数的引用。

下面我们来梳理一下this在不同环境中所指代的对象。

this的绑定

在谈this之前,有必要将其生效的作用域和传统意义上的词法作用域(Lexical Scope)区分开来,因为对于一个变量或者函数来说,其作用域是由定义位置决定的。而对于this,它是由执行上下文和函数调用方式决定的。

概念

this的绑定:JS确定函数的调用(执行)时this所指代对象的过程。

this的具体绑定规则

1.默认绑定(Default Binding)

当一个函数独立调用时,该函数的this默认绑定到全局对象,对于NodeJS环境来说,全局对象是global,对于浏览器,全局对象是window。在严格模式下,全局对象的this值为undefined。

function sayHello() {
    console.log("this is " + this.name);
}

//非严格模式
name = "Global";
sayHello(); 

// 在严格模式下,this是undefined
function strictSayHello() {
    'use strict';
    console.log("this is " + this.name);
}
strictSayHello(); // 抛出错误,因为this是undefined

直接通过functionName()调用的方式就叫做独立调用。

2.隐式绑定(Implicit Binding)

当函数被绑定到某个对象中,this被绑定到该对象。

const obj = {
    name: "Object",
    sayHello: function() {
        console.log("this is " + this.name);
    }
};

obj.sayHello(); // 输出: this is Object

函数sayHello()作为对象obj的一个属性被调用,此时this被绑定到obj上。

  • 隐式丢失
    隐式绑定丢失是指原本通过隐式绑定确定的this(this的绑定状态),在某些状况下丢失了与预期对象的关联,从而导致this变为遵守默认绑定规则的情况。

    情况1:通过赋值的形式将对象中的方法单独调用时,this会丢失与原对象的绑定状态,变为指向全局对象。

    const obj = {
        name : 'novalic',
        ability : function(){
            console.log(this.name + " like programing.")
        }
    }
    
    const newFunc = obj.ability;
    
    newFunc();//undefined
    

    这里的newFunc()是一个独立调用,this应当按照默认绑定规则指向全局对象。但在当前浏览器中,会输出undedined。原因如下:

    在非严格模式下,按照JS的历史行为,this会指向全局对象(NodeJS中是global,浏览器中是window),但随着ECMAScript标准的改进,现代环境的大多情况下,倾向于让没有明确绑定的this值默认绑定到undefined,减少错误的发生。上述例子是由于newFunc得到的只是函数的引用,而并不是直接调用函数本身,在这个过程中this丢失了与obj对象的绑定关系。

    情况2:当this在回调函数的传递过程中会出现隐式绑定丢失的情况。

    var obj = {
        name: "Object",
        display: function() {
            console.log("this is" + this.name);
        }
    };
    
    setTimeout(obj.display, 1000); //this is undefined
    

    上述display()作为setTimeout的回调函数,但是setTimeout不清楚display的this绑定的对象是谁,故只能为函数display重新绑定this值,于是绑定到了全局对象上。(结果为undefined,原因见情况1)

显式绑定(Explicit Binding)

一种使用call()、apply()或bind()人为设定函数中this绑定的对象的一种手段。

function greet(name) {
    console.log("Hello, " + this.title + " " + name);
}

var user = {title: "Mr."};

// 使用.call()
greet.call(user, "John"); // 输出: Hello, Mr. John

// 使用.apply()
greet.apply(user, ["Jane"]); // 输出: Hello, Mr. Jane

// 使用.bind()创建新函数
var greetMr = greet.bind(user);
greetMr("Doe"); // 输出: Hello, Mr. Doe

我们一个个分析:

  • call( )
    上述call(),我们传入了this值,也就是让greet中的this去指向对象user。另外一个参数是形参name的值。

  • apply( ) 上述apply(),我们同样传入了this值:greet,此时greet中的this指向了对象user。它与call()都是立即调用,区别是前者参数形式为列表,后者参数形式为数组。

  • bind( ) 上述bind()创建一个新的函数,这个新函数的this值被永久地绑定到了bind()的第一个参数,而且可以部分或全部预设函数的参数。但是它不会立即调用,需要我们接收其引用后间接调用。

这种显示绑定this值的方式清晰明了,不仅修改了函数的this指向,还执行了一遍函数。特别是bind( ),在许多复杂场景都有其用处,在文末我将给出手写call( )的示例。

new绑定(NEW Binding)

new关键字在我的JS专栏--基础篇四中有提到,通过形如const array = new Array();的方式创建一个实例化对象,能够使新对象共享构造函数的属性。

当使用构造函数实例化(创建)一个新对象时,构造函数中this会绑定到新对象上。

function Person(name) {
    this.name = name;
    console.log("Creating " + this.name);
}

var person = new Person("novalic"); // 输出: Creating novalic
console.log(person.name); // 输出: novalic

我们已经知道,正常通过字面量创建一个对象,它是没有任何显式属性的。而当我们通过new关键字创建对象后,新对象可以共享构造函数中的属性。

其原理就是:this.name = name;这句代码。this此时绑定到了person上,故这样做我们可以认为:在person对象上使用点表示法添加了一个属性name,后续通过对象名自然可以访问到(第7行)。

特殊的箭头函数(Arrow Funcion)

箭头函数是ES6引入的一种特殊函数定义形式。它与传统函数处理this值的方式截然不同,它不绑定自己的this值,而是继承其外部词法作用域(Lexical scope)中的this。也就是说箭头函数的this值就是其定义时所在作用域的this值。

const obj = {
    name: "Object",
    sayHello: () => {
        console.log("Hello, I am " + this.name);
    }
};

obj.sayHello(); // 输出: Hello, I am undefined

上述sayHello是一个箭头函数,因为其没有自己的this,所以绑定到定义时的词法作用域的this上下文,由于对象不形成作用域,则绑定到外层的非箭头函数作用域——全局作用域的this上下文,但是在全局对象中并没有找到name属性,故打印undefined

我们可以来验证一下上述解释:

this.name = 'novalic';
const obj = {
    name: "Object",
    sayHello: () => {
        console.log("Hello, I am " + this.name);
    }
};

obj.sayHello(); // 输出: Hello, I am novalic

结果也正是输出全局的this.name

image.png

继续来体会一下箭头函数常见的场景:

监听事件中:

document.getElementById('myButton').addEventListener('click', () => {
    console.log(this); // 这里的this取决于外部作用域,而不是按钮元素
});

简化闭包使用:

class User {
    constructor(name) {
        this.name = name;

        // 使用传统函数时,需要使用bind来确保内部函数的this正确指向
        // this.greet = function() {
        //     setTimeout(function() {
        //         console.log("Hello, " + this.name);
        //     }.bind(this), 1000);
        // };

        // 使用箭头函数简化处理,自动捕获外部的this
        this.greet = function() {
            setTimeout(() => {
                console.log("Hello, " + this.name);
            }, 1000);
        };
    }
}

const user = new User("Alice");
user.greet(); // 1秒后输出: Hello, Alice

上述例子的箭头函数会自动捕获其外部的this上下文,也就是构造函数中的this。在使用传统的匿名函数作为setTimeout中的回调函数时,会导致setTimeout函数中的this绑定到全局对象中。所以也需要借助bind( ),不如箭头函数简便。

手写call( )

实现手写call( ),就是实现其内部的功能。

功能1:接收对象和其他参数。

功能2:修改调用其的对象的this值。

功能3:返回原有的函数调用结果。

const obj = {
    a : 1
};

function foo(x, y) {
    console.log(this.a, x + y); // 打印obj的a属性值和x与y的和
    return 123; // 函数返回值为123
}

Function.prototype.myCall = function () { 
    const context = arguments[0]; // 获取传入的第一个参数作为上下文
    const args = Array.from(arguments).slice(1); // 将除了第一个参数外的其他参数转为数组

    // 将当前函数(即foo)作为一个临时属性挂载到context上
    context.foo = this;

    // 利用新添加的foo属性调用函数,此时foo内部的this指向context
    const res = context.foo(...args); // 使用扩展运算符传递剩余参数

    // 调用后删除临时添加的foo属性
    delete context.foo;

    return res; // 返回函数调用的结果
};
foo.myCall(obj,4,5);//输出 : 1,9

上述过程我们在构造函数的显示原型上添加了自定义方法myCall()。首先使用关键字argument获取了传入的参数,并将需要改变this指向的对象(本例是函数foo)临时添加到obj对象上,并调用此函数,将myCall()中接收的参数列表(除原对象)拷贝一份,待最后返回。然后,我们将对象上临时添加的foo()删除,最后返回结果。

其实foo.myCall(obj,4,5);这句话就是想将foo()里的this绑定到obj的身上,并且根据传入的参数执行原函数返回结果。下面看看效果:

调用手写myCall( ):

image.png

调用原生call( ):

image.png

可以看到完全实现了原生函数的效果,手写call( )的最大难点就是我们要足够了解原生call(),如果我们知道其内部执行机制,那么就很简单了

总结

本期内容总结:

  • this的引入
  • this的绑定
    • 概念
    • this的绑定规则
      • 默认绑定
      • 隐式绑定
      • 显式绑定
      • new绑定
      • 特殊的箭头函数
    • 手写call()

以上就是关于this的内容,我们从this的显示绑定(绑定到对象)引入,剖析了this的4种绑定规则:默认绑定、隐式绑定、显示绑定和new绑定,还认识了箭头函数this的特殊性,从原理上剖析了call()的手写实现。

this的优点有目共睹:通过使用this代替对象,可以避免编写冗余的代码,特别是对于一个函数的编写来说,这样的方式使得函数复用性更好。但是在复杂场景中,this的绑定问题需要我们足够了解它,才能用好。

本期内容就是这些,下一期是new关键字的手写实例,如果这篇文章对你有帮助,请给个小赞,这将是我持续创作的动力,感谢!

本人拙见,若有错误,敬请指正。

参考:
this - JavaScript | MDN (mozilla.org)