JS深入系列:关于"this",多的是你不知道的事

489 阅读12分钟

--本文采自本人公众号【猴哥别瞎说】

如果你了解了关于词法作用域相关的知识后,你就会觉得,this 关键字,似乎和词法作用域没啥关系(词法作用域是在定义的时候确定,而this的指向则是在运行时候动态确定的)。更多的,this与词法作用域的对立面--动态作用域的相似性较大。

在 JS 世界里,this 关键字是其中最为复杂的机制之一。"the magic of JavaScript"。可能你知道自己不了解它,于是尽量避免使用它。你可能正在使用它,但是却不一定敢拍着胸脯说:this 很简单啊,我了如指掌。如果你是上面两种情况之一,那么这篇文章适合你。

ps: 文章内容大部分出自《你不知道的JavaScript<上卷>》,因该书对于本部分内容的书写太棒了,于是猴哥对其进行了吸收并输出。读者若有兴趣,墙裂推荐该书的阅读。

使用this的好处

关于this,与它同在的一个词汇是“上下文对象”(而不是“作用域”)。使用this的好处,简单讲,就是为了动态化,为了让函数可以自动引用合适的上下文对象 (在日常编码过程中,经常this一起出现的是applycall。下面的章节中,我们会具体介绍其用法)。

举个使用this与不使用this的对比栗子:

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

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

var me = { name : "Frank"};
var you = {name : "NiuNiu"};

speak.call(me); // FRANK
speak.call(you); // NIUNIU

//作为对比,下面代码不使用 this,而是显式使用上下文来实现同样的功能

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

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

var me = { name : "Frank"};
var you = {name : "NiuNiu"};

speak(me); // FRANK
speak(you); // NIUNIU

两段代码实现的功能是一样的。如果不熟悉this,第二段代码更好懂。然而,this提供了一种更加优雅的方式来隐式“传递”一个对象引用,因此可以将代码设计得更加简洁并且易于复用。

随着你的使用模式变得越来越复杂,显式传递上下文对象会让代码变得越来越混乱,使用this则不会这样。如果你了解了 JavaScript 中的对象与原型,就会明白函数可以自动引用合适的上下文对象是非常重要的功能。

网络上的优秀源码中很多部分都使用了this,当我们阅读它们的时候,读不懂this的含义,可能会略显尴尬哦。

this不是什么

很多时候,我们对一件东西的认识,都是从误解开始的。消除了对其错误的认识,那么就会更加明白,它是什么。我们来看看几个对于this的误解。

误解一:this指向的是其自身

因为是this嘛,从英文单词的含义来看的话,指向函数自身似乎没啥子毛病。那么我们可能会问了:什么情况下,需要函数内部指向自身呢?答案是递归的时候。

让我们用栗子来说明这个认识是不对的:

function foo(num){
    console.log(" foo : " + num);
    
    //count变量 想要记录foo函数被调用的次数
    this.count ++;
}

foo.count = 0; //初始化
var i;

for(i = 0; i<3; i++){
    foo(i);
}
// foo: 0
// foo: 1
// foo: 2

//让我们康康foo被调用了多少次?
console.log(foo.count); // 0 -- 纳尼?!

我们看到日志输出了三条记录,证明函数foo确实被调用了3次,但是foo.count变量的值依然是0。这就说明,我们认为this指向自身的看法是错的。实际上,此时的函数foo内的this指向的是全局对象window

误解二:this指向函数的作用域

可能很多人会有这种认知:this指向的是其作用域。这个情况有点复杂,因为在某些情况下它是正确的,但其他情况下它却是错误的。

必须明确的是:this在任何情况下都不指向函数的词法作用域(关于作用域的描述,请看该文章)。在JavaScript内部,作用域“对象”无法通过JavaScript代码访问,它存在于JavaScript引擎内部。

我们举如下这个经典的栗子,它试图使用this来隐式引用函数的词法作用域,但失败了:

function foo(){
    var a = 1;
    this.bar();
}

