this全面解析

  ​this​关键字是JavaScript中最复杂的机制之一,它是一个很特别的关键字,被定义在所有函数的作用域中。

  JavaScript的this机制并没有那么复杂,但是开发者往往会把理解过程复杂化,因此在缺乏清晰认知的情况下,this​对于开发者来说是一种魔法。

this到底是什么

  ​this​是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件,this​的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

  当一个函数被调用时,会创建一个活动记录,这个记录会包含函数在哪里被调用,函数的调用方式,传入的参数等信息,this​就是这个记录的一个属性,会在函数执行的过程中用到。

  每个函数的this​是在调用时被绑定的,完全取决于函数的调用位置。

为什么要使用this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function identify() {
return this.name.toUpperCase();
}

function speak() {
var greeting = "Hello I'm " + identify.call(this);
console.log(greeting);
}

var me = {
name: "DIAOAN"
}

var you = {
name: "Reader"
}

identify.call(me); // DIAOAN
identify.call(you); // READER

speak.call(me); // Hello I'm DIAOAN
speak.call(you); // Hello I'm READER

  这段代码可以在不同的上下文对象中重复使用identify​和speak​。如果不使用this​,就需要给identify​和speak​传入一个上下文对象:

1
2
3
4
5
6
7
8
function identify(ctx) {
return ctx.name.toUpperCase();
}

function speak(ctx) {
var greeting = "Hello I'm " + identify(ctx);
console.log(greeting);
}

  然而this​提供了一个优雅的方式来隐式传递一个对象引用,因此可以将API设计得更加简洁并且易于复用。当程序的逻辑越来越复杂,显式传递上下文会让代码越来越复杂,使用this就不会这样。

误解

  首先要消除一些关于this​的错误认知,太拘泥于this的字面意思就会产生一些误解,下面有两种常见的对于this的误解。

指向自身

  由于this​的字面意义,我们很容易把this​理解成指向自身,但是this​并不像我们想的那样指向函数本身。下面的代码我们要记录foo​被调用的次数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function foo(num) {
console.log("foo: " + num);
this.count++;
}

foo.count = 0;
var i = 0;
for (i = 0; i < 5; i++) {
foo(i);
}

console.log(foo.count);
// foo: 0
// foo: 1
// foo: 2
// foo: 3
// foo: 4
// 0

  ​console.log​语句产生了5条输出,证明foo​确实被调用了5次,但是foo.count​仍然是0,显然从字面意思理解this是错误的。在执行foo.count = 0​时,的确对foo函数这个对象添加了一个count​属性,但是函数内部的代码this.count​中的this​并不是指向函数对象,所以虽然属性名相同,根对象却并不相同,这也解释了为什么最后输出的是0。

  如果要从函数对象内部引用自身,那只使用this​是不够的,一般来说需要一个指向函数对象的变量来引用它:

1
2
3
4
5
6
7
function foo(num) {
foo.count++; // foo指向自身
}

setTimeout(() => {
// 匿名函数无法指向自身
}, 0)

  第一个函数称为具名函数,在它内部可以使用foo​来引用自身。第二个是匿名函数,没有标识符,因此无法从函数内部引用自身。

  所以,对于上面的例子,一个解决办法就是用foo​代替this​:

1
2
3
4
function foo(num) {
console.log("foo: " + num);
foo.count++;
}

  然而这个办法回避了this​的问题,并且依赖于变量foo​。

  还有一种办法是强制this​指向foo​函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function foo(num) {
console.log("foo: " + num);
this.count++;
}

foo.count = 0;
var i = 0;
for (i = 0; i < 5; i++) {
foo.call(foo, i);
}

console.log(foo.count);
// foo: 0
// foo: 1
// foo: 2
// foo: 3
// foo: 4
// 5

  这样我们使用了this​,没有回避它。

它的作用域

  第二种误解是this​指向函数的作用域。需要明确的是,this​在任何情况下都不指向函数的词法作用域。思考下面的代码:

1
2
3
4
5
6
7
8
9
10
function foo() {
var a = 2;
bar();
}

function bar() {
console.log(this.a); //TypeError: Cannot read properties of undefined (reading 'a')
}

