「JS-Learning」理解this关键字

本篇涉及到的名词:闭包,函数调用位置,this指向等。

一、引言

  • 《JavaScript高级程序设计(第3版)》
    • this 对象是在运行时基于函数的执行环境绑定的:在全局函数中,this 等于 window,而当函数被作为某个对象的方法调用时,this 等于那个对象。(P182)
  • 《你不知道的JavaScript(上)》
    • this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this 既不指向函数自身也不指向函数的词法作用域(P80)
  • MDN
    • 在绝大多数情况下, 函数的调用方式决定了 this 的值。(原链接

一提到 JavaScript 的 this 关键字,以上是几种常见的开场白。我们看到当中有两个关键词: 在运行时绑定函数的调用方式决定 this 的值

先不纠结这些概念,下面根据我的理解,从 this 诞生的起源开始逐步掌握这块磨人的知识点。

二、this 是什么

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

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

三、this 的作用

this 的诞生主要是因为在对象内部的方法中使用对象内部的属性是一个非常普遍的需求

什么意思呢,看下面代码:

var myBlog = {
    name: "www.wenyuanblog.com",
    printName: function () {
        console.log(name)
    }    
}

let name = "文渊博客"
myBlog.printName()

相信读过《理解JS的闭包》这篇文章中关于作用域词法作用域作用域链三小节内容后,很容易知道最后输出的结果是 “文渊博客”。

这是因为 myBlog 不是一个函数,因此 myBlog 当中的 printName 其实是一个全局声明的函数,myBlog 当中声明的 name 只是对象的一个属性,也和 printName 没有联系。因此,printName 会通过词法作用域链去它声明的环境(也就是全局环境)中查找 name

不过按照常理来说,调用 myBlog.printName 方法时,方法内部的变量 name 应该使用 myBlog 对象中的,因为它们是一个整体,事实上大多数面向对象语言都是这样设计的。

基于这个需求,JavaScript 就搞出来一套 this 机制。在 JavaScript 中可以使用 this 实现在 printName 函数中访问到 myBlog 对象的 name 属性了。具体该怎么操作呢?你可以调整 printName 的代码,如下所示:

var myBlog = {
    name: "www.wenyuanblog.com",
    printName: function () {
        console.log(this.name) // 修改了这一行
    }    
}

let name = "文渊博客"
myBlog.printName()

接下来我们就展开来介绍 this,不过在这之前需要强调一句,作用域链和 this 是两套不同的系统,它们之间基本没太多联系。明确了这点,可以避免你在学习 this 的过程中,和作用域产生一些不必要的关联。

四、如何寻找函数的调用位置

既然 this 指向什么完全取决于函数在哪里被调用,我们就要学会寻找函数的调用位置,从而判断函数在执行过程中会如何绑定 this

最简单的方法是,通过浏览器的调试工具查看调用栈:给目标函数的第一行代码设置断点,或者直接在目标函数的第一行代码之前插入一条 debugger; 语句。运行代码时,调试器会在那个位置暂停,同时会展示当前位置的函数调用列表,这就是你的调用栈,然后找到栈中第二个元素,这就是真正的调用位置。

举个例子,见下方代码,我们要寻找 foo 函数的调用位置。

function baz() { 
  console.log("baz");
  bar();
}

function bar() {
  console.log("bar");
  foo();
}

function foo() {
  debugger;
  console.log("foo");
}

baz();

使用浏览器的调试工具来查找 foo 函数的调用位置,如下图所示:

寻找函数的调用位置

寻找函数的调用位置

你也可以通过阅读代码进行分析,方法是把调用栈想象成一个函数调用链。只不过这种方法非常麻烦并且容易出错,下面的代码演示了这种分析过程,嫌麻烦的话可以直接跳过

function baz() {
  // 当前的调用栈是:baz
  // 因此,当前调用位置是全局作用域

  console.log("baz");
  bar(); // <-- bar 的调用位置
}

function bar() {
  // 当前的调用栈是:baz -> bar
  // 因此,当前调用位置在 baz 中

  console.log("bar");
  foo(); // <-- foo 的调用位置
}

function foo() {
  // 当前的调用栈是:baz -> bar -> foo
  // 因此,当前调用位置在 bar 中

  debugger;
  console.log("foo");
}

baz(); // <-- baz 的调用位置

五、在调用位置查找 this 绑定对象

找到函数的调用位置后,按照下面的步骤,就可以判断出 this 的绑定对象。

  • Step1 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象;
    var bar = new foo()
    
  • Step2 函数是否通过 callapply(显式绑定)或者硬绑定调用?如果是的话 this 绑定的是指定的对象;
    var bar = foo.call(obj2)
    
  • Step3 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话 this 绑定的是那个上下文对象;
    var bar = obj1.foo()
    
  • Step4 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到全局对象。
    var bar = foo()
    

六、this 的缺陷以及应对方案

this 在使用过程中存在着非常多的坑,下面举两个例子。

1. 嵌套函数中的 this 不会从外层函数中继承

先看下面一段代码,试分析两次 this 打印出来是什么?

var myBlog = {
  name : "www.wenyuanblog.com", 
  showThis: function(){
    console.log(this)
    function printName(){console.log(this)}
    printName()
  }
}
myBlog.showThis()

执行这段代码后,你会发现函数 printName 中的 this 指向的是全局 window 对象,而函数 showThis 中的 this 指向的是 myBlog 对象。这就是 JavaScript 中非常容易让人迷惑的地方之一,在实际项目开发中也是很多问题的源头。

要解决这个问题,你可以有两种思路:

  • 第一种是把 this 保存为一个 self 变量,再利用变量的作用域机制传递给嵌套函数;
  • 第二种是继续使用 this,但是要把嵌套函数改为箭头函数,因为箭头函数没有自己的执行上下文,所以箭头函数的 this 就是它外层函数的 this。。

2. 普通函数中的 this 默认指向全局对象 window

上面介绍过,在默认情况下调用一个函数,其执行上下文中的 this 是默认指向全局对象 window 的。

不过在实际工作中,有时候我们并不希望函数执行上下文中的 this 默认指向全局对象,因为这样会打破数据的边界,造成一些误操作。如果要让函数执行上下文中的 this 指向某个对象,最好的方式是通过 call 方法来显示调用。

这个问题也可以通过设置 JavaScript 的「严格模式」来解决。在严格模式下,默认执行一个函数,其函数的执行上下文中的 this 值是 undefined,这就解决上面的问题了。

七、总结

在使用 this 时,为了避坑,要谨记以下三点:

  1. 当函数作为对象的方法调用时,函数中的 this 就是该对象;
  2. 当函数被正常调用时,在严格模式下,this 值是 undefined,非严格模式下 this 指向的是全局对象 window
  3. 嵌套函数中的 this 不会继承外层函数的 this 值。


参考
《JavaScript高级程序设计(第3版)》
《你不知道的JavaScript(上)》
MDN
极客时间


  目录