JS中this详解

132 阅读16分钟

为什么会用到this

this关键字是JavaScript中最复杂的机制之一。被自动定义在所有函数作用域中。

考虑如下代码:

function identify (context) {
  console.log(context.name)
  return context.name
}
function speak (context) {
  const greeting = "Hello, I'm " + identify(context)
  console.log(greeting)
}
const you = {
  name: 'LiLei'
}
const me = {
  name: 'John'
}
speak(you) // Hello, I'm LiLei
identify(me) // John

我们没有用到this关键字,都是通过对象的显式传递来进行的,当调用speak函数时,函数speak内部还调用了identify函数,而这个函数也需要对象作为参数来获取属性name的值,这样就相当于获取到最终结果需要两次显式的参数传递。更极端一点,如果存在更多函数间继续相互调用呢?那就需要更多的参数传递,不仅容易出现漏传的错误情况,代码也很不优雅。

我们用this进行改造,如下:

function identify () {
  console.log(this.name)
  return this.name
}
function speak () {
  const greeting = "Hello, I'm " + identify.call(this)
  console.log(greeting)
}
const you = {
  name: 'LiLei'
}
const me = {
  name: 'John'
}
speak.call(you) // Hello, I'm LiLei
identify.call(me) // John

call方法接收一个this值对象和一个或多个参数列表来调用函数。除了call,还有apply,用法和call相同,唯一不同的是apply的参数列表是数组。

和第一段代码片段来比较,这段代码是不需要给函数显式传入当前对象作为参数的,都是通过call函数来完成,这样的话,在被执行函数中,只要通过this关键字就可以获取call方法中第一个参数作为上下文对象,再进行逻辑取值。

对比显式调用,this方式被称为隐式调用。代码比函数调用传参满天飞,就显得更优雅和逻辑清晰。

在普通函数中,this指向什么呢?

考虑如下代码:

var b = 2
function foo () {
  var a = 1
  console.log(this.a, this.b) // undefined 2
}
foo()

其实对于this的误解太多了,有观点认为this指向函数自身,如果这么说,上述代码中this.a是不是不应该是undefined了,而这里,this指向的是全局作用域。

误解

指向自身

this的字面意思来理解,人们很容易把它理解为指向函数本身。一般在函数内部引用函数本身常见的场景是递归,需要在函数内部循环调用函数本身直到某个条件终止。

考虑如下代码:

function foo (num) {
  console.log('foo: ' + num) // 输出了4次:foo: 6,7,8,9
  this.count++
}
foo.count = 0
for (var i = 0; i < 10; i++) {
  if (i > 5) {
    foo(i)
  }
}
console.log(foo.count) // 0

通过函数foo内输出的值,可以看出函数执行了4次,但最后一行代码输出的值仍然是0,说明了foo.countfoo函数内部的this.count并不指向同一个变量。在上篇,我们知道,foo函数内部的this.count指向的是全局变量count,而foo.count是函数的属性count

这也说明了,this并不指向函数本身。

function foo () {
  foo.count = 4
}
setTimeout(() => {
  // xxx
}, 100)

具名函数可以通过foo.count的方式来指向函数本身。但匿名函数想要引用到本身,唯一的一种方法是通过arguments.callee来获取,但现在已经被弃用了,不应该再使用这种方式。如果想要引用函数本身最好的方式就是定义出一个具名函数。

指向函数作用域

还有一种误解,this指向函数作用域。

this在任何情况下都不会指向函数的词法作用域。

考虑如下代码:

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

foo函数内能找到bar函数,完全是因为this此时指向的是全局作用域。虽然bar函数是在foo函数内执行,但在bar函数内想通过this来获取foo函数内定义的变量a的值,是不可能获取到的,首先,thisfoo函数关系不大,而且this是无法指向函数的词法作用域的。

我们可以绑定foo函数对象给bar函数:

function foo () {
  foo.a = 2
  this.bar.call(foo)
}
function bar () {
  console.log(this.a) // 2
}
foo()

总结来说,this更趋向于理解为执行上下文,它出现的初衷也是函数被绑定于不同对象后,this可以获取到当前对象的某些属性。对象是一个容器,this是对象和函数中的一个桥梁。

词法

箭头函数

