JavaScript 中的 this

在许多面向对象语言中,this 是实例方法用来引用它们自身的一个变量。在所有语言中 this 通常都是一个不可变的引用或者指针,用来引用当前运行代码的对象、类或其他实体的关键字。因此,this 所引用的实体取决于执行上下文(例如,哪个对象正在调用它的方法)。

this 的作用

前面说到 this 是实例方法用来引用它们自身的一个变量,下面我们来说一下为什么要使用 this

比如我们有一个 say() 函数,用来输出一段字符串。

1
2
3
4
5
function say(name) {
console.log(`I'm ${name}`);
}

say('A'); // I'm A

我们传入了 name 参数,则在控制台输出了 I'm 字符串与 name 参数的组合。如果我们要将 say() 应用的更广一点:

1
2
3
4
5
const person1 = { name: 'A' };
const person2 = { name: 'B' };

say(person1.name); // I'm A
say(person2.name); // I'm B

这时我们需要调用 say() 函数并手动传入 name 值。这时我们可以抽象一下 person1person2 ,让其复用 say() 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function say(name) {
console.log(`I'm ${name}`);
}

function Person(name) {
const obj = {};
obj.name = name;
obj.greet = function() {
say(name);
};
return obj;
}

const person1 = Person('A');
const person2 = Person('B');

console.log(person1); // { name: 'A', greet: [Function] }
console.log(person2); // { name: 'B', greet: [Function] }
person1.greet(); // I'm A
person2.greet(); // I'm B

这里我们用闭包缓存了 name ,并在 greet() 函数中显式传入了 name ,也就是说 person1.greet() 相当于 say(person1.name),因此才能正确的调用 say() 函数。下面我们用 this 来实现一下这个功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function say() {
console.log(`I'm ${this.name}`);
}

function Person(name) {
this.name = name;
this.greet = say;
}

const person1 = new Person('A');
const person2 = new Person('B');

console.log(person1); // Person { name: 'A', greet: [Function: say] }
console.log(person2); // Person { name: 'B', greet: [Function: say] }
person1.greet(); // I'm A
person2.greet(); // I'm B

这里 say() 函数不再需要传入 name 参数,而是直接使用 this.name 。在 Person 构造器的中定义 Personname 为传入的 name 参数,greet 为 say() 函数,这里直接使用了 say() 函数并且没有做任何更改。可以看到 person1person2 成功的输出了自己的名字。

可以看到使用 this 来隐式地 “传递” 参数,相比于其他方式更加简洁也更好实现复用。

this 的指向

开头我们说过,this 所引用的实体取决于执行上下文(例如,哪个对象正在调用它的方法)。

在 JavaScript 中以下几种情况:

函数上下文

简单调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 在 Node 中
function f1() {
console.log('f1', this === global);

function f2() {
console.log('f2', this === global);

function f3() {
console.log('f3', this === global);
}

f3(); // f3 true
}

f2(); // f2 true
}

f1(); // f1 true

上例中在 f1() 函数中嵌套了 f2() 函数,f2() 函数中嵌套了 f3() 函数。并且都是直接调用了对应的函数,可以看到此时 this 的指向均为 Node 中的全局对象 global 。在浏览器中全局对象为 window 。也就是说使用简单调用可以用 this 来调用全局对象的属性。

1
2
3
4
5
6
7
8
// 在浏览器中
window.name = 'A';

function f1() {
console.log(this.name);
}

f1(); // A

作为对象的方法

1
2
3
4
5
6
7
8
const person1 = {
name: 'A',
greet() {
console.log(`I'm ${this.name}`); // I'm A
},
};

person1.greet();

当函数作为对象里的方法被调用时,它们的 this 是调用该函数的对象。上例中 greet() 函数的 thisperson1

作为构造函数

1
2
3
4
5
6
7
function Person(name) {
this.name = name;
}

const person1 = new Person('A');
console.log(person1) // Person { name: 'A' }
console.log(person1.name); // A

当一个函数用作构造函数时(使用 new 关键字),它的 this 被绑定到正在构造的新对象。上例中 Person 构造函数中的 this 指向了 Person,因此赋值语句含义为:为 Person 的 name 属性赋值为传入参数的 name

需要注意的是如果显式返回了一个对象,那么与 this 绑定的默认对象将被丢弃,不显式返回则没有这个问题。

1
2
3
4
5
6
7
8
9
10
function Person(name) {
this.name = name;
return {
name: 'B',
};
}

const person1 = new Person('A');
console.log(person1); // { name: 'B' }
console.log(person1.name); // B

Function.prototype 的 call、apply 或 bind 调用

1
2
3
4
5
6
7
8
9
10
const person1 = {
name: 'A',
greet() {
console.log(`I'm ${this.name}`);
},
};

