方法是自由的!

在以往的编程学习中,当我们面向过程时,函数是特定作用域下的一组指令序列;在面向对象时,函数往往又依附于某个类而存在,作为其成员方法。但是到了JavaScript中,函数却成了一等公民,它不再像在Java中一样作为某个对象的附庸而存在。

上述只是我在第一次看到“在JavaScript中,函数是一等公民”这句话时候的直观感受,但是由此我又有了疑惑:何谓“一等公民”?

查阅资料,我们发现在《Programming Language Pragmatics》这本书中,给出了关于编程语言中“一等公民”的权威解释。

In general, a value in a programming language is said to have first-class status if it can be passed as a parameter, returned from a subroutine, or assigned into a variable.

翻译一下就是:在编程语言中,一等公民可以作为函数的参数、返回值、也可以赋值给变量。我们知道,函数在JavaScript中是Function对象的实例,其当然符合“一等公民”的身份。

在这样的情况下,我们也就需要一种机制来动态地指定函数对象的执行环境,this的主要作用也就在此。

而我们之所以在过去很长一段时间里总是在讨论“this指向”这个问题,其根源也在于在JavaScript中,函数是对象,其据以执行的环境对象并不像Java这样的完备的面向对象语言中这么明确。所以在讨论this指向之前,我们需要明确,this的在JavaScript中的作用是为了动态地指定函数对象据以执行的环境对象。而不是像Java中那样主要用于在类的方法中区分函数的形参和实例属性。

这篇博客主要是想帮助自己梳理一下不同情况下的this指向,我这里将其分为如下四类情况:

  • 全局环境下&对象上下文情况下的this指向
  • 作为构造函数和new配合使用时的this指向
  • call/apply/bind指定this指向
  • 箭头函数的this指向

在讨论这些情况的this指向之前,我觉得我们得先明确一点:一般函数对象会在什么时候确认其this的指向?(这里强调一般是为了区别箭头函数,因为其并没有this)

红宝书中总结了函数对象在其创建和执行的过程中所发生的一系列动作):

  1. 函数创建
    • 构造其作用域链,对象中[[Scope]]指向作用域链
  2. 函数执行
    • 创建函数的执行环境
    • 复制作用域链到执行环境
    • 创建函数的变量对象(活动对象)
      • 获取this和arguments到活动对象
    • 在作用域链前端压入当前函数的变量对象

我们可以看到,函数一直到执行时,才会获取this指针的指向并保存在当前的变量对象(活动对象)中。我们现在明确了this在什么时刻确认其指向,那么我们再来讨论上述的四种情况。

全局环境下&对象上下文情况下的this指向

在写这篇博客之前,翻看了知乎上很多回答,也去看了阮一峰老师那篇流传甚广的关于this指向的博客,可是所看到的内容几乎都在在讨论现象,即告诉读者在全局环境下this指向全局对象、在对象上下文中this指向调用它的对象……但是并没有给出原因,即函数在执行时自身做了怎样的判断来确认其this的指向。其实我最终也没有能给出一个可以得到验证的答案,所以这种情况下我的下述讨论只是一种协助理解的对原因的猜测,并不一定正确。

这里我先说我的结论:严格模式下,

obj.fn() → fn.call(obj)

fn() → fn.call(undefined)

结合下述代码,我们对上述结论进行验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
"use strict"        
var word = 'global'; // 这里注意是var,let声明的变量不再作为顶层对象的属性存在
function fn () {
console.log(this.word);
}
let obj1 = {
word: 'obj1',
fn: fn
}
let obj2 = {
word: 'obj2',
fn: function () {
obj1.fn();
}
}
let obj3 = {
word: 'obj3',
fn: function () {
let fn = obj1.fn;
fn();
}
}
fn(); // fn.call(undefined)
obj1.fn(); // fn.call(obj1)
obj2.fn(); // fn.call(obj1)
obj3.fn(); // fn.call(undefined)

上述代码的控制台输出:

undefined
{word: “obj1”, fn: ƒ}
{word: “obj1”, fn: ƒ}
undefined

作为构造函数和new配合使用时的this指向

这种情况下的this指向相对来说比较好解释,我们只需要知道在使用new来调用构造函数时,JavaScript引擎做了哪些工作。根据红宝书的明确描述,包含以下四个步骤:

  1. 创建一个新的对象
  2. 将函数的this指向这个对象
  3. 执行函数体
  4. 返回这个对象(即this)

如果不考虑细节,上述步骤可以用代码这样描述:

1
2
3
4
5
// 构造函数为Foo()
obj = {};
obj._proto_ = Foo.prototype;
Foo.call(obj)
return obj

很明确对不对,构造函数的this就指向了他要构造的实例。

call/apply/bind指定this指向

这类情况函数的this指向则更为明确,函数的this指向就是call/apply/bind所指定的对象。

箭头函数的this指向

在讨论的一开始,我们就将箭头函数和普通函数对象做了区分,因为在MDN规范中,直接指出了箭头函数没有this,即在函数执行的开始,箭头函数并不会主动的去添加this到其当前变量对象(活动对象)中。

在箭头函数中使用this,我们只需将其看作一个普通变量,顺着函数的作用域链逐级向上寻找就行。

箭头函数的这种特性让其在将函数作为参数传递等场景下大放异彩,因为在其出现前,如果我们需要维护一个函数的this指向,我们需要调用函数原型中的bind方法,而我们知道bind函数返回的是一个闭包,这一定会为程序带来额外的开销。

总结

不考虑箭头函数,那么一个函数对象的this指向什么不取决于函数的定义,也不取决于函数在哪儿被调用,而是取决于如何被调用:

  • 裸奔调用:f() → f.call(undefined)
  • 被对象中的引用调用:obj.f() → f.call(obj)
  • call/apply/bind调用:f.call(obj)
  • new调用构造函数:new f() → this指向新构造的实例
  • 箭头函数:没有this,在作用域链中查询

在当前ES6中已经有了class以及箭头函数后,这篇博文所讨论的问题似乎已经没有了价值,因为在JavaScript逐步向OOP靠拢的趋势下,this的指向问题对开发者几乎可以看作是透明的了,知乎上包括黄玄在内一众大佬均认为,在ES6的环境下,this仅应该和class配合使用才是正确的表达方式。类似我这里讨论的这些关于this 的冗长繁复的所谓原理,从某些角度看,都只是早期JavaScript的设计失误。所以,在ES6环境下使用this时,我们只需要明确,this在class中使用,指向该类实例。这里需要注意如果class中的方法作为参数传递到class外部,我们要么在constructor中使用bind将当前this绑定到函数对象,要么使用传递一个箭头函数并在箭头函数中通过this调用要传递的函数。

写这篇博客的初衷或许也只是觉得了解的更深入一下this或许没有什么坏处,也能更自信的使用不用担心其背后可能会发生的问题。还有保不准以后会遇到科举考官呢😏