javascript ·

深入理解JavaScript中的this

很多人看到this这个关键字就会感觉很恶心,因为this 关键字是 JavaScript 中最复杂的机制之一。它是一个很特别的关键字,被自动定义在所有函数的作用域中。但是即使是非常有经验的 JavaScript 开发者也很难说清它到底指向什么。

那么既然this这么难用,为啥我们还一定要用他呢?

this的作用

我们先来看这样一段代码:

function myName() {
 return this.name;
}
function guest() {
 var greeting = "OECOM, 我是访客 " + myName.call( this );
 console.log( greeting );
}
var one = {
 name: "张三"
};
var two = {
 name: "李四"
};
myName.call( one ); // 张三
myName.call( two ); // 李四
guest.call( one ); // OECOM, 我是访客 张三
guest.call( two ); // OECOM, 我是访客 李四

上面这段代码可以在不同的上下文中重复使用,无需根据不同对象编写不同的函数,也无需设置很多的形参。this为其提供了一种优雅的方式来隐式“传递”一个对象引用,因此可以将 API 设计得更加简洁并且易于复用。随着你的使用模式越来越复杂,显式传递上下文对象会让代码变得越来越混乱,使用 this则不会这样。

this初理解

我们不限要理解的是this是在运行时绑定的,而非是在编写时绑定,他的上下文取决于函数调用时的各种条件,并且this的绑定和函数的声明位置基本上没有关系,一定要记住,他只取决于函数的调用方式。

当一个函数被调用的时候,我们之前说过会创建一个活动记录/执行上下文,这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传输的参数等信息,this就是记录的其中一个属性,会在函数执行过程中使用到。

function foo(num) {
 console.log( "foo: " + num );
 // 记录 foo 被调用的次数
 this.count++;
}
foo.count = 0;
var i;
for (i=0; i<10; i++) {
    if (i > 5) {
        foo( i );
    }
}
console.log( foo.count );

前面的输出很简单,不用多说,重点想说的是foo.count这个值,他的结果是0。可能会出乎一部分人的意料,你可能认为在foo中this只的应该是foo,如果你这么想就错了。

当执行 foo.count = 0 时,的确向函数对象 foo 添加了一个属性 count。但是函数内部代码this.count 中的 this并不是指向那个函数对象,所以虽然属性名相同,根对象却并不相同。这里的this.count实际上指的是在全局变量中的count,外部没有声明,其自动创建了一个。

这是因为this指向的作用域在任何情况下都不会指向函数的词法作用域,在 JavaScript 内部,作用域确实和对象类似,可见的标识符都是它的属性。但是作用域“对象”无法通过 JavaScript代码访问,它存在于 JavaScript 引擎内部。

function foo() {
 var a = 2;
 this.bar();
}
function bar() {
 console.log( "123",this.a );
}
foo();

来思考一下上面这段代码,这是一个非常经典的诠释出this是如何错误使用的。这段代码看似很合理,但是你要明白在foo中调用bar使用this是很危险的,因为一旦foo指定了this指向(下面会解释),那么bar必然无法调用成功,如果你想使用 this 联通 foo() 和 bar() 的词法作用域,从而让bar() 可以访问 foo() 作用域里的变量 a。这是不可能实现的,你不能使用 this 来引用一个词法作用域内部的东西。

每当你想要把 this 和词法作用域的查找混合使用时,一定要提醒自己,这是无法实现的。

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 的调用位置

上面代码一定要倒着看,先看baz(),因为是从调用位置开始,而不是声明位置,我这里再次的说明了一下,因为很多人会犯这个问题。函数调用的位置决定了this的绑定,所以一定要理解调用位置是在哪,如果你感觉看着不容易看出来,可以使用chrome的调试工具来打断点,一步一步的看代码是如何走的。

this的绑定规则

this的绑定规则可以分为四种:

  • 默认绑定
  • 隐式绑定
  • 显示绑定
  • new绑定

下面我们来一一查看

默认绑定

这里要介绍的是最常用的函数调用类型:独立函数调用,也就是正常调用。你可以吧这条规则看做是无法应用其他规则时的默认规则。

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

这个示例和一开始说的那个示例原理是一样的。我们知道,声明在全局作用域中的变量就是全局对象的一个同名属性。在全局作用域中this.a和a是相同的。我们看到他最终输出结果是2,就说明在调用foo函数时,this.a被解析成了全局变量a。这就是因为在函数调用时使用了this的默认绑定,此时this指向全局对象。

区分默认绑定很简单,可以通过分析调用位置来看foo是如何调用的,foo是不带任何修饰的函数进行直接调用,因此只能使用默认绑定,无法使用其他规则。

但是有一点是需要注意的,就是当使用严格模式时,this会绑定的undefined,因为全局对象无法使用默认绑定。

隐式绑定

隐式绑定规则考虑的是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含。来看一下下面这段代码


function foo() { console.log( this.a ); } var obj = { a: 2, foo: foo }; obj.foo(); // 2

首先需要注意的是 foo() 的声明方式,及其之后是如何被当作引用属性添加到 obj 中的。但是无论是直接在 obj 中定义还是先定义再添加为引用属性,这个函数严格来说都不属于obj 对象。

然而,调用位置会使用 obj 上下文来引用函数,因此你可以说函数被调用时 obj 对象“拥有”或者“包含”它。

无论你如何称呼这个模式,当 foo() 被调用时,它的落脚点确实指向 obj 对象。当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。因为调用 foo() 时 this 被绑定到 obj,因此 this.a 和 obj.a 是一样的。