之前我们介绍了四种规则基本使用所有正常的函数。但在ES6中新增了一种特殊的函数类型并不遵守这四种规则:箭头函数。

思考如下代码:

function foo () {
  return () => {
    console.log(this.a)
  }
}
var obj1 = {
  a: 1
}
var obj2 = {
  a: 2
}
var bar = foo.call(obj1)
bar.call(obj2) // 1

输出结果是1,也就是对象obj1中变量a的值。

说明最后一行表达式bar.call(obj2)中通过call的显式绑定并没有生效,其中barfoo函数中返回的箭头函数,也就是说,箭头函数内的this并没有被直接绑定了,而是获取的foo函数内的执行上下文。

回调函数

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

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

除了箭头函数回调,经常还会使用词法变量的形式来绑定this,看如下程序:

function foo () {
  var self = this
  setTimeout(function (){
    console.log(self.a)
  }, 100)
}
var obj = {
  a: 1
}
foo.call(obj) // 1

在这里,是通过self变量存储了foo函数的执行上下文,然后在setTimeout回调函数中使用,正常情况下,setTimeout回调函数是正常函数,有其自己的作用域,若没有self变量,则无法在词法上继承foo函数的执行上下文的。

这种情况在开发中也经常,虽然起到了一定的作用,但用法并不优雅,相比较不如使用箭头函数来完成,它会自动绑定。

如果你经常使用this风格的代码,但在很多情况下还会使用self = this或箭头函数在否定this的机制,如果存在这类情况,那你最好是:

  • 只使用词法作用域并抛弃完全错误的this机制相关代码
  • 完全采用this机制的代码并不再使用self = this或箭头函数,在必要时,使用bind等显式绑定方式来实现

这两条理论无非就是让你非此即彼,其实我认为没有必要,在不同场景下可能需要并用,像箭头函数也是常用的方式。

调用位置

this是在运行时进行绑定的,并非在编译阶段绑定,它取决于函数被调用时的各种上下文条件,this绑定和函数声明的位置没有任何关系,仅仅与函数被调用的位置有关。

考虑如下代码:

function foo () {
  console.log('foo...')
  bar()
}
function bar () {
  console.log('bar...')
  debugger
  baz()
}
function baz () {
  console.log('baz...')
  debugger
}
foo()

在这段代码中,我们打了2个断点,当执行到第1个断点的时候,正在执行bar函数,并且bar函数是在foo函数内触发,所以最上面箭头指向的是当前函数调用栈,而第2个调用栈就是调用当前函数的调用位置。调用栈展示如下:

1671153750028.jpg

继续往下执行,在bar函数内部又调用了baz函数,断点也打在了baz函数内,所以调用栈最上方函数baz是正在被执行的函数,而第2个调用栈bar也是调用baz函数的位置。调用栈展示如下:

image.png

总结来说:通过查看调用栈,我们就可以知道当前执行的函数是在调用堆栈的第二个元素位置被触发或调用的。

绑定规则

默认绑定

考虑如下代码:

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

这是一种最常见的调用类型:独立函数调用。它没有手动绑定任何上下文。这种情况下,它默认绑定了全局对象,所以它输出了在全局声明和赋值的变量a的值。

如果在严格模式下,它的表现又有所不同:

function foo () {
  "use strict"
  console.log(this.a) // TypeError: Cannot read properties of undefined (reading 'a')
}
var a = 1
foo()

foo函数作用域内使用严格模式,则this不会默认绑定全局对象,所以输出报错TypeError

而在调用foo函数时使用严格模式,还是可以默认绑定。

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

严格模式如果使用的话就都使用,或者都不使用严格模式,否则可能会出现很多奇怪的问题。当我们使用第三方库的时候,可能严格模式策略和本身项目的不一致,会造成兼容问题,也需要我们注意。

隐式绑定

思考如下代码:

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

输出结果1,说明foo函数内this.a指向的是obj对象的变量a,也就是说obj.foo()这种调用方式可以把对象obj隐式绑定到foo函数内,通过this执行上下文获取对象的值。

那为什么说这种是隐式绑定呢,明明obj.foo()是很显式的调用啊?