function bar(){
    console.log(this.a);
}

foo(); // ReferenceError: a is not defined

首先,这段代码试图通过this.bar()来引用bar()函数。这样调用能够成功纯属意外,我们待会就知道原因。此外,改代码还试图使用this联通foo()bar()的词法作用域,从而让bar()可以访问到foo()作用域里的变量a。这是不可能实现的,使用this不可能在词法作用域中查到什么。

this的定义

排除了误解之后,我们来看看this到底是一种怎么样的机制。

之前我们说过this是运行时绑定的,并不是在编写代码的时候绑定。它的上下文取决于函数被调用时候的各种条件。this的绑定和函数声明的位置没有任何关联,只取决于函数的调用方式。

当一个函数被调用时,会创建一个活动记录(也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方式、传入的参数等信息。this就是这个记录的一个属性,会在函数执行的过程中用到。

this的四条绑定规则

这个章节我们来看看,如何根据特定的规则来查找函数的调用位置,从而判断函数在执行过程中会如何绑定this。有四条规则可以遵循,我们一一来看。

默认绑定

首先要介绍的是最常见的函数调用类型:独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则。

举个栗子:

function foo(){
    console.log( this.a );
}

var a = 2;

foo(); // 2

变量var a = 2声明的作用域是全局作用域。需要明确的一点是:声明在全局作用域的变量,就是全局对象的一个同名属性。也就是说:变量a其实是全局变量的一个属性。而默认绑定的规则就是:在没有其他绑定规则的情况下,this指向全局对象。于是在该例子中可以正常输出。

在严格模式 (strict mode) 下,则不能将全局对象用于默认绑定。在实际编码时候,这个点值得注意。

那么我们如何判断这里应用了默认绑定呢?也简单,我们会接着学习下面的三个绑定规则,你会发现它们都没有被应用到这个栗子中,那么剩下的规则就是默认绑定。

隐式绑定

另一条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含。我们来看栗子:

function foo(){
    console.log( this.a );
}

var obj = {
    a : 1,
    foo: foo
};

obj.foo(); // 1

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

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

当函数引用有上下文对象时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象中。

关于隐式绑定,有两个需要注意的点:

  1. 对象属性引用链中只有上一层或者最后一层在调用位置中起作用。
  2. 当出现参数传递现象的时候,隐式绑定会失效,即隐式丢失现象。

关于第1点,通俗代码即可理解:

function foo(){
    console.log( this.a );
}

var obj2 = {
    a : 2,
    foo: foo
};

var obj1 = {
    a : 1,
    obj2: obj2
}

obj1.obj2.foo(); // 输出2, 只有最后一层起作用了

关于第2点的隐式丢失问题,我们来看一个栗子:

function foo() {
    console.log( this.a );
}

function doFoo(fn){
    fn();
}

var obj = {
    a: 2,
    foo: foo
}

var a = "hello, I am frank"; // a是全局对象的属性

doFoo( obj.foo ); // "hello, I am frank"

这里的参数传递的过程就是赋值的过程。因此,函数doFoo()里的传入参数引用的就是函数foo()本身。

显式绑定

隐式绑定的时候,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把this间接绑定到对象上。

那么如果我们不想再对象内部包含函数引用,而想在某个函数上强制调用函数,该怎么做呢?

嗯,是的,就是我们文章开头讲到的call(...)apply(...)方法啦。这两个方法在JavaScript中的“所有”函数中都有包含,因此都可以调用这两个方法来进行显式绑定。这两个方法的第一个函数是一个对象(是给this准备的),接着在调用函数时将其绑定到this

还是栗子:

function foo() {
    console.log( this.a );
}

var obj = {
    a: 2
}

foo.call(obj); //2

这个例子很清晰地表明了foo函数将this绑定到了obj对象身上。apply()call()的用法类似,在此不展开。

而对于隐式绑定的丢失问题,显式绑定的一个变种可以给出一种解决方案:硬绑定。

function foo() {
    console.log( this.a );
}

var obj = {
    a: 2
}

var bar = function(){
    foo.call(obj);
}

//被硬绑定之后的bar,任何操作都不能改变它的`this`
bar(); // 2
setTimeout(bar, 100); // 2
bar.call(window); // 2

我们创建了函数bar(),并且在它的内部手动调用了硬绑定foo.call(obj),因此强制把foothis绑定到了obj。无论之后如何调用函数bar,它总是手动在obj上调用foo。这种绑定是一种显示的强制绑定,我们称之为硬绑定

通过new关键字绑定

有趣的一个点来了。将操作符new与关键字this放在一起讨论的时候,基础不好的话,是很容易混淆的。

我们来看操作符new。在传统的面向类的编程语言中,"构造函数"是类中的一些方法,使用new初始化类时会调用类中的构造函数。通常的形式是这样的:something = new MyClass(..);

JavaScript 中也有一个new,使用方法看起来和那些面向类的语言一样。但是,JavaScript 中new的机制实际上和面向类的语言完全不同。

首先我们重新定义 JavaScript 中的“构造函数”。在 JavaScript 中,构造函数只是一些使用new操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个雷。实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被new操作符调用的普通函数而已。

实际上,并不存在所谓的“构造函数”,只有对于函数的“构造调用”。

使用new来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。

  1. 创建(或者说构造)一个全新的对象。
  2. 这个新对象会被执行[[Prototype]]连接。
  3. 这个新对象会绑定到函数调用的this
  4. 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。

我们暂时跳过第2步,来思考下面的代码:

function foo( a ){
    this.a = a;
}

var bar = new foo(233);

console.log( bar.a ); //233

使用new来调用foo(..)时,我们会创建一个新对象并把它绑定到foo(..)调用中的this上。

规则排序

上面的四条规则,各自应用的优先级如何呢?

其排序优先级为:

new绑定 > 显示绑定 > 隐式绑定 > 默认绑定。

如果你想要验证的话,可以自己设计对应的验证逻辑,亦可参考《你不知道的JavaScript<上卷>》的对应内容。

箭头函数与this

ES6 中介绍的一类新的特殊函数类型:箭头函数。箭头函数非常受欢迎,一个原因在于其更加简洁的语法,另一个原因在于它对于this的使用与限定。通过了解箭头函数的this,我们会更加理解this的本质。

箭头函数不使用this的四种标准规则,而是根据外层(函数或者全局)词法作用域来决定this

我们来看看箭头函数的词法作用域:

function foo(){
    //返回一个箭头函数
    return (a)=> {
        //this继承自foo()
        console.log(this.a);
    }
}

var obj1 = {a : 2};
var obj2 = {a : 3};

var bar = foo.call( obj1 );
bar.bind(obj2); // 2, 不是3!

foo()内部创建的箭头函数会捕获调用时foo()this。由于foo()this绑定到obj1bar(引用箭头函数)的this也会绑定到obj1,箭头函数的绑定无法被修改。(new绑定也不行!)

总结

如果要判断一个运行中函数的this绑定,就需要找到这个函数的直接调用位置。找到之后就可以顺序应用下面这四条规则来判断this的绑定对象。

  1. new调用?绑定到新创建的对象。
  2. call或者apply调用?绑定到指定的对象。
  3. 由上下文对象调用?绑定到那个上下文对象。
  4. 默认:在严格模式下绑定到 undefined,否则绑定到全局对象。

ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定this,具体来说:箭头函数会继承外层函数调用的this绑定(无论this绑定到什么)。


JavaScript 深入系列文章:

"var a=1;" 在JS中到底发生了什么?

为什么24.toString会报错?

这里有关于“JavaScript作用域”的你想要了解的一切

关于JS中的"this",多的是你不知道的事

JavaScript是面向对象的语言。谁赞成,谁反对?

JavaScript中的深浅拷贝

JavaScript与Event Loop

从 Iterator 讲到 Async/Await

探究 JavaScript Promises 的详细实现