但是需要注意的一点就是他只和调用这个函数的直接对象有关系,例如

function foo() {
 console.log( this.a );
}
var obj2 = {
 a: 42,
 foo: foo
};
var obj1 = {
 a: 2,
 obj2: obj2
};
obj1.obj2.foo(); // 42

这种绑定方式有一个可能会出现的隐藏bug,如果你不留意就会产生,那就是this的隐式丢失。

function foo() {
 console.log( this.a );
}
var obj = {
 a: 2,
 foo: foo
};
var bar = obj.foo; // 函数别名!
var a = "oecom"; // a 是全局对象的属性
bar(); // "oecom"

上面这段代码就是一种隐式的丢失,这是因为bar实际上是对foo的一个引用,他就相当于foo函数,调用bar()和调用foo()是没有区别的,会自动采用默认绑定。

另一种可能会造成隐式丢失的就是我们常用的回调函数

function foo() {
 console.log( this.a );
}
function doFoo(fn) {
 // fn 其实引用的是 foo
 fn(); // <-- 调用位置!
}
var obj = {
 a: 2,
 foo: foo
};
var a = "oecom"; // a 是全局对象的属性
doFoo( obj.foo ); // "oecom"

其原理和上面的示例是一样的,形参fn也只是对foo的一个地址引用。说到回调函数,其实我们经常使用的setTimeout和setInterval中的function也是一种回调函数,同样是会造成this的隐式丢失,在使用过程中需要注意一下。

显式绑定

隐式绑定一般在函数式编程中很是常见,如果是正常的一般函数调用就会出现上面说到的容易产生this丢失问题。如果说感觉上面的方法很麻烦,不想用对象包含函数的方式来实现,那么我们可以使用显示绑定的方式来实现。就比如文章最开始的那个例子,使用call或者apply。

call和apply同榆树Function.prototype的一个方法,是在js引擎内在实现的,实现原理不在本文的讨论范围,我们只说他的使用方法。

先说一下他们的作用,两个方法的作用都是给函数的this指定一个对象,在函数调用时,使用this就是指的这个对象,因为你可以直接指定this的绑定对象,所以我们称此方法为显示的绑定。

这两个方法的区别就是在于后面的参数形式,call的参数可以是多个,但是apply的参数只能是两个,一个是指定的对象,另一个就是函数传入的参数。如下代码:

function foo(yourname,name) {
 console.log( this.a,yourname+'你好,我是'+name );
}
var obj = {
 a:2
};
foo.call( obj,'张三','李四' ); // 2 张三你好,我是李四
foo.apply( obj,['张三','李四'] ); // 2 张三你好,我是李四

通过上面这段代码你可以很清晰的知道call和apply的区别是什么。

当然,对于第一个参数this,我们可以像上面似的传入一个对象,也可以传入一个原始值,例如字符串类型,布尔类型或者数字类型,这个原始值会自动转换成他的对象形式,就是给他自动new了一下,这种方式我们称之为“装箱”。

这种方式其实还是没有办法解决上面隐式绑定中所提到的绑定丢失问题,但是我们可以换个思路去解决。

硬绑定

function foo(yourname,name) {
 console.log( this.a,yourname+'你好,我是'+name );
}
var obj = {
 a:2
};
var bar = function() {
 foo.apply( obj,arguments);
 //或者使用foo.call( obj,...arguments);
};
bar('张三','李四'); // 2 张三你好,我是李四
setTimeout( bar('张三','李四'), 100 ); // 2

这段代码的思路就是创建一个包裹函数,将绑定操作放到这个包裹函数中,在之后bar函数的调用中,无论如何调用,都会执行foo.call来强制给foo的this指定到obj。即使我们强制给bar绑定this也是无法再次修改foo的this指向。这种方式就是所谓的硬绑定。

当然,我们可以通过回调函数来封装一个可用来重复使用的函数

function bind(fn,obj){
    return function(){
        return fn.apply(obj,arguments);
    }
}

bind方法想必大家都已经很熟悉了吧,在使用react的时候,放置到元素上的点击事件都会写成this.xxx.bind(this)。这里的bind是ES5内置的方法,上面的代码示例可以说是他的一个实现原理。

来看一下bind在普通函数中的使用

function foo(something) {
 console.log( this.a, something );
 return this.a + something;
}
var obj={
    a:2
}
var bar = foo.bind(obj);
var m = bar("oecom");//2 oecom
console.log(m);//2oecom

想必大家都使用过jq,在jq中绑定点击事件,如下

$("#id").click(function(){
    console.log($(this).html());//这里的this指的就是$("#id");
})

new绑定

相信new这个关键字大家都不怎么陌生吧,很多面向对象语言都有他的存在,通常情况下,使用new初始化类是会调用类中的构造函数。但是在js中却是有区别的,他只是使用方法有些相似,本质却完全不同。

在JavaScript中new操作符后面跟的只是使用new操作符是被调用的函数而已,他们并不会归属于某个类,也不会实例化一个类,他们就是一个普通的函数,只是被new操作符调用了而已。

我们在使用JavaScript的内置对象时,一般会使用new,比如Date,当他被new时,他会初始化新创建一个对象,当使用new来调用函数时,会自动执行下面的操作。

  • 创建一个全新的对象
  • 这个新对象会绑定到函数调用的this
  • 如果函数没有返回其他对象,那么new表达式中的函数会自动返回这个新对象。
function foo(a) {
 this.a = a;
}
var bar = new foo(2);
console.log( bar.a ); // 2

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

参考文献:《你不知道的JavaScript》

参与评论