Bootstrap

JS入门难点解析7-this

(注1:如果有问题欢迎留言探讨,一起学习!转载请注明出处,喜欢可以点个赞哦!)
(注2:本文首发于我的简书更多内容请查看我的目录。)

1. 简介

老样子,我们列一下执行上下文的三大属性:
* 变量对象(Variable object,VO)
* 作用域链(Scope chain)
* this

this是一个非常容易让人混淆的概念。首先我们思考一下JS中为什么会有this。

2. this的作用

看一下如下代码:

function identify() {
    return this.name.toUpperCase();
}

function speak() {
    var greeting = "Hello, I'm " + identify.call(this);
    console.log(greeting);
}

var me = {
    name: "Kyle"
};
var you = {
    name: "Reader"
};
identify.call(me); // KYLE
identify.call(you); // READER
speak.call(me); // Hello, 我是 KYLE 
speak.call( you ); // Hello, 我是 READER

这段代码可以在不同的上下文对象(me 和 you)中重复使用函数 identify() 和 speak(), 不用针对每个对象编写不同版本的函数。

如果不使用 this,那就需要给 identify() 和 speak() 显式传入一个上下文对象。如下所示:

function identify(context) {
    return context.name.toUpperCase();
}

function speak(context) {
    var greeting = "Hello, I'm " + identify(context);
    console.log(greeting);
}

identify(you); // READER
speak(me); //hello, 我是 KYLE

然而,this 提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将 API 设计得更加简洁并且易于复用。随着使用模式越来越复杂,显式传递上下文对象会让代码变得越来越混乱,使用 this 则不会这样。后面介绍对象和原型时,你就会明白函数可以自动引用合适的上下文对象有多重要。

3. this的两种错误解读

this常见的错误解读有两种,下面我们来仔细分析一下。

3.1 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 ); 
    }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo被调用了多少次?
console.log( foo.count ); // 0

console.log 语句产生了 4 条输出,证明 foo(..) 确实被调用了 4 次,但是 foo.count 仍然是 0。显然从字面意思来理解 this 是错误的。其实,此处this.count++创建了一个全局变量count。执行count++以后count变量成了NaN。如果不相信,大家可以在最后一行尝试输出window.count。

这个例子说明this并不能单纯理解为指向这个函数本身。不过,既然this不是指向函数本身,我们在函数内部如何引用函数本身呢?主要有以下三个方法:
1. 具名引用
例如:

function foo() {
  foo.count = 4; // foo指向它自身
}

该方法只能用于具名函数中。
2. arguments. callee
例如:

setTimeout( function(){
  arguments.callee.count = 4;  // 匿名(没有名字的)函数无法指向自身
}, 10 );

但是这种写法已被废弃,不建议使用。
3. this
刚才不是说this不是指向函数本身么?可是现在为什又说可以呢?别急,等看完这篇文章,你自然会有答案。

3.2 this指向其作用域

这是this最使人混淆的地方。需要明确的是,this 在任何情况下都不指向函数的词法作用域。在 JavaScript 内部,作用域确实和对象类似,可见的标识符都是它的属性。但是作用域“对象”无法通过 JavaScript 代码访问,它存在于 JavaScript 引擎内部。同时,this与作用域链也不相关。

看下面的代码:

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

这段代码本意是想,foo在全局定义,那么this就指向全局,this.bar就可以调用全局中定义的bar,而bar执行的时候呢正好是在foo的执行上下文,所以this指向foo。其实这里对this的两处解读都是错误的。首先,this.bar()能够运行完全是一种偶然,怎样的偶然呢?你使用的是非严格模式,你是在浏览器环境运行而不是在node运行,你是独立调用的foo而正好bar在全局声明。是不是很巧合呢?是不是有点迷糊,不要紧,继续往下看。第二点,代码视图在bar里面打印foo的变量,这里是完全错误的,因为bar在运行时,this也是指向了全局(非严格模式,下面我们的讨论都是基于运行于浏览器的非严格模式)。

3.3 this的真正解读

this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调 用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

4. 绑定规则与函数调用方式

this在运行时进行绑定,绑定主要有四种规则,取决于绑定时候函数的调用方式。

4.1 默认绑定与独立调用(函数调用模式)

