沐光

记录在前端之路的点点滴滴

深入了解 this

前言

在 JavaScript 学习过程中,除了作用域与闭包之外,困扰我们的难点还有 this,甚至可以说是前端 JS 学习的噩梦。实际上 this 并没有想象中的那么复杂,只是开发者往往会将理解过程复杂化,在缺乏清楚认识的情况下,this 对我们来说就完全是一种魔法了。而此篇就来揭开 this 神秘的面纱。

注:此篇为学习《你所不知道的 JS》时的心得总结

示例分析

工作中我们常常遇到 this 指向问题,看个简单的代码:

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

foo.count = 0;

// case 1:
for (var i = 0; i < 10; i++) {
if (i > 5) {
foo(i);
}
}
console.log(foo.count); // 0

// case 2:
for (var i = 0; i < 10; i++) {
if (i > 5) {
foo.call(foo, i);
}
}
console.log(foo.count); // 4

了解比较深入的会一眼看出为什么加一个 call 就没问题(强绑定指向),了解的一般的就会通过声明一个全局变量来缓存对应的值来绕过 this,虽然用这种其它变量替代 this 能够绕过这个问题,但是这样永远也了解不到 this 的真谛。回过头来思考,为什么必须强绑定才能正确指向?如果不进行强绑定,this 默认又指向什么呢?要深入了解到 this 的原理,首先我们得认识到自己的理解误区。

理解误区

学习 this 时常见的错误理解大致有两种:

  • this 指向函数自身
  • this 指向函数作用域

可能因为我们最早接触的语言是 C 语言,而 C 语言的编译顺序大致为:

  1. 词法分析
  2. 语法分析
  3. 语义分析
  4. 中间代码生成
  5. 代码优化(可不要)
  6. 目的代码生成

因此在学习 JS 是会自然而然的套用此方法来理解函数。我们可以试想一下,如果 JS 也走这么一套流程来处理,那么网页的加载得多慢啊。因此在编译这部分我们得抛弃 C 语言的那一套逻辑,这样才方便理解 this 的指向。

与 C 语言编译不同,JS 的编译为运行时编译,其会先从头到尾“过”一遍待解析的文件,粗略的提取其中的变量声明,然后逐行解析编译(解释了为什么会变量提升),因此 this 的状态就并非那种能够生成中间代码的存储态,而是动态的。也就是说,当函数执行时,this 才会真正的进行绑定,而并不是在最开始(遍历 JS 语法)时绑定,毕竟没有听说过什么“ this 提升”对吧?

简而言之,因为 this 是在函数调用时才进行绑定,所以 this 并不是指向其所在的函数自身,也不指向函数所在的词法作用域,它的指向完全取决于函数在哪里被调用的。

this 全面解析

调用位置

前面有提到 this 指向取决于函数是在哪里调用的,即找到函数的调用位置,相当于我们就定位了 this。而寻找函数的调用位置,首先得找到函数所在的栈的位置,该栈所在的环境即 this 的指向。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function foo() {
// 当前所在的栈为 foo
// 因此,当前调用位置是 全局作用域(this = window)
console.log('foo');

bar(); // bar 调用位置
}

function bar() {
// 当前所在的栈为 foo -> bar
// 因此当前的调用位置是在 foo 中 (this = this(foo) = window)
console.log('bar');
}

foo(); // foo 调用的位置

既然我们已经找到了 this 调用位置,那么 this 的值此时到底是什么,它所绑定的内容又是什么呢,而这就涉及到 this 的绑定规则。

绑定规则

找到 this 的调用位置之后,我们可以按照以下四条规则来判断 this 绑定的对象。

  1. 如果由 new 调用,那么 this 绑定到新创建的对象。
  2. 如果由 call 或者 applay(或者 bind)调用,那么绑定到其指定的对象。
  3. 如果由上下文对象调用,则绑定到该上下文对象。
  4. 默认:严格模式下绑定至 undefined,否则绑定至 window。

接下来就让我们自下而上,由面到点的了解这些绑定规则以及其排序来由。

注:上下文对象绑定即所谓的隐式绑定

默认绑定

举个很简单的例子:

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

var a = 2;

foo(); // 2

调用 this 的调用栈在 foo 中,当前所处的位置是全局作用域,因此 this 指向全局的 window。

回头看先前调用位置时列举的例子,通过默认绑定规则,再加上引用传递,因此各部分的 this 指向都指向全局的 window 对象。

隐式绑定

隐式绑定,即规则的调用位置是否拥有其上下文(默认为全局的 window 对象)。例如:

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

const obj = {
name: 'test',
foo: foo,
};

obj.foo(); // test

在非严格模式下,全局环境的 this.name === window.name,此时所处的上下文环境为全局环境,而此例的 foo 调用是通过 obj.foo 来调用的,this 的调用位置为 foo,但是 foo 的引用落脚点此时不是 window 了,而是 obj 了,因此通过隐式绑定,this 此时之乡的是 obj,即此时的 this.name === obj.name。

此处需注意的一点是 obj.foo 的 this 绑定的是 obj(隐士绑定),这是因为,对象属性的引用链只有最顶层会影响调用位置(或者是说最后一层),例如:

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

const obj1 = {
name: 'test1',
obj2: obj2,
};

const obj2 = {
name: 'test2',
foo: foo,
};

obj1.obj2.foo(); // test2

而在隐式调用的情况下,最容易出现的问题是隐式丢失,例如:

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

const obj = {
name: 'test',
getName: foo,
};

// Case 1: 解构
const { getName } = obj;
getName(); // undefined

// Case 2: 直接取引用(类比解构)
const linkFunc = obj.getName;
linkFunc(); // undefined

// Case 3: 引用传递(类比 setTimeout)
function linkPassFunc(fn) {
fn();
}
linkPassFunc(obj.getName);

注:参数传递就是一种隐式传递,其取得是对象的引用

显示绑定

JS 提供一种显示更改 this 绑定的函数:apply、call,例如:

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

const obj = {
name: 'test',
};

foo.call(obj); // test

通过 foo.call 可以显示的将 this 绑定在 obj 上,显示绑定无法解决隐式绑定中的引用传递问题(上面的 Case 3),而通过一种变形“硬绑定”可以解决。

硬绑定

与显示绑定不同的是,硬绑定会提前将 this 的指向在过渡函数内提前绑定上,这样当传入这个过渡函数时,this 就不会被隐式修改了,例如:

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

const obj = {
name: 'test',
};

// 过渡函数,内部提前显示绑定 this
function bar() {
foo.call(obj);
}

bar(); // test

setTimeout(bar, 100); // test

其实这种方式特别常见(如大多数源码中),将上例稍微变化一下,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function foo(otherContent) {
return `${this.prefix}-${otherContent}`;
}

function bar(fn, obj) {
// 此处的 arguments 为此无名函数的 arguments,硬绑定函数 this 指向
return function () {
return fn.apply(obj, arguments);
};
}

const obj = {
prefix: 'my',
};

const prefixFunc = bar(foo, obj);

const prefixStr = prefixFunc('age');

绕是绕了点,根据函数式编程的带入思想,其实就相当于

1
const prefixStr = foo.apply(obj, ['age']);

注: 硬绑定会大大降低函数的灵活性,使用硬绑定后就无法使用隐式绑定或者显示绑定来改变 this 了。

new 绑定

JavaScript 中所谓的“构造函数”其实与其它语言的类的构造函数不同,它并不属于某一个类,也不会去实例化一个类。在 JavaScript 中,构造函数只是一些使用 new 操作符时被调用的函数。使用 new 来调用函数时,会执行以下操作:

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

面试题中可能会遇到这么一个题:

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

function foo2(a) {
this.a = a;
return { a: 2 };
}

const f1 = new foo1(1);
const f2 = new foo2(1);

console.log(f1.a === f2.a);

知道这个 new 返回对象的“魔术”之后,我们就很清楚答案是 false 了,因为 foo2 有返回对象。而 foo1 因为没有函数返回值,因此会构造一个新对象并将 this 绑定至该新对象上。

new 是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定。

提示: f1.a 为 1; f2.a 为 2

优先级

现在规则我们已经比较清楚了,那么剩下的就是找到函数的调用位置,然后该使用那种规则去匹配。“默认绑定”毫无疑问是四条规则中优先级最低的,那么另外三条规则的优先级又该如何排序呢?那么我们一一来分析~

先给出结论: new 绑定 > 显示绑定 > 隐式绑定 > 默认绑定

隐式绑定和显示绑定

举个很简单的例子:

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

const obj1 = {
a: 2,
foo: foo,
};

const obj2 = {
a: 3,
foo: foo,
};

// 隐式绑定
obj1.foo(); // 2
obj2.foo(); // 3

// 显示绑定尝试更改
obj1.foo.call(obj2); // 3
obj2.foo.call(obj1); // 2

通过对比可以看到,显示绑定优先级更高。那么隐式绑定和 new 绑定的优先级顺序呢?

隐式绑定和 new 绑定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo(a) {
this.a = a;
}

const obj1 = {
foo: foo,
};

// 隐式绑定为 obj1 绑定 a 属性
obj1.foo(2);
console.log(obj1.a); // 2

// new 绑定尝试更改返回对象中的 this 指向(隐式原理应该 this 指向链尾的 obj1)
const bar = new obj1.foo(4);
console.log(obj1.a); // 2
console.log(bar.a); // 4

可以看到 new 绑定比隐式绑定优先级高,接下来就只剩 new 绑定和显示绑定的优先级了。

new 绑定和显示绑定

由于 new 和 call/apply 无法一起使用,因此无法通过 new foo.call(obj1) 来直接进行测试。但是我们可以用硬绑定来测试它们的优先级。举个例子:

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

const obj1 = {};

// 为 foo 显示绑定 this 为 obj1
const bar = foo.bind(obj1);
bar(2);
console.log(obj1.a); // 2

// new 尝试修改返回对象的 this 指向
const baz = new bar(3);
console.log(obj1.a); // 2
console.log(baz.a); // 3

在 new 中使用硬绑定函数,主要目的是预先设置函数的一些参数,这样在使用 new 进行初始化时就可以只传入其余的参数。bind(…)的功能之一就是可以把除了第一个参数(第一个参数用于绑定 this)之外的其他参数都传递给下层的函数(这种技术称为“部分应用”,是“柯里化”的一种)。举例来说:

1
2
3
4
5
6
7
8
9
10
11
function foo(p1, p2) {
this.val = p1 + p2;
}

// 之所以使用 null 是因为在本例中我们并不关心硬绑定的 this 是什么
// 反正使用 new 时 this 会被修改
const bar = foo.bind(null, 'p1');

const baz = new bar('p2');

baz.val; // p1p2

注意,这种通过 null 的绑定其实会使用“默认绑定”规则,将 this 绑定至全局对象,这在使用第三方库时会存在风险,更为稳妥的方法是创建一个真正意义上的空对象,如: let ø = object.create(null),然后将此 ø 作为绑定的作用域(const bar = foo.bind(ø, ‘p1’);)

绑定例外

被忽略的 this

call、apply、bind 接受绑定的对象为 undefined、null 时,在调用时会被忽略,使用的是默认绑定规则,严格来说最好使用一个真正的 ø 元素来作为绑定对象(前面已经提到)。

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

var a = 2;

// 其实绑定的还是 window,可能造成全局污染
foo.call(null); // 2
间接引用

这个就不难说明了,间接引用会使得隐式绑定最顶端的对象改变,造成 this 的指向非我们所想象的对象,例如:

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

var a = 3;

// test 已经不是绑定顶端的对象,变成了 window 了
const func = test.foo;
func(); // 3

箭头函数

ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据外层作用域来决定 this。例如:

1
2
3
4
5
6
function foo() {
setTimeout(() => {
// 此处的 this 在词法上继承自 foo
console.log(this.a);
});
}

因此,我们在使用这种 ES6 的“胖箭头”时,就可以抛弃 ES5 中的 var self = this 的“词法作用域”式的代码了。