如果你系统学习过JS,肯定花过不少精力在this的机制上,但估计大部分人从来没彻底搞明白过,而现实开发过程中,确实发生过一些this导致的bug,而且这种bug有不错的隐蔽性,难以排查。this有时会略显玄学,主要是因为这几点:
- this的绑定发生在函数调用时;
- this的指向可以被开发者手动更改;
- 箭头函数的this规则和普通函数是完全不一样的;
重要:下文中若无说明,所有例子都运行在浏览器环境下;Node.js中的this有一些不同,文章最后会提到。
this到底是什么?
JS的函数本质上是一个对象,比如有个函数function foo() {}
,在内存中foo
保存的其实是函数的地址,而函数本身则单独存在一块内存区域中,所以foo
函数定义在哪里并不意味着其执行在哪里(好像是句废话),我们需要以某种方式获取到当前的执行环境(一般称之为执行上下文,context),这就是this存在的目的。
如何准确判断this的指向
一、找到函数的调用位置
理解了前面说的this的存在意义,则 “this取决于函数的调用位置” 这句话就不难理解了,先来看个简单的例子:
#例子1:
var x = 1;
function foo() {
var x = 2;
console.log(this.x);
}
foo(); // ?
浏览器环境下答案为:1。
这里foo
函数在全局环境下被调用,所以函数中的this指向的就是全局环境,即:
function foo() {
console.log(this === window); // true
}
foo();
调用独立函数时this的绑定一般称为默认绑定
然而,默认绑定有个限制条件:函数运行在非严格模式下!如果函数运行在严格模式下,如:
var x = 1;
function foo() {
'use strict';
var x = 2;
console.log(this.x);
}
foo();
则会报错Uncaught TypeError
,因为严格模式下,函数中的this无法绑定全局环境。
接下去,我们看一种稍微复杂点的情况:
#例子2:
var a = {
x: 1,
y: function() {
return this.x;
}
};
console.log(a.y()); // ?
var b = {
x: 2,
y: a.y
};
console.log(b.y()); // ?
答案:a.y()结果为1,b.y()结果为2。
来分析一下:
- 对象a中定义了一个函数并赋值给了属性y,属性y保存了函数的地址;
- 在对象a中找到y保存的内存地址所指向的函数,并执行该函数,所以当前函数执行在a环境下,查找到a环境下的x为1;
- 将对象a下属性y保存的函数地址赋值给对象b的属性y;
- 在对象b下找到y保存的内存地址所指向的函数,并执行该函数,所以该函数目前的执行环境是b,查找到b环境下的x为2;
上述的分析过程中很重要的一点是理解属性y保存的仅仅是函数的地址,而函数的this则取决于是谁通过函数的地址找到了函数本身并调用了!
例子2中的写法其实和下面的写法输出结果是一样的,属性y都只是拿函数的地址而已:
function foo() {
return this.x;
}
var a = {
x: 1,
y: foo,
};
var b = {
x: 2,
y: foo,
};
console.log(a.y()); // ?
console.log(b.y()); // ?
类似上面这种函数的调用位置已存在上下文对象的情况(或者说,函数被调用在某个对象里面),一般称之为this的隐式绑定。
#例子3:
var x = 1;
var a = {
x: 2,
y: function() {
return this.x;
}
};
var b = a.y;
console.log(b()); // ?
按照上面的步骤去分析,很容易得到答案1。因为函数的地址最终赋值给了一个全局变量,并且在全局环境下执行了,这里this最后应用了默认绑定。
这种情况有个术语叫做隐式丢失,即函数中this丢失了它想要绑定的对象,而绑定到了全局上(或者undefined上,取决于是否严格模式)。
变态的情况来了:
#例子4:
var x = 1;
function foo() {
function bar() {
console.log(this.x);
}
bar();
}
var a = {
x: 2,
y: foo,
}
a.y();
答案:1。
这里依然还是理解一点:虽然foo
函数是在对象a下调用的,但是foo
函数本身不属于对象a,所以foo
函数内部调用的bar
函数与对象a无关!所以bar
函数的this使用的默认绑定,即绑定在全局对象上。
接着看高阶函数中this的绑定行为,这可能也是很多人为之困惑的一个地方,来看个例子:
#例子5:
var x = 1;
var a = {
x: 2,
y: function() {
console.log(this.x)
}
}
setTimeout(a.y, 10)
答案:浏览器中执行,结果为1。
浏览器环境内置的setTimeout
函数实现和下面的伪代码类似:
function setTimeout(fn, delay) {
fn(); // delay毫秒之后执行fn
}
和前面分析的一样,通过a.y
保存的是函数的地址,现在把该函数地址传递给了setTimeout
,setTimeout
内部调用该函数时已与对象a无关了,所以这里应用了默认绑定,又是一个隐式丢失的问题!
二、手动更改this的指向
上面所说的都是在开发者无意识情况下的this绑定,下面介绍开发者刻意更改this指向的情况。
JS提供了几个可以更改函数执行上下文的方式,首先是著名的call
和apply
,我们正常调用一个函数时样子:
function foo(arg1, arg2, ...) {}; // 声明函数
foo(arg1, arg2, ...); // 调用函数
调用函数的行为foo()
其实可以显式成foo.call(ctx, arg1, arg2, ...)
或foo.apply(ctx, [args])
。call
和apply
的第一个参数,就是要执行的函数的上下文,默认情况下,ctx指向foo的调用者,比如例子2中,a.y()
和a.y.call(a)
等效,b.y()
和b.y.call(b)
等效。我们也可以更改ctx:
var a = {
x: 2
}
function foo() {
console.log(this.x); // 2
console.log(this === a); // true
}
foo.call(a);
这种绑定方式称之为显示绑定。
如果你传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作 this 的绑定对 象,这个原始值会被转换成它的对象形式(也就是new String(..)、new Boolean(..)或者 new Number(..)),这通常被称为“装箱”。
call
和apply
两个方法作用是一样的,只是他们的传参方式不一样。现在我们可以在调用函数时,任意更改函数的this指向了!但是,例子5中的情况我们应该如何做呢?call
或apply
只能在调用时进行手动绑定,可setTimeout
这类JS内置函数我们无法更改回调函数的调用行为。
JS提供了bind
方式来解决这种问题。如例子5这样写:
var x = 1;
var a = {
x: 2,
y: function() {
console.log(this.x); // 2
}
}
setTimeout(a.y.bind(a), 10);
bind() 方法会创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
关于bind
的用法,这里不做更多介绍,可查看MDN,里面介绍很详细
三、new绑定
简单来说,当我们用new
关键字修饰一个函数时,会自动执行下面一系列操作:
- 创建(或者说构造)一个全新的对象。
- 这个新对象会被执行[[原型]]连接。(本文不关心原型知识)
- 这个新对象会绑定到函数调用的this。
- 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。
#例子6:
function Foo() {
this.x = 2;
}
foo = new Foo();
console.log(foo.x); // 2
构造函数很简单,是初级的JS基础,但简单的语法可能遇到复杂场景,比如当new
绑定遇上隐式绑定甚至显式绑定时,会发生什么?
先来看下如果遇到隐式绑定的情况:
#例子7:
function foo(num) {
this.x = num;
}
var a = {
foo: foo
};
a.foo(1)
console.log(a.x); // ?
var b = new a.foo(2);
console.log(a.x); // ?
console.log(b.x); // ?
答案:第一个a.x结果为1,第二个a.x结果也为1,b.x结果为2。
执行结果告诉我们,当函数调用位置在某个对象中时(即隐式绑定),如果调用使用了new
关键字进行修饰,则函数内部的this指向的是新创建的实例对象,即:new绑定的优先级高于隐式绑定。
接下去探索new绑定遇上显式绑定的情况,注意:call
或apply
不能与new
一起使用,即JS不允许这么做:new foo.call(ctx)
,会报错:Uncaught TypeError: foo.call is not a constructor
。但我们通过bind
来绑定this:
#例子8:
function foo(num) {
this.x = num;
}
var a = {
x: 0,
};
var bar = foo.bind(a); // 将foo内的this绑定到a上
bar(1);
console.log(a.x); // ?
var b = new bar(2);
console.log(a.x); // ?
console.log(b.x); // ?
答案:第一个a.x结果为1,第二个a.x结果也为1,b.x结果为2。
虽然我们先通过bind
把foo
函数的this绑定到了对象a上,但是我们new
这个绑定后的函数,函数内部的this依然指向了新的实例对象!由此可见:new绑定的优先级高于显式绑定。
普通函数的this绑定小总结
- 判断this指向最重要的一点是找到函数的调用位置,还要理解一点:无论函数如何被赋值,赋值的都是函数的地址,而不是函数本身。函数的this取决于是谁通过函数的地址找到了函数本身并调用了。
- 如果函数被调用在全局环境,严格模式下,this无法指向
window
,而是undefined
。 -
call
、apply
、bind
可以手动更改函数的this指向,但遇到new
实例化函数时,new
绑定的优先级最高!
ES6中的箭头函数的this绑定
回顾一下前文关于隐式丢失的例子5:
var x = 1;
var a = {
x: 2,
y: function() {
console.log(this.x);
}
}
setTimeout(a.y, 10); // 输出:1
如果不用显示绑定,我们该如何做呢?我相信很多人都写过类似这样的代码:
#例子9:
var x = 1;
var a = {
x: 2,
y: function() {
var self = this;
setTimeout(function() {
console.log(self.x);
}, 10);
}
}
a.y(); // 输出:2
例子9中我们使用了熟悉的词法作用域来解决this绑定丢失的问题:在this指向确定的作用域内将this赋值给一个变量,此时,该作用域内的子作用域内,就可以直接拿这个变量进行使用了,非常直观!
虽然这样比较完美的解决了this绑定丢失的问题,但是如果项目中充斥着这样的代码,会显得不美观优雅,现在ES6的箭头函数很好地解决了这个问题:
#例子10:
var x = 1;
var a = {
x: 2,
y: function() {
setTimeout(() => {
console.log(this.x);
}, 10);
}
}
a.y(); // 输出:2
例子10中的箭头函数的行为和例子9中的写法类似,但是代码更简洁优雅。
箭头函数的this绑定规则和普通函数是完全不一样的,它用箭头函数所定义的词法作用域覆盖了this本来的值。
我们把例子2中的函数改成箭头函数形式,再来看下结果:
var x = 1;
var a = {
x: 2,
y: () => {
return this.x;
}
};
console.log(a.y()); // ?
var b = {
x: 3,
y: a.y
};
console.log(b.y()); // ?
答案:a.y()输出1,b.y()输出也是1。
即使用call
/apply
/bind
,箭头函数的this也不会改变:
var x = 1;
var a = {
x: 2
}
var foo = () => {
console.log(this.x);
}
foo.call(a);
答案:1。
记住:箭头函数没有自己this,所以它的this就是它所在的作用域的this!也正因为箭头函数没有this,所以箭头函数不能作为构造函数用new
实例化。
Node.js中的this机制
Node.js下的this和浏览器环境下有所不同,主要表现在以下几处:
1. 全局环境的this指向module.exports
console.log(this === module.exports); // true
2. 独立普通函数的this指向global
function foo() {
console.log(this === global);
}
foo(); // true
这里需要注意一点,Node.js的每个js文件都是单独的模块,就算使用var定义在顶层,也只是这个module的全局变量。
var x1 = 1;
global.x2 = 2;
function foo() {
console.log(this.x1); // undefined
console.log(this.x2); // 2
}
foo();
3. setTimout机制和浏览器不一致
Node.js的setTimeout
可查看官方文档:timer,Node.js环境的setTimeout
中的this指向的是Timeout
类(setInterval
也是一样),看个例子:
var x = 1;
var a = {
x: 2,
y: function() {
console.log(this)
}
}
setTimeout(a.y, 10)
输出结果为:
大致就是这些不同,若还有别的情况下this绑定不一致,请无情指出,谢谢!
总结
确实蛮复杂。