你真的了解JavaScript中的this吗?

293 阅读32分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

1.this语法

1.1利用引用标识符绑定this

var name = 'Yuki';
var examCount = 0;
var card = {
  name: 'Haruhi',
  examCount: 0,
  show: function showSum() {
    var itSelf = this;
    if (itSelf.examCount < 1) {
      setTimeout(function sum() {
        console.log(itSelf.name);
      }, 1000);
    }
  },
};
card.show(); /* 我们在这里调用对应对象内部的函数方法 Haruhi */

通过var itSelf=this,我们利用闭包和词法作用域的原理,将itSelf设置为一个进行引用的标识符,从而轻松得到了同this的绑定对象(card),无需关心this绑定的过程发生了什么。

首先,itSelf在词法作用域的”力量“之下,将this的绑定指向了card,在外部作用域的调用下,this将忠心耿耿地跟随card不动摇,可能有些同学就会比较迷惑了:

啊这?我要是不对this进行标识符引用呢?会发生什么事?


var name = 'Yuki';
var examCount = 0;
var card = {
  name: 'Haruhi',
  examCount: 0,
  show: function showSum() {
    /*    var itSelf = this */
    if (this.examCount < 1) {
      setTimeout(function sum() {
        console.log(this.name);
      }, 1000);
    }
  },
};
card.show(); /* Yuki*/

事实上,这就涉及到了隐式绑定丢失的问题,因为setTimeout是JS的内置方法,其执行的上下文是基于全局作用域,当函数引用存在上下文对象时,隐式绑定规则会把函数调用中的this绑定到当前上下文的对象。

show无论是直接在card中先定义还是再添加为引用属性,这个函数严格来说都不属于card对象,card拥有的只不过是该函数的引用,并非函数本身。

利用setTimeout进行执行时,如果不对this进行同向绑定,直接指向了setTimeout所在的全局作用域。

1.2箭头函数里面的this

问题是解决了,但是这玩意拉拉垮垮一大串,有没有点先进的写法呢?

当然有,这就是ES6的大杀器——箭头函数

show: function showSum() {
  if (this.examCount < 1) {
    setTimeout(() => console.log(this.name), 1000); //Haruhi
  }
},

箭头函数完全摒弃了普通函数绑定this的规则,直接利用当前词法作用域覆盖了this原本的值(调用作用域链?我不干!),这里事实上根本没有什么复杂的内部操作,单纯就是箭头函数的作用下直接”继承“了show函数的this绑定,稳就一个字,谁也别想干扰我。

当然了,有些人(面试官)说:

“啊不不不不,我就不用箭头函数,我也不要用你那个难看的this指定,我要简单!简单利落的写法!”

话音刚落,你摔门而出(×)

只见你沉着了下来,在纸上刷刷刷写出让对方眼前一亮的写法:

var name = 'Yuki';
var examCount = 0;
var card = {
  name: 'Haruhi',
  examCount: 0,
  show: function showSum() {
    if (this.examCount < 1) {
      setTimeout(
        function sum() {
          console.log(this.name);
        }.bind(this),
        1000
      );
    }
  },
};
card.show(); /* Haruhi*/

好家伙,你直接利用bind()来直接套牢了setTimeout!直接将this又绑给了show的引用对象!

(注意,利用bind绑定this,不会立即执行该函数)

面试官:......什么时候来上班?

玩笑归玩笑,利用bind绑定对应的this对象,事实上是最稳妥的一种方法。

关于理解bind的传送门->developer.mozilla.org/zh-CN/docs/…

事实上,我是最推荐第三种写法的,如果你大量使用第二种显示指定this的绑定对象,确实会让代码变得既不优雅也显得混乱。

2.理解this到底指向谁

2.1this指向函数?

this和函数的关系

function iPhone(version) {
  console.log('this is iPhone' + version);
  this.count++;
  }
  /* 我们为iphone函数里面的count赋值 */
iPhone.count= 0;
for (var i = 0; i < 17; i++) {
  if (i >= 8) {
    iPhone(i);
  }
}
console.log(iPhone.count); /* --> 输出结果为0 */

我们首先把version和count区分开来,version确实得到了正确的输出,但是作为计数器的count却为0,这不对啊,不是说好了this绑定指向函数自身么?

事实上,我们执行iPhone.count=0时,确实为函数自身添加了一个名为count的属性,但是iPhone函数内部的this.count的this并未指向iPhone函数内部的局部变量count。

this.count的查询流程

  1. iPhone函数内部的this.count,它首先是一个隐式赋值的操作(this.count=this.count+1),会基于LHS查询在由里向外一层层查询count。

  2. 当查到顶层都未查询到count时,他会自动为count在顶层创建一个值为undefined的属性(非严格模式,严格模式下报ReferenceError错误)。

  3. 随后的this.count++操作直接导致了数值算法的识别错误,返回一个NaN(undefined类型运用于数值运算会返回NaN)。