foo();

  首先这段代码尝试通过foo​来调用bar​函数,然后试图使用this​联通foo​和bar​的词法作用域,从而让bar​可以访问foo​的作用域里的a​。这是不可能实现的,使用this​不可能在词法作用域里查到什么。

调用位置

  调用位置就是函数在代码中被调用的位置,而不是声明的位置。

  寻找调用位置就是寻找函数被调用的位置,重要的是分析调用栈,就是为了到达当前执行位置所调用的所有函数。我们关心的调用位置就在当前正在执行的函数的前一个调用中。下面看看什么是调用栈和调用位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function baz() {
// 调用栈:baz
// 当前的调用位置是全局作用域
console.log("baz");
bar();
}

function bar() {
// 调用栈:baz->bar
// 调用位置在baz
console.log("bar");
foo();
}

function foo() {
// 调用栈:baz->bar->foo
// 调用位置在bar
console.log("foo");
}

baz();

  把调用栈想象成一个函数调用链,从代码中找到函数的调用栈很麻烦而且容易出错,另一个查看调用栈的方法是使用浏览器的开发者工具,在foo​函数的第一行插入debugger​,运行代码时就会在这里暂停,并展示出调用栈。

绑定规则

  要确定this​的绑定对象,必须要找到调用位置,然后判断需要用下面四条规则的哪一条。

默认绑定

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

  思考下面代码:

1
2
3
4
5
6
7
function foo() {
console.log(this.a);
}

var a = 2;

foo(); // 2

  声明在全局作用域的变量就是全局对象里的一个同名属性。当调用foo​时,this.a​被解析成了全局对象的a​,这时因为函数调用时应用了this​的默认绑定,因此this​指向全局对象。

  通过调用位置分析,在代码中,foo​是直接使用标识符进行调用的,因此只能使用默认绑定,

  如果使用严格模式,则不能将全局对象用于默认绑定,因此this​会绑定到undefined​:

1
2
3
4
5
6
7
8
function foo() {
"use strict";
console.log(this.a);
}

var a = 2;

foo(); // TypeError: Cannot read properties of undefined (reading 'a')

  还有一个细节是,只有foo​不运行非严格模式下,默认绑定才能绑定到全局对象;但是在严格模式下调用则不影响默认绑定:

1
2
3
4
5
6
7
8
9
10
function foo() {
console.log(this.a);
}

var a = 2;

(function (){
"use strict";
foo(); // 2
})();

隐式绑定

  另一个需要考虑的是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含。

1
2
3
4
5
6
7
8
9
10
function foo() {
console.log(this.a);
}

var obj = {
a: 2,
foo: foo
};

obj.foo(); // 2

  首先要注意的是foo​的声明方式,无论它是直接在obj​中定义还是先定义再添加为属性,这个函数严格来说都不属于obj对象。

  然而,调用位置会使用obj​上下文来引用函数,因此可以说这个obj​对象拥有或包含foo​函数。

  当函数引用拥有上下文对象时,隐式绑定规则会把函数调用中的this​绑定到这个上下文对象。

隐式丢失

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function foo() {
console.log(this.a);
}

var obj = {
a: 2,
foo: foo
};

var bar=obj.foo;

var a = "oops,global";

bar(); // oops,global

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

  另一种更常见更出乎意料的情况发生在传入回调函数时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo() {
console.log(this.a);
}

function doFoo(fn) {
fn();
}

var obj = {
a: 2,
foo: foo
};

var a = "oops,global";

doFoo(obj.foo); // oops,global

  参数传递也是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一个例子一样。

  如果把函数传入语言内置的函数而不是传入自己声明的函数,结果也是一样的:

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
console.log(this.a);
}

var obj = {
a: 2,
foo: foo
};

var a = "oops,global";

setTimeout(obj.foo, 0);

   回调函数丢失this是非常常见的,除此之外,还有一种情况this​的行为会出乎我们意料:回调函数的函数可能会修改this​。

  无论是哪种情况,this​的改变都是意想不到的。

