在JavaScript
中,this 这个特殊的变量是相对比较复杂的,因为this
不仅仅用在面向对象环境中,在其他任何地方也是可用的。
this
这个问题说实话是真的烦,与我们常见的很多语言不同,JS函数中的this
指向并不是在函数定义的时候确定的,而是在调用的时候确定的。换句话说,函数的调用方式决定了this
指向。
在日常开发过程中,经常遇到由于this问题引发的bug,写一份总结让过往经验更有价值
this的作用
this是JavaScript语言中定义的众多关键字之一,它的特殊在于它自动定义于每一个函数域内。
function identify() {
return this.name.toUpperCase();
}
function sayHello() {
var greeting = "Hello, I‘m " + identify.call( this );
console.log( greeting );
}
var person1= {
name: "Yerik"
};
var person2= {
name: "SHU"
};
这段代码很简单,我们定义了两个函数,分别为identify
和sayHello
。并且在不同的对象环境下执行了它们,达到了复用的效果,而不用为了在不同的对象环境下执行而必须针对不同的对象环境写对应的函数了。简而言之,this
给函数带来了复用。
问题来了,这么简单的效果实现,我们不用this照样可以实现啊
function identify(context) {
return context.name.toUpperCase();
}
function sayHello(context) {
var greeting = "Hello, I‘m " + identify( context);
console.log( greeting );
}
var person1= {
name: "Yerik"
};
var person2= {
name: "shu"
};
随着代码的增加,函数嵌套、各级调用等变得越来越复杂,那么传递一个对象的引用将变得越来越不明智,它会把你的代码弄得非常乱,甚至你自己都无法理解清楚。而this机制提供了一个更加优雅而灵便的方案,传递一个隐式的对象引用让代码变得更加简洁和复用。
对于this的误解
在很多编程语言中都有this
的机制,惯性思维把其它语言里对它的理解带到了JavaScript
中,毕竟在函数中,this
通常被认为是一个额外的,隐含的参数。同时,由于this
这个单词的理解导致了我们产生了对它各种各样的误解。
this引用function本身
我们都知道,在函数里引用函数可以达到递归和给函数属性赋值的效果。而这在很多应用场景下显得非常有用。所以,很多人都误以为this
就是指引function
本身。例如:
function fn(num) {
console.log( "fn: " + num );
// count用于记录fn的被调用次数
this.count++;
}
上面我们想要记录fn被调用的次数,可是明显fn被调用了四次但count仍然为0。怎么回事呢?这里简单解释下,fn里第4行的自增隐式的创建了一个全局变量count,由于初始值为undefined,所以每一次自增其实依然不是一个数字,你在全局环境下打印count(window.count)输出的应该是NaN。而第6行定义的函数熟悉变量count依然没变,还是0。在这里你只需要知道,this引用的是function这种理解是错误的就行。
既然this
不是引用function
,那么我要实现递归函数,该咋引用呢?这里简单回答下这个问题,两种方法:
- 函数体内用函数名来引用函数本身,对于匿名函数来说需要的是为匿名函数配置一个函数名(推荐)
- 函数体内使用
arguments.callee
来引用函数(不推荐)。
this引用的是function的词法作用域
存在这种误解的人可能更多一些。首先,this并没有引用function的词法作用域。的确JS的引擎内对词法作用域的实现的确像是一个对象,拥有属性和函数,但是这仅仅是JS引擎的一种实现,对代码来说是不可见的,也就是说词法作用域"对象"在JS代码中取不到。
看个错误的例子:
function fn1() {
var a = 2;
this.fn2();
}
function fn2() {
console.log( this.a );
}
fn1();
上面的代码明显没有执行出想要的结果,从而可以看到this并没有引用函数的词法作用域。甚至,可以肯定的说,这个例子里fn2可以在fn1里正确执行都是偶然的(理解了词法作用域你就知道为什么这里执行不报错了)。
正确的函数调用
函数调用方式主要有三种:
- 直接调用(this 在非严格模式下为全局对象,在严格模式下为
undefined
) - 方法调用(this 是指方法调用的接收者)
- new 调用(this 指向新创建的实例)
特殊的调用方式,比如通过bind()
将函数绑定到对象之后再进行调用、通过call()
、apply()
进行调用等。而es6 引入了箭头函数之后,箭头函数调用时,其this
指向又有所不同。
直接调用
直接调用,就是通过函数名(...)
这种方式调用。这时候,函数内部的 this 指向全局对象,在浏览器中全局对象是window
,在NodeJs
中全局对象是global
。
// 简单兼容浏览器和 NodeJs 的全局对象
const _global = typeof window === "undefined" ? global : window;
function testDirect() {
console.log(this === _global)
}
testDirect()
这里需要注意的一点是,直接调用并不是指在全局作用域下进行调用,在任何作用域下,直接通过函数名(...)来对函数进行调用的方式,都称为直接调用。比如下面这个例子也是直接调用
(function(_global) {
// 通过 IIFE 限定作用域
function testDirect() {
console.log(this === _global);
}
testDirect(); // 非全局作用域下的直接调用
})(typeof window === "undefined" ? global : window);
bind() 对直接调用的影响
Function.prototype.bind()的作用是将当前函数与指定的对象绑定,并返回一个新函数,这个新函数无论以什么样的方式调用,其 this 始终指向绑定的对象。
const obj = {};
function testBind() {
console.log(this === obj);
}
const testObj = testBind.bind(obj);
testBind();
testObj();
那么bind()
干了啥?不妨模拟一个bind()
来了解它是如何做到对this
产生影响的。
const obj = {};
function testBind() {
console.log(this === obj);
}
// 自定义的函数,模拟 bind() 对 this 的影响
function myBind(func, target) {
return function() {
return func.apply(target, arguments);
};
}
const testObj = myBind(testBind, obj);
testBind();
testObj();
从上面的示例可以看到,首先,通过闭包,保持了target
,即绑定的对象;然后在调用函数的时候,对原函数使用了apply
方法来指定函数的this
。当然原生的bind()
实现可能会不同,而且应该会更高效。不过这个示例足以说明了bind()
的对this
的影响。
call 和 apply 对 this 的影响
上面的示例中用到了Function.prototype.apply(),与之类似的还有Function.prototype.call()。这两方法的用法请大家自己通过链接去看文档。相同点在与,它们的第一个参数都是指定函数运行时其中的this
指向。
不过使用apply
和call
的时候仍然需要注意,如果目录函数本身是一个绑定了 this
对象的函数,那apply
和call
不会像预期那样执行,比如
const obj = {};
function testBind() {
console.log(this === obj);
}
// 绑定到一个新对象,而不是 obj
const testObjNew = testBind.bind({});
testBind.apply(obj);
// 期望 this 是 obj,即输出 true
// 但是因为 testObjNew 绑定了不是 obj 的对象,所以会输出 false
testObjNew.apply(obj);
使用bind()
的时候谨慎一些,debug会累死的:)
方法调用
方法调用是指通过对象来调用其方法函数,它是对象.方法函数(...)
这样的调用形式。这种情况下,函数中的this
指向调用该方法的对象,这就类似于传统的面向对象的语言:this指向接受者,方法被调用的对象。这里同样需要注意bind()
的影响。
const obj = {
// 第一种方式,定义对象的时候定义其方法
test1() {
console.log(this === obj);
}
};
// 第二种方式,对象定义好之后为其附加一个方法(函数表达式)
obj.test2 = function() {
console.log(this === obj);
};
// 第三种方式和第二种方式原理相同
// 是对象定义好之后为其附加一个方法(函数定义)
function test() {
console.log(this === obj);
}
obj.test3 = test;
// 这也是为对象附加一个方法函数
// 但是这个函数绑定了一个不是 obj 的其它对象
obj.test4 = (function() {
console.log(this === obj);
}).bind({});
obj.test1();
obj.test2();
obj.test3();
// 受 bind() 影响,test4 中的 this 指向不是 obj
obj.test4();
这里需要注意的是,后三种方式都是预定定义函数,再将其附加给obj
对象作为其方法。再次强调,函数内部的this
指向与定义无关,受调用方式的影响。
方法中 this 指向全局对象的情况
注意这里说的是方法中而不是指在方法调用中。方法中的this
指向全局对象,如果不是因为bind()
,那就一定是因为不是通过使用的方法调用的方式,比如
const obj = {
test1() {
console.log(this === obj);
}
};
const tFunction = obj.test1;
tFunction();
tFunction
就是obj
的test1
方法,但是tFunction()
调用时,其中的this
指向了全局。
之所以要特别提出这种情况,主要是因为常常将一个对象方法作为回调传递给某个函数之后,却发现运行结果与预期不符——因为忽略了调用方式对this
的影响。比如下面的例子是在页面中对某些事情进行封装之后特别容易遇到的问题:
class Handlers {
// 这里 $button 假设是一个指向某个按钮的 jQuery 对象
constructor(data, $button) {
this.data = data;
$button.on("click", this.onButtonClick);
}
onButtonClick(e) {
console.log(this.data);
}
}
const handlers = new Handlers("string data", $("#someButton"));
// 对 #someButton 进行点击操作之后
// 输出 undefined
// 但预期是输出 string data
很显然this.onButtonClick
作为一个参数传入on()
之后,事件触发时,是对这个函数进行的直接调用,而不是方法调用,所以其中的this
会指向全局对象。要解决这个问题有很多种方法
// 这是在 es5 中的解决办法之一
var _this = this;
$button.on("click", function() {
_this.onButtonClick();
});
// 也可以通过 bind() 来解决
$button.on("click", this.onButtonClick.bind(this));
// es6 中可以通过箭头函数来处理,在 jQuery 中慎用
$button.on("click", e => this.onButtonClick(e));
不过请注意,将箭头函数用作 jQuery
的回调时造成要小心函数内对this
的使用。jQuery
大多数回调函数(非箭头函数)中的this
都是表示调用目标,所以可以写$(this).text()
这样的语句,但jQuery
无法改变箭头函数的this
指向,同样的语句语义完全不同。
new 调用
如果通过new
运算符调用函数,则函数将成为构造函数。该运算符创建一个新的对象,并通过this传递给构造函数。
在es6
之前,每一个函数都可以当作是构造函数,通过new
调用来产生新的对象(函数内无特定返回值的情况下)。而es6
改变了这种状态,虽然class
定义的类用typeof
运算符得到的仍然是"function"
,但它不能像普通函数一样直接调用;同时,class
中定义的方法函数,也不能当作构造函数用new
来调用。
而在es5
中,用new
调用一个构造函数,会创建一个新对象,而其中的this
就指向这个新对象。这没有什么悬念,因为new
本身就是设计来创建新对象的。
var data = "Hi";
function TestClass(data) {
this.data = data;
console.log(this)
}
var obj = {
data: ‘yerik‘
};
var test1 = new TestClass("Hello World");
console.log(test1.data);
console.log(data);
var test2 = new TestClass("Hello World");
console.log(test1 === test2);
TestClass.call(obj, ‘yerik‘)
TestClass.apply(obj, [‘yerik‘])
var test3 = TestClass.bind(obj, ‘yerik‘)
var test4 = new test3()
在这个示例中,call
、apply
和bind
的this
都指向了obj
,都能正常运行;call
、apply
会立即执行函数,call
和apply
的区别就在于传递的参数,call
接收多个参数列表,apply
接收一个包含多个参数的数组;bind
不是立即执行函数,它返回一个函数,需要执行test4
才能返回结果。
箭头函数中的 this
先来看看 MDN 上对箭头函数的说明
An arrow function expression has a shorter syntax than a function expression and does not bind its own this, arguments,super, or new.target. Arrow functions are always anonymous. These function expressions are best suited for non-method functions, and they cannot be used as constructors.
这里已经清楚了说明了,箭头函数没有自己的 this 绑定。箭头函数中使用的 this,其实是直接包含它的那个函数或函数表达式中的 this。比如
const obj = {
test() {
const arrow = () => {
// 这里的 this 是 test() 中的 this,
// 由 test() 的调用方式决定
console.log(this === obj);
};
arrow();
},
getArrow() {
return () => {
// 这里的 this 是 getArrow() 中的 this,
// 由 getArrow() 的调用方式决定
console.log(this === obj);
};
}
};
obj.test(); // true
const arrow = obj.getArrow();
arrow(); // true
示例中的两个this
都是由箭头函数的直接外层函数(方法)决定的,而方法函数中的this
是由其调用方式决定的。上例的调用方式都是方法调用,所以this
都指向方法调用的对象,即obj
。
箭头函数让大家在使用闭包的时候不需要太纠结this
,不需要通过像_this
这样的局部变量来临时引用 this 给闭包函数使用。来看一段 Babel 对箭头函数的转译可能能加深理解:
// ES6
const obj = {
getArrow() {
return () => {
console.log(this === obj);
};
}
}
// ES5,由 Babel 转译
var obj = {
getArrow: function getArrow() {
var _this = this;
return function () {
console.log(_this === obj);
};
}
};
另外需要注意的是,箭头函数不能用new
调用,不能bind()
到某个对象(虽然 bind() 方法调用没问题,但是不会产生预期效果)。不管在什么情况下使用箭头函数,它本身是没有绑定this
的,它用的是直接外层函数(即包含它的最近的一层函数或函数表达式)绑定的 this。
总结:this机制的四种规则
this到底绑定或者引用的是哪个对象环境决定于函数被调用的地方。而函数的调用有不同的方式,在不同的方式中调用决定this引用的是哪个对象是由四种规则确定的。
默认绑定全局变量
这条规则是最常见的,也是默认的。当函数被单独定义和调用的时候,应用的规则就是绑定全局变量,也就是我们前面所介绍的直接调用
隐式绑定
隐式调用的意思是,函数调用时拥有一个上下文对象,就好像这个函数是属于该对象的一样。
function fn() {
console.log( this.a );
}
var obj = {
a: 2,
fn: fn
};
obj.fn();
需要说明的一点是,最后一个调用该函数的对象是传到函数的上下文对象(绕懵了)。如:
function fn() {
console.log( this.a );
}
var obj2 = {
a: 42,
fn: fn
};
var obj1 = {
a: 2,
obj2: obj2
};
obj1.obj2.fn();
还有一点要说明的是,失去隐式绑定的情况,如下:
function fn() {
console.log( this.a );
}
var obj = {
a: 2,
fn: fn
};
var bar = obj.fn; // 函数引用传递
var a = "全局"; // 定义全局变量
bar();
如上,第8行虽然有隐式绑定,但是它执行的效果明显是把fn赋给bar。这样bar执行的时候,依然是默认绑定全局变量,所以输出结果如上。
显式绑定
也就是我们前面介绍的通过bind()\apply()\call()
来进行绑定的操作
它接收的第一个参数即是上下文对象并将其赋给this。看下面的例子:
function fn() {
console.log( this.a );
}
var obj = {
a: 2
};
fn.call()
如果我们传递第一个值为简单值,那么后台会自动转换为对应的封装对象。如果传递为null,那么结果就是在绑定默认全局变量,如:
function fn() {
console.log( this.a );
}
var obj = {
a: 2
};
var a = 10;
fn.call( null);
new新对象绑定
如果是一个构造函数,那么用new
来调用,那么绑定的将是新创建的对象。如:
function fn(a) {
this.a = a;
}
var bar = new fn( 2 );
注意,一般构造函数名首字母大写,这里没有大写的原因是想提醒读者,构造函数也是一般的函数而已。