独立调用是指函数作为一个普通函数来调用。当一个函数并非一个对象的属性时,那么它就是被当做一个函数来调用的。对于普通的函数调用来说,函数的返回值就是调用表达式的值。

使用函数调用模式调用函数时,非严格模式下,this被绑定到全局对象;在严格模式下,this是undefined。

以下是四种常见的独立调用场景。
1. 普通独立调用

function foo(){
    console.log(this === window);
}
foo(); //true
  1. 被嵌套的函数独立调用
//虽然test()函数被嵌套在obj.foo()函数中,但test()函数是独立调用,而不是方法调用。所以this默认绑定到window
var a = 0;
var obj = {
    a : 2,
    foo:function(){
            function test(){
                console.log(this.a);
            }
            test();
    }
}
obj.foo();//0

3.IIFE(立即执行函数)

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

其实立即执行函数可以理解为立即赋值并独立调用。上面代码其实和下面效果一样:

var a = 0;
function foo(){
    var temp = (function test(){
        console.log(this.a);
    });
    temp();  // 独立调用
};
var obj = {
    a : 2,
    foo:foo
}
obj.foo();//0
  1. 闭包
var a = 0;
function foo(){
    function test(){
        console.log(this.a);
    }
    return test;
};
var obj = {
    a : 2,
    foo:foo
}
obj.foo()();//0

4.2. 隐式绑定和方法调用(方法调用模式)

当一个函数被保存为对象的一个属性时,我们称它为一个方法。当一个方法被直接对象所调用时,this会被隐式绑定到该对象。如果调用表达式包含一个提取属性的动作,那么它就是被当做一个方法来调用。要记住,对象属性引用链中只有最顶层或者说最后一层会影响调用位置。

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

//foo()函数的直接对象是obj1,this隐式绑定到obj1
obj1.foo();//1

//foo()函数的直接对象是obj2,this隐式绑定到obj2
obj1.obj2.foo();//2

对于隐式绑定,是最容易出现错误的地方,也是面试出陷阱题最多的地方。因为很容易出现所谓的隐式丢失。隐式丢失是指被隐式绑定的函数丢失绑定对象,从而默认绑定到window。我们来看一下哪些情况会出现隐式丢失。
1. 函数别名

var a = 0;
function foo(){
    console.log(this.a);
};
var obj = {
    a : 2,
    foo:foo
}
//把obj.foo赋予别名bar,造成了隐式丢失,因为只是把foo()函数赋给了bar,而bar与obj对象则毫无关系
var bar = obj.foo;
bar();//0

等价于

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

其实,要理解一点。就是函数在进入函数执行上下文时才会进行this绑定,也就是这个函数调用以后才会进行this绑定。而此处仅仅是做了引用赋值,然后进行了bar的独立调用。后面出现的所谓隐式丢失,其实都可以用这个道理去解释。
2. 参数传递

var a = 0;
function foo(){
    console.log(this.a);
};
function bar(fn){
    fn();
}
var obj = {
    a : 2,
    foo:foo
}
//把obj.foo当作参数传递给bar函数时,有隐式的函数赋值fn=obj.foo。与上例类似,只是把foo函数赋给了fn,而fn与obj对象则毫无关系
bar(obj.foo);//0

等价于

//等价于
var a = 0;
function bar(fn){
    fn();
}
bar(function foo(){
    console.log(this.a);
});
  1. 内置函数
var a = 0;
function foo(){
    console.log(this.a);
};
var obj = {
    a : 2,
    foo:foo
}
setTimeout(obj.foo,100);//0

等价于

var a = 0;
setTimeout(function foo(){
    console.log(this.a);
},100);//0
  1. 间接引用
function foo() {
    console.log( this.a );
}
var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4 };
o.foo(); // 3
//将o.foo函数赋值给p.foo函数,然后立即执行。相当于仅仅是foo()函数的立即执行
(p.foo = o.foo)(); // 2

不等价于

