this的指向原理浅谈

370 阅读18分钟

前言

之前写博客,经常需要引用一些基础的内容,每次都花不少时间找合适的文章,索性花点时间自己写,也当是巩固下基础。于是有了这个系列(JS核心基础)的文章。

目前已经完成的文章:

js从编译到执行过程 - 掘金 (juejin.cn)

从异步到promise - 掘金 (juejin.cn)

从promise到await - 掘金 (juejin.cn)

浅谈异步编程中错误的捕获 - 掘金 (juejin.cn)

作用域和作用域链 - 掘金 (juejin.cn)

原型链和原型对象 - 掘金 (juejin.cn)

概述

关于this指向的问题,网上已经有很多文章,但是大部分只是讲结论,让开发者记住就行。实际上,this的指向有着更深层的原理。和执行上下文、作用域链、原型链的知识息息相关。本文将从这个角度重新梳理this的指向问题。

先帮助读者明确this是js对象体系的东西。它并不会指向函数自身,也不会指向作用域,更不会指向执行上下文。

然后阐明this到底是个什么东西,何时生成,存储在哪里,指向何方。

接着讲解显示绑定改变this指向的call/bind/apply/new的实现原理。

最后讲解特殊的箭头函数的this规则。

一,理解this需要的前置知识

首先,在理解this前,需要明确变量和属性的访问区别。

当我们访问变量的时候,是查找的作用域链;访问属性的时候,是查找的原型链。(原型链是给对象体系使用的)。而this,和原型链一样,是给对象体系使用的。this.a访问的是属性(或者方法)。

function dog() {
  this.age="18"
  this.eat=function(){
    console.log("吃东西")
  }
}
function cat(){
  const age="17"
  function eat(){
    console.log("吃东西")
  }
}
dog()
console.log(dog.age)//undefined
console.log(cat.age)//undefined

如上奇怪的代码,我们是把dag和cat都当作对象(查找),访问其中的属性(上文代码中的写法实际上并未创建属性)。

当访问到dog和cat的时候,是利用的作用域链。进而访问age属性的时候,就是在原型链上查找。

当执行完dog()之后,它的内存图如下:

1,内存情况.drawio.png

原型图如下:

2,function的实例对象.drawio.png

可以看到,dog和cat原型链上并没有age属性,所以才会打印undefined。

修改代码看打印情况:

function dog() {
  this.age="18"
  this.eat=function(){
    console.log("吃东西")
  }
}
//第一步
dog()
console.log(age)//18
eat()//吃东西
//第二步
dog.age=5
console.log(dog.age)//5
第一步,执行dog()之后,因为this指向的是全局,所以会在全局创建age属性和eat方法。因而能够直接访问age和调用eat。
第二步,dog.age=5在dog对象上新增了这个age属性,所以dog.age这时候能打印出来。

这一节读完后,读者需要明白函数和对象虽然本质上是同一个东西(见我原型链那篇文章),但是我们在实际使用时,还是要有对象和函数的区分。特别是我们使用this的时候,就是用的对象体系。这在下文call的实现那里体现尤其明显。

foo.fn.myCall(A,"猪八戒",200)//见下文手写call

这里的fn就是当对象来看,执行其中的mycall方法。

二,对this指向常见的两个误区

像《你不懂的Javascript》一书中一样,需要读者修正两个常见的理解错误。

2.1,this不是指向函数自身

对于这一点,很容易验证,如下代码:

function dog() {
  this.age="18"
  this.eat=function(){
    console.log("吃东西")
  }
}
dog.age=5
dog()
console.log(dog.age)//5

如果this指向的是函数自身,也就是dog,在执行完dog()之后,打印值应该是18才对。现在打印的是5,说明this并没有指向dog。

2.2,this不是指向作用域或者执行上下文

function foo() {
  var a = 2;
  var obj={
    a:4,
    bar:bar
  }
  function bar(){
    var a=3
    console.log(this.a)
  }
  obj.bar()
}