我们都知道,obj对象foo属性的值是函数foo的引用,也就是一个函数的地址,并非函数本身,从这个角度来说,函数其实并不属于obj对象,也就是说对象obj和函数foo本来没关系,obj.foo仅仅是对正常属性的获取,但最后却可以通过执行上下文来关联。

需要注意的是:对象属性引用链中只有上一层或最后一层在调用位置上起作用。

function foo () {
  console.log(this.a)
}
var obj1 = {
  a: 1,
  foo: foo
}
var obj2 = {
  a: 2,
  obj1: obj1
}
obj2.obj1.foo() // 1

对象obj1obj2中都定义了变量a的值,但最终的结果要看是哪个对象直接调用的foo函数,那就会绑定它作为函数的执行上下文。

上述代码中是obj1对象直接调用了foo函数,所以输出的结果是obj1.a的值。

隐式丢失

思考如下代码:

function foo() {
  console.log(this.a)
}
var obj = {
  a: 1,
  foo: foo
}
var bar = obj.foo
var a = 'global value'
bar() // global value

这里和第一段代码的区别在于,多了一个中间变量bar,但输出的结果完全不同。

其实经过上篇默认绑定和隐式绑定的分析,就比较容易理解了。在第一段代码中,执行的是obj.foo(),而这里是定义了全局变量bar,属于独立函数调用,执行了默认绑定。因为在之前也分析过,foo函数本质上是不属于obj对象的,就看如何调用它了,这也就是this的生存法则,看函数如何被调用了,完全由调用位置或方式来决定当前执行上下文。

显式绑定

在前两篇,我们介绍了this的默认绑定和隐式绑定规则。和隐式绑定规则对应的就是显式绑定规则。顾名思义,显式绑定就是给函数直接绑定上对应的this执行上下文,也可以说是硬绑定

常用的方法有:callapplybind

call

思考如下代码:

function foo (b, c) {
  console.log(this.a, b, c)
}
var obj = {
  a: 1
}
var a = 2
foo.call(obj, 100, 200) // 1 100 200

foo函数的执行上下文通过call方法显式绑定了obj,所以this.a的值就是obj.a。call方法的第一个参数是函数的执行上下文对象,后面的都是作为参数列表来给函数传值,并且可选。

apply

思考如下代码:

function foo (b, c) {
  console.log(this.a, b, c)
}
var obj = {
  a: 1
}
var a = 2
foo.apply(obj, [100, 200]) // 1 100 200

apply在给函数绑定执行上下文来说,和call的功能是完全一样的,仅有的差别是传参方式不同,它是将所有的参数列表放进一个数组中,函数的接收方式仍然是相同的。

bind

bind方法是ES5新增的方法。

思考如下代码:

function foo (b) {
  console.log(this.a, b)
}
var obj = {
  a: 1
}
var bar = foo.bind(obj)
bar(100) // 1 100

foo函数通过bind方法也绑定了obj对象作为执行上下文。但bind和call或apply方法绑定策略有所不同,执行bind方法时,会生成一个新的函数,新函数的this值指向bind方法所传入的对象,参数是在新生成的函数调用时传入。

除了以上常用的显式绑定外,还有一些内置函数API调用时可以传入的上下文,例如forEach,其实它的入参除了第一个是函数,还有第二个可选参数对象,就可以给函数传入执行上下文,可能我们用的不多。

function foo(el) {
  console.log(el, this.a)
}
var obj = {
  a: 1
};
[100,200,300].forEach(foo, obj) // 100 1; 200 1; 300 1

函数foo内的执行上下文就是通过forEach函数第2个参数来绑定。

new绑定

在ES6中,开始新增了类的概念。我们一般声明的类也可以称为构造函数,和其他面向对象语言一样,通过new关键字来生成一个新的对象。

虽然生成对象的用法差不多,但内部的生成新的对象的机制和原理完全不同。这些所谓的类或构造函数只是使用new操作符时被调用而已,本质上是属于最普通的函数,它不会属于某个类,也不会实例化一个类。只是一般首字母会大写,以作为形式上的区分。

使用new操作符来调用函数,一般会自动执行以下一系列操作:

  • 创建或构造一个全新的对象
  • 这个新对象会被执行[[Prototype]]连接,也就是与构造函数形成关联关系,后面还会仔细说明
  • 这个新对象会绑定到函数调用的this
  • 如果函数没有返回其他对象,那么new表达式中的函数调用会返回这个全新的对象

