JavaScript

最通俗的this讲解

Posted on 2018-04-21,14 min read

前言

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的绑定不符合预期的时候,去控制台打印一下看看是不是发生了间接引用了。

作者:落叶卢生
链接:https://luoyelusheng.com/post/最通俗的this讲解
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

下一篇: CSS使用技巧-过渡与动画→