function foo() {
    console.log( this.a );
}
var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4 };
o.foo(); // 3
//将o.foo函数赋值给p.foo函数,之后p.foo函数再执行,是属于p对象的foo函数的执行
p.foo = o.foo;
p.foo();//4
  1. 其他情况
    在javascript引擎内部,obj和obj.foo储存在两个内存地址,简称为M1和M2。只有obj.foo()这样调用时,是从M1调用M2,因此this指向obj。但是,下面三种情况,都是直接取出M2进行运算,然后就在全局环境执行运算结果(还是M2),因此this指向全局环境。
var a = 0;
var obj = {
    a : 2,
    foo:foo
};
function foo() {
    console.log( this.a );
};

(obj.foo = obj.foo)(); // 0

(false || obj.foo)(); // 0

(1, obj.foo)(); // 0

// 直接加括号并不会有造成隐式丢失
(obj.foo)(); // 2

总结:其实,隐式绑定只有在直接进行对象方法调用时才会出现,就是读取到属性方法以后直接在后面加括号调用,如下:

obj.foo();

如果在调用前经过了任何运算,比如“=”,“,”“||”等运算(注意”()”并不是运算符),其实都是执行了一个隐式的赋值引用,然后对被隐式赋值的函数进行了直接调用,如下:

(obj2.foo = obj.foo)();
(obj.foo = obj.foo)();
(false || obj.foo)();
(1, obj.foo)();
...

等价于

var temp = (obj2.foo = obj.foo);temp();
var temp = (obj.foo = obj.foo);temp();
var temp = (false || obj.foo);temp();
var temp = (1, obj.foo);temp();
...

4.3 显式绑定与间接调用(间接调用模式)

在分析隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把 this 间接(隐式)绑定到这个对象上。 那么如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么做呢?

可以通过call()、apply()、bind()方法把对象绑定到this上,这种做法叫做显式绑定。对于被调用的函数来说,叫做间接调用。

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

obj并没有指向函数foo的属性,但却可以间接调用foo。这时候我们可以回答文章开头提出的问题了。另一种指向自身的方式,使用this进行显示绑定。

function foo(){
    console.log(this);
}
foo();  // Window
foo.call(foo);  // f foo

不过,这种普通的显式绑定无法解决前面提到的隐式丢失问题。以前面所举的函数别名导致隐式丢失的代码为例。

var a = 0;
function foo(){
    console.log(this.a);
};
var obj = {
    a : 2,
    foo:foo
}
//把obj.foo赋予别名bar,造成了隐式丢失,因为只是把foo()函数赋给了bar,而bar与obj对象则毫无关系
var bar = obj.foo;
bar();//0

改成如下显示绑定:

var a = 0;
function foo(){
    console.log(this.a);
};
var obj = {
    a : 2,
    foo:foo
}
var obj2 = {
    a : 3,
}
var bar = obj.foo;
bar();//0
bar.call(obj);//2
bar.call(obj2);//3

说明,call传入的对象改变时,隐式绑定的对象也发生了改变,this不再绑定foo的直接拥有者obj,发生了隐式丢失。那么如何防止这种隐式丢失呢?只要想办法让this始终指向其属性拥有者即可。当然我们也可以让this指向任何事先设定的对象,做到一种强制的绑定,也就是所谓的硬绑定。

var a = 0;
function foo(){
    console.log(this.a);
};
var obj = {
    a : 2,
    foo:foo
}
var obj2 = {
    a : 3,
}
var bar = function () {
  foo.call(obj);
};
bar();//2
bar.call(obj);//2
bar.call(obj2);//2

不管给call传入什么,最后this实际绑定的对象都是预先指定的obj。

JavaScript中新增了许多内置函数,具有显式绑定的功能,如数组的5个迭代方法:map()、forEach()、filter()、some()、every()

var id = 'window';
function foo(el){
    console.log(el,this.id);
}
var obj = {
    id: 'fn'
};
[1,2,3].forEach(foo); // 1 "window" 2 "window" 3 "window"
[1,2,3].forEach(foo,obj); // 1 "fn" 2 "fn" 3 "fn"

4.4 new绑定和构造函数调用(构造函数调用模式)

如果函数或者方法调用之前带有关键字new,它就构成构造函数调用。对于this绑定来说,称为new绑定。要注意以下几点:

  1. 构造函数通常不使用return关键字,它们通常初始化新对象,当构造函数的函数体执行完毕时,它会显式返回。在这种情况下,构造函数调用表达式的计算结果就是这个新对象的值。
