你真的理解 this 了?

383 阅读7分钟

前言

JavaScript 中的 this 是一个非常重要的概念,也是一个令新手开发者甚至有些不深入理解的多年经验开发者都会感到困惑的概念。

如果你希望自己能够使用 this 编写更好的代码或者更好理解他人的代码,那就跟着我一起理解一下this吧。

为什么要深入理解this的原因

  • 第一点原因,学习 this 可以帮助我们更好地理解代码的上下文和执行环境。

举一些例子来看一下

例1

function speakfullName(){
    console.log(this.firstname + this.lastname)
}

var firstname = '南'
var lastname = '墨'

const gril = {
    firstname: '李',
    lastname: '梅',
    speakfullName,
}

const boy = {
    firstname: '王',
    lastname: '明',
    speakfullName,
}

 gril.speakfullName(); // 输出: 李梅
 boy.speakfullName(); // 输出: 王明
 speakfullName(); // 输出: 南墨

在这个例子中,如果你没理解 this 的用法,那么阅读这段代码就会觉得奇怪,为什么执行同一个函数会输出不同的结果。之所以奇怪,是因为你不知道它的上下文到底是什么。

  • 第二点原因,学习 this 可以帮助我们编写更具可重用性和可维护性的代码

在例1中可以在不同的上下文中使用 this,不用针对不同版本写不同的函数。当然不使用 this也是可以的。

例2

function speakfullName(person){
    console.log(person.firstname + person.lastname)
}

const gril = {
    firstname: '李',
    lastname: '梅',
}

const boy = {
    firstname: '王',
    lastname: '明',
}

speakfullName(gril); // 李梅 
speakfullName(boy); // 王明

虽然目前这段代码没有问题,如果后续使用的模式越来越复杂,那么这样的显示传递会让代码变得难以维护和重用,而相对例子1中的写法,this的隐式传递会显得更加优雅一些。因此,学习this可以帮助我们编写更具有可重用性和可维护性的代码。

解析 this

接下来我们开始正式全面解析 this,我相信大家多多少少都理解一些 this 的用法。

很多人认为 this 写在哪里就是指向所在位置本身,如下代码:

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

有些人认为会输出1,实际是输出2,这就是不够理解 this 所产生的的误会。

this的机制到底是什么样的呢?

其实this不是写在哪里就被绑定在哪里,而是代码运行的时候才被绑定的。也就是说如果一个函数中存在this,那么this到底被绑定成什么取决于这个函数以什么样的方式被调用。

既然已经提出了这样一个机制,那我们该如何根据这个机制,去理解和判断this被绑定成什么呢?

下面我们继续介绍这个机制的基本原理。

调用位置

上面说过,函数的调用位置会影响this被绑定成什么了,所以我们需要知道函数在哪里被调用了。

我们回头去看一下 例1,来理解什么是调用位置:

function speakfullName(){ 
    console.log(this.firstname + this.lastname) 
} 

var firstname = '南' 
var lastname = '墨'

const gril = { 
    firstname: '李', 
    lastname: '梅', 
    speakfullName, 
} 

const boy = { 
    firstname: '王', 
    lastname: '明', 
    speakfullName, 
} 

gril.speakfullName(); // 输出: 李梅 
boy.speakfullName(); // 输出: 王明 
speakfullName(); // 输出: 南墨

gril 对象中调用时,输出了李梅,在 boy 对象中调用时,输出了王明,在全局调用时输出了南墨

可以看出同一个函数 speakfullName,在不同的对象下调用,它的输出就不一样。

当然上例中的调用位置是非常容易看出来的。如果是在套多层的情况下,我们需要分析调用栈才能看出调用位置。

先看个简单例子:

function baz() {
  // 当前调用栈:baz
  console.log('baz')
  bar(); // bar 调用的位置
}

function bar() {
  // 当前调用栈:baz-bar
  console.log('bar')
  foo(); // foo 调用的位置
}

function foo() {
  // 当前调用栈:baz-bar-foo
  console.log('foo')
}
baz() // baz的调用位置

其实调用栈就是调用位置的链条,就像上面代码例子中所分析的一样。不过在一些复杂点的代码中,这样去分析很容易出错。所以我们可以用现代浏览器的开发者工具帮助我们分析。

比如上例中,我们想找到 foo 的调用位置,在 foo 中第一行输入debugger

// ...

function foo() {
  debugger
  // ...
}
// ...

或者打开浏览器的开发者工具到源代码一栏找到,foo的代码的第一行打一个断点也行,如下图:

1682495831231.png