当然了,聪明的你肯定想到了好方法来解决这个问题啦,那就是:

 用什么this!老夫从来都是具名函数名指哪打哪,函数名运用一把梭!

function iPhone(version) {
  console.log('this is iPhone' + version);
  iPhone.count++; 
}  
  /* 初始化iphone的count */
  iPhone.count = 0; 
  for (var i = 0; i < 17; i++) {
    if (i >= 8) {
      iPhone(i);
    }
  }
    
console.log(iPhone.count); /* 9 */

 利用具名函数来指向具体的对象,这下确实是出来了。

但问题是如果我们这么操作,那就又回到了词法作用域的应用范围,而没有利用this完成对应的操作,而且一旦遇到以下存在需要setTimeout利用回调函数的情况,立刻就无效了。

for (var i = 0; i < 17; i++) {
  if (i >= 8) {
    (function (j) {
      setTimeout(function () {
        console.log('this is iPhone' + j);
        iPhone.count++; 
      }, 100);
    })(i);
  }
}
console.log(iPhone.count); /* //报错,iPhone is not defined */

 传入setTimeout的回调函数直接就变成了匿名函数,压根没法在函数内部对自身进行再引用,故具名函数的应用,存在较大的局限性。

上述的例子你就算写成行内函数表达式(setTimeout(function iPhone(){......}))也没有用,外部也根本访问不到。

事实上,第二种方法完全是基于词法作用域的理念指导下实现的,众所周知,this的指导思想类似于动态作用域,要遵循函数调用的位置去指向具体的对象。

利用具名函数手动指向对应对象的方法,就是基于函数iPhone的词法作用域去实现调用对象的绝对指向,完全背离了我们用this的宗旨。

 说到这里,其实很多读者已经想出了最佳的解决方法。

 那就是call()/apply()/bind()。 

 首先我们要知道一件事情,那就是函数也属于对象,函数可以通过直接设置属性或者通过this设置属性值。。 

function iPhone(version) {
  console.log('this is iPhone' + version);
  this.count++;
} 
iPhone.count = 0;
for (var i = 0; i < 17; i++) {
  if (i >= 8) {
    iPhone.call(iPhone, i); 
  }
}
console.log(iPhone.count); /* --> 9 */

通过call来保证this指向函数本身,是一种最佳的解决方案。 

 与此同时,通过这个例子,你可以发现:

this根本就不会指向函数的词法作用域,它的指向取决于函数执行的上下文环境。

 下面我们通过一段经典代码来看一下这个大坑,包括笔者在内经常都会遇到这个问题:

function apple() {
var a = 1;    
this.orange();  
} 
function orange() {
console.log(this.a)
}  
apple()

你们猜最后打印出来的是啥? 事实上根据RHS查询,最后打印出来的应该a is not defined的ResferenceError类型的报错,但是当前版本的谷歌浏览器会倾向于将其输出为undefined(非严格模式)。

当apple被调用时,是基于全局作用域进行的调用,当它调用内部的orange时,确实函数是基于全局作用域被找到并且调用了,以至于orange内部输出的this指向的是window。 

 this:你随便定义,我要是一头撞进你的词法作用域里面算我输。 

有人可能会觉得,哎?那我要是再在这里利用call绑定apple的this不就行了吗?那我不也可以绑定对应函数的词法作用域?

很遗憾,并不可以,请看代码:

function apple() {
  var a = 1;
  orange.call(apple);
}
function orange() {
  console.log(this.a); 
  console.log(this);
}
apple();

this.a并没有指向对应函数apple的词法作用域中的属性a,尽管this指向的是函数apple本身,但并不指向apple函数的局部变量,函数内部的局部变量不是函数的属性

在 JavaScript 中,函数的局部变量和函数的属性是两个不同的概念。

在这个修改后的代码中,this.a = 1; 这行代码将 a 设置为 apple 函数的一个属性。然后,orange.call(this); 这行代码将 orange 函数的 this 设置为 apple,所以 this.a 在 orange 函数中的值是 1

var a = 1; 这行代码在 apple 函数的作用域内定义了一个局部变量 a这个变量只能在 apple 函数内部访问,而不能通过 apple.a 或 this.a(当 this 指向 apple 时)访问。

如果你想让 a 成为 apple 函数的一个属性,你需要这样写:

function apple() {
  this.a = 1;
  orange.call(this);
}

和前面利用call绑定函数本身并传参的打印结果(见下)的区别在于:

前面代码最后打印的是全局作用域下iPhone函数本身的内部值(直接使用词法引用标识符,基于词法作用域查询),而这里使用的是函数内部的this(确实指向函数,但不指向函数的词法作用域),足以看出this并没有追踪对应函数的词法作用域的能力。

console.log(iPhone.count); /* --> 9 */

为什么会这样,只需要记住一句话就够了:

this 只能访问其指向的对象的属性,不能访问对象内部的局部变量。

2.2如何判断this的调用位置

我们必须要明确一个概念,this是类似于动态作用域机制的产物,其产生绑定效果时,是基于运行时绑定,而非编写时绑定,和调用位置没有任何关系,只取决于你的上下文究竟是怎样调用的。 

函数被调用时,会创建一个执行上下文,这个执行上下文包括了被调用的位置,函数以何种形式来进行调用,以及对应的传入参数等上述信息。

而this,就是这个记录的一个属性,我们在函数执行的过程中,将会运用到this。

在 JavaScript 中,this 并不指向函数的词法作用域。this 是在运行时基于函数的执行上下文动态绑定的,它可能指向一个对象,也可能指向全局环境,或者是 undefined(在严格模式下)。

词法作用域是在函数定义时确定的,而不是在函数调用时。它是由函数被声明时的位置决定的。词法作用域关注的是变量和块作用域。

相反,this 的值是在函数调用时确定的,它取决于函数是如何被调用的。例如,如果一个函数被作为对象的方法调用,this 将指向那个对象。如果一个函数被作为普通函数调用,this 将指向全局对象(非严格模式)或 undefined(严格模式)。

唯一的例外是箭头函数。箭头函数不绑定自己的 this,它会捕获其所在(即定义的位置)上下文的 this 值。这种行为类似于词法作用域的规则。

如果利用call/bind/apply使得this同向绑定到对应的函数,那也无法利用this追踪到对应函数的词法作用域。

我们在关注this的调用来源时,首要关注的是当前执行函数的前一个调用,你可以将杨宗纬的《洋葱》中的那句“一层一层剥开你的心”作为理解this调用的抽象记忆。

下面我们直接来看一下前面贴的代码的改版,这是一个动态作用域的调用演示案例,举个🌰:

function apple() {
  /* 当前调用栈是apple,如果它位于全局作用域,那么当前调用位置就是全局作用域 */
  /* 如果它的上一层调用上下文是属于某个函数的执行上下文,它就属于某个函数 */
  var a = 1;
  orange();
}
function orange() {
  /* 当前调用栈属于orange->apple,目前的调用位置被锁定在apple! */
  console.log('我在中间这层!');
  banana();
}
function banana() {
  /* 当前调用顺序是 banana->orange->apple 噢!我的真正“指挥者”是apple! */
  console.log('我在最里面那层!');
}
apple();

注意,我们可以看出apple是基于全局作用域来调用的,而函数被追踪出来的调用位置将决定了this的绑定位置。

总结:决定函数this指向的是函数在哪里被调用,this指向的是函数执行位置的上下文动态绑定的。

2.3this的绑定规则

首先,this的绑定规则分为四个大类,分别是默认绑定、隐式绑定、显示绑定和new绑定,这四个绑定规则将使得我们对this的调用思路更加清晰化和明了化。

当然,我们在运用这四个规则时,你必须要自己找到this的“真正”调用位置。

2.3.1默认绑定

下面举个🌰

(非严格模式)

var a = 5;
function apple() {
  var a = 1;
  console.log(this.a);
}
apple();

你猜结果是多少?

相信经过前面讲解的你已经可以得出结论了:没错!是5!

函数apple在运行的过程中,this.a遵守默认绑定原则,直接对准自身函数的调用位置

下面模拟一下a的查询之路:

  • apple里面的a(向this提问):嘿老哥,我是a的影子斥候,你从哪把我调用的我的真身?
  • this:稍等,我问一下编译器。
  • 编译器:滚滚滚,你这种运行时生成的代码别问我(this在函数运行时生成),去问JS引擎,我是在代码执行前进行静态编译的!
  • this:呃...好吧,那引擎大哥,我带来的a到底是谁派来的啊?
  • JS引擎:咳咳咳等会,你是执行栈里面的还是调用栈的...嗯嗯...是执行栈里面的兄弟啊,我看看....哦找到了,你的老大apple函数是在全局作用域调用的,在那个作用域里面有一个属性a,去找吧。
  • this:好的老哥!我这就去告诉a!嘿!a!我们去全局作用域里面找!
  • a:好嘞,我这就去找...嗯,我找到我的真身了!它的值是5!

