0%

this全面解析

this 是 JavaScript 中最复杂的机制之一。它是一个很特别的关键字,被自动定义在所有函数的作用域中。与词法作用域不同,this 是在运行时进行绑定的,并不是在编写时,它的上下文取决于函数调用的各种条件。this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

0.关于 this

关于 this 主要有两种误解,一种是认为 this 指向函数自身,另一种是 this 指向函数的作用域。

0.1 指向自身

思考以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
function foo(num) {
console.log("foo: " + num);
// 记录 foo 被调用的次数
this.count++;
}
foo.count = 0;

for(var i = 0; i < 5; i++) {
foo(i);
}
// foo被调用了多少次?
console.log(foo.count); // 0 -- 为什么?

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

实际上,如果深入探索的话,就会发现这段代码在无意中创建了一个全局变量 count,它的值为 NaN。

如果要让上面的代码实现我们的功能,我们可以用 foo 来代替 this 来引用函数对象:

1
2
3
4
5
6
7
8
9
10
11
12
function foo(num) {
console.log("foo: " + num);
// 记录 foo 被调用的次数
foo.count++;
}
foo.count = 0;

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

console.log(foo.count);

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

1
2
3
4
5
6
7
8
9
10
11
12
function foo(num) {
console.log("foo: " + num);
// 记录 foo 被调用的次数
this.count++;
}
foo.count = 0;

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

console.log(foo.count); // 5

0.2 它的作用域

第二种常见的误解是,this指向函数的作用域。需要明确的是,this在任何情况下都不指向函数的词法作用域。

1
2
3
4
5
6
7
8
function foo() {
var a = 2;
this.bar();
}
function bar() {
console.log(this.a);
}
foo(); //ReferenceError: a in not defined

因此在学习 this 之前,我们必须明白,this 既不指向函数自身也不指向函数的词法作用域,this 实际上是在函数被调用时发生绑定的。

1.调用位置

在理解this的绑定规则之前,首先要理解调用位置,即函数在代码中被调用的位置。最重要的是要分析调用栈,我们关心的调用位置就是当前正在执行的函数的前一个调用中。

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

function bar() {
// 当前调用栈是:baz->bar
// 因此调用位置在 baz 中
console.log("bar");
foo(); // <-- foo的调用位置
}

function foo() {
// 当前调用栈是:baz->bar->foo
// 因此调用位置在 bar 中
console.log("foo");
}
baz(); // <-- baz的调用位置

注意我们是如何分析出真正的调用位置的,因为它决定了 this 的绑定。

2.绑定规则

我们首先需要找到调用位置,然后判断需要应用下面四条规则中的哪一条。首先会介绍四条规则,然后说明多条规则都可以使用时的优先级。

2.1 默认绑定

默认绑定就是简单的独立函数调用,可以把这条规则看作是无法应用其它规则时的默认规则。

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

在代码中,foo是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定。在非严格默认下,默认绑定的 this 指向全局对象,严格模式下为 undefined。

2.2 隐式绑定

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

1
2
3
4
5
6
7
8
function foo() {
console.log(this.a);
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); //2

当 foo 被调用时,它前面加上了对 obj 的引用。当函数引用有上下文对象时,隐式绑定的规则会把函数调用中的 this 绑定到这个上下文对象。

对象属性链中只有上一层或者说最后一层在调用位置中起作用。举例来说:

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
console.log(this.a);
}
var obj2 = {
a: 42,
foo: foo
};
var obj1 = {
a: 2,
obj2: obj2
};

obj1.obj2.foo(); // 42

隐式丢失

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

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 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
function foo() {
console.log(this.a);
}
function doFoo(fn) {
// fn 其实引用的是 foo
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
function foo() {
console.log(this.a);
}

var obj = {
a: 2,
foo: foo
};
var a = "oops,global";

setTimeout(obj.foo, 1000); // "oops,global"

经过上面的分析我们知道,回调函数丢失 this 绑定是非常常见的。

2.3 显示绑定

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

如果我们不想在对象内部包含函数的引用,而想在某个对象上强制调用函数,这是我们需要使用函数的 call() 和 apply() 方法。

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

1
2
3
4
5
6
7
function foo() {
console.log(this.a);
}
var obj = {
a:2
};
foo.call(obj); // 2

显示绑定的另一种情况就是硬绑定。

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
console.log(this.a);
}
var obj = {
a: 2
};
var bar = function() {
foo.call(obj);
}
setTimeout(bar, 1000); // 2

// 硬绑定的 bar 不可能再修改它的 this
bar.call(window); // 2

因为我们把 bar 函数内部调用了 foo,而 foo 的 this 已经被强制绑定在 obj 上,因此无论之后如何调用 bar 函数,它总会手动在 obj 上调用 foo。

硬绑定的另一种应用场景就是创建一个包裹函数,负责接收参数并返回值:

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

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

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;
}
var obj = {
a: 2
};
function bind(fn, obj) {
return function() {
return fn.apply(obj, arguments);
}
}

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

ES5 中提供了 Function.prototype.bind 函数,它的用法如下:

1
2
3
4
5
6
7
8
9
10
unction foo(something) {
console.log(this.a, something);
return this.a + something;
}
var obj = {
a: 2
};
var bar = foo.bind(obj);
var b = bar(3); // 2 3
console.log(b); // 5

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

2.4 new绑定

在传统的面向对象的语言中,“构造函数”是类中的一些的特殊方法,使用 new 初始化类时会调用类中的构造函数。Javascript 中也有一个 new 操作符,但是 Javascript 中 new 的机制实际上和面向对象的语言完全不同。在 Javascript 中,构造函数只是一些使用 new 操作符时被调用的函数。它们并不属于某个类,也不会实例化一个类。

使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作:

  1. 创建一个全新的对象。
  2. 这个对象会被执行 [[Prototype]] 连接。
  3. 这个新对象会被绑定到函数调用的 this。
  4. 如果函数没有返回其它对象,那么 new 表达式中的函数调用会自动返回这个新对象。

思考下面代码:

1
2
3
4
5
function foo(a) {
this.a = a;
}
var bar = new foo(2);
console.log(bar.a); // 2

使用 new 来调用 foo() 时,我们会构造一个新对象并把它绑定到 foo() 调用中的 this 上。 new 是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定。

3.判断 this

学习了上面四条规则,我们可以根据下面的顺序来判断 this 绑定的对象:

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

4.绑定例外

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

4.1 被忽略的 this

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

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

一种常见的做法是使用 apply(…) 来“展开”一个数组,并当作参数传入一个函数。类似地,bind(…)可以对参数进行柯里化,这种方法有时非常有用:

1
2
3
4
5
6
7
8
9
function foo(a ,b) {
console.log("a: " + a + ", b: " + b);
}
// 把数组展开成参数
foo.apply(null, [2, 3]); //a: 2, b: 3

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

4.2 间接引用

另一个需要注意的是你可能有意或者无意地创建一个函数的”间接引用“,在这种情况下,调用这个函数会应用默认绑定规则。

间接引用最容易在赋值的时候发生:

1
2
3
4
5
6
7
8
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()。

5.this 词法

我们之前介绍的四条规则已经可以包含所有的正常函数。但是在 ES6 中介绍了一种无法使用这些规则的特殊类型函数:箭头函数。

箭头函数不使用 this 的四种标准规则,而是根据外层作用域来决定 this。

我们来看看箭头函数的词法作用域:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function foo() {
return a => {
// this继承自 foo()
console.log(this.a);
}
}
var obj1 = {
a: 2
};
var obj2 = {
a: 3
};
var bar = foo.call(obj1);
bar.call(obj2); // 2

对比正常的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
return function() {
console.log(this.a);
}
}
var obj1 = {
a: 2
};
var obj2 = {
a: 3
};
var bar = foo.call(obj1);
bar.call(obj2); // 3

foo() 内部的箭头函数会捕获调用时 foo() 的 this。由于 foo() 的 this 绑定到 obj1,bar 引用箭头函数的 this 也会绑定到 obj1,箭头函数的绑定无法修改。(new 也不行)

箭头函数最常用于回调函数,例如事件处理器或者定时器:

1
2
3
4
5
6
7
8
9
10
function foo() {
setTimeout(() => {
// 这里的 this 在词法上继承 foo
console.log(this.a);
})
}
var obj = {
a: 2
};
foo.call(obj); // 2