对于 JavaScript 开发者来说 this 是一种非常复杂的机制,那它到底有用在哪里呢?

function identify() {
return this.name.toUpperCase();
}
function speak() {
var greeting = "Hello, I'm " + identify.call( this );
console.log( greeting );
}
var me = { name: "Kyle" };
var you = { name: "Reader" };
identify.call( me ); // KYLE
identify.call( you ); // READER
speak.call( me ); // Hello, 我是 KYLE
speak.call( you ); // Hello, 我是 READER

这段代码可以在不同的上下文对象(me 和 you)中重复使用函数 identify() 和 speak()

this 提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将 API 设计得更加简洁并且易于复用。


1.this指向

但是this到底指向什么?

有两种常见的对于 this 的解释,但是它们都是错误的。

1.指向自身

很容易把 this 理解成指向函数自身。

通常会认为,既然函数看作一个对象(JavaScript 中的所有函数都是对象),那就可以在调用函数时存储状态(属性的值)。这是可行的,有些时候也确实有用。

但是,this 并不像我们所想的那样指向函数本身。

function foo(num) {
// 记录 foo 被调用的次数
console.log( "foo: " + num );
this.count++;
}
foo.count = 0;
var i;
for (i=0; i<10; i++) {
if (i > 5) {
foo( i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被调用了多少次?
console.log( foo.count );
// 0

console.log 语句产生了 4 条输出,证明 foo(..) 确实被调用了 4 次,但是 foo.count 仍然是 0。

显然this指向函数自身这个理解是错误的。

执行 foo.count = 0 时,的确向函数对象 foo 添加了一个属性 count。但是函数内部代码 this.count 中的 this 并不是指向那个函数对象,所以虽然属性名相同,根对象却并不相同。

如果要从函数对象内部引用它自身,那只使用 this 是不够的。一般来说你需要通过一个指向函数对象的词法标识符(变量)来引用它。

function foo(num) { 
console.log( "foo: " + num ); // 记录 foo 被调用的次数
foo.count++;
}
// 使用 foo 标识符替代 this 来引用函数对象
foo.count = 0;
var i;
for (i=0; i<10; i++) {
if (i > 5) {
foo( i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被调用了多少次?
console.log( foo.count ); // 4

然而,这种方法同样回避了 this 的问题,并且完全依赖于变量 foo 的词法作用域。

另一种方法是强制 this 指向 foo 函数对象

function foo(num) { 
console.log( "foo: " + num ); // 记录 foo 被调用的次数
foo.count++;
}
// 使用 foo 标识符替代 this 来引用函数对象
foo.count = 0;
var i;
for (i=0; i<10; i++) {
if (i > 5) {
// 使用 call(..) 可以确保 this 指向函数对象 foo 本身
foo.call( foo, i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被调用了多少次?
console.log( foo.count ); // 4

2.指向它的作用域

第二种常见的误解是,this 指向函数的作用域。这个问题有点复杂,因为在某种情况下它是正确的,但是在其他情况下它却是错误的。

需要明确的是,this 在任何情况下都不指向函数的词法作用域。

在 JavaScript 内部,作用域确实和对象类似,可见的标识符都是它的属性。

但是作用域“对象”无法通过 JavaScript 代码访问,它存在于 JavaScript 引擎内部。

function foo() {
var a = 2;
this.bar();
}
function bar() {
console.log( this.a );
}
foo(); // ReferenceError: a is not defined

上面这段代码试图(但是没有成功)跨越边界,使用 this 来隐式引用函数的词法作用域。

但其实函数中的this指向的是全局作用域。 由于a是foo函数内的变量,全局作用域中不存在a变量。


3.this到底是什么

当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。

this 就是记录的其中一个属性,会在函数执行的过程中用到

this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。

this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。


2.this的绑定规则

在函数的执行过程中调用位置如何决定 this 的绑定对象。

必须找到调用位置,然后判断需要应用下面四条规则中的哪一条。

1.默认绑定

最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则。

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

声明在全局作用域中的变量(比如 var a = 2)就是全局对象的一个同名属性。

可以看到当调用 foo() 时,this.a 被解析成了全局变量 a。

为什么?

因为在本例中,函数调用时应用了 this 的默认绑定,因此 this 指向全局对象。

通过分析调用位置来看看 foo() 是如何调用的。在代码中,foo() 是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其他规则。

如果使用严格模式(strict mode),那么全局对象将无法使用默认绑定,因此 this 会绑定到 undefined

虽然 this 的绑定规则完全取决于调用位置,但是只有 foo() 运行在非严格模式夏下时,默认绑定才能绑定到全局对象;严格模式下与 foo() 的调用位置无关。


2.隐式绑定

另一条需要考虑的规则是:

  • 调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含
function foo() { 
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2

首先需要注意的是 foo() 的声明方式,及其之后是如何被当作引用属性添加到 obj 中的。 但是无论是直接在 obj 中定义还是先定义再添加为引用属性,这个函数严格来说都不属于 obj 对象。

然后,调用位置会使用 obj 上下文来引用函数,因此你可以说函数被调用时 obj 对象“拥 有”或者“包含”它。

无论你如何称呼这个模式,当 foo() 被调用时,它的落脚点确实指向 obj 对象当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。因为调用 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

可以看到foo()函数中的this之和调用链最后一层有关,也从另外一方面说明this指向只与它的调用位置有关


🍅🍅隐式丢失

一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把 this 绑定到全局对象或者 undefined 上,取决于是否是严格模式。

function foo() { 
console.log( this.a );
}
var obj = { a: 2, foo: foo };
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a 是全局对象的属性
bar(); // "oops, global"

虽然 bar 是 obj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,因此此时的 bar() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定。

一种更微妙、更常见并且更出乎意料的情况发生在传入回调函数时:

function foo() { 
console.log( this.a );
}
function doFoo(fn) {
// fn 其实引用的是 foo
fn(); // <-- 调用位置!
}
var obj = { a: 2, foo: foo };
var a = "oops, global"; // a 是全局对象的属性
doFoo( obj.foo ); // "oops, global"

参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以发生了隐式丢失。

把函数传入语言内置的函数而不是传入我们自己声明的函数,结果还是一样的。

回调函数丢失 this 绑定是非常常见的。


3.显示绑定

在分析隐式绑定时,我们必须在一个对象内部包含一个指向函 数的属性,并通过这个属性间接引用函数,从而把 this 间接(隐式)绑定到这个对象上。

那么如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么做呢?

通过call方法或者apply方法显示的绑定this

它们的第一个参数是一个对象,它们会把这个对象绑定到 this,接着在调用函数时指定这个 this。因为你可以直接指定 this 的绑定对象,因此我们称之为显式绑定。

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

注意:

如果你传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作 this 的绑定对象,这个原始值会被转换成它的对象形式(也就是 new String(..)、new Boolean(..) 或者 new Number(..))。这通常被称为“装箱”。

🍅🍅🍅可惜,显式绑定仍然无法解决我们之前提出的丢失绑定问题。


1.硬绑定(bind)

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

function foo() { 
console.log( this.a );
}
var obj = { a:2 };
var bar = function() {
// 通过在函数内部显示的绑定this到obj上
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;
}
var obj = { a:2 };
var bar = function() {
return foo.apply( obj, arguments );
};
var b = bar( 3 ); // 2 3
console.log( b ); // 5

另一种使用方法是创建一个可以重复使用的辅助函数:

function foo(something) { 
console.log( this.a, something );
return this.a + something;
}
// 简单的辅助绑定函数
function bind(fn, obj) {
return function() {
return fn.apply( obj, arguments );
};
}
var obj = { a:2 };
var bar = bind( foo, obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5

由于硬绑定是一种非常常用的模式,所以在 ES5 中提供了内置的方法 Function.prototype.bind

bind(..) 会返回一个硬编码的新函数,它会把参数设置为 this 的上下文并调用原始函数。


4.new绑定

在 JavaScript 中,构造函数只是一些 使用 new 操作符时被调用的函数。

它们并不会属于某个类,也不会实例化一个类。

实际上, 它们甚至都不能说是一种特殊的函数类型,它们只是被 new 操作符调用的普通函数而已。

JavaScript中实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。

使用new调用函数,或者说发生构造函数调用时,会执行以下操作:

  1. 在内存中创建一个新对象。
  2. 这个新对象内部的[[Prototype]]指针被赋值为构造函数的 prototype 属性。
  3. 这个新对象会绑定到函数调用的 this,并调用这个构造函数。
  4. 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
function foo(a) {
this.a = a;
}
var bar = new foo(2);
console.log( bar.a ); // 2

使用 new 来调用 foo(..) 时,我们会构造一个新对象并把它绑定到 foo(..) 调用中的 this 上。


3.优先级

  • 默认绑定的优先级是四条规则中最低的。
  • 显示绑定的优先级比隐式绑定的优先级高。
function foo() { 
console.log( this.a );
}
var obj1 = { a: 2, foo: foo };
var obj2 = { a: 3, foo: foo };

obj1.foo.call(obj2);// 3
  • new绑定优先级比隐式绑定优先级高
function foo(something) {
this.a = something;
}
var obj1 = { foo: foo };
obj1.foo(2); //隐式绑定 在obj1上增加了一个a属性

var bar = new obj1.foo( 4 );
console.log( obj1.a ); // 2
console.log( bar.a ); // 4
  • new可以修改硬绑定中的this,但是会生成一个新对象。
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

4.this判断规则

现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的顺序来进行判断:

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

5.绑定例外

1.被忽略的this

如果你把 null 或者 undefined 作为 this 的绑定对象传入 call、apply 或者 bind,这些值在调用时会被忽略,实际应用的是默认绑定规则。

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

那么什么情况下你会传入 null 呢?

bind(..) 可以对参数进行柯里化(预先设置一些参数),这种方法有时非常有用,

function foo(a,b) { 
console.log( "a:" + a + ", b:" + b );
}

// 使用 bind(..) 进行柯里化
var bar = foo.bind( null, 2 );
bar( 3 ); // a:2, b:3

这两种方法都需要传入一个参数当作 this 的绑定对象。如果函数并不关心 this 的话,你仍然需要传入一个占位值,这时 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()。根据我们之前说过的,这里会应用默认绑定。


6.箭头函数的this指向

ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定 this,具体来说,箭头函数会继承外层函数调用的 this 绑定(无论 this 绑定到什么)。

这其实和 ES6 之前代码中的 self = this 机制一样。


参考:

  • 你不知道的 JavaScript (上卷)