【前端进阶】全面剖析JS之“this” 看到最后发现你原来枪里没有子弹

1.认识this

1.1为什么要用this

对于一个javaScript开发者来说,this是一种非常复杂的机制的机制,那它到底有用在哪里呢?它值得我们付出大代价去学习吗?的确。

解释一下为什么要使用this。

function identify(){
    return this.name.toUpperCase();
}
function speak(){
    var greeting="Hello,I'm"+identify.call(this);
    console.log(greeting);
}
var me={
    name:"DaLi"
};
var you={
    name:"Xi"
};
identify.call(me);//DALI
identify.call(you);//XI
speak.call(me);//Hello,I'm DALI
speak.call(you);//Hello,I'm XI

现在如果看不懂这代码不要紧,接下来会讲解。如果不懂call()的用法可以看我另一篇博客,我们先专注于为什么。
这段代码可以在不同的上下文对象(me和you)中重复使用函数identify()和speak(),不用针对每个对象编写不同版本函数。

如果不使用this,那要实现上面的同样结果,就需要给identify()和speak()显式的传入一个上下文对象。

function identify(context){
    return context.name.toUpperCase();
}
function speak(context){
    var greeting="Hello,I'm"+identify(context);
    console.log(greeting);
}
var me={
    name:"DaLi"
};
var you={
    name:"Xi"
};
identify(me);//DALI
identify(you);//XI

然而,this提供了更优雅的方式来隐式“传递”一个对象引用,这样则更加简洁并易于复用。随着使用的模式越来越复杂,显示传递上下文对象会让代码看起来越来越复杂,使用this则不会这样。

1.2 this的误解:this指向自身

我们很容易把this理解成函数自身,从英语单词的语法角度来说是说得通的。
那我们看下面的代码验证代码是不是指向自身。

function foo(num){
    console.log("foo:",num);
    this.count++;//记录foo()被调用的次数
}
foo.count=0;
var i;
for(i=0;i<10;i++){
    if(i>5){
        foo(i);
    }
}
console.log(foo.count);//0----why

//foo:6
//foo:7
//foo:8
//foo:9

从console.log()产生四条输出语句,证明foo()的确被调用了4次,但foo.count仍然是0。显然将this理解为指向自身是错误的。

遇到这样的问题时,我们可能不会思考为什么this的行为和预期的不一致,也不会试图回答那些很难解决但却很重要的问题。他们可能会回避这个问题并使用其它方法来达到目的。
比如创建另一个带有count属性的对象:

function foo(num){
    console.log("foo:",num);
    data.count++;//记录foo()被调用的次数
}
var data={
    count:0
};
var i;
for(i=0;i<10;i++){
    if(i>5){
        foo(i);
    }
}
console.log(data.count);//4

//foo:6
//foo:7
//foo:8
//foo:9

从某种角度来讲这个方法的确是“解决”了问题,但是开发者忽略了真正的问题----无法理解this的含义和工作原理----而是返回舒适区,使用了上面解决问题的词法作用域,更熟悉的技术。
可能有的人也会这样修改:

function foo(num){
    console.log("foo:",num);
    foo.count++;//记录foo()被调用的次数
}
foo.count=0;
var i;
for(i=0;i<10;i++){
    if(i>5){
        foo(i);
    }
}
console.log(foo.count);//4

//foo:6
//foo:7
//foo:8
//foo:9

这种方法也回避了this的问题,并且完全依赖于变量foo的词法作用域。而且这种方法的缺点是:在匿名函数种则无法从函数内部引用自身。

匿名函数:
setTimeout(function(){
	//匿名(没有名字的函数)函数无法指向自身
},1000)

最棒的方法就是理解this,而不是逃避它
强制this指向foo函数对象:

function foo(num){
    console.log("foo:",num);
    this.count++;//注意:在当前调用方式下,this确实指向foo
}
foo.count=0;
var i;
for(i=0;i<10;i++){
    if(i>5){
        foo.call(foo,i);//使用call()可以确保this指向函数对象foo本身
    }
}
console.log(foo.count);//4

//foo:6
//foo:7
//foo:8
//foo:9

this的正确理解: this实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。

2.this全面解析

2.1 调用位置

在理解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中
}
baz();//-->baz的调用位置

注意: 我们是如何(从调用栈中)分析出真正的调用位置的,因为它决定了this的绑定。

2.2 绑定规则

找到调用位置,然后判断需要应用下面四条规则中的哪一条。首先会分别解释这四条规则,然后解释多条规则都可用时它们的优先级如何排列。

2.2.1 默认绑定

规则1: 首先介绍的是最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用其它规则时的默认规则。

看下面代码:

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

我们可以看到当调用foo()时,this.a被解析成了全局变量a。为什么?因为在本例中,函数调用时应用了this的默认绑定,因此this指向了全局对象。

那么怎么知道this应用了默认绑定呢?可以通过分析调用位置来看看foo()是如何调用的。在代码非严格模式中,foo()是直接使用不带任何修饰的函数引用进行调用的,因此this这能使用默认绑定,无法应用其它规则。