此时你会发现,apple函数的调用压根没用任何修饰符(XXX.apple()),所以会被强制应用默认绑定,其他绑定原则不可生效。

一旦使用默认绑定,那就基于最终调用上下文的位置,决定了其this指向。

再举一个小例子,了解一下默认绑定原则的间接引用可能会引起的问题:

function fruit() {
  console.log(this.a);
}
var a = 3;
var apple = { a: 2, fruit: fruit };
var banana = { a: 5 };
apple.fruit(); //2
banana.fruit = apple.fruit;
banana.fruit();  //5

我们可以看到,banana.fruit调用的是fruit函数本身,但由于其修饰符为banana,所以调用位置基于banana的对象上下文去查找a,这里会直接使用默认绑定原则寻找this.a。

当然了,你有没有发现开头我写的那个“(非严格模式)”呢?

在严格模式下,全局作用域不可应用于默认绑定规则,最终this是谁也找不到,报一个TypeError:this is undefined的错误。


var a = 5;
function apple() { 
  "use strict"
  var a = 1;
  console.log(this.a);
}
apple();

注意,TypeError代表的是属性被找到了,但是将属性运用于后续的操作,导致属性类型和操作方式发生了冲突,从而产生了 TypeError

决定this绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式,如果函数体处于严格模式,this会被绑定到undefined,否则会被绑定到全局对象。

如果你在全局作用域下,基于严格模式用立即执行表达式来在内部引用apple(),事实上是可以找到全局作用域中的a值的,当然这个没有太大的讨论意义。

2.3.2 隐式绑定

在这里,我们要基于前面说的默认绑定里面提到的“apple函数的调用压根没用任何修饰符”,继续讲讲隐式绑定。

你还记得开头的第三段代码吧?

var name = 'Yuki';
var examCount = 0;
var card = {
  name: 'Haruhi',
  examCount: 0,
  show: function showSum() {
    if (this.examCount < 1) {
      setTimeout(
        function sum() {
          console.log(this.name);
        }.bind(this),
        1000
      );
    }
  },
};
card.show(); /* Haruhi*/

来,我们看一下show()的声明方式,你还记不记得我在讲这段代码的时候,刻意提到过“show无论是直接在card中先定义还是再添加为引用属性,这个函数严格来说都不属于card对象,card拥有的只不过是该函数的引用,并非函数本身。”

同学们!大家要注意看这段话!

尽管show不属于card对象,但是在全局作用域使用card.show()来引用函数时,调用位置正是card对象!

我们不要混淆概念,因为很多同学心里面都在默认“啊那个函数也属于对象啊,啊那个函数也属于对象啊”,师父你先别念了,听我下面要说的:

对象内部可以包含对函数的引用,且this调用位置存在上下文对象!—— 这是最关键的分水岭。

同时笔者在某种程度上认为,对象不构成作用域,而函数是会构成作用域的(存疑,大家先别确定这句话,只是笔者在研究JS的时候自由心证的结论)。

function sum里面的this在查询自身归属时会发现,show()方法存在card上下文对象的引用,当函数引用存在上下文对象(card)时,隐式绑定会把函数调用内部的this直接绑定在这个上下文对象,故this.name===card.name。

同时,对象属性的引用规则只有在最后一层调用位置中有效,你可以武断的理解为:带对象引用的函数最后在的引用符是哪个上下文对象,那它的this就指向这个上下文对象!

function mac(){
  console.log(this.a);
}
var apple={
  a:'M1 PRO',
  mac:mac
}
var intel={
  a:"11900k",
  apple:apple
}
intel.apple.mac() //输出为M1 PRO!哎嘿!我还是用了自家芯片!

我们可以看到,mac的最终调用位置为apple,this.a自然输出的是"M1 PRO"。

大坑:隐式绑定的丢失

实际上,这个隐式绑定还是非常不靠谱的。

上面说到的隐式函数绑定看起来很稳,但事实上隐式绑定中的绑定对象是个非常不靠谱的主儿,借此套用一句中学教科书的名言:

“我的附庸的附庸不是我的附庸”。

看这!

function pineapple() {
  console.log(this.a);
}
var apple = {
  a: '我是苹果!',
  pineapple: pineapple,
};
function fruit(mine){
//实际调用的是pineapple本身
mine();
}
fruit(apple.pineapple); 
var a = '我是全局作用域的数据';
fruit(); //调用位置在全局作用域,输出 '我是全局作用域的数据'

虽然fruit调用时的参数apple.pineapple,看起来像是apple对象内部的pineapple函数基于mine()而被调用,但实际上被调用的根本不是apple内部的pineapple,它是基于全局作用域直接引用了pineapple本身,其在调用的时候没有带任何修饰符,直接在非严格模式下应用了默认绑定。