foo(); //4

如上代码,当执行console.log(this.a)的时候,一共生成了3层执行上下文:

3,this的执行上下文.drawio.png 之前这篇文章js从编译到执行过程 - 掘金 (juejin.cn)的第四节讲到,如果这里的this指向的是作用域,按照很多人理解的,是在foo中调用bar,那么应该指向foo的作用域。看上图,也就是foo执行上下文中的变量环境和词法环境,但是可以看到其中的a是为2,并不是最后打印的4,所以this指向作用域或者执行上下文的说法都是错误的。

三,this到底指向什么

还是这篇文章js从编译到执行过程 - 掘金 (juejin.cn),其中2.2小节有讲到执行上下文的创建,在创建执行上下文的时候,就是在创建:变量环境和词法环境(这两者构成它的作用域)和this的绑定。

那这个this到底是啥?指向谁?

实际上this指向的是一个对象。注意它是指向一个对象,不是函数,也不是作用域,也不是执行上下文,它只是执行上下文中的一个属性(看第二节中的图就能明白)。

所以我才一直在上文强调对象属性的访问用的是原型链而不是作用域链。并且在代码执行期间确定。

也就是上文说的:this指向的是对象,this.a访问的是属性,所以走的是原型链。不会访问作用域链。

那指向的是啥对象:this指向的是当前执行上下文(活动对象)的调用对象

如上文第二节的代码,当前执行上下文是bar,这个上下文的调用对象是obj,所以其中的this就指向obj,所以this.a就等价于obj.a,最后打印4。

理解了this指向的是个对象,而不是其他之后,才能继续往下理解更多this的指向规则。

四,this的指向原则

首先需要理解调用位置:调用位置就是函数在代码中被调用的位置(而不是声明的位置)。这其中最重要的是要分析执行栈。我们关心的调用位置就在当前正在执行的函数的前一个调用中。

如下代码(选自《你不知道的js》):

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 的调用位置

对应的执行栈的情况如下:

4.this的调用位置.drawio.png

按照我上文一直强调的概念,this指向的是当前执行上下文的调用对象。例如执行到foo()内部代码的时候,当前调用栈是全局-> baz -> bar->foo,当前执行上下文(也就是一些文章说的活动对象或者是栈顶)是foo,它的调用对象是window对象。(至于为啥是window对象,下文4.1会讲到)

4.1,通过对象方法调用函数,this 指向当前上下文的调用对象,找不到则默认指向window(非严格模式)

很多人会说,如下代码:

var a=1
function foo() {
  var a = 2;
  console.log(this.a)
}
foo(); //浏览器中:1

在浏览器中运行打印的是1,这不是全局执行上下文的作用域中的a嘛?

其实这里是执行的window.foo(),当前执行上下文是foo,它的调用对象是window,而var a=1会在window上创建一个名为a的属性。这才能使用this.a访问到window.a这个属性。

并不是我们常规理解的this指向全局上下文或者作用域啥的。

再来看这段代码:

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

当执行到console.log( this.a );的时候,执行栈是全局->foo,当前执行上下文是foo,它的调用对象是obj,于是这个this就指向obj对象。所以打印的是2.具体的执行栈和内存图如下:

5,this对obj的访问.drawio.png

再来看上文的代码:

function baz() {
  console.log( "baz",this);
  bar(); 
}
function bar() {
  console.log( "bar",this );
  foo(); 
}
function foo() {
  console.log( "foo",this);
}
baz(); 

这里三个this打印出来都是window对象。因为这里没有对象调用bar,baz,foo函数,就默认走的window调用。所以this都指向window。

如下代码:

function foo() {
  console.log( this.a );
}
function doFoo(fn) {
  fn(); 
}
var obj = {
  a: 2,
  foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
doFoo( obj.foo ); // "oops, global"

因为在js中函数的传参是值传递(复制一份值,引用类型则是复制引用值)。所以上述代码等价于:

function foo() {
  console.log( this.a );
}
function doFoo() {
  var fn=obj.foo
  fn(); 
}
var obj = {
  a: 2,
  foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
doFoo(); // "oops, global"

于是就很好理解fn相当于函数foo的别名,没有对象调用它,于是走的默认规则,this指向window。

如下代码,在内置函数中传入函数也一样:

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

4.2,显式绑定改变this的指向

4.1中说的只是默认的规则,实际上,我们常常需要手动指定this指向某一个目标对象,这时候,就可以使用显式绑定。常用的用call,bind,apply。

4.2.1,call改变this的指向

call:立即调用,返回函数执行结果,this指向第一个参数,后面可有多个参数,并且这些都是fn函数的参数。

var foo = {
  value: 1,
  fn(name,age){
    console.log(name,age,this.value)
  }
};
const A={
  value:3
}
foo.fn("孙悟空",510)//孙悟空 510 1
foo.fn.call(A,"猪八戒",200)//猪八戒 200 3

如上代码,foo.fn.call(A,"猪八戒",200)就是改变了this的指向,让它指向了A对象。

这里还是要再次强调下,在讲this的时候,对象和函数是有区别的,方便理解。

比如说,这个foo.fn("孙悟空",510)中的fn是函数方法。而foo.fn.call(A,"猪八戒",200)中的fn是对象,我们访问并使用它原型链上的call方法,从而改变了this的指向。

那它是如何改变this的指向呢?原理还是4.1节中的原理。

现在我们来手写一下call方法的实现。

4.2.2,手写call方法

这个说来其实很简单,call方法定义在Function的原型对象上,只有这样,所有的函数才能在原型链上共享这个方法。于是结构就出来了:

Function.prototype.myCall=function(){}

然后call的首参是this的绑定对象,剩余参数是原方法实参。于是可以利用展开符号接收剩余参数。并且javascript要求,当我们target传入的是一个非真值的对象时,target指向window,于是需要修正下target:

Function.prototype.myCall = function(target,...args){
   target = target || window
}

接着注意到foo.fn.call(A,"猪八戒",200)中的fn是当对象处理(访问其中的call方法),应用4.1中的知识很容易知道,这里call方法中的this指向的就是fn。

于是就可以在myCall中暂时给目标对象新增个引用,指向这个fn,让目标对象调用这个fn,这样fn中的this就指向了目标对象。

为了避免目标对象正好有我们定义的临时方法属性,我们用Symbol来创建这个临时属性。并且在使用完毕后删除。

Function.prototype.myCall = function(target,...args){
  target = target || window
  const symbolKey = Symbol()
  target[symbolKey] = this//target[symbolKey]和foo.fn都指向同一个地址
  target[symbolKey](...args)//传入参数,让目标对象调用这个方法,并执行,于是this就指向了这个目标对象
  delete target[symbolKey]//使用完毕后删除这个目标对象的临时方法
}

而当我们的方法有返回时,还需要接收结果返回。于是最终的实现:

Function.prototype.myCall = function(target,...args){
  target = target || window
  const symbolKey = Symbol()
  target[symbolKey] = this
  const res = target[symbolKey](...args)
  delete target[symbolKey] 
  return res
}
//开始测试
var foo = {
  value: 1,
  fn(name,age){
    console.log(name,age,this.value)
  }
};
const A={
  value:3
}
foo.fn("孙悟空",510)//孙悟空 510 1
foo.fn.myCall(A,"猪八戒",200)//猪八戒 200 3

4.2.3,apply方法改变this的指向及手写applay

apply:立即调用,返回函数的执行结果,this指向第一个参数,第二个参数是个数组,这个数组里内容是fn函数的参数。

这个和call差不多,只不过是传入目标函数的参数是个数组,放apply的第二个形参位置上罢了。

var foo = {
  value: 1,
  fn(name,age){
    console.log(name,age,this.value)
  }
};
const A={
  value:3
}
foo.fn("孙悟空",510)//孙悟空 510 1
foo.fn.apply(A,["猪八戒",200])//猪八戒 200 3

手写apply后的测试效果如下:

Function.prototype.myApply = function(target,args){
  if(!(args instanceof Array)){
    throw new TypeError(`args is not an array!`)
  }
  target = target || window
  const symbolKey = Symbol()
  target[symbolKey] = this
  const res = target[symbolKey](...args)
  delete target[symbolKey] 
  return res
}
//开始测试
var foo = {
  value: 1,
  fn(name,age){
    console.log(name,age,this.value)
  }
};
const A={
  value:3
}
foo.fn("孙悟空",510)//孙悟空 510 1
foo.fn.myApply(A,["猪八戒",200])//猪八戒 200 3

4.2.4,bind方法改变this的指向及手写bind

相较于call和apply,bind显得更有意思,它返回一个绑定后的新函数。并且参数既可在bind中传递,也可在返回函数中传递。

var foo = {
  value: 1,
  fn(name,age){
    console.log(name,age,this.value)
  }
};
const A={
  value:3
}
foo.fn("孙悟空",510)//孙悟空 510 1
const testBind=foo.fn.bind(A)
testBind("猪八戒",260)//猪八戒 260 3

这又是怎么实现的呢?

Function.prototype.myBind = function(target,...outArgs){
  target = target || {}
  const self = this//暂存目标函数的引用
  const fn=function(...args){
      return self.call(target, ...outArgs,...args);
  }
  return fn
}
 //开始测试
var foo = {
  value: 1,
  fn(name,age){
    console.log(name,age,this.value)
  }
};
const A={
  value:3
}
foo.fn("孙悟空",510)//孙悟空 510 1
const testBind=foo.fn.myBind(A)
testBind("猪八戒",260)//猪八戒 260 3

其实和call一样的,通过把foo.fn当作对象,调用它的myBind方法,把它添加到目标对象上作为它的属性方法调用,这样this就指向了目标对象。

现在已经初步实现了 bind 函数,但仍有欠缺,因为原生 bind 函数还有一个用法:作为构造函数使用的绑定函数。对此,简单来说就是:bind返回的绑定函数也能使用 new 操作符创建对象:这种行为就像把原函数当成构造器,提供的 this 值被忽略,同时调用时的参数被提供给模拟函数。如下例:

this.value=4
function Fn(name,age){
    this.name=name
    this.age=age
    console.log(name,age,this.value)
  }
const A={
  value:3
}
Fn("孙悟空",510)//孙悟空 510 1
const testBind=Fn.bind(A)
const test2=new testBind("猪八戒",260)
//就像把原函数当成构造器,提供的 `this` 值被忽略,同时调用时的参数被提供给模拟函数。
console.log(test2)//{name: '猪八戒', age: 260},
const test3=new Fn("沙悟净",300)
console.log(test3)//{name: '沙悟净', age: 300}

于是修改代码:

Function.prototype.myBind = function(target,...outArgs){
  target = target || {}
  const self = this//暂存目标函数的引用
  const fn=function(...args){
    console.log("---",this)//如果是作为构造函数,这里面的this指向新创建的对象(是fn的实例对象),于是self(目标函数)让它指向这个this
    return self.call(this instanceof fn ? this : target, ...outArgs,...args);
  }
  return fn
}
//开始测试
this.value=4
function Fn(name,age){
    this.name=name
    this.age=age
    console.log(name,age,this.value)
  }
const A={
  value:3
}
const testBind=Fn.myBind(A,"猪八戒")
const test2=new testBind(260)//当bind返回的函数被用作构造函数时,this绑定失效,让它成为普通构造函数
console.log(test2)//{name: '猪八戒', age: 260},
const test3=new Fn("沙悟净",300)
console.log(test3)//{name: '沙悟净', age: 300}

当bind返回的函数被用作构造函数时,this绑定需要失效,于是myBind返回函数中的this应该指向新创建的对象。(new关键字的this指向新创建的对象下文会讲到)

到目前为止,看起来我们手写的bind已经很完善了。但是如上文代码,test2是Fn的实例对象,那么应该存在test2.__proto__===Fn.prototype是true。

const testBind=Fn.myBind(A,"猪八戒")
const test2=new testBind(260)//当bind返回的函数被用作构造函数时,this绑定失效,让它成为普通构造函数
Fn.prototype.pro="原型数据"
console.log(test2.__proto__===Fn.prototype)//false
console.log(test2.pro)//undefined

然而打印的是false,原型链上也没有找到pro属性。这说明我们还缺少个原型的绑定。

Function.prototype.myBind = function(target,...outArgs){
  target = target || {}
  const self = this//暂存目标函数的引用
  const fn=function(...args){
    return self.call(this instanceof fn ? this : target, ...outArgs,...args);
  }
  //绑定原型
  fn.prototype=self.prototype
  return fn
}

这里我看了十几篇网上的文章,都说这样子绑定原型是有问题的,说是这时候test2.__proto__===Fn.prototype,这样会导致修改test2.__proto__上的属性,从而引发Fn.prototype属性的变更。但是我直接用bind,发现它就是允许变更的啊:

//开始测试
this.value=4
function Fn(name,age){
    this.name=name
    this.age=age
  }

const A={
  value:3
}
const testBind=Fn.bind(A,"猪八戒")
const test2=new testBind(260)
Fn.prototype.pro="原型数据"
console.log(test2.pro)// 原型数据
test2.__proto__.pro="我修改的原型数据"
console.log(test2.pro)//我修改的原型数据
console.log(Fn.prototype.pro)//我修改的原型数据

这个和我使用我手写的myBind不是一样的结果嘛?

//开始测试
this.value=4
function Fn(name,age){
    this.name=name
    this.age=age
  }

const A={
  value:3
}
const testBind=Fn.myBind(A,"猪八戒")
const test2=new testBind(260)
Fn.prototype.pro="原型数据"
console.log(test2.pro)// 原型数据
test2.__proto__.pro="我修改的原型数据"
console.log(test2.pro)//我修改的原型数据
console.log(Fn.prototype.pro)//我修改的原型数据

有知道前因后果的大佬,可以说说哈。

4.3,new关键字改变this指向和手写new

通过 new的方式 this 指向的是生成的对象实例

function Fn(name,age){
  this.name=name
  this.age=age
}
Fn.prototype.height="180"
const test1=new Fn("孙悟空",560)
console.log(test1)//{ name: '孙悟空', age: 560 }
console.log(test1.height)//180

于是我们可以看到new做的事情:

1,首先创建一个新的空对象
2,它将新生成的对象的 __proto__ 属性赋值为构造函数的 prototype 属性,使得通过构造函数创建的所有对象可以共享相同的原型。这意味着同一个构造函数创建的所有对象都继承自一个相同的对象,因此它们都是同一个类的对象。
3,改变 this 的指向,指向空对象
4,对构造函数的返回值做判断,然后返回对应的值
	一般是返回第一步创建的空对象;
	但是当构造函数有返回值时,则需要做判断【再返回对应的值】:是对象类型则返回该对象;是原始类型则返回第一步创建的空对象。

于是我们先写出第一版代码,让它满足123三点。

function myNew() {
  var obj = new Object(),
  Constructor = [].shift.call(arguments);//取出第一个参数,shift会修改源对象,所以第一个参数被去除了
  obj.__proto__ = Constructor.prototype;//让新对象的__proto__指向构造函数的原型对象
  Constructor.apply(obj, arguments);//让构造函数的this指向新对象,并传入剩余参数
  return obj;
};
//开始测试
function Fn(name,age){
  this.name=name
  this.age=age
}
Fn.prototype.height="180"
const test1=myNew(Fn,"孙悟空",560)
console.log(test1)//{ name: '孙悟空', age: 560 }
console.log(test1.height)//180

接着考虑构造函数有返回值的情况:

function Fn(name,age){
  this.name=name
  this.age=age
  return {
    height:180
  }
}
const test1=new Fn("孙悟空",560)
console.log(test1)//{ height: 180 }返回的是对象类型则返回该对象
function Fn(name,age){
  this.name=name
  this.age=age
  return "lalallalala"
}
const test1=new Fn("孙悟空",560)
console.log(test1)//{ name: '孙悟空', age: 560 }返回值是原始类型时,又是返回创建的新对象

于是修改代码为:

function myNew() {
  var obj = new Object(),
  Constructor = [].shift.call(arguments);//取出第一个参数,shift会修改源对象,所以第一个参数被去除了
  obj.__proto__ = Constructor.prototype;//让新对象的__proto__指向构造函数的原型对象
  var result =Constructor.apply(obj, arguments);//让构造函数的this指向新对象,并传入剩余参数
  return typeof result === 'object' ? result : obj;;
};


//开始测试
function Fn(name,age){
  this.name=name
  this.age=age
  return "kkjkk"
}
const test1=myNew(Fn,"孙悟空",560)
console.log(test1)//{ name: '孙悟空', age: 560 }

4.4,箭头函数的this指向

箭头函数比较特殊,它没有自己的this,它的this指向最近的外层作用域的this

值得注意的是,this都是执行时绑定的,所以它最近的外层作用域的this也是在执行时确定。

接下来用一些例子来讲解:

4.4.1,箭头函数直接执行

var a=2
obj={
  a:1,
  test:()=>{
    console.log(this.a)
  }
}
obj.test()//2

从这里看test是个箭头函数,于是走箭头函数的this指向规则。

1,先找test最近的外层作用域,因为obj对象不会生成作用域(作用域只有全局、函数、块级,对象不会有),所以找到的是window对象。
2,当test执行的时候,window所在的执行上下文的this指向window,于是test箭头函数中的this也指向window

4.4.2,箭头函数在函数中

name = 'tom'
const obj = {
    name: 'zc',
    intro:function ()  {
      console.log(this)//obj
        return () => {
            console.log('My name is ' + this.name)
        }
    }
}
obj.intro()()//My name is zc
1,先找箭头函数最近的外层作用域是intro函数,所以到时候代码执行,这个函数中的this指向就是箭头函数的this指向。
2,当箭头函数执行的时候,intro函数是被obj调用的,它的this指向obj对象,于是箭头函数的this就指向obj。

4.4.3,箭头函数遇上new

function Fn(name, age) {
  this.name = name;
  this.age = age;
  this.testThis = () => {
      console.log('My age is ' + this.age)
  }
}
var fn = new Fn('孙悟空', 560);
fn.testThis();//My age is 560
1,先找到箭头函数,它的最近的外层作用域是Fn函数。
2,当执行testThis的时候,Fn的this指向新对象fn,于是testThis中的this也指向新对象fn。

4.4.4,显示绑定修改箭头函数的this指向

箭头函数由于没有this,不能通过call\apply\bind来修改this指向,但可以通过修改外层作用域的this来达成间接修改。

var name = 'window'
var obj1 = {
  name: 'obj1',
  intro: function () {
    console.log(this.name)//obj2
    return () => {
      console.log(this.name)//obj2
    }
  }
}
var obj2 = {
  name: 'obj2'
}
obj1.intro.call(obj2)()

五,总结

本质上,this的指向只有两条规则:

1,普通函数执行,this指向当前执行上下文的调用对象,没找到这个调用对象则是window。而call/bind/apply指向首参对象,new指向新对象的底层也是基于这个原理实现。
2,箭头函数中的this指向,继承了它最近的外层作用域的this指向。