浅析this--this绑定规则及优先级
1. 什么是this
1.1 this到底是什么
科学的尽头是神学,this关键字就是JS中极其复杂的一个机制,往往被人们神化,它自动被定义在每一个函数之中,就像是每一个函数的伴生品,亦正亦邪。使用恰当,往往会使得一段代码更加精短且强大,但往往会出现许多意外事件,使得它不稳定。实际上,this关键字并没有那么神秘,但是在缺乏清晰的认识下,this就像是一个魔法。今天,就让我们来揭开它那神秘的面纱。
现在先声明一下,this是在运行时绑定的,它绑定哪个对象,完全依赖于它在声明时候的调用!this的绑定和声明位置并没有任何关系,只取决于它的调用位置。
function demo(num){
console.log(num);
//记录demo的调用次数,待会赋值为0
this.count++;
};
demo.count = 0;
demo(1); //1
demo(2); //2
demo(3); //3
//来看看demo.count为多少
console.log(demo.count);//0 ????what??
在上面我们可以很清晰的知道demo()函数一共是调用了3次,但是当我们输出计数器count时候却发现为0?
所以,由此可见,从字面意思来理解this是错误的,this并非指向自身的。在执行demo.count()时,的确给函数增加了count,但是在函数内部的this.count指向的并不是外部的那个count,虽然两个属性名一样,但是却不是同一个属性。外部的count是一个全局变量,而this.count则是demo()的属性,为何会是这样,这就要从上下文来解释了。这里暂时不对上下文进行过多的阐述。
那么,如何才可以解决上面出现的问题呢?既然两个是不同的count,那么我们让两个count变成同一个,不就可以解决了?
function demo(num) {
console.log(num); //记录demo的调用次数,待会赋值为0
demo.count++; //将this.count改成demo.count
};
demo.count = 0;
demo(1); //1
demo(2); //2
demo(3); //3
console.log(demo.count);//3
通过这个方法,从某种层面来说,我们确实是解决了上述的问题,但是同时也是完美的避开了this。所以从某个方面来说,我们还是失败了。
1.2 为什么要使用this
根据上述内容可以知道,this机制是如此的复杂,那么我们为什么还要使用它呢?它到底有什么用呢?它是否真的值得我们如此深入的去了解它吗?带着这一系列问题,我来为大家解释一下为什么我们还是要坚持使用如此复杂的this。
function upcase() { return this.name.toUpperCase();};
function test() { let demo = "demo:" + upcase.call(this); console.log(demo);};
let name1 = { name: "demo1"};
let name2 = { name:"demo2" };
test.call(name1); //demo:DEMO1
test.call(name2); //demo:DMEO2
如果未接触this或者对JS语法了解不够深入,那么这段代码往往是比较晦涩的。在这里,我们通过使用this,发现在不需要给upcase()和test()函数传入对象就可以使用其函数的功能。在过去不提及this,这就是一个神话。如果不使用this,就需要给upcase函数和test函数显式传入一个上下文对象,才可以完成这一操作,代码如下:
function upcase(name) { return name.name.toUpperCase();};
function test(name) { let demo = "demo:" + upcase(name); console.log(demo);};
let name1 = { name: "demo1"};
let name2 = { name:"dmeo2"};
test(name1); //demo:DEMO1
test(name2); //demo:DMEO2
通过两段代码比较,我们可以发现,使用this可以更加优雅的来传递一个对象的引用,可以使得我们的代码更加简洁且易于使用。尤其是在我们使用多个函数互相调用,不断套娃时,我们显示的使用的上下文传递对象,很容易使得代码混乱,且使代码可读性降低,使用this则不会这样。所以,使用this更有利于自己代码开发和后期代码维护。
1.3 它的作用域
人们常常会理解为this是指向函数的作用域的,这种理解既可以说是正确的,也可以说是错误的。这是因为this机制的复杂性,决定了this的指向也是极其复杂的,所以说this是指向函数作用域,也不是指向函数的作用域。首先需要明确的是,**this是绝不指向函数的词法作用域的。**作用域和对象是很类似的,在内部的标识符都是他们的属性,但是对象是通过JS代码访问属性,而作用域则是在JS引擎内部,访问的区别导致了他们的不一致,同时也决定了this是不会指向函数的作用域的,在上文也提到过,this的绑定和声明位置并没有任何关系,只取决于它的调用位置!this的绑定和声明位置并没有任何关系,只取决于它的调用位置!!this的绑定和声明位置并没有任何关系,只取决于它的调用位置!!!
2. 绑定规则
2.1 调用规则
在理解在理解 this 的绑定过程之前,首先要理解调用位置:调用位置就是函数在代码中被调用的位置(而不是声明的位置)。只有仔细分析调用位置才能回答这个 this 到底引用的是什么?或者说指向了什么。
寻找调用位置就是寻找“函数被调用的位置”,但是做起来并没有这么简单,因为某些编程模式可能会隐藏真正的调用位置。最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的调用位置就在当前正在执行的函数的前一个调用中。
下面我们来看看到底什么是调用栈和调用位置:
function demo1() {
// 当前调用栈是:demo1
// 因此,当前调用位置是全局作用域
console.log("demo1");
dmeo2(); // <-- demo2 的调用位置
};
function dmeo2() {
// 当前调用栈是 demo1 -> demo2
// 因此,当前调用位置在 demo1 中
console.log("demo2");
demo3(); // <-- demo3 的调用位置
};
function demo3() {
// 当前调用栈是 dmeo1 -> demo2 -> demo3
// 因此,当前调用位置在 demo2 中
console.log("demo3");
};
demo1(); // <-- demo1 的调用位置
注意我们是如何从调用栈中分析出真正的调用位置的,因为它决定了 this 的绑定。关于调用栈,你可以很简单的理解为函数调用的传送链,将不同的函数通过不断内嵌调用形成的一个路径,就像铁路线,你想去一个地方,需要根据铁路线不断转车才可以到达想去的地方,当然也不排除有些地方可以直达。就像demo1中可以直接到demo2中,但是到demo3却需要进入demo2才可以进入demo3。了解完如何寻找调用位置,我们就可以开始谈谈this的几个绑定规则了,这也是this入门的开始。
2.2 默认绑定
首先出场的肯定是最简单最常用的函数调用类型:简单函数调用。可以把这看作是完全没有规则的一条规则,毕竟要给this找个对象。
function demo() {
console.log(this.a);
};
var a = 2;
demo(); // 2 <--demo的真正引用位置
首先应该注意到,声明在全局作用域中的变量var a = 2就是全局对象的一个属性。同时this.a引用的,就是全局变量a,因为demo声明在全局作用域中,所以this.a在demo内部没有找到属性a的情况下,就到全局对象上引用了a。或许有人问为什么?
因为在本例中,函数调用时应用了 this 的默认绑定,因此 this 指向全局对象。那么我们怎么知道这里应用了_默认绑定_呢?我们只需要分析demo() 是如何调用的。在代码中,demo() 是直接进行调用的,因此只能使用默认绑定,无法应用其他规则。
但是如果使用严格模式(strict mode),那么全局对象将无法使用默认绑定,因此 this 会绑定到 undefined:
function foo() { "use strict"; console.log( this.a ); }
var a = 2; foo(); // TypeError: this is undefined
这里有一个非常重要的细节,虽然 this 的绑定规则完全取决于调用位置,但是只
有 foo() 运行在非 strict mode 下时,默认绑定才能绑定到全局对象;严格模式下与 foo()
的调用位置无关:
function foo() { console.log( this.a ); }
var a = 2; (function(){ "use strict"; foo(); // 2 })();
通常来说你不应该在代码中混合使用 strict mode 和 non-strict mode。整个程序要么严格要么非严格。然而,有时候你可能会用到第三方库,其严格程度和你的代码有所不同,因此一定要注意这类兼容性细节。
2.3 隐式绑定
隐式绑定则是另一条需要考虑的规则,在调用位置是否有上下文对象。
function demo() { console.log( this.a ); }
var obj = {
a: 2,
demo: demo
};
obj.foo(); // 2
首先需要注意的是 demo() 的声明方式,及其之后是如何被当作引用属性添加到 obj 中的。这个函数严格来说都不属于obj 对象。然而,调用位置会使用 obj 上下文来引用函数,因此你可以说函数被调用时 obj 对象“包含”它。当 demo() 被调用时,它的this确实指向 obj 对象。因此当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。因为调用 demo() 时 this 被绑定到 obj,因此 this.a 和 obj.a 是一样的。
既然存在隐式绑定,那么会不会存在隐式丢失呢?答案当然是肯定的!隐式丢失是一个最常见的一种丢失,通常存在与隐式绑定与默认绑定混用之中。
function foo() { console.log(this.a); }
var obj = { a: 2, foo: foo };
var bar = obj.foo; // 函数别名!调用后便会出现隐式丢失
var a = "oops, global"; // a 是全局对象的属性
bar(); // "oops, global";
虽然 bar 是 obj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,因此此时的bar() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定,this便会指向bar所在的作用域的a,出现隐式丢失。隐式丢失同时在回调函数中存在特别广泛,例如在JS内置的setTimeout() 函数之中,我们可以简写该函数便可以清楚的知道是如何丢失的。
function setTimeout(fn(),delay){
delay(); //使得函数等待一段时间
fn(); //<--调用位置 等待完毕开始执行函数。 }
可见回调函数丢失 this 绑定是非常常见的。除此之外,还有一种情况 this 的行为会出乎我们意料:在一些流行的JS 库中事件处理器常会把回调函数的 this 强制绑定到触发事件的 DOM 元素上。这在一些情况下可能很有用,但是有时它可能会让你感到非常郁闷。遗憾的是,这些工具通常无法选择是否启用这个行为。
2.4 显式绑定
当我们不想在对象内部对函数进行像隐式绑定那样引用,而想圆一霸王梦,在某个对象上强制调用函数,该怎么做呢?JS中的几乎每个函数都有一些有用的特性: call(..) 和apply(..) 方法。正如我们上门中第一次成功使用this的方式,他们会接受一个参数,接着把函数的this绑定到这个参数上,实现显式绑定。
function foo() { console.log( this.a ); }
var obj = { a:2 };
foo.call( obj ); // 2
我们通过使用call()方法,传入我们想绑定的对象,将this绑定到该对象上。有时候当你传入的是一个简单数据类型,而非一个对象,这时候call方法会自动对其进行转换,转换成该数值的对象形式,然后进行绑定,这个过程业内又称之为“自动装箱”。
但是显示绑定虽然好用,但是任然会存在刚刚出现的隐式丢失,那么我们如何才可以解决这个问题呢?目前普遍使用的是两种办法
1.硬绑定
硬绑定属于显式绑定的一个变种,但是却可以解决这个问题。
function foo() {console.log( this.a ); }
var obj = { a:2 };
var bar = function() { foo.call( obj ); };
bar(); // 2
setTimeout( bar, 100 ); // 2
// 硬绑定的 bar 不可能再修改它的 this bar.call( window ); // 2
在函数 bar()中,我们调用了 foo.call(obj),这样会强制把 foo 的 this 绑定到了 obj。无论之后如何调用函数 bar,this都会再次绑定到obj。这种绑定是一种显式的强制绑定,因此我们称之为硬绑定。
2.API调用上下文
第三方库的许多函数,以及 JS语言和环境中许多新的内置函数,都提供了一个可选的参数,通常被称为“上下文”,其作用和 bind(..) 一样,确保你的回调函数使用指定的 this。
function foo(id) { console.log( id, this.id ); }
var obj = { id: "dmeo" };
// 调用 foo(..) 时把 this 绑定到 obj
[1, 2, 3].forEach( foo, obj ); // 1 demo 2 demo3 demo
2.5 new绑定
在了解new绑定前,我们首先了解一下使用new的时候,发生了什么,就可以很清楚的知道怎么进行了绑定。使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
-
创建(或者说构造)一个全新的对象。
-
这个新对象会被执行原型链连接。
-
这个新对象会绑定到函数调用的 this。
-
如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。
所以根据上面,我们可以很清楚的知道在使用new的时候,他们就已经自动进行了一次绑定。
3. 绑定规则优先级
3.1 优先级比较
接下来我们就要对上述几个绑定规则进行优先级比较,看看哪个的地位更高,并将它们进行一次排序,方便我们今后使用的时候更加清楚他们的绑定位置。
首先默认绑定在最开始的时候就介绍了,是没有规则的时候才不得已使用的规则。所以是最没有牌面的!
接下来我们根据上文中在显示绑定中出现过解决隐式丢失的问题,如果说隐式丢失是隐式绑定的“爸爸”,那么显示绑定就是隐式绑定爸爸的爸爸,所以说隐式绑定在显示绑定面前就是个弟弟
所以显示绑定肯定是大于隐式绑定的!您要是真不信,那咱们,上代码!
function foo() { console.log( this.a ); }
var obj1 = { a: 2, foo: foo };
var obj2 = { a: 3, foo: foo };
obj1.foo(); // 2
obj2.foo(); // 3
obj1.foo.call( obj2 ); // 3
obj2.foo.call( obj1 ); // 2
这回,您信了吧。所以现在的排名是显示>隐式>默认,那么new该插在哪里呢?擒贼先擒王,我们先直接把new和显示绑定进行比较!
function foo(something) {this.a = something; }
var obj1 = {};
var bar = foo.bind( obj1 );
bar( 2 );
console.log( obj1.a ); // 2
var baz = new bar(3);
console.log( obj1.a ); // 2
console.log( baz.a ); // 3
现在看来,胜负已分!
new绑定成功把显示绑定给狠狠地敲打了一顿,直接百万军中,取显式之狗头!
所以我们对其四个规则进行排序:new绑定>显式绑定>隐式绑定>默认绑定
同时我们可以得出一套规律:
- 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。
var bar = new foo()
- 函数是否通过 call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是指定的对象。
var bar = foo.call(obj2)
- 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象。
var demo2= obj.foo()
- 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到全局对象。
demo();
4. 小结
如果要判断一个运行中的this绑定,重中之重就是找到它的调用位置,找到之后根据下面四条规则来判断this指针的绑定对象。
- 是否由new声明绑定到指定对象。
- 是否由call、apply或者bind调用。
- 是否上下文调用
- 默认绑定,在严格模式下为undefined,否则绑定到全局对象。
同时要注意有时候会触发绑定例外,需要注意甄别。在ES6的箭头函数中并不会使用上列的绑定规则,而是根据当前的词法作用域来决定this的绑定对象。简单点说,就是箭头函数会直接继承上文函数调用的this绑定。
因为篇幅的原因(其实我很努力压制,但是依旧成为一篇长文),在此只给大家简单的进行简单的this绑定规则的阐述,this的用法其实还是多种多样的,但是熟练掌握上述四种规则及优先级,就可以较为简单的进行this的绑定判断了!
转载自:https://juejin.cn/post/6925082635442225160