10分钟掌握JS函数中this指针的指向

4,662 阅读10分钟

前言

JavaScript中最复杂的机制之一 this指针,即使是非常有经验的JavaScript开发者也并不一定能说出它究竟是什么

从字面上理解,this好像是“这里”的意思,因此我们常常认为this在哪个词法作用域里面,this就指向这个作用域。可是总有那么一天,你会发现结果与我们的思考背道而驰,这时,this对我们来说完全就是一种魔法了!

一个小案例

当我们一直认为this是指向自身的词法作用域时,下面这个例子我们就会觉得很奇怪,为什么result是0呢?

function foo(num) {
        console.log('foo:' + num); 
        this.count++;
    } 
    foo.count = 0; 
    for (var i = 0; i < 10; i++) {
        if (i > 5) {
            foo(i);
        }
    }
    // foo:6
    // foo:7
    // foo:8
    // foo:9 
    console.log(foo.count); // 0 ?? <-为什么是0? 

首先,当i>5时,我们就会调用foo()这个函数,在foo()函数中我们会输出foo被调用的次数,并且count这个变量增加1。一直到i=9,循环结束了,我们再输出count变量。在执行foo.count = 0时,我们确实给函数对象增加了一个属性count,因此我们传统的认为函数中的this就指向这个count,因此最后输出的结果应该是4。可是这里为什么输出是0?

原因就在于我们对this指向的误解了,在这里this的指向并没有指向foo函数本身的词法作用域,而是指向了全局作用域。由于全局作用域中没有count这个变量,它便自己创建了这个变量并初始化为NAN。因此每次循环的时候foo()函数的属性count根本没变,输出就是0。我们的疑惑随之产生,为什么它会在全局变量中会创建一个新变量呢?这时候我们便要来了解一下关于引擎查找变量的内容了。

认识引擎在作用域中的查找规则

要掌握this指针指向问题,我们必须了解引擎的查找规则。考虑以下例子

 // 1
function foo(a) { // 2
    var b = a * 2;

    function bar(c) { // 3
        console.log(a, b, c);
    }
    bar(b * 3);
}
foo(2); 

在这个例子中有三个逐级嵌套的作用域

  1. 包含着整个全局作用域,有一个标识符foo
  2. 包含着foo所创建的作用域,有b、a、bar标识符
  3. 包含着bar所创建的作用域,只有一个c标识符

引擎在查找标识符的位置时,总会从最内层的作用域向外开始查找,当查找到第一个匹配的标识符时,查找便会停止。在上述代码片段中,引擎在执行consol.log(....)声明时会查找变量a、b、c的引用,从最内层的作用域开始也就是bar(...)函数的作用域开始查找,引擎无法找到a,因此会去bar(...)上一层的作用域foo(...)作用域查找,这是找到a了,因此引擎就使用了这个引用。b、c都是如此。

上述的代码清楚的展示了引擎在作用域的查找规则,但是如果引擎始终没有查找到变量,那该怎么办呢?考虑以下代码

function foo(obj) {
    with (obj) {
        a = 2;
    }
}
var o1 = {
    a: 3
};
var o2 = {
    b: 3
}
foo(o1);
console.log(o1.a); // 2

foo(o2);
console.log(o2.a); // undefined
console.log(a); // !!! 2 不好,2怎么跑到全局作用域里面去了

可以这样理解,当我们传递o1给with时,with会修改o1的属性a。但是当传递o2给with时,o2并没有属性,因此引擎会逐层向外查找,直到找到全局作用域都没找到a变量,因此就在全局作用域中创建了一个a变量。这就是所谓的变量“泄露”问题了。

深入调用位置

this的绑定实际上是函数发生调用时绑定的,它的指向完全取决于函数在哪里被调用,通常来说,分析函数的调用位置就是分析调用栈。(调用栈:为了到达当前位置需要调用的所有函数),让我们来看下面这个案例

function baz() {
    console.log('baz');
    bar();
}
function bar() {
    console.log('bar');
    foo();
}
function foo() {
    console.log('foo');
}
baz(); 

在这里要调用foo()就要先调用bar(),要调用bar()就要先调用baz(),因此在这里调用栈应该是baz()->bar()->foo()。在这里我们还可以用浏览器中内置的开发者工具来查看调用栈。

foo(...)函数的第一行穿插了一个debugger,调试器运行到这里便会停止,此时我们看右方便可以找到调用栈了。在浏览器开发者工具里面看到的最后一个元素就是真正的调用位置了。

牢记绑定规则

了解了函数调用栈也就找到了函数的调用位置,接着我们要判断可以应用哪个绑定规则。绑定规则总共有四个

1. 默认绑定

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

在这里this指向全局作用域,因为函数调用的位置是在全局作用域中,像foo()这种直接使用不带任何修饰的函数引用进行调用时,只能使用默认绑定,这也是我们最常见的独立函数调用。

2. 隐式绑定

隐式绑定是当调用位置周围含有上下文对象时需要考虑的,举个例子

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

当函数调用位置周围有上下文对象时,隐式绑定会把函数中的this绑定到这个上下文对象。换句话说,在这里,我们在obj对象中调用了foo()函数,调用位置正好处于obj对象中,因此隐式绑定把函数中的this绑定到了obj对象上,因此这里输出的是obj对象中的属性a。