显式绑定

  如果我们不想再对象内部包含函数,而且想在某个对象上强制调用函数,JavaScript中所有函数都有一些有用的特性可以用来解决这个问题。具体点说,可以用函数的call​和apply​两个方法。

  这两个方法的第一个参数是一个对象,是给this​准备的,接着在调用函数时将其绑定到this​。因为可以直接指定this​的绑定对象,因此我们称之为显式绑定。

1
2
3
4
5
6
7
8
9
function foo() {
console.log(this.a);
}

var obj = {
a: 2
};

foo.call(obj); // 2

  通过foo.call​,我们可以把foo​的this​绑定到obj​上。但是,显式绑定仍然无法解决我们之前提出的丢失绑定问题。

硬绑定

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo() {
console.log(this.a);
}

var obj = {
a: 2
};

var bar = function () {
foo.call(obj);
};

bar(); // 2
setTimeout(bar, 100); // 2

bar.call(window); // 2

  我们创建了函数bar​,并在它的内部手动调用了foo.call​,因此强制把foo​的this​绑定到了obj​上,无论之后怎么调用bar​,它总会手动在obj​上调用foo​,这是一种显式的强制绑定,因此成为硬绑定。

  硬绑定的典型应用场景就是创建一个可以重复使用的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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);
console.log(b); // 5

  硬绑定是一种很常用的模式,所以ES5提供了一种内置的方法bind​:

1
2
3
4
5
6
7
8
9
function foo(something) {
console.log(this.a + something);
return this.a + something;
}

var obj = { a: 2 };

var bar = foo.bind(obj);
console.log(bar(3)); // 5

  ​bind​回返回一个新的函数,他会把指定的参数设置为this​的上下文并调用原始函数。

API调用的上下文

1
2
3
4
5
6
7
8
9
10
11
12
function foo(el) {
console.log(el, this.id);
}

var obj = {
id: "awesome"
};

[1, 2, 3].forEach(foo, obj);
// 1 awesome
// 2 awesome
// 3 awesome

new绑定

  ​new​绑定是最后一种this的绑定规则。在传统的面向类的语言中,“构造函数”是类的一些特殊方法,使用new​初始化类会调用类中的构造函数。JavaScript也有new​操作符,使用方法也和其他语言一样,但是JavaScript中的new​机制实际上喝面向类的语言完全不同。

  在JavaScript中,构造函数只是一些使用new​操作符时被调用的函数,它并不会属于某个类,也不会实例化某个类。构造函数甚至不能说是一种特殊的函数,他们只是被new​操作符调用的普通函数而已。

  使用new来调用函数,会自动执行下面的操作:

  1. 创建一个全新的对象。
  2. 这个新对象会被执行[[Prototype]]​链接。
  3. 这个新对象会绑定到函数调用的this​。
  4. 如果函数没有返回其他对象,那个new​表达式中的函数调用会自动返回这个对象。
1
2
3
4
5
6
function foo(a) {
this.a = a;
}

var bar = new foo(2);
console.log(bar); // foo { a: 2 }

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

