最通俗的this讲解

作者: 落叶卢生 | 来源:发表于2018-04-23 17:40 被阅读547次

    前言

    this关键字可以说是贯穿JavaScript这门语言的一个精髓,若是不能好好理解this关键字,那在实际的开发中也是会遇到各种各样莫名其妙的问题,让人百思不得其解,所以若能好好的理解其工作原理,那对于提升自身的编程能力是百利而无一害的。

    初识this

    function foo(){
        console.log(this)
        return this.name.toUpperCase();
    }
    var obj = {
        name: 'Tom'
    }
    foo.call(obj)//TOM
    
    

    这段代码最终输出了TOM字符串,可能你还没缓过神来,好奇为何在foo函数中没有name属性,却能打印出obj中的name属性,这里的主要原因是引用改变了作用域,初始作用域是函数里面,是没有任何变量的,包括name属性,在调用函数后调用call函数从而改变了foo作用域,最终访问到了name属性,我们可以打印foo函数的this

    /*未改变作用域前*/
    foo()//Window
    
    /*改变作用域之后*/
    foo.call(obj)//obj
    
    

    如果不使用this,代码如下:

    function foo(ctx){
        return ctx.name.toUpperCase();
    }
    
    

    但你发现了,相比起this,这样写似乎不够优雅。

    再来看一段代码:

    function foo(){
        this.count++;
    }
    foo.count=0;
    foo()
    foo()
    foo.count//0
    
    

    这段代码最终会输出0,可能你会好奇,我明明调用了两次,按理来说应该是1,但却什么都没有增加,但是你别忘了,在上面我们已经指出了在函数里面打印this的时候显示window,也就是此时指向的是全局对象,记住,这里我们并没有改变作用域,所以这里表达式this.count++会转变为window.count++,由于全局并没有定义这个变量,所以其值是undefined,对undefined执行自增运算,最终会变成NaN,我们可以打印一下从而验证:

    window.count//NaN
    
    

    在了解上面的知识后,我们回到第一个问题,考虑下面这段代码:

    function foo(){
        this.count++;
    }
    foo.count=0;
    foo.call(foo)
    foo.call(foo)
    foo.count//2
    
    

    这里的预期似乎和我们一样,这一段代码和上一段代码的不同之处在于我们改变了词法作用域,让函数内部的this指向foo函数,所以count值能符合预期增加,如果没有改变词法作用域,那么函数的this则指向全局window

    下面来小结一下:this是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

    调用位置

    上一小节讲了this的绑定取决于函数的调用方式,调用方式又是相对调用位置来讲的,下面我们来看一看调用栈和调用位置,具体代码如下:

    function baz() {
        // 当前调用栈是:baz
        // 因此,当前调用位置是全局作用域
        console.log( "baz" );
        bar(); // <-- bar 的调用位置
    }
    function bar() {
        // 当前调用栈是 baz -> bar
        // 因此,当前调用位置在 baz 中
        console.log( "bar" );
        foo(); // <-- foo 的调用位置
    }
    function foo() {
        // 当前调用栈是 baz -> bar -> foo
        // 因此,当前调用位置在 bar 中
        console.log( "foo" );
    }
        baz(); // <-- baz 的调用位置
    
    
    我们可以通过浏览器的调试工具来验证我们的猜想,设置断点如下: 图片

    可以新建一个文件copy上面的代码,然后在浏览器中运行,然后打开控制台单步调试,首先我们来看第一个执行的断点:

    图片

    第一个执行函数是baz,我们可以看到它的调用栈是baz,调用位置是调用栈第二个元素,也就是anonymous,它指向的是全局作用域,在下图中我们可以看到它作用域是global,也就是全局作用域。

    图片 我们执行下一个断点: 图片

    我们可以看到当前的调用栈是bar,调用位置为调用栈中的第二个元素,我们可以看到是baz。 继续执行下一个断点:

    图片

    同理我们可以看到当前的调用栈是foo,调用位置为调用栈中的第二个元素,也就是bar中,至此我们继续执行下一个断点,函数执行完毕。

    绑定规则

    调用规则总体总结为四条规则,你必须找到调用位置,然后判断应用下面四条规则中的那一条。

    默认规则

    默认绑定是我经常会使用的调用函数的情况,调用位置是在全局的,因此this绑定在全局作用域中,考虑下面这段代码:

    function foo() {
        return this.a;
    }
    var a = 10;
    foo();//10
    
    

    我们都知道这里的a是一个全局变量,因为定义在了全局作用域中,我们的foo函数由于调用位置是全局作用域,因此可以打印出全局的a变量,这里就是应用了默认绑定规则,那我们怎么判断应用了默认绑定规则呢?在代码中foo()是直接不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其他规则。

    当然在严格模式下我们则无法使用默认绑定规则,因此会返回undefined

    function foo() {
        "use strict";
        return this.a;
    }
    var a = 10;
    foo();//undefined
    
    

    隐式绑定

    如果一个函数的调用位置拥有了上下文对象,也可以说是函数被包含在某个对象中,考虑下面这一行代码:

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

    我们可以看到我们定义了一个函数,然后将其添加为对象的引用属性,最终函数的this将会绑定到obj作用域。 对象属性引用链中只有最顶层或者说是最后一层才会影响调用位置,举例来说:

    function foo() {
        return this.a;
    }
    var obj1 = {
        a: 10,
        foo: foo
    }
    var obj2 = {
        a: 100,
        obj1: obj1
    }
    obj2.obj1.foo();//10
    
    

    我们可以简单的理解为,无论嵌套多少层对象,只不过都是一个引用属性,最终调用函数foo的都是最后一个调用它对象,而最后一个对象的作用域就会绑定到函数this上面,我们将上面的代码改写一下:

    function foo() {
        return this.a;
    }
    var obj1 = {
        a: 10,
        foo: foo
    }
    var obj2 = {
        a: 100,
        obj1: obj1
    }
    var obj3 = {
        a: 1000,
        obj2: obj2
    }
    obj3.obj2.obj1.foo();//10
    //等价于
    var last = obj3.obj2.obj1;
    last===obj1//true
    last.foo();//10
    
    

    在这里尽管我用到了三个对象来引用一个函数的调用,但是最终都只是一个引用,因此最终调用的函数的对象都是obj1,我们可以使用last===obj1验证,最终显示结果为true

    再来看另一种情况:

    function foo() {
        return this.a;
    }
    var obj = {
        a: 10,
        foo: foo
    }
    var a = "global";
    
    var b = obj.foo;
    b();//"global"
    
    

    如果理解了刚刚讲的多个对象引用同一个函数,最终的作用域绑定将会是最后一个调用函数的对象问题后,这个也很好理解,因为对象里面的foo属性只是一个引用传递,所以var b = obj.foo这段代码将整个函数又指向了b变量,我们可以打印b变量验证一下:

    图片

    我们可以看到此时的b变量指向的就是一个函数,跟原来的obj对象没有半毛钱关系,既然没了obj对象的关系,也没用使用任何带修饰的引用调用,那么此时的函数调用就符合第一条作用规则默认绑定,因此函数里面this绑定到了全局作用域,所以打印出了global,再举一个例子:

    function foo() {
        return this.a;
    }
    var obj = {
        a: 10,
        foo: foo
    }
    var a = "global";
    
    setTimeout( obj.foo, 100)//"global"
    
    

    可能就会有人好奇,我传的不是obj.foo吗,为何还是this还是应用的默认绑定规则,我们来看一下setTimeout()函数的大概实现的伪代码:

    function setTimeOut(fn,delay){
        //等待delay毫秒
        fn();//<-- 调用位置
    }
    
    

    虽然我们貌似没有用一个变量指向这个函数,并将这个函数传递进去调用,但是这里的obj.foo却被函数的形参fn给接住了,间接的创建了一个局部变量,并将这个变量指向了函数foo,最终的调用效果和obj依旧没有半毛钱关系,因此应用默认绑定行为。

    显式绑定

    我们在隐式绑定中知道,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把this间接(隐式)绑定到这个对象上,那有没一种方法直接让我们强制绑定this作用域呢?

    JavaScript中为绝大多数函数以及你自己创建的函数都提供了callapply方法,这两个的区别主要是传递的参数形式不一样,call可以接受参数列表,比如call(null,arg1,arg2),而apply则是接受单个参数数组,比如apply(null,[1,2,3]),在了解上面的知识后,我们来看一个例子:

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

    在这里,我们通过call函数显式的指定this绑定的作用域为obj,因此可以访问到obj.a,当然你可以传入一个原始值(字符串,布尔或者数字类型),最终这些原始值都会转换为原始对象形式,类似于(new String(),new Boolean()或者new Number())。

    硬绑定

    考虑下面这段代码:

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

    bar函数我将foo函数绑定到了obj上面,在调用bar的时候我们试图重新绑定this作用域,但是由于在调用的时候我们又重新绑定到了obj上面,所以导致最终输出10,这种绑定的策略我们称之为强制绑定,因此我们称为硬绑定。

    由于硬绑定是一种常用的模式,所以es5中内置了方法Function.prototype.bind,用法如下:

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

    这段代码首先绑定了obj对象到函数上面,紧接着又执行了这个函数。

    new绑定

    初学js语言使用new关键字的时候,如果之前有过oop的编程经验,那可能会认为这个new和他们以前自己接触的oopnew一样,然而实际却是没有半毛钱的关系,使用new来调用函数,或者说发生构造函数调用时,会自动执行下面的操作:

    1. 创建(或者说构造)一个全新的对象。
    2. 这个新对象会被执行 [[ 原型 ]] 连接。
    3. 这个新对象会绑定到函数调用的 this
    4. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。

    考虑下面的代码:

    function Foo(a) {
       this.a = a;
    }
    var foo = new Foo(10);
    foo.a//10
    
    

    如果按照之前规则解释,这里执行的foo.a将会输出undefined,因为函数里面的this指向全局作用域,也就是this.a将会解释成window.a从而创建一个全局变量a但是这里我们使用了new关键字,我们遵从上面使用new调用函数的四个操作,其中的第三步操作中指明了:这个新对象会绑定到函数调用的 this,简单来说就是我们这里的this绑定到了foo变量上面,而foo本身就是一个新创建的函数,因此var foo = new Foo(10)这一行代码将this绑定到foo的作用域上,也就是说this.a = a;将会解释成foo.a = a;,因此我们打印foo.a才会显示10

    优先级

    绑定有四种,js中不可能都是单一规则,通常都是几种规则混合在一起,因此这四种规则得有个优先级,具体优先级为:

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

    我们看一个一例子:

    function Foo(name){
        this.name = name;
    }
    var obj = {};
    var bar = Foo.call(obj);
    bar(10);
    obj.name//10
    var baz = new bar(100);
    obj.name//10
    baz.name//100
    
    

    这段代码首先使用了显示绑定,因此obj.name输出10;紧接着又使用了new绑定;由于new绑定优先级高于显示绑定,此时的this绑定到了baz函数上,从而在baz.name输出100,而没有改变obj.name的值。

    判断js

    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()

    特殊情况

    1.如果我们把null或者undefined作为this的绑定对象传入callapply或者bind中,那么应用的是默认规则,举个栗子:

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

    这里应用的是默认规则。

    2.间接引用

    在有些情况下,我们可能还不知道我们间接引用了,考虑下面一段代码:

    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函数体,最终就是使用foo函数调用,也就是默认绑定,我们可以打印一下这段表达式看看返回什么:

    图片

    返回的就是foo的函数体,所以当下次发现this的绑定不符合预期的时候,去控制台打印一下看看是不是发生了间接引用了。

    相关文章

      网友评论

        本文标题:最通俗的this讲解

        本文链接:https://www.haomeiwen.com/subject/nnvllftx.html