接着在源代码一栏,找到调用堆栈的foo的下一个就是bar,bar就是foo的调用位置。

1682495571021.png

绑定规则

接下来看看调用位置如何决定this被绑定成什么,并且进行总结。

默认规则

第一种情况是函数最经常被调用的方式,函数被单独调用。看以下例子:

var name = '王明'
function fn(){
    console.log('我是' + this.name)
}
fn() // 我是王明

运行fn后,最终输出了 我是王明

上例中的 name 是全局的变量,说明fn中的 this 被绑定成了全局对象。因此,this指向了全局对象。

foo 是在全局中调用的,没有其他任何修饰, 所以我们称之为默认规则

如果使用了严格模式的话,上例代码会出现什么样的情况呢?

var name = '王明'
function sayName(){
    "use strict"
    console.log(this) // (1)
    console.log('我是' + this.name) // (2)
}
fn() 
// undefined 
// TypeError: cannot read properties of undefined (reading 'name') as sayName

可以看出来(1)也就是this,输出了undefiend 所以(2)就直接报错了。

因此我们可以得出默认规则的结论:在非严格模式下,this默认绑定成全局对象,在严格模式下,this 被绑成 undefined

隐式绑定

这条规则需要去判断函数调用是否有上下文对象,即函数调用时前面是否跟了一个对象,举个例子看下。

function sayName() {
    console.log(`我是` + this.name)
}
var person = {
    name: '王明',
    sayName,
}
person.sayName(); // 我是王明

在这个例子中, sayName 前面有一个 person对象,函数中 this 被绑定成了person

在观察隐式绑定的时候,有两种值得我们注意的情况:

  • 第一种情况,如果说一个函数是通过对象的方式调用时,只有最后一个对象起到上下文的作用。

如下例:

function sayName() {
    console.log(`我是` + this.name)
}

var child = {
    name: '王明',
    sayName,
}

var father = {
    name: '王勇',
    child,
}

father.child.sayName(); // 我是王明

此例是通过一个对象链调了sayName,输出是我是王明。因此 this 指向了child 对象,说明this 最终绑定为对象链的最后一个对象。

  • 第二种情况,隐式丢失的情况就是被隐式绑定的函数丢失绑定的上下文,转而变成了应用默认绑定。
function sayName() {
    console.log(`我是` + this.name)
}
var person = {
    name: '王明',
    sayName,
}

var personSayName = person.sayName;

var name = '南墨'

pesonSayName() // '我是南墨'

虽然 personSayName 看起来是由 person.sayName 赋值得来,拥有上下文对象person,但实际上 personSayName 被赋予的是 sayName 函数本身。

因的 personSayName其实是一个不带修饰的函数,所以会被认为是默认绑定。

显示绑定

隐式绑定是通过一个看起来不经意间的上下文的形式去绑定的,那也当然也有通过一个明显的上下文强制绑定的,这就是显示绑定

javaScript 中,要使用显示绑定就要通过 callapply方法去强制绑定上下文了

这两个方法如何使用呢?

callapply的第一个参数都是传入一个上下文。

来看一下下面的例子

function sayName () {
 console.log(this.name)
}

var person = {
   name: '南墨'
}

sayName.call(person) // 南墨

我们通过call的方式,将函数的上下文绑定为了 person,因此打印出了 南墨

new绑定

new 绑定也可以影响函数调用时的 this 绑定行为,我们称之为new 绑定。

思考如下代码:

function person(name) {
   this.name = name;
   this.sayName = function() {
     console.log(this.name)
   }
}
   
var personOne = new person('南墨')

personOne.sayName() // 南墨

personOne.sayName 能够输出南墨,是因为使用 new 调用 person 时,会创建一个新的对象并将它绑定到 person 中的 this 上。

所以sayName函数中的 this.name 等于 personthis.name

规则之外

值得一提的是ES6的箭头函数,它的this无法使用以上四个规则,而是根据外层(函数或者全局)作用域来决定this

  function sayName () {
     return () => {
       console.log(this.name)
     }
  }

  var person1 = {
      name: '南墨'
  }

  var person2 = {
      name: '王明'
  }
  sayName.call(person1) // 南墨
  sayName.call(person1).call(person2) // 输出王明,如果是普通函数会输南墨

可以看出来,箭头函数里面的this是由它外层的上下文绑定成什么,它就是什么。

总结

要想判断一个运行的函数中this的绑定,首先要找到函数调用位置,因为它会影响this的绑定。然后使用四个绑定规则:new绑定、显示绑定、隐式绑定、默认规则 来判断this的绑定。