优先级

  现在我们已经了解了this​绑定的四种规则,我们需要做的就是找到函数的调用位置并判断规则。但是如果某个调用位置可以应用多条规则,为了解决这个问题,我们必须给这些规则设置优先级。

  默认绑定的优先级是四条里最低的,我们可以先不考虑它。

  隐式绑定和显式绑定的优先级更高?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function foo(a) {
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​绑定和隐式绑定的优先级谁高谁低:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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​绑定和显式绑定谁的优先级高呢?

  ​bind​函数会创建一个新的包装函数,这个函数会忽略它当前的this​绑定,并把我们提供的对象绑定到this​上。这样看来硬绑定的优先级会比new​的优先级高,看看是不是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
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

  出乎意料,bar​被硬绑定到了obj1​上,但是new bar(3)​并没有把obj1.a​修改成3,相反创建了一个名字为baz​的对象,并且baz.a​的值是3。

判断this

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

  1. 函数是否在new​中调用, 如果是的话,this绑定的是新创建的对象。

    var baz = new bar();

  2. 函数是否通过call​、apply​、或者bind​调用,如果是的话,this​绑定的是那个上下文对象。

    foo.call(obj1);

  3. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this​绑定的是那个上下文对象。

    obj1.foo();

  4. 如果都不是的话,那就是使用默认绑定,如果在严格模式下,就绑定到undefined​,否则绑定到全局对象。

    foo();

绑定例外

  规则总有例外,这里也一样。

被忽略的this

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

1
2
3
4
5
6
function foo() {
console.log(this.a);
}

var a = 2;
foo.call(null); // 2

  虽然看起来很怪异,但是这也是一样非常常见的做法,比如用apply​来展开一个数组,并当作参数传入一个函数:

1
2
3
4
5
function foo(a, b) {
console.log(a, b); // 2 3
}

foo.apply(null, [2, 3]);

  然而总是使用null​来忽略this​绑定可能会产生一些副作用,如果函数里确实使用了this​,尤其是第三方库里的函数,那默认绑定规则就会把this​绑定到全局对象,这会导致不可预计的后果。

更安全的this

  一种更安全的this​是传入一个特殊的对象,把this​绑定到这个对象不会产生任何副作用。这个对象是一个空对象,可以把它命名为Φ​,表示为一个空对象。JavaScript中创建空对象最简单的方法是Object.create(null);​,Object.create(null);​和{}​很像,但是不会创建Object.prototype​这个原型,所以它比{}​更空。

1
2
3
4
5
6
7
function foo(a, b) {
console.log(a, b);
}

var Φ = Object.create(null);

foo.apply(Φ, [2, 3]);

间接引用

  我们有可能有意或无意地创建一个函数的间接引用,在这种情况下,这个函数会应用默认绑定规则。

1
2
3
4
5
6
7
8
9
10
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​,因此会引用默认绑定。

this词法

  ES6添加了一个特殊的声明函数的语法用于函数声明,叫做箭头函数:

1
2
var foo = a => { console.log(a) };
foo(1);

  它被当成function​的简写。箭头函数不仅仅可以减少代码的数量,还有更重要的作用,下面的代码有问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
var obj = {
count: 0,
cool: function () {
if (this.count < 1) {
setTimeout(function timer() {
this.count++;
console.log(this.count); // NaN
}, 100);
}
}
}

obj.cool();

  ​timer​函数打印的结果为NaN​,问题在于函数丢失了同this之间的绑定。在timer​函数中,this​绑定到了setTimeout​函数上。解决这个问题有很多种办法,在箭头函数出现前最常用的是var that = this;​:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var obj = {
count: 0,
cool: function () {
var that = this;
if (that.count < 1) {
setTimeout(function timer() {
that.count++;
console.log(that.count); // 1
}, 100);
}
}
}

obj.cool();

  ​var that = this;​虽然解决了this绑定的问题,但是增加了代码的数量,因此ES6的箭头函数引入了叫做this​词法的行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
var obj = {
count: 0,
cool: function () {
if (this.count < 1) {
setTimeout(() => {
this.count++;
console.log(this.count);
}, 100);
}
}
}

obj.cool(); // 1

  箭头函数在涉及this​绑定时的行为和普通函数的行为完全不一致。它放弃了所有普通this​绑定的规则,取而代之的是用当前的词法作用域覆盖this本来的值。

  上面的代码就是继承了cool​函数的this​绑定。这样除了可以少写一些代码,并且把一些错误给标准化了。

  解决这个问题的另一个合适的办法是正确使用this​机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
var obj = {
count: 0,
cool: function () {
if (this.count < 1) {
setTimeout(function timer() {
this.count++;
console.log(this.count);
}.bind(this), 100);
}
}
}

obj.cool();

  无论是用箭头函数中的this​词法还是使用bind​,都需要知道箭头函数不仅仅意味着可以少写代码,箭头函数的this​绑定机制需要理解和掌握。

  虽然var that = this;​和箭头函数都可以取代bind​,但是从本质来说,它们想取代的是this机制。如果经常编写this​风格的代码,但是绝大部分会使用var that = this;​或者箭头函数,那么应该:

  1. 只是用词法作用域并完全摒弃错误的this​风格代码。
  2. 完全采用this​,在必要时使用bind,尽量避免使用var that = this;​和箭头函数。

  当然包含两种风格的代码可以正常运行,但是在同一个函数或者同一个程序中混合使用会使代码难以维护,并且更难编写。

  ‍