前言
this
关键字可以说是贯穿JavaScript
这门语言的一个精髓,若是不能好好理解this
关键字,那在实际的开发中也是会遇到各种各样莫名其妙的问题,让人百思不得其解,所以若能好好的理解其工作原理,那对于提升自身的编程能力是百利而无一害的。
初识this
function foo(){
console.log(this)
return this.name.toUpperCase();
}
var obj = {
name: 'Tom'
}
foo.call(obj)//TOM
这段代码最终输出了TOM
字符串,可能你还没缓过神来,好奇为何在foo
函数中没有name
属性,却能打印出obj
中的name
属性,这里的主要原因是引用改变了作用域,初始作用域是函数里面,是没有任何变量的,包括name
属性,在调用函数后调用call
函数从而改变了foo
作用域,最终访问到了name
属性,我们可以打印foo
函数的this
。
/*未改变作用域前*/
foo()//Window
/*改变作用域之后*/
foo.call(obj)//obj
如果不使用this
,代码如下:
function foo(ctx){
return ctx.name.toUpperCase();
}
但你发现了,相比起this
,这样写似乎不够优雅。
再来看一段代码:
function foo(){
this.count++;
}
foo.count=0;
foo()
foo()
foo.count//0
这段代码最终会输出0
,可能你会好奇,我明明调用了两次,按理来说应该是1
,但却什么都没有增加,但是你别忘了,在上面我们已经指出了在函数里面打印this
的时候显示window
,也就是此时指向的是全局对象,记住,这里我们并没有改变作用域,所以这里表达式this.count++
会转变为window.count++
,由于全局并没有定义这个变量,所以其值是undefined
,对undefined
执行自增运算,最终会变成NaN
,我们可以打印一下从而验证:
window.count//NaN
在了解上面的知识后,我们回到第一个问题,考虑下面这段代码:
function foo(){
this.count++;
}
foo.count=0;
foo.call(foo)
foo.call(foo)
foo.count//2
这里的预期似乎和我们一样,这一段代码和上一段代码的不同之处在于我们改变了词法作用域,让函数内部的this
指向foo
函数,所以count
值能符合预期增加,如果没有改变词法作用域,那么函数的this
则指向全局window
。
下面来小结一下:this
是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this
的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
调用位置
上一小节讲了this
的绑定取决于函数的调用方式,调用方式又是相对调用位置来讲的,下面我们来看一看调用栈和调用位置,具体代码如下:
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 的调用位置
我们可以通过浏览器的调试工具来验证我们的猜想,设置断点如下:
可以新建一个文件copy
上面的代码,然后在浏览器中运行,然后打开控制台单步调试,首先我们来看第一个执行的断点:
第一个执行函数是baz
,我们可以看到它的调用栈是baz
,调用位置是调用栈第二个元素,也就是anonymous
,它指向的是全局作用域,在下图中我们可以看到它作用域是global
,也就是全局作用域。
我们可以看到当前的调用栈是bar
,调用位置为调用栈中的第二个元素,我们可以看到是baz
。 继续执行下一个断点:
同理我们可以看到当前的调用栈是foo
,调用位置为调用栈中的第二个元素,也就是bar
中,至此我们继续执行下一个断点,函数执行完毕。
绑定规则
调用规则总体总结为四条规则,你必须找到调用位置,然后判断应用下面四条规则中的那一条。
默认规则
默认绑定是我经常会使用的调用函数的情况,调用位置是在全局的,因此this
绑定在全局作用域中,考虑下面这段代码:
function foo() {
return this.a;
}
var a = 10;
foo();//10
我们都知道这里的a
是一个全局变量,因为定义在了全局作用域中,我们的foo
函数由于调用位置是全局作用域,因此可以打印出全局的a
变量,这里就是应用了默认绑定规则,那我们怎么判断应用了默认绑定规则呢?在代码中foo()
是直接不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其他规则。
当然在严格模式下我们则无法使用默认绑定规则,因此会返回undefined
:
function foo() {
"use strict";
return this.a;
}
var a = 10;
foo();//undefined
隐式绑定
如果一个函数的调用位置拥有了上下文对象,也可以说是函数被包含在某个对象中,考虑下面这一行代码:
function foo() {
return this.a;
}
var obj = {
a: 10,
foo: foo
}
obj.foo();//10
我们可以看到我们定义了一个函数,然后将其添加为对象的引用属性,最终函数的this
将会绑定到obj
作用域。 对象属性引用链中只有最顶层或者说是最后一层才会影响调用位置,举例来说:
function foo() {
return this.a;
}
var obj1 = {
a: 10,
foo: foo
}
var obj2 = {
a: 100,
obj1: obj1
}
obj2.obj1.foo();//10
我们可以简单的理解为,无论嵌套多少层对象,只不过都是一个引用属性,最终调用函数foo
的都是最后一个调用它对象,而最后一个对象的作用域就会绑定到函数this
上面,我们将上面的代码改写一下:
function foo() {
return this.a;
}
var obj1 = {
a: 10,
foo: foo
}
var obj2 = {
a: 100,
obj1: obj1
}
var obj3 = {
a: 1000,
obj2: obj2
}
obj3.obj2.obj1.foo();//10
//等价于
var last = obj3.obj2.obj1;
last===obj1//true
last.foo();//10
在这里尽管我用到了三个对象来引用一个函数的调用,但是最终都只是一个引用,因此最终调用的函数的对象都是obj1
,我们可以使用last===obj1
验证,最终显示结果为true
。
再来看另一种情况:
function foo() {
return this.a;
}
var obj = {
a: 10,
foo: foo
}
var a = "global";
var b = obj.foo;
b();//"global"
如果理解了刚刚讲的多个对象引用同一个函数,最终的作用域绑定将会是最后一个调用函数的对象问题后,这个也很好理解,因为对象里面的foo
属性只是一个引用传递,所以var b = obj.foo
这段代码将整个函数又指向了b
变量,我们可以打印b
变量验证一下:
我们可以看到此时的b
变量指向的就是一个函数,跟原来的obj
对象没有半毛钱关系,既然没了obj
对象的关系,也没用使用任何带修饰的引用调用,那么此时的函数调用就符合第一条作用规则默认绑定
,因此函数里面this
绑定到了全局作用域,所以打印出了global
,再举一个例子:
function foo() {
return this.a;
}
var obj = {
a: 10,
foo: foo
}
var a = "global";
setTimeout( obj.foo, 100)//"global"
可能就会有人好奇,我传的不是obj.foo
吗,为何还是this
还是应用的默认绑定
规则,我们来看一下setTimeout()
函数的大概实现的伪代码:
function setTimeOut(fn,delay){
//等待delay毫秒
fn();//<-- 调用位置
}
虽然我们貌似没有用一个变量指向这个函数,并将这个函数传递进去调用,但是这里的obj.foo
却被函数的形参fn
给接住了,间接的创建了一个局部变量,并将这个变量指向了函数foo
,最终的调用效果和obj
依旧没有半毛钱关系,因此应用默认绑定
行为。
显式绑定
我们在隐式绑定中知道,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把this
间接(隐式)绑定到这个对象上,那有没一种方法直接让我们强制绑定this
作用域呢?
JavaScript中为绝大多数函数以及你自己创建的函数都提供了call
和apply
方法,这两个的区别主要是传递的参数形式不一样,call
可以接受参数列表,比如call(null,arg1,arg2)
,而apply
则是接受单个参数数组,比如apply(null,[1,2,3])
,在了解上面的知识后,我们来看一个例子:
function foo() {
return this.a;
}
var obj = {
a: 10
}
foo.call( obj )//10
在这里,我们通过call
函数显式的指定this
绑定的作用域为obj
,因此可以访问到obj.a
,当然你可以传入一个原始值(字符串,布尔或者数字类型),最终这些原始值都会转换为原始对象形式,类似于(new String(),new Boolean()或者new Number())。
硬绑定
考虑下面这段代码:
function foo() {
console.log( this.a );
}
function bar(){
foo.call( obj )
}
var obj = {
a: 10
}
bar()//10
bar.call( window )//10
在bar
函数我将foo
函数绑定到了obj
上面,在调用bar
的时候我们试图重新绑定this
作用域,但是由于在调用的时候我们又重新绑定到了obj
上面,所以导致最终输出10
,这种绑定的策略我们称之为强制绑定,因此我们称为硬绑定。
由于硬绑定是一种常用的模式,所以es5
中内置了方法Function.prototype.bind
,用法如下:
function foo() {
console.log( this.a );
}
var obj = {
a: 10
}
foo.bind(obj)()//10
这段代码首先绑定了obj
对象到函数上面,紧接着又执行了这个函数。
new
绑定
初学js
语言使用new
关键字的时候,如果之前有过oop
的编程经验,那可能会认为这个new
和他们以前自己接触的oop
中new
一样,然而实际却是没有半毛钱的关系,使用new
来调用函数,或者说发生构造函数调用时,会自动执行下面的操作:
- 创建(或者说构造)一个全新的对象。
- 这个新对象会被执行 [[ 原型 ]] 连接。
- 这个新对象会绑定到函数调用的
this
。 - 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。
考虑下面的代码:
function Foo(a) {
this.a = a;
}
var foo = new Foo(10);
foo.a//10
如果按照之前规则解释,这里执行的foo.a
将会输出undefined
,因为函数里面的this
指向全局作用域,也就是this.a
将会解释成window.a
从而创建一个全局变量a
,但是这里我们使用了new
关键字,我们遵从上面使用new
调用函数的四个操作,其中的第三步操作中指明了:这个新对象会绑定到函数调用的 this
,简单来说就是我们这里的this
绑定到了foo
变量上面,而foo
本身就是一个新创建的函数,因此var foo = new Foo(10)
这一行代码将this
绑定到foo
的作用域上,也就是说this.a = a;
将会解释成foo.a = a;
,因此我们打印foo.a
才会显示10
。
优先级
绑定有四种,js
中不可能都是单一规则,通常都是几种规则混合在一起,因此这四种规则得有个优先级,具体优先级为:
new
绑定 > 显式绑定 > 隐式绑定 > 默认绑定
我们看一个一例子:
function Foo(name){
this.name = name;
}
var obj = {};
var bar = Foo.call(obj);
bar(10);
obj.name//10
var baz = new bar(100);
obj.name//10
baz.name//100
这段代码首先使用了显示绑定,因此obj.name
输出10
;紧接着又使用了new
绑定;由于new
绑定优先级高于显示绑定,此时的this
绑定到了baz
函数上,从而在baz.name
输出100
,而没有改变obj.name
的值。
判断js
- 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。 var bar = new foo()
- 函数是否通过 call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是 指定的对象。 var bar = foo.call(obj2)
- 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上 下文对象。 var bar = obj1.foo()
- 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到 全局对象。 var bar = foo()
特殊情况
1.如果我们把null
或者undefined
作为this
的绑定对象传入call
、apply
或者bind
中,那么应用的是默认规则,举个栗子:
function foo() {
console.log( this.a );
}
var a = 2;
foo.call( null ); // 2
这里应用的是默认规则。
2.间接引用
在有些情况下,我们可能还不知道我们间接引用了,考虑下面一段代码:
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
函数体,最终就是使用foo
函数调用,也就是默认绑定
,我们可以打印一下这段表达式看看返回什么:
返回的就是foo
的函数体,所以当下次发现this
的绑定不符合预期的时候,去控制台打印一下看看是不是发生了间接引用了。