一、关于 this
this 关键字是 JavaScript 中最复杂的机制之一。它是一个很特别的关键字,被自动定义在所有函数的作用域中。
消除误解:
- this 不指向函数自身
- this 不指向函数的词法作用域
排除一些错误理解之后,来看 this 到底是一种什么样的机制。
this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
二、this 全面解析
2.1 调用位置
在理解 this 的绑定过程之前,首先要理解调用位置:调用位置就是函数在代码中被调用的位置(而不是声明的位置)。通常来说,寻找调用位置就是寻找“函数被调用的位置”,最重要的是要分析 调用栈。调用位置就在当前正在执行的函数的前一个调用中。
示例:分析调用栈和调用位置
function baz() {
// 当前调用栈是:baz
// 因此,当前调用位置是全局作用域
bar() // bar 的调用位置
}
function bar() {
// 当前调用栈是:baz -> bar
// 因此,当前调用位置在 baz 中
foo() // foo 的调用位置
}
function foo() {
// 当前调用栈是:baz -> bar -> foo
// 因此,当前调用位置在 bar 中
console.log('foo')
}
baz() // baz 的调用位置
要准确从调用栈中分析出真正的调用位置,因为它决定了 this 的绑定。
2.2 绑定规则
找到调用位置,然后判断需要应用下面四条规则中的哪一条。
2.2.1 默认绑定
可以把这条规则看作是无法应用其它规则时的默认规则。最常用的场景是:函数独立调用。
示例:
var a = 2
function foo() {
console.log(this.a)
}
foo() // 2
在示例中,函数调用时应用了 this 的默认绑定,因此 this 指向全局对象。
如果使用严格模式( strict mode
),则不能将全局对象用于默认绑定,因此 this 会绑定 undefined
。
2.2.2 隐式绑定
隐式绑定需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含。
示例:
function foo() {
console.log(this.a)
}
var obj = {
a: 4,
foo: foo
}
obj.foo() // 4
当 foo()
被调用时,它的前面加上了对 obj 的引用。当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。
隐式丢失
一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把 this 绑定到全局对象或 undefined 上,取决于是否是严格模式。
示例:
function foo() {
console.log(this.a)
}
var obj = {
a: 2,
foo: foo
}
var bar = obj.foo // 函数别名
var a = 'global'
bar() // 'global'
虽然 bar
是 obj.foo
的一个引用,但是实际上,它引用的是 foo
函数本身,因此此时 bar()
其实是一个没有上下文对象的函数调用,所以应用了默认绑定。
一种更微妙、更常见的情况是传入回调函数时:
function foo() {
console.log(this.a)
}
function doFoo(fn) {
// fn 是 foo 的引用
// 调用位置
fn()
}
var a = 'global'
var obj = {
a: 2,
foo: foo
}
doFoo(obj.foo) // 'global'
参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一个例子一样。
就像我们看到的那样,回调函数丢失 this 绑定是非常常见的。
2.2.3 显示绑定
在分析隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把 this 间接(隐式)绑定到这个对象上。
如果不想在对象内部包含函数引用,而想在某个对象上强制调用函数,这个时候就可以使用显示绑定。
JavaScript 中的 “所有” 函数都有一些有用的特性,具体点说,就是可以使用函数的 call(..)
和 apply(..)
方法。它们的使用方法类似,第一个参数都是一个对象,是给 this 准备的,在调用函数时将其绑定到 this 。区别是 call(..)
的后面参数是一个一个传入函数的,而 apply(..)
的后面参数是一个参数数组,一起传入函数。
示例:
function foo(n) {
console.log(n)
console.log(this.a)
}
var obj = { a: 2 }
foo.call(obj, 6) // 6 2
通过调用 foo.call(..)
,我们可以在调用 foo
时强制把它的 this 绑定到 obj 上。
硬绑定
function foo() {
console.log(this.a)
}
var obj = { a: 2 }
function bar() {
foo.call(obj)
}
bar() // 2
setTimeout(bar, 100) // 2
// 硬绑定的 bar 无法再修改它的 this
bar.call(window) // 2
我们创建了函数 bar()
,并在它的内部手动调用了 foo.call(obj)
,因此强制把 foo 的 this 绑定到了 obj 。无论之后如何调用函数 bar ,它总会手动在 obj 上调用 foo 。这种绑定是一种显式的强制绑定,因此我们称之为硬绑定。
硬绑定的典型应用场景就是创建一个包裹函数,负责接收参数并返回值:
function foo(something) {
console.log(this.a, something)
return this.a + something
}
function bind(fn, obj) {
return function(...args) {
return fn.apply(obj, args)
}
}
var obj = { a: 2 }
var bar = bind(foo, obj)
var result = bar(3) // 2 3
console.log(result) // 5
由于硬绑定是一种非常常用的模式,所以 ES5 提供了内置的方法 Function.prototype.bind
,用法如下:
function foo(something) {
console.log(this.a, something)
return this.a + something
}
var obj = { a: 2 }
var bar = foo.bind(obj)
var result = bar(4) // 2 4
console.log(result) // 6
bind(..)
方法会返回一个硬编码的新函数,它会把你指定的参数设置为 this 的上下文并调用原始函数。
API 调用的 “上下文”
JavaScript 语言和宿主环境中许多新的内置函数,都提供了一个可选的参数,通常被称为“上下文” ,其作用和 bind(..)
一样,确保你的回调函数使用指定的 this 。
举例来说:
var arr = [1, 2, 3]
var obj = { a: 2 }
var a = 3
arr.map(function(item) {
return item * this.a
}) // [3, 6, 9]
arr.map(function(item) {
return item * this.a
}, obj) // [2, 4, 6]
这些函数实际上就是通过 call(..) 或者 apply(..) 实现了显式绑定。
2.2.4 new 绑定
这是最后一条 this 的绑定规则,在讲它之前我们要澄清一个常见的关于 Javascript 中函数和对象的误解。
在传统的面向类的语言中,“构造函数”是类中的一些特殊方法,使用 new 初始化类时会调用类中的构造函数。
JavaScript 也有一个 new 操作符,使用方法看起来也和那些面向类的语言一样,然而,JavaScript 中 new 的机制实际上和面向类的语言完全不同。
首先,在 JavaScript 中,构造函数只是一些使用 new 操作符时被调用的函数。实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被 new 操作符调用的普通函数而已。
所以,包括内置对象函数在内的所有函数都可以用 new 来调用,这种函数调用被称为构造函数调用。这里有一个重要但是非常细微的区别:实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。
使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作:
- 创建一个全新对象。
- 这个新对象会被执行[[Prototype]]连接。
- 这个新对象会绑定到函数调用的 this。
- 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。
2.3 优先级
new 绑定 > 显示绑定 > 隐式绑定 > 默认绑定
总结:判断 this 使用规则
现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的顺序来进行判断:
- 函数是否在 new 中调用( new 绑定)?如果是的话 this 绑定的是新创建的对象。
- 函数是否通过 call 、 apply (显式绑定)或者硬绑定调用?如果是的话, this 绑定的是指定的对象。
- 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话, this 绑定的是那个上下文对象。
- 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined ,否则绑定到全局对象。
2.4 绑定例外
2.4.1 被忽略的 this
如果把 null 或 undefined 作为 this 的绑定对象传入 call 、apply 或 bind ,这些值在调用时会被忽略,实际上应用的是默认绑定规则。
function foo() {
console.log(this.a)
}
var a = 2
foo.call(null) // 2
2.4.2 软绑定
上面讲过,硬绑定这种方式可以把 this 强制绑定到指定的对象,防止函数调用应用默认绑定规则。问题在于,硬绑定会大大降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改 this 。
如果可以给默认绑定指定一个全局对象和 undefined 以外的值,那就可以实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改 this 的能力。
可以通过一种被称为软绑定的方法来实现我们想要的效果:
if (!Function.prototype.softBind) {
Function.prototype.softBind = function(obj, ...args1) {
var fn = this
var bound = function(...args2) {
return fn.apply(
(!this || this === (window || global)) ? obj : this,
args1.concat(args2)
)
}
bound.prototype = Object.create(fn.prototype)
return bound
}
}
软绑定 softBind(..)
的原理与 ES5 内置的 bind(..)
类似。它会对指定的函数进行封装,首先检查调用时的 this ,如果 this 绑定到全局对象或者 undefined ,那就把指定的默认对象 obj 绑定到 this ,否则不会修改 this 。
2.5 this 词法
之前介绍的四条规则已经可以包含所有正常的函数。但是 ES6 中介绍了一种无法使用这些规则的特殊函数类型:箭头函数。
箭头函数并不是使用 function 关键字定义的,而是使用被称为“胖箭头”的操作符 =>
定义的。箭头函数不使用 this 的四种标准规则,而是根据外层作用域来决定 this 。
箭头函数最常用于回调函数中,例如事件处理器或者定时器:
function foo() {
setTimeout(() => {
// 这里的 this 在词法上继承自 foo()
console.log(this.a)
}, 100)
}
var obj = { a: 2 }
foo.call(obj) // 2
foo() 内部创建的箭头函数会捕获调用时 foo() 的 this ,并且箭头函数的绑定无法被修改。
箭头函数可以像 bind(..)
一样确保函数的 this 被绑定到指定对象,此外,其重要性还体现在它用更常见的词法作用域取代了传统的 this 机制。
在 ES6 之前我们就已经在使用一种几乎和箭头函数完全一样的模式:
function foo() {
var self = this
setTimeout(function() {
console.log(self.a)
}, 100)
}
var obj = { a: 2 }
foo.call(obj) // 2
2.6 小结
如果要判断一个运行中函数的 this 绑定,就需要找到这个函数的直接调用位置。找到之后就可以顺序应用下面这四条规则来判断 this 的绑定对象。
- 由 new 调用?绑定到新创建的对象。
- 由 call 或者 apply (或者 bind )调用?绑定到指定的对象。
- 由上下文对象调用?绑定到那个上下文对象。
- 默认:在严格模式下绑定到 undefined ,否则绑定到全局对象。
ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定 this ,具体来说,箭头函数会继承外层函数调用的 this 绑定。这其实和 ES6 之前代码中的 self = this
机制一样。
参考资料
- 《你不知道的Javascript(中卷)》