person1.greet(); // I'm A
person1.greet.call({ name: 'B' }); // I'm B
person1.greet.apply({ name: 'C' }); // I'm C

当一个函数在其主体中使用 this 关键字时,可以通过使用函数继承自 Function.prototype 的 callapply 方法将 this 值绑定到调用中的特定对象。上例中第 9 行将 this 绑定到了对象 { name: 'B' } 上,而第 10 行将 this 绑定到了对象 { name: 'C' } 上。

1
2
3
4
5
6
7
8
9
10
11
const person1 = {
name: 'A',
greet() {
console.log(`I'm ${this.name}`);
},
};

const person2Greet = person1.greet.bind({ name: 'B' });
person2Greet(); // I'm B
const person3Greet = person2Greet.bind({ name: 'C' });
person3Greet(); // I'm B

ECMAScript 5 引入了 Function.prototype.bind 方法。调用 function.bind(someObject) 会创建一个与 function 具有相同函数体和作用域的函数,但是在这个新函数中,this 将永久地被绑定到了 bind 的第一个参数,无论这个函数是如何被调用的。可以看到 person2Greetthis 绑定到对象 { name: 'B' } 不可再绑定到其他对象。

原型链中的 this

1
2
3
4
5
6
7
8
9
10
11
function Person() {}

Person.prototype.greet = function() {
console.log(`I'm ${this.name}`);
};

const person1 = new Person();
person1.name = 'A';
console.log(person1); // Person { name: 'A' }
console.log(person1.__proto__); // Person { greet: [Function] }
person1.greet(); // I'm A

JavaScript 给对象提供了一个 __proto__ 的属性,指向该对象构造器的原型对象。上例中,对象 person1greet 属性继承自它的原型 Person 。在调用 greet 时,首先遍历 person1 中的所有属性,没有找到 greet 于是接着查找 person1 构造器的原型,然后在 Person 中找到了 greet 属性。查找过程是从 person1.greet 的引用开始,所以函数中的 this 指向 person1 ,也就是说,因为 greet 是作为 person1 的方法调用的,所以它的 this 指向了 person1 ,于是 this.nameA

getter 与 setter 中的 this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Person {
constructor(name) {
this.nameValue = name;
}

get name() {
return `getter: ${this.nameValue}`;
}

set name(value) {
this.nameValue = `[${value}]`;
}
}

const person1 = new Person('A');
console.log(person1); // Person { nameValue: 'A' }
console.log(person1.name); // getter: A
person1.name = 'B';
console.log(person1.name); // getter: [B]

与原型链中的 this 类似,用作 gettersetter 的函数都会把 this 绑定到设置或获取属性的对象。

全局上下文

无论是否在严格模式下,在全局执行上下文中(在任何函数体外部)this 都指代全局对象。

1
2
3
4
// 在浏览器中
console.log(this === window); // true
this.a = 'MDN';
console.log(window.a); // MDN

在浏览器中 window 对象同时也是全局对象,因此 this 指代 window 。但是在 Node 环境有所不同:

1
2
3
4
// 在 Node 中
console.log(this === module.exports); // true
this.a = 'MDN';
console.log(module.exports.a); // MDN

在 Node 中 this 指代 module.exports ,这是因为在 Node 执行这段代码时,Node 会将这段代码包裹在一个 wrapper 函数中再导出。

1
2
3
4
5
6
7
8
9
const moduleMock = { exports: {} };

function wrapper() {
console.log(this === moduleMock.exports); // true
this.a = 'MDN';
console.log(moduleMock.exports.a); // MDN
}

wrapper.apply(moduleMock.exports);

这里相当于 Function.prototype 的 apply 调用,因此 this 指向 module.exports 。上例中指向了 moduleMock.exports

补充

箭头函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 在浏览器中
const f1 = () => console.log(this === window);
// 在 Node 中
// const f1 = () => console.log(this === module.exports);
f1(); // true

function Person(name) {
this.name = name;
this.greet = () => console.log(`I'm ${this.name}`);
}

const person1 = new Person('A');
console.log(person1); // Person { name: 'A', greet: [Function] }
person1.greet(); // I'm A

在箭头函数中,this 与封闭词法上下文的 this 保持一致。在全局代码中,它将被设置为全局对象。上例中 f1() 函数在浏览器环境 this 指向了全局对象 window ,但是在 Node 环境中 this 指向了 module.exports ,原因在全局上下文已经解释过。而在构造函数 Person 中,this 指向了 Person ,因此 greet() 函数输出了 person1name

注意:如果将 this 传递给 call、apply、或者 bind,它将被忽略。不过你仍然可以为调用添加参数,不过第一个参数(thisArg)应该设置为 null。(也就是说无法通过 call、apply 或者 bind 来重置 this 的指向)

this 的丢失