function fn(){
    this.a = 2;
}
var test = new fn();
console.log(test);  // {a:2}
  1. 如果构造函数使用return语句但没有指定返回值,或者返回一个原始值,那么这时将忽略返回值,同时使用这个新对象作为调用结果。
function fn(){
    this.a = 2;
    return 1;
}
var test = new fn();
console.log(test); // {a:2}
  1. 如果构造函数显式地使用return语句返回一个对象,那么调用表达式的值就是这个对象。
var obj = {a:1};
function fn(){
    this.a = 2;
    return obj;
}
var test = new fn();
console.log(test);  // {a:1}
  1. 尽管有时候构造函数看起来像一个方法调用,它依然会使用这个新对象作为this。也就是说,在表达式new o.m()中,this并不是o。
var o = {
    m: function(){
        this.a = 1;
        return this;
    }
}
var obj = new o.m();
console.log(obj, obj === o);  // {a:1} , false
console.log(obj.a);  // 1
console.log(o.a);  // undefined
console.log(o.m.a);  // undefined
console.log(obj.constructor === o.m);  // true

5. this绑定优先级

现在我们已经了解了函数调用中 this 绑定的四条规则,你需要做的就是找到函数的调用位置并判断应当应用哪条规则。但是,如果某个调用位置可以应用多条规则该怎么办?为了 解决这个问题就必须给这些规则设定优先级,这就是我们接下来要介绍的内容。

毫无疑问,默认绑定的优先级是四条规则中最低的,所以我们可以先不考虑它。现在我们将其与另外三种规则互相比较。

5.1 显式绑定 vs 隐式绑定

function foo() {
    console.log( this.a );
}
var obj1 = {
    a: 2,
    foo: foo
};
var obj2 = {
    a: 3,
    foo: foo
};
obj1.foo(); // 2
obj2.foo(); // 3
//在该语句中,显式绑定call(obj2)和隐式绑定obj1.foo同时出现,最终结果为3,说明被绑定到了obj2中
obj1.foo.call( obj2 ); // 3
obj2.foo.call( obj1 ); // 2

可以看到,显式绑定优于隐式绑定

5.2 new绑定 vs 隐式绑定

function foo(something) {
    this.a = something;
}
var obj1 = {foo: foo};
var obj2 = {};

obj1.foo( 2 );
console.log( obj1.a ); // 2
obj1.foo.call(obj2,3);
console.log( obj2.a ); // 3

//在下列代码中,隐式绑定obj1.foo和new绑定同时出现。最终obj1.a结果是2,而bar.a结果是4,说明this被绑定到new创建的新对象上
var bar = new obj1.foo( 4 );
console.log( obj1.a ); // 2
console.log( bar.a ); // 4

可以看到,new绑定优先于隐式绑定

5.3 new绑定 vs 显式绑定

function foo(something) {
    this.a = something;
}
var obj1 = {};

// 先将obj1绑定到foo函数中,此时this值为obj1
var bar = foo.bind( obj1 );
bar( 2 );
console.log(obj1.a); // 2
// 通过new绑定,此时this值为baz
var baz = new bar( 3 );
console.log( obj1.a ); // 2
// 说明使用new绑定时,在bar函数内,无论this指向obj1有没有生效,最终this都指向实例baz
console.log( baz.a ); // 3

6. 总结

关于this的绑定,可以按照如下顺序判定:
1. 是否是new绑定?如果是,this绑定的是新创建的实例对象

var bar = new foo();   // 绑定bar
  1. 是否是显式绑定?如果是,this绑定的是指定的对象
var bar = foo.call(obj2);  // 绑定obj2
  1. 是否是隐式绑定?如果是,this绑定的是调用的对象
var bar = obj1.foo();   // 绑定obj1
  1. 如果都不是,则使用默认绑定
var bar = foo();  // 绑定到全局对象(非严格模式)或者undefined(严格模式)

参考

深入理解this机制系列第一篇——this的4种绑定规则
JavaScript深入之从ECMAScript规范解读this
深入理解javascript函数系列第一篇——函数概述
深入理解this机制系列第二篇——this绑定优先级
BOOK-《你不知道的JavaScript》 第2部分

;