一连串的函数调用会有函数调用栈,那么函数包含在一连串对象中是什么呢?答案是:对象属性链

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

在这里我们可以看到有多个对象,如果我们理解了函数调用栈的话,举一反三,栈中最后一个元素就是我们的调用位置,因此这里函数最后是在obj1上调用的,this应该被绑定在obj1上,输出应该是2,可为什么这里是1呢?

因为对象属性链中只有第一层在调用位置中起作用,换句话说,函数调用位置只在第一层对象中绑定。我们再来看,foo()最开始是在obj2被调用的,因此this被绑定在了obj2上,接着obj2中的foo属性又在obj1中调用了,但是此时我们已经不再去考虑后面this的绑定了,因为this的绑定已经终结在了第一层。

小黄书上(你不知道的JavaScript)中曾提到过隐式丢失的问题,以下是隐式丢失的具体定义

一个最常见的this绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把this绑定的全局对象或undefined上。

举个例子

function foo() {
    console.log(this.a);
};
var obj = {
    a: 1,
    foo: foo
};
var bar = obj.foo; //传递了函数,隐式绑定丢失了

var a = 'hello';

bar(); // 'hello' 

首先在这里我们创建了一个bar全局变量,并且把obj中的foo属性传递给了它,由于foo属性对应一个函数,因此 var bar = obj.foo 相当于把foo(...)这个函数传给了bar,当我们再调用bar时,其实是一个不带任何修饰的函数调用,因此应用了默认绑定,这时隐式绑定就丢失了。如果我们就想this就绑定到obj上怎么办?下面介绍显示绑定。

3. 显示绑定

显示绑定很好理解,我们希望this绑定在哪个对象上我们就用方法绑定它,具体有三种方法可以达到这个效果,需要注意的是一旦我们 显示绑定 之后我们便无法再绑定了。

  • call(..)
  • apply(..)
  • bind(..)

这里运行的结果我们可想而知是2,call(..)apply(..)bind(...)绑定方法都是一样的,第一个参数是一个对象,后面都是传参数,只是两者传参数的方式不一样,这里不深入讨论。

4. new绑定

使用new来调用函数,第一步是创建了一个全新的对象,第二步是这个新对象会绑定到函数调用的this,什么意思呢?看以下这个案例

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

首先,创建了一个新的对象bar,当使用new来调用foo(..)函数时,我们会把bar对象中的this绑定在foo(...)函数中,因此这里bar对象中的a指向foo(..)函数中的a,所以输出是2。

绑定优先级

当我们在判断该应用四种规则中的哪一条时,我们就已经非常优秀了。但是有时我们会发现似乎这个调用可以应用多条规则,这时我们就要考虑绑定规则的优先级了。

  • 隐式绑定 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

obj1.foo.call(obj2); // 3 显示绑定
obj2.foo.call(obj1); // 2 显示绑定

从上面这个例子我们可以看出显示绑定的优先级要高于隐式绑定,因为在隐式绑定之后我仍可以用显式绑定。

  • new 绑定 VS 隐式绑定
function foo(something) {
    this.a = something;
}
var obj1 = { 
    foo: foo
}
var obj2 = {}
obj1.foo(2);
console.log(obj1.a);    // 2
    
var bar = new obj1.foo(4);  
console.log(obj1.a); // 2
console.log(bar.a); // 4 

从上面这个例子我们可以看出new绑定的优先级要高于隐式绑定,因为在隐式绑定之后我仍可以用new绑定。

  • new绑定 VS 显示绑定
function foo(something) {
    this.a = something;
}
var obj1 = {};
var bar = foo.bind(obj1);
bar(2);
console.log(obj1.a);    // 2
    
var baz = new bar(3);  
console.log(obj1.a); // 2 ??
console.log(baz.a); // 3

我们会很惊奇的发现,obj1.a的result是2不是3,为什么呢?毕竟我们创建baz对象的时候重新给bar(..)赋值了,然后bar(..)又被绑定在obj1上,结果应该是3啊。是这样的,当我们创建baz时,实际上是创建了一个新对象,新对象的this指向函数调用的this,因此虽然前面bar(..)被硬绑定到了obj1上,但是new绑定修改了bar(..)中的this,该this最终指向foo(..)函数,所以obj1中的a并没有被修改,同时在baz中创建了一个新的属性。从上面这个例子我们可以看出new绑定的优先级要高于显式绑定,因为在显式绑定之后我仍可以用new绑定。

箭头函数的中this的指向

箭头函数不像普通函数有多个规则,它不考虑绑定的四个规则,箭头函数=>的this是根据它外层作用域来决定的,看下面这个例子

var obj = {
    count: 0,
    cool: function () {
        console.log(this); // obj 对象 
        setTimeout(() => {
            console.log(this); // obj对象
            this.count++;
            console.log("awesome?");
        }, 100);
    }
}
obj.cool();

上面这个例子中,obj中的匿名函数的this指向obj,因为它的调用位置是在obj对象里面,内置函数setTimeout(..)中有一个箭头函数,箭头函数中的this指向它外层的作用域,箭头函数当前处的作用域是setTimeout(..)中,它的上层作用域就是匿名函数的作用域,因此箭头函数的this指向匿名函数作用域,结果都是指向obj对象。

结语

理解this指针的指向在我们写代码时非常重要,虽然碰到this指针指向错误时我们会很头疼,但是它是我们迈向高级前端工程师的重要基石!