思考如下代码:

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

根据上面new操作符的自动执行步骤,在new调用函数foo时,首先就会创建一个全新的对象,第二步新对象会被执行[[Prototype]]连接,然后在执行foo函数时,新创建的对象会作为执行上下文绑定到foo函数,这里foo函数内的this指向的就是新创建的对象;并且函数foo内并未返回其他的对象,所以会把新创建的对象作为返回值返回,然后赋值给bar,所有bar.a的值是等于2

这里之所以没用大写的Foo,只是为了表达构造函数本质上也就是普通函数,只是通过new操作符来调用完成了某些其他的步骤。

在整个过程中,其实所谓的新对象对我们来说是比较隐藏的存在,无法直接感知到。但它确实是存在的,因为正常执行foo函数,它没有任何返回值,更不可能是一个对象,而且bar.a是2。

优先级

在上两篇,我们说了this的绑定规则,主要有4类:默认绑定隐式绑定显示绑定new绑定

如果它们是单独出现的时候,那没问题,遵守各自的的绑定规则即可。但如果同时出现,这个时候我们就需要来判断谁的优先级更高。

我们可以先大体猜测一下:默认绑定不用说,绑定优先级肯定是最低的,只有其他规则不符合时,才会按照默认绑定规则执行;隐式绑定其次低,因为显示绑定和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

第一个输出obj1.a是2,好理解,通过硬绑定到obj1对象,执行bind生成的新函数,传参2,而this指向obj1this.a = 2,自然obj1.a等于2.

继续执行,通过new操作符来调用bar函数,在上篇我们也说过,调用new操作符的时候,会生成新的对象,执行的函数上下文指向这个对象,而且当函数未返回其他对象的时候,则返回新创建的对象。

new bar(3)的时候,foo函数内this指向新生成的对象,并且foo函数没有任何返回对象,所以baz指向的就是新的对象。

而在输出baz.a的之前,obj1.a的值并未受到任何影响,仍然输出的是原值。其实这也很好理解,因为new操作符生成了个新的对象,并非原来bind的obj1,就相当于obj1和baz对象是两个完全不同的对象,所以它们之间输出的值是没有相互影响的。

说明下:这里显式绑定举例的是硬绑定bind来进行测试,是因为new操作符和apply和call显式绑定是不能一起使用的。

绑定例外

this除了4种绑定规则外,还有一些例外的情况。

被忽略的this

思考如下代码:

function foo (a) {
  console.log(this.a)
}
var a = 1
foo.call(null) // 1

nullundefined作为call或apply函数的this绑定对象的时候,会被忽略。这时候应用的是默认绑定规则。所以输出的是全局变量a的值。

在实际开发中,我们还是会使用null作为上下文的,一般在展开数组或柯里化的时候会用到。

function foo (b, c) {
  console.log('b = '+ b, 'c = '+ c)
}
foo.apply(null, [1, 2]) // b = 1 c = 2
// 使用bind进行柯里化
var baz = foo.bind(null, 2)
baz(3) // b = 2 c = 3

展开数组功能在ES6新增了spread运算符...,现在就完全不需要这么做了。

其实通过nullundefined来作为上下文,虽然能被忽略,但恰恰是这样被忽略而启用默认规则指定到了全局作用域,可能会产生隐患。因为假如在函数中通过this调用了第三方库的函数或变量,这样是不是可能会将该变量暴露到全局而引起非预期的状况。

最好使用Object.create(null)来创建空对象。它不会创建Object.prototype委托,比纯粹的空对象{}更干净。

间接引用

思考如下代码:

function foo () {
  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

对于第一个输出3,没什么疑问,因为这里执行了隐式绑定规则,this的上下文是对象o,所以this.a === o.a

然而,最后输出的是全局变量a的值,就说明foo函数的执行上下文指向了全局作用域。其实原因之前也分析过,o.foo其实是一个函数的引用,而非函数本身,而赋值表达式p.foo = o.foo仅仅是把函数的引用给赋值了,此时执行foo函数不应该是取的p的变量a的值么?

但是赋值表达式返回的是目标函数的引用,而非p.fooo.foo,所以this指向的还是全局作用域。