2.2.2 隐式绑定

规则2: 考虑调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含。
看下面代码:

形式1:先定义foo函数,再添加为obj对象属性引用
function foo(){
    console.log(this.a);
}
var obj={
    a:2,
    foo:foo
};
obj.foo();//2



形式2:直接再obj中定义
var obj={
    a:2,
    foo:function(){
        console.log(this.a);
    }
};
obj.foo();//2

首先需要注意的是foo()的声明方式,以及之后是如何被当作引用对象属性添加到obj中的。但是无论是直接在obj中定义还是先定义再添加为引用属性,这个函数严格来说都不属于obj对象。

然而,调用位置会使用obj上下文来引用函数,因此你可以说函数被调用时obj对象“拥有”或者“包含”它。
当foo()被调用时,他的前面确实加上了对obj的引用。当函数引用有上下文对象时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象。因为调用foo()时this被绑定到obj,因此this.a==obj.a。

对象属性引用链中只有最后一层在调用位置中起作用。看下面代码:

function foo(){
    console.log(this.a);
}
var obj2={
    a:42,
    foo:foo
};
var obj1={
    a:2,
    obj2:obj2
}
obj1.obj2.foo();//42
隐式丢失

一个最常见的this绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把this绑定到全局对象(非严格模式)或者undefined(严格模式)上。
看下面代码:

function foo(){
    console.log(this.a);
}
var obj={
    a:42,
    foo:foo
};
var bar=obj.foo;//函数别名
var a="oops,global";//a是全局对象的属性
bar();//执行函数,输出“oops,global”

你是否认为它会输出的是42?虽然bar是obj.foo的一个引用,但是实际上,它引用的是foo函数本身,因此此时的bar()是一个不带任何修饰的函数调用,因此应用默认绑定

这种情况还会发生在传入回调函数中,看下面代码:

function foo(){
    console.log(this.a);
}
function doFoo(fn){
    //fn真正引用的就是上面的foo函数
    fn();//调用位置
}
var obj={
    a:42,
    foo:foo
};
var a="oops,global";//a是全局对象的属性
deFoo(obj.foo);//执行函数,输出“oops,global”

传递参数就是一种隐式赋值,因此我们传入函数时也会被隐私赋值。正如我们所看到的,回调函数丢失this是非常常见的,之后我们学习通过固定this来修复这个问题。

2.2.3 显示绑定

在分析隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把this间接(隐式)绑定到这个对象上。那么如果我们不想在对象内部包含函数引用,而想在某个对象强制调用函数,该怎么做呢?使用call()和apply()方法进行绑定。这两函数的第一个参数是一个对象,用于直接指定this的绑定对象,因此,称之为显示绑定。
看下面代码:

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

通过foo.call(),我们可以在调用foo时强制把它绑定到obj上。
可惜,显式绑定无法解决上面提出的丢失绑定问题。

1.硬绑定

显示绑定的一个变种可以解决丢失绑定问题。
看下面代码:

function foo(){
    console.log(this.a);
}
var a="global";
var obj={
    a:2
};
var bar=function(){
    foo.call(obj);
}
bar();//2
setTimeout(bar,1000);//2
bar.call(window);//2,硬绑定的bar不可能再修改this的指向

硬绑定的典型应用场景就是创建一个包裹函数,负责接收参数并返回值:

function foo(something){
    console.log(this.a,something);
    return this.a+something;
}
var a="global";
var obj={
    a:2
};
var bar=function(){
    return foo.apply(obj,arguments);
}
var b=bar();//2,3
console.log(b);//5

另一种使用方法是创建一个可以重复使用的辅助函数:

function foo(something){
    console.log(this.a,something);
    return this.a+something;
}
//简单的辅助绑定函数
function bind(fn,obj){
    return function(){
        return fn.apply(obj,arguments);
    }
}
var a="global";
var obj={
    a:2
};
var bar=bind(foo,obj);
var b=bar();//2,3
console.log(b);//5

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

function foo(something){
    console.log(this.a,something);
    return this.a+something;
}

var a="global";
var obj={
    a:2
};
var bar=foo.bind(obj);
var b=bar(3);//2,3
console.log(b);//5

bind()会返回一个硬编码的新函数,它会把你指定的参数设置为this的上下文并调用原始函数。

2.2.4 new绑定

使用new来调用函数,会自动执行下面的操作。

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

看下面代码:

function foo(something){
    this.a=something;
}
var bar=new foo(2);
console.log(bar.a);//2

使用new来调用foo()时,会构造一个对象并把它绑定到foo()调用中的this上。

3.四条规则的优先级

如果某个调用位置可以运用多条规则该怎么办?优先级
根据下面顺序进行判断:

  1. 函数是否在new中调用?如果是的话,this绑定的是最新创建的对象。
    var bar=new foo();

  2. 函数是否通过call、apply或bind进行硬绑定调用?如果是的话,this绑定的是那个上下文对象。
    var bar=foo.call(obj);

  3. 函数是否在某个上下文对象中调用?如果是的话,this绑定的是那个上下文对象。
    var bar=obj.foo();

  4. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到全局对象。
    var bar=foo();

对于正常的函数调用来说,理解了这些知识就可以理解this的绑定原理了。

2.4 绑定例外

对于正常函数,上面四条规则就够了。但总有例外。在某些场景下this的绑定行为会出乎意料,你认为应当应用其它绑定规则时,实际上应用的可能是默认绑定规则。

2.4.1 被忽略的this

如果你把null或者undefined作为this的绑定对象传入call、apply或者bind,这些值在调用时会被忽略,实际应用的是默认绑定规则。
看下面代码:

function foo(){
    console.log(this.a);
}
var a=2;
foo.call(null);//2,使用默认绑定

那么什么情况下你会传入null呢?
一种非常常见的做法是使用apply()来“展开”一个数组,并当作参数传入一个函数。类似的,bind()可以对参数进行柯里化(预先设置一些参数),这种方法有时非常有用:

function foo(a,b){
    console.log("a:"+a+",b:"+b);
}
//把数组“展开”成参数
foo.apply(null,[2,3]);//a:2.b:3
//使用bind()进行柯里化
var bar=foo.bind(null,2);
bar(3);//a:2,b:3

这两种方法都需要传入一个参数当作this的绑定对象。如果函数并不关心this的话,你仍然需要传入一个占位符,这时null可能是一个不错的选择。
然而,总是使用null来忽略this绑定可能产生一些副作用。如果某个函数确实使用了this,那默认绑定规则会把this绑定到全局对象(在浏览器中这个对象是window),这将导致不可预计的后果(比如修改全局对象)。显而易见,这种方式可能会导致许多难以分析和追踪的bug。

更安全的this

一种“更安全”的做法是传入特殊的对象,把this绑定到这个对象不会对你的程序产生任何副作用。就像网络一样,我们可以创建一个“DMZ”对象——它就是一个空的非委托的对象。

如果我们在忽略this绑定时总是传入一个DMZ对象,那就什么都不用担心了,因为任何对于this的使用都会被限制在这个空对象中,不会对全局对象产生任何影响。

在JS中创建一个空的对象最简单的方法都是Object.create(null)。Object.create(null)和{}很像,但是并不会创建Object.prototype这个委托,所以它比{}“更空”:

function foo(a,b){
    console.log("a:"+a+",b:"+b);
}

var DMZ=Object.create(null);//我们的DMZ空对象
foo.apply(DMZ,[2,3]);//a:2.b:3。把数组展开成参数

var bar =foo.bind(DMZ,2);//使用bind()进行柯里化
bar(3);//a:2,b:3

创建了DMZ对象来替换之前的null,则即使函数有this也不会引用全局对象变量了。

2.4.2 间接引用

你可能有意或无意创建一个函数的“间接引用”,在这种情况下,调用这个函数会应用默认绑定规则。

间接引用最容易在赋值时发生:

function foo(a,b){
    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()而不是p.foo()或者o.foo()。根据之前说过,这里会进行默认绑定。如果函数处于严格模式,this会被绑定到undefined,否则this会被绑定到全局对象。

2.5 this词法

我们上面介绍的四条规则已经可以包含所有正常的函数。但在ES6中介绍了一种无法使用这些规则的特殊函数类型:箭头函数。

箭头函数并不是使用function关键字定义的,而是使用操作符=>定义的。箭头函数不使用this的四条标准规则,而是根据外层(函数或全局)作用域来决定this。
看看箭头函数的词法作用域:

function foo(){
    return (a)=>{
        console.log(this.a);
    }
}
var obj1={
    a:2;
}
var obj2={
    a:3;
}
var bar=foo.call(obj1);
bar.call(obj2);//2,而不是3

foo()内部创建的箭头函数会捕获调用时foo()的this。由于foo()的this绑定到obj1,bar(引用箭头函数)的this也会绑定到obj1,箭头函数的绑定无法被修改。(new也不能在修改箭头函数this的绑定)。

箭头函数常用于回调函数:

function foo(){
    setTimeout(()=>{
        console.log(this.a);//这里的this在词法上继承自foo()
    },1000)
}
var obj={
    a:2;
}
foo.call(obj);//2

可以看出,箭头函数可以像bind()一样确保函数的this被绑定到指定对象,此外,其重要性还体现在它用更常见的词法作用域取代了传统的this机制。

在ES6之前我们使用一种几乎和箭头函数完全一样的模式:

function foo(){
    var that=this;//var self=this;
    setTimeout(function(){
        console.log(that.a);
    },1000)
}
var obj={
    a:2;
}
foo.call(obj);//2

小结: ES6中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定this。具体来说,箭头函数会继承外层函数调用的this绑定。这其实和ES6之前代码中的that=this机制是一样的。

猜你喜欢

转载自blog.csdn.net/weixin_43334673/article/details/106547784
今日推荐