apple:没想到吧,你要是直接调用我的pineapple引用,那压根和我没关系。

全局作用域下,假设没有a这个同名属性,同样也无法生成输出结果,因为当前执行上下文根本没有这个值。

我们需要记住一件事情,参数的传递事实上是一种隐式赋值(LHS查询)。

隐式赋值时,被赋值的参数会根据调用函数实际所在执行上下文的位置(上述例子中的apple.pineapple实际上调用的是pineapple,而执行调用的fruit位于全局作用域),来决定this的调用。

即便我们将pineapple写入apple对象,我们最终看的也是调用上下文fruit的实际调用位置来决定。 pineapple写入了apple对象,pineapple实际上也不属于apple对象,apple里面的Pineapple保存的是实际的函数对象的一个内存地址,指向对应函数对象的堆栈空间保存的数据。

就算你使用ES6的简介语法声明(不需要function,直接在对象内定义pineapple(){...})也是一样的,前者只不过是利用匿名函数赋值的一种语法糖,没有改变JS的规则。

总结一下:

1.无论是基于对象调用对象内部的函数,还是通过声明一个属性,以此属性引用对象内部的指向函数属性“间接”调用,实际上都看最终调用者的调用位置。

2.对象引用函数只是函数引用而非函数本身,但我们确实可以通过上下文对象引用函数的方法将this绑定到上下文对象上面去,从而读取对象上面的值。

接下来,我们可以来利用显示绑定聊聊如何修复这个问题。

2.3.3 显式绑定

如果我们想在一个对象上强制调用某个函数,令其this指向本对象,而不想再用上述绕弯弯一般的“对象内部包含指定函数引用“的方法,我们该怎么做呢?

相信很多人已经想到了,那就是

1.function.call(thisArg, arg1, arg2, ...)

2.function.apply(thisArg, [argsArray])

3.function.bind(thisArg, arg1,arg2,...)

call和apply的第一个参数指定是一个对象,用于指定this,将调用函数指向对应的对象。第二个参数为携带参数,用来传递函数的额外参数。

这,就是显式绑定。

var a = '不!你不是!';
var apple = {
  a: '我是凤梨',
};
function pineapple() {
  console.log(this.a); 
}
pineapple.call(apple); //输出结果->'我是凤梨'

如果我们利用闭包结合显式绑定的方式来绑定对应的上下文对象,可以牢牢地将this绑定到对应的上下文对象,无论以后怎么改都不会变更绑定后的this绑定主体,这种方法可以称之为显式绑定的硬绑定

function pineapple() {
  console.log(this.a);
}
var apple = {
  a: '我是凤梨',
};
var a = '不!你不是!';
var fruit=function(){
  pineapple.call(apple)
}
setTimeout(fruit, 100); //'我是凤梨'
fruit.call(window); //'我是凤梨'

无论我们从何处调用fruit或者更改fruit的指向,它都会牢牢地在apple上调用pineapple函数,这就是显式硬绑定,和bind绑定效果是等效的(但是两者代码架构完全不一样,只是效果一致)。

我们可以通过显式硬绑定原理创建一个简单的组合辅助函数compose,来进一步了解显式硬绑定的原理:

function apple(num) {
  console.log(this.a, num);
  return this.a + num;
}
function compose(fn, obj) {
  return function () {
    return fn.apply(obj, arguments);
  };
}
var market = {
  a: 1
};
var superMarket = compose(apple, market);
var getSums = superMarket(5)
console.log(getSums);
  1. 我们通过superMarket调用compose,并将apple函数作为第一个参数传入,market对象作为第二个参数传入。

  2. 现在我们来看compose,在得到调用后返回一个匿名函数function () {    return fn.apply(obj, arguments)},该匿名函数符合闭包的创建条件,执行完成后,compose函数被回收,其apple函数和market对象和匿名函数的作用域依然被保留,并赋值给superMarket。

  3. 随后通过getSums调用superMaket,并且隐式赋值传入一个参数5。

  4. 毫无疑问,apply函数中的arguments接收了参数5(LHS查询),返回一个apple.apply(market,5)的结果,apply函数会自动运行,打印出1 5这样的结果。

  5. 随后fn.apply(obj,arguments)被回收,返回一个6的结果,apple函数也被回收(返回基本数据类型不会构成闭包),最终返回的结果6由getSums接收。

  6. 打印getSums的值。

核心代码:

 return function () {
    return fn.apply(obj, arguments);
  };    //显式硬绑定

通过这个组合辅助函数,我们可以逐个生成对应的闭包函数,在apply函数自动执行后,superMarket(5)也会被回收,达到了打印携带参数值和返回结果的效果。