开头说过:this 所引用的实体取决于执行上下文(例如,哪个对象正在调用它的方法)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const person1 = {
name: 'A',
greet() {
console.log(`I'm ${this.name}`);
},
};
person1.greet(); // I'm A

const greetCopy = person1.greet;
greetCopy(); // I'm undefined
// 在浏览器中
window.name = 'B';
// 在 Node 中
// global.name = 'B';
greetCopy(); // I'm B

person1 中调用 greet() 此时作为对象的方法调用,因此 this 指向了 person1 。但是当使用 greetCopy 引用 person1 中的 greet() 时,此时是简单调用的方式,this 指向了全局对象。另外在回调函数中也会出现这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function say() {
console.log(`I'm ${this.name}`);
}

function callback(fn) {
fn();
}

const person1 = {
name: 'A',
greet: say,
};

person1.greet(); // I'm A
callback(person1.greet); // I'm undefined

我们使用 callback() 函数传递了 person1.greet 函数,此时相当于进行了隐式赋值,与上一种情况的 const greetCopy = person1.greet 类似,这里 fn = person1.greet ,所以在 callback() 中 this 也就指向了全局对象。

this 指向的判断

在了解了上面不同 this 指向的调用方法后,现在已经很好判断 this 的指向了,但是如果有复合的情况 this 又会指向什么呢。

从调用方式来看可以简单的分为下面 4 种:

  1. 简单调用(直接调用使用了 this 的函数)
  2. 依托于上下文调用(不管是作为对象的方法、原型链中的 this、getter 与 setter 还是箭头函数,都是在某个对象下再调用使用了 this 的函数)
  3. 作为构造函数调用(使用了 new,this 指向绑定的新对象)
  4. Function.prototype 的 call、apply 或 bind 调用(使用 Funnction.prototype 的方法改变了 this 的绑定)

一般来说简单调用不依托于其他上下文,此时 this 指向全局对象,因此简单对象的优先级最低。在 Function.prototype 的 call、apply 或 bind 调用的介绍中,我们使用 call、apply 或 bind 改变了作为对象的方法调用中的 this ,因此 Function.prototype 的 call、apply 或 bind 调用的优先级要高于依托于上下文的调用。

此时优先级排序为:方法 4 > 方法 2 > 方法 1 。接下来我们看一下作为构造函数的优先级如何。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function setName(name) {
this.name = name;
}

const person1 = {
name: 'A',
setName,
};

console.log(person1.name); // A
person1.setName('B');
console.log(person1.name); // B

const person2 = new person1.setName('C');
console.log(person1.name); // B
console.log(person2.name); // C

可以看到 person2 作为构造函数调用后, person2.name 值为 C ,而此时 person1.name 值仍然为 B ,说明作为构造函数调用把原 person1.setName()this 指向 person1 更改为了新的对象 person2 ,故 person2.name 值为传入的 C

此时的优先级排序为:方法 3 > 方法 2 > 方法 1 。由于 new 无法与 call / apply 一起使用,我们只能使用 bind ,因为 bind 会创建一个新的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
function setName(name) {
this.name = name;
}

const person1 = {};
const person1SetName = setName.bind(person1);
person1SetName('A');
console.log(person1.name); // A

const person2 = new person1SetName('B');
console.log(person1.name); // A
console.log(person2.name); // B

同样地 person2 作为构造函数调用后,person2.name 的值为 B ,而 person1.name 的值仍然为 A ,说明作为构造函数调用把原 person1SetNamethis 指向 person1 更改为了新的对象 person2 ,故 person2.name 值为传入的 B

使用 new 来更改 bind 的绑定,可以实现预先设置函数的一些参数,这样在使用时只用传入剩余参数,也就是柯里化。

1
2
3
4
5
6
7
8
9
10
11
function add(x, y) {
this.value = x + y;
}

const addOne = add.bind(null, 1);
const addTwo = add.bind(null, 2);

const result1 = new addOne(1);
console.log(result1.value); // 2
const result2 = new addTwo(1);
console.log(result2.value); // 3

上例中 bind 不再关心绑定对象,因为 new 会重置绑定对象,所以传入了 null 。可以看到基于 add() 函数实现了 addOne() 和 addTwo() 功能的函数。

所以作为构造函数调用的优先级应该高于 Function.prototype 的 call、apply 或 bind 调用,所以方法 3 优先级高于 方法 4。此时的优先级排序为:方法 3 > 方法 4 > 方法 2 > 方法 1 。

总结

this 的指向可以按照下面的顺序来进行判断:

  1. 是否作为构造函数调用(new)?如果是则 this 指向新创建的对象。
  2. 是否使用 Function.prototype 的 call、apply 或 bind 调用?如果是则 this 指向绑定的对象。
  3. 是否依托于上下文对象调用?如果是则 this 指向具体的上下文对象。
  4. 否则是简单调用,this 指向全局对象。

参考资料: