深入理解this

本文深入解析JavaScript中this关键字的绑定规则,包括默认、隐式、显式及new绑定,并探讨了箭头函数中的this行为。

在this的学习中需要明确的一件事就是this既不指向函数本身,也不指向函数的词法作用域,this属于动态词法,运行时才会决定真正的指向,完全取决于函数在哪里被调用。
this关键字是JavaScript中极其复杂的机制之一,它会被自动定义在函数作用域内,在缺乏清晰的认知下,this完全是一种魔法。

任何足够先进的技术都和魔法无异。 ——Arthur C. Clarke

调用位置

在理解this指向前先要明白一个概念: 调用位置,调用位置指的就是函数在代码中被调用的位置(不是声明位置),只有分析确切的调用位置,才能搞明白this到底是指向哪里,也就是分析调用栈,而调用位置就是正在执行函数的前一个调用中。
看下面这个“真实”的🌰

function foo () {
	var a = 2
  this.bar()
}

function bar () {
	console.log(this.a)
}

foo() // undefined

这个🌰中, foo() 方法中调用的 this 指向的并不是 foo() ,看它的调用位置是 foo() ,其实就是 window.foo 也就是说 this 其实指向的是window,但是为什么 this.bar 可以被正常调用呢?因为 bar 被声明在window上,this.bar 安全被调用完全是个偶然,但是到了 bar() 方法被调用时实际执行的还是 window.bar() 因为 foo() 内部的 this 指向window,所以 bar() 中 this.a 实际指向为 window.a。

function foo () {
  // 当前call stack:foo调用栈
  // scope:this -> window
	var a = 2
  this.bar()
}

function bar () {
  // 当前call stack:bar调用栈
  // scope:this -> window
	console.log(this.a)
}

foo() // foo被调用位置:window

绑定规则

在函数执行时执行位置是如何决定 this 绑定规则的呢,首先需要找到执行位置,然后才是以下的规则。

1、默认绑定

在函数被独立调用时,也就是无法使用其他绑定规则时的默认绑定规则,就是默认绑定规则。比如以下代码

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

首先使用 var 声明了一个全局的变量 a(使用 var 在全局声明,其实就是挂载在 window 上, 等同于 window.a),然后声明了 foo 函数,在执行 foo 函数时,我们的调用位置为 window,所以内部的 this.a 为 2,指向 window.a。那么我们如何判断是否应用了默认绑定呢?因为 foo 函数是不带任何修饰的函数引用直接独立调用的,只能使用默认绑定规则,不能应用其他以外的绑定规则。
但是在严格模式下,全局对象无法被当做默认绑定的对象,所以 this 会被绑定到 undefined ,比如加上 "use strict"后,会出现 TypeError

"use strict"
function foo() {
	console.log( this.a )
}
var a = 2
foo() // TypeError: this is undefined

2、隐式绑定

调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含,不过这种说法可能会造成一些误导。
比如以下🌰

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

首先我们需要考虑的是, foo 函数在被调用时,是否被其他的外层对象包裹,当函数执行有上下文时,隐式绑定规则会将调用的this绑定到这个上下文对象上,因此这里的 obj.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

对象属性引用链中只有上一层或者说最后一层在调用位置中起作用。也就是无论 obj2.foo() 函数上有多少层,foo() 函数的 this 指向始终只指向上一层,也就是 obj2。

隐式丢失

被隐式绑定的函数会丢失绑定的对象,也就是说它会使用默认绑定,将 this 绑定到 window 或 undefined 上(取决于是否为严格模式)。
举个🌰

function foo() {
  console.log( this.a )
}
var obj = {
  a: 2,
  foo: foo
}
var bar = obj.foo
var a = 99; // a 是全局对象的属性
bar(); // 99

虽然 bar 函数为 obj.foo 的引用,但是它实际引用的 foo 函数本身,因此在 bar 函数执行时,是个没有任何修饰的独立函数调用,所以这里用的是默认绑定。

那在我们为方法传入一个回调函数时呢?举个🌰

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

其实在之前学习LHS,RHS查询时,有个概念就是传入方法时,其实做了一次隐式赋值,这里的 fn 函数其实还是引用的 foo 函数本身,实际与上一个的例子执行是一样的。

3、显式绑定

在我们不想通过隐式绑定,在对象内部调用的方式去引用函数时,怎么做 this 的绑定呢?原生JavaScript提供了call,apply方法,在调用时将 this 绑定到提供的第一个参数上,这种绑定规则叫做显式绑定。

例如

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

通过 foo.call ,我们可以在调用 foo 函数时,强制将 this 绑定到 obj 上(call,apply区别只有参数上的差异)。
但是显式绑定也无法解决绑定丢失的问题。

硬绑定

但是显式绑定的一个变种可以解决这个问题。

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

在上面例子中,创建一个函数 bar ,在 bar 内部,将 foo 的 this 始终指向 obj,无论外部如何调用,bar 函数内部的 foo 函数的 this 始终指向 obj,且遵循一个原则:函数的 this 指向始终只指向上一层。硬绑定就是创建一个外部包裹的函数,负责绑定指向,接受入参,并返回值。
而 bind 函数就是一个可重复的硬绑定方法。

API调用的“上下文”

在很多第三方库中许多函数,以及JavaScript和一些宿主环境中都包含很多内置函数,提供了一个 context 的上下文参数,用来确保你调用的 this 是指定的 this。
比如已经实现好的forEach方法,第二个参数就是传入的 this。

function foo (el) {
  console.log(el, this.id)
}
var obj = {
  id: 'awesome'
}
var arr = [1, 2, 3]
// 调用 foo(..) 时把 this 绑定到 obj
arr.forEach(foo, obj) // 1 awesome 2 awesome 3 awesome

4、new绑定

在传统面向对象的语言中,构造函数是类中的特殊方法,是用new初始化类时,会调用类中的构造函数。JavaScript中的 new 关键字在使用上看起来与大多数面向对象的语言一样,但是JavaScript中的new机制实际上与大部分面向对象语言不同。

首先重新定义JavaScript中的“构造函数”,在JavaScript中,构造函数只是在使用 new 操作符时被调用的函数,它不属于某个类,也不会实例化一个类,实际上他们都不能算是一种特殊的函数类型,只是被 new 操作符调用的普通函数而已。
在使用 new 来发生构造函数调用时,会触发以下几步操作:

  1. 创建一个全新的对象
  2. 这个新对象会被执行[[Prototype]]连接(将__proto__指向原型 prototype)
  3. 新对象将会被绑定到函数调用的 this
  4. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象
function foo(a) {
  this.a = a
}
var bar = new foo(2)
console.log( bar.a ) // 2

在使用 foo 构造函数时,我们会构造一份新的对象,并把它绑定到 foo 调用位置的 this 上,这种方式叫做 new 绑定。

优先级

this 绑定的四条规则:默认绑定、隐式绑定、显式绑定、new绑定,还有一个问题就是这四条规则如何应用,并且如何进行优先应用,甚至调用位置可以使用多个规则,这就需要一个优先级。

在四条规则中优先级最低的就是默认绑定,可以先不考虑。

隐式绑定和显示绑定那个优先级更高呢?看个🌰

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

这条例子中,明显通过显式绑定的优先级更高,所以可以在判断时优先考虑是否是显式绑定。

然后再看new 绑定、隐式绑定的优先级谁更高,在举个🌰

function foo (something) {
  this.a = something
}
var obj1 = {
  foo: foo
}
var obj2 = {}
obj1.foo(2)
console.log(obj1.a) // 2
obj1.foo.call(obj2, 3)
console.log(obj2.a) // 3
var bar = new obj1.foo(4)
console.log(obj1.a) // 2
console.log(bar.a) // 4

可以看出,new 绑定的优先级是要高于隐式绑定的,那么 new绑定和显示绑定的优先级谁更高呢?

在JavaScript中,new 和 call/apply 是无法一起使用的,因此无法直接通过 new foo.call(obj)
来直接进行测试,所以可以通过硬绑定来测试优先级。

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 this指向为baz,所以为改变 obj1.a 的值
console.log(baz.a) // 3

当我们将 bar 硬绑定到了obj1上,但是 new bar(3) 并没有奖 obj1.a 修改为3,而是使用了 new 绑定,所以将 baz.a 的值修改为3。

在ES5中内置的 Function.prototype.bind(…) 实现更为复杂,是一种polyfill代码

判断 this

所以就可以根据优先级来判断函数在某个调用位置应用的是那条规则。可以按照顺序来进行判断。

  1. 函数是否在 new 中调用(new绑定)?如果是的话,this绑定的是新创建的对象。
var bar = new foo()
  1. 函数是否通过call、apply等显示绑定或硬绑定调用?如果是的话,this绑定的是指定的对象。
var bar = foo.call(obj)
  1. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this绑定的是指定的那个上下文对象。
var bar = obj.foo()
  1. 如果都是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到全局对象。
var bar = foo()

正常来说就是这样的规则,还是会有例外🐶

绑定例外

在某些场景下的this的绑定行为会出乎意料,你认为应当应用其他规则时,实际上应用的可能就是默认规则。

被忽略的this

如果把参数null、undefined作为 this 指向参数传入 call、apply、bind,这些值在调用时会被忽略,实际应用的是默认绑定规则:

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

间接引用

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

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

在上面的赋值表达式中,p.foo = o.foo 的返回值为目标函数的引用,因此调用位置是 foo() 而不是 p.foo() 或者 o.foo()。所以根据之前的解释,这里应用默认绑定。
Tips:上面的默认绑定,决定this绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式。严格模式下,this会被绑定到undefined,否则会被绑定到全局对象。

箭头函数

箭头函数是ES6的特殊函数类型,箭头函数不是使用function关键字定义的,而是使用箭头 => 来定义的,箭头函数不使用this绑定的4个规则,而是根据外层作用域(函数作用域 or 全局作用域)来决定的 this。
举个🌰

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

foo 函数内部创建的箭头函数会捕获调用 foo() 函数时的this。由于 foo() 的this使用 call(obj1) 绑定到了obj1 对象,bar 引用箭头函数的的 this 也会被绑定到 obj1,箭头函数的绑定是无法被更改的,即便是new绑定。

小结

判断运行中函数的this绑定,需要找到函数的直接调用位置,找到后按照顺序规则进行判断 this 的绑定对象。

  1. 是否是 new 关键字创建?是则绑定到新对象
  2. 是否由call、apply、bind的方式硬绑定?是则绑定到指定的对象
  3. 是否由上下文的对象调用?是则绑定到调用的上下文对象
  4. 默认绑定到全局作用域,严格模式下将会被绑定到undefined

ES6中的箭头函数并不会使用四条绑定规则,而是根据当前的词法作用域来决定 this,也就是箭头函数会继承外层函数调用的 this 绑定,等同于在外层将this 通过 self 的方式保存,箭头函数中的 this 指向这个 self。

作者:胡涛

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值