而apple函数里面的this.a直接指向了我们前面说的显式硬绑定market对象,牢牢地在对应的对象上调用对应方法,而不受外界影响。

2.3.4 new绑定

在讲解new绑定开始之前,我们首先要对构造函数有一定的了解。

JavaScript中的构造函数在使用new操作符调用函数时,和其他语言的操作截然不同!

JS中使用new调用的函数只是普通函数,而并非是一个真正的类,事实上JS中根本不存在真正的类,通过new调用的函数并不会真正去实例化生成某一个类。

function pineapple(num) {
  this.num = num;
}
var apple = new pineapple(5);
console.log(apple.num); //5

用new来调用函数在ES5的语言规范中,概括来说其定义是“构造函数调用”,JS中使用new来调用函数,会执行以下操作:

  1. 根据被调用的函数从而构造出一个全新的新对象。
  2. 这个全新生成的对象的[[prototype]]属性会自动关联到new调用的构造函数
  3. 生成的新对象会绑定函数调用中的this,也就是new调用的构造函数中的this会在构造完成后指向新对象。
  4. 若是碰到new调用的函数内部没有return一个指定的对象,那么new表达式的函数调用也会返回给这个新生成的对象。

这就是最后一种this绑定——new绑定。

2.3.5 绑定的优先级顺序

了解各种绑定的优先级有利于我们对项目的功能代码进行迭代,也能快速排查错误。

我们从前面的例子大概可以了解到,默认绑定的优先级处于最低级。

那么显式绑定、隐式绑定和new绑定的this绑定优先级哪个更高呢?

function showPriority(num) {
  this.a = num;
}
//定义一个对象,内部指定了showPriority的函数引用
var apple = {
  showPriority: showPriority,
};
var banana = {};
//隐式绑定
apple.showPriority(5);
console.log(apple.a); //5
//显式绑定,变更绑定对象为banana并携带参数2,根据输出结果可以看出显式绑定优先级高于隐式绑定
apple.showPriority.call(banana, 2);
console.log(banana.a);  //2
//new绑定
var orange=new apple.showPriority(4);
//注意!apple对象的a没有被改变
console.log(apple.a); //5
//new绑定优先级高于隐式绑定,orange的内部属性a的值为new绑定时的携带参数4
console.log(orange.a); //4

我们可以看到,利用new绑定构造了apple.showPriority()之后,apple对象的属性a并未受到影响,只是构造了一个全新的对象并赋值给orange,但是可以看出new绑定的优先级是高于隐式绑定的。

我们目前可以得出一个结论,new绑定和显式绑定优先级均高于隐式绑定。

接下来我们继续比较new绑定和显式绑定的优先级:

首先我们需要明确一点:new和call/apply不能组合使用,故不可直接比较new绑定和显式绑定的优先级,但我们可以利用bind来确定前述两者的优先级。

bind和组合辅助函数compose虽然运行运行效果极其相似,但是实际上代码架构是完全不一样的,请看以下代码:

function apple(params){
  this.a=params
}
var obj={}
var banana=apple.bind(obj);
banana(2);
console.log(obj.a); //2
var orange=new banana(3)
console.log(obj.a); //2
console.log(orange.a); //3

你会发现bind绑定被new绑定直接“劫持”了banana()到obj的this指向,使apple应用函数里面的this指向了新生成的orange对象!

我们前面在讲解显式硬绑定的时候,执行显式硬绑定的函数可是无论如何都不会被变更this绑定对象的。

这是因为如果我们使用类似显式硬绑定效果的bind绑定进行绑定,new绑定是可以更改bind绑定的this绑定对象。

那这时你可能会问:你刚刚不是说bind绑定效果等同于你上面写的显式硬绑定函数吗?为什么显式绑定不能用,而只能用new绑定结合bind绑定?那如果我就要结合new绑定用显式硬绑定呢?

很遗憾,请务必记得,new绑定和call/apply是不能一起使用的,我们前面自定义的显式硬绑定polyfill最终生效还是要靠apply/call来整体实现类似bind绑定的效果,因为new调用的返回值并非函数,只是单纯的基本数据类型,导致最后生成的只有一个空对象。

    function apple(num) {
      return (this.a = num);
    }
    function compose(fn, obj) {
      return function () {
        return fn.apply(obj, arguments);
      };
    }
    var market = { a: 1 };
    var superMarket = compose(apple, market);
    var getSums = superMarket(5);
    console.log(getSums); //5
    console.log(market.a); //5
    var newBind = new superMarket(3);  //本质上是 new fn.apply(obj,arguments)
    console.log(market.a); //3
    console.log(newBind); //{} 针对数字3进行new绑定,只会默认返回一个空对象

这就涉及到一个关键的知识点: 当你使用 new 关键字调用一个经过 bind 绑定的函数时,会发生以下几件事情:

  1. 创建一个新的空对象。
  2. 这个新对象的 [[Prototype]] 属性会被设置为绑定函数的 prototype 对象。
  3. 绑定函数会以这个新对象作为 this 的上下文被调用。
function Fruit(name) {
  this.name = name;
}

let Apple = Fruit.bind(null, 'Apple');

let apple = new Apple();

console.log(apple.name); // 输出 "Apple"

在这个例子中,Apple 是 Fruit 绑定了参数 'Apple' 的结果。当我们使用 new 调用 Apple 时,this 会被绑定到新创建的对象,而 bind 的第一个参数 null 会被忽略。所以,新创建的对象的 name 属性会被设置为 'Apple'。

1.new修改this的时候,会判断bind函数是否被new调用,如果是的话就用新创建的this替换bind绑定的this,bind只不过是把第一个参数用于绑定this,其他的传递参数直接传递应用该绑定函数的下层函数,可以理解为一种函数的柯里化。

2.new绑定结合bind绑定使用,不会修改bind绑定原有的绑定对象,因为它会生成一个新的对象,将全新的赋值和生成的对象交给接收属性。

因为我们首先通过bind绑定的接收函数Fruit是已经被定义了,但并没有被调用,只是保留了携带参数,整个绑定你可以理解为实现的是默认绑定原则,根据apple的创建位置直接绑定到了全局对象。

结论

new绑定>显式绑定(包括了类显式硬绑定效果的bind绑定)>隐式绑定>默认绑定。

3.进阶理论

如果我们选用null和undefined来作用this的绑定对象,以此来应用call,apply,bind,实际上调用的函数还是会默认生效的,只不过对应的this会被自动忽略,最终应用的依然是默认绑定原则。

但是我们非常不推荐使用这种方法,如果按照上述的法则进行绑定,根据默认绑定规则会直接把this指定为全局对象(window),这是非常危险的操作。

function fruit(a, b) {
  this.num = a + b;
  console.log(this.num); 
}
var apple = fruit.call(null, ...[1,2]); //3

我们可以看到,如果按照上述方法来进行null调用,会直接导致this绑定依照默认绑定规则将this绑定到了全局作用域。

3.1 利用空非委托对象指定一个“空”的this

事实上,根据上述的利用null来生成一个空对象是不保险的。

我们存在一种安全的改进方法,那就是通过一个空的非委托对象来替换null,作为一个暂时提供“跳板”作用的空对象,但是其空对象内部不会存在[[prototype]]原型链。

function fruit(a, b) {
  this.num = a + b;
  console.log(this.num); //3
}
var emptyZone = Object.create(null);
var apple = fruit.call(emptyZone, ...[1, 2]);
console.log(this.num);  //undefined 全局作用域上并没有num

这使得this绑定不会遵循默认绑定原则直接被“丢”到全局作用域,防止全局作用域修改对象内部值,可以说是通过建立一个这样的“缓冲区”隔绝了冲突风险,还能随时供应new绑定来调用。

3.2 利用软绑定“改造”显式硬绑定

我们通过显式硬绑定来将this牢牢地绑定到指定的对象,尽管我们可以通过bind绑定(类显式硬绑定)结合new绑定的方式来更改this绑定指向,但是终究还是不够灵活。

下面,我们通过《你不知道的JavaScript》上册来了解一下软绑定,笔者已经进行了细致的讲解和标注:

    if (!Function.prototype.softBind) {
      Function.prototype.softBind = function (obj) {
        var fn = this;
        var curried = [].slice.call(arguments, 1);
        var bound = function () {
          return fn.apply(
            !this || this === (window || global) ? obj : this,
            curried.concat.apply(curried, arguments)
          );
        };
        bound.prototype = Object.create(fn.prototype);
        return bound;
      };
    }

下面我们来根据上述代码逐个讲解:

  1. var fn = this;:这行代码获取了调用 softBind 方法的原始函数。

  2. var curried = [].slice.call(arguments, 1);:这行代码获取了传递给 softBind 方法的额外参数(除了第一个参数之外的所有参数)。

  3. var bound = function () {...}:这行代码定义了一个新的函数。这个新函数在调用时会调用原始函数,并使用特定的 this 值和参数。

    • !this || this === (window || global) ? obj : this:这行代码决定了新函数的 this 值。如果新函数的 this 值是 undefinednull 或全局对象,那么 this 值将被设置为 softBind 方法的第一个参数。否则,this 值将保持不变。
    • curried.concat.apply(curried, arguments):这行代码获取了新函数的参数,并将它们添加到 curried 数组的末尾。这样,新函数在调用时会使用 softBind 方法的额外参数和自己的参数。
  4. bound.prototype = Object.create(fn.prototype);:这行代码将新函数的 prototype 属性设置为原始函数的 prototype 属性的一个新实例。这样,新函数可以继承原始函数的方法。

  5. return bound;:这行代码返回新函数。

总的来说,softBind 方法允许你创建一个新的函数,这个新函数在调用时会使用特定的 this 值和参数。但是,与 bind 方法不同,softBind 方法允许你在调用新函数时覆盖 this 值(除非 this 值是 undefinednull 或全局对象)。

我们直接来看例子:

function foo() {
  console.log('name:' + this.name);
}
var obj = { name: 'obj' },
  obj2 = { name: 'obj2' },
  obj3 = { name: 'obj3' };
var fooOBJ = foo.softBind(obj);
fooOBJ(); //name:obj
obj2.foo = foo.softBind(obj);
obj2.foo(); //name:obj2 -->this绑定被变更到obj2
fooOBJ.call(obj3); //name:obj3 -->this绑定被变更到obj3
setTimeout(obj2.foo, 10);
//name:obj->应用了全局对象,因为我们在这里并没有设置接收的调用函数

可以看到,我们成功的“改造”了显式硬绑定函数,令其this绑定可以更改到指定的对象。

3.3 真正的“扫地僧”->箭头函数

我们是否还记得一开始的“开胃小菜”,那就是结合箭头函数来绑定this?

在这段代码里面,this死死的指向了show函数的调用对象,任凭谁也无法改变。

show: function showSum() {
  if (this.examCount < 1) {
    setTimeout(() => console.log(this.name), 1000);
  }
},

首先,我们需要明确一点定义:箭头函数并没有使用function关键字来定义,它不适用于上述的this的四大绑定,只认一个死理——“外层作用域的this是谁,俺的this就是谁!”

我们改造一下上述代码:

function showCum() {
  /* 返回箭头函数 */
  return () => {
    /* this继承showCum的调用位置 */
    console.log(this.a);
  };
}
var apple = {
  a: 3,
};
var banana = {
  a: 4,
};
var fruit = showCum.call(apple);
fruit(); //3
fruit.call(banana); //3 哦豁!改不了!

你会发现,showCum函数内部的箭头函数自从一开始绑定了apple函数的this调用以后,引用了apple对象的fruit的this也会被牢牢绑定到apple对象上面,任凭如何改变this指向,都没有任何作用。

其实箭头函数应用的正是词法作用域,它没有应用任何this绑定方法,也不允许任何this绑定方法更改其this指向,我们通过下一段代码更深入窥探箭头函数绑定this的实现原理:

function showCum() {
  var self = this;
  return function () {
    console.log(self.a);
  };
}
var apple = {
  a: 3,
};
var banana = {
  a: 4,
};
var fruit = showCum.call(apple);
fruit();
fruit.call(banana);

我们在showCum被回调函数fruit所调用时,showCum已经将this绑定指向了apple对象,也就意味着,在此处我们手动绑定了self的this已经指向了apple对象。

其内部的匿名函数function () { console.log(self.a); };被返回,在这里其实产生了一个闭包,也就是self作为showCum的内部词法作用域被保留,在showCum被调用完毕回收后,self和内部匿名函数并没有被回收,而是返回给了fruit。

在fruit()执行后,该内部匿名函数和self被推进了执行栈进行执行,在执行完毕后才被回收。

再看最后一行代码fruit.call(banana);,虽然此时再次执行了call()绑定和回调,但是已经“为时已晚”,此时的self早已经被绑定在了apple身上,就算再执行了一次this绑定指向,self也早已被手动指向了apple对象。

总结

  1. this四大绑定的优先级排序为:new绑定>显式绑定(包括了类显式硬绑定效果的bind绑定)>隐式绑定>默认绑定。而箭头函数不受this绑定的影响。

  2. new调用会自动生成一个全新的对象,并将this绑定指向新创建的全新对象。

  3. 上下文对象的调用,一定要确定其修饰符的最终指向,谁最后调用了this,那this就指向谁。

  4. 箭头函数会继承外层函数调用的this绑定,原理可见2.4.2.

  5.   隐式绑定中的函数引用有上下文对象时,隐式绑定会将函数中的this绑定到这个上下文对象,此时这个上下文对象拥有该函数的引用而非函数本身。

  6. this不指向函数的词法作用域。

  7. 在new绑定结合bind绑定时,可利用函数的柯里化方法利用bind预设部分参数,再利用new初始化指定对应的绑定对象。

完毕!希望大家可以通过本篇,彻底精通this的原理~