基础篇 | this 及其四种绑定规则

394 阅读11分钟

上一篇讲到了 JavaScript 中各种作用域,今天引出最让我头疼的 this

this 关键字是 JavaScript 中最复杂难懂的机制之一。对于 this 的最大误解是绑定到函数自身,或者是函数的作用域,需要明确的是, this 在任何时候都不会绑定到函数自身的词法作用域。 那 this 到底绑定在哪里呢? this 又是什么呢?

动态绑定的 this

this 的机制实际上是动态绑定的,它是实际运行时进行绑定的。this 只和函数的方式有关系。this 指向哪里,取决于这个函数在哪里被调用。那么,我们要怎么找到这个调用位置呢?

举个栗子:

function baz() {
  bar();
}

function bar() {
  foo();
}

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

baz();

分析调用位置,假设当前是在 global 中,那么:

  • 当进入到 baz,当前的调用栈为 global → baz
  • 当进入到 bar: global → baz → bar
  • 当进入到 foo: global → baz →bar → foo

当代码进入函数到内执行时,函数的上下文会被推到一个上下文栈中, 当函数执行完毕之后,上下文栈会弹出该函数上下文,将控制权交回给上一个执行上下文。 这个上下文栈会记录这个函数为了到达当前执行位置所调用的所有函数信息,包括在哪里进行调用,调用的方式,传入的参数信息等等。this 便是承担这个记录的一个属性,会在执行的过程中用到。

而我们所关心的 this 的调用位置,就存在于正在执行的函数的前一个调用中,这也决定了 this 的绑定。换言之,this 永远指向最后调用它的那个对象。

this 实际使用中, this 的真实绑定除了和调用位置有关以外,还和 this 绑定的规则有关。那,调用位置是如何决定 this 的绑定对象呢?

this 的绑定规则

TL;DR 版本:

  • 默认绑定:独立函数调用
  • 隐式绑定:是否有上下文对象,即这个函数是否被某个对象所拥有或者包含
  • 显式绑定:通过 call、apply、bind 的方式显式绑定了其 this 所指向的对象
  • new 绑定: 当使用 new 来调用某个函数的时候,JavaScript 会构造一个新对象并把其绑定到函数调用中的 this 上。

默认绑定

默认绑定可以理解为最最普通,最原始的一种绑定方式,比如在全局环境下调用不带任何前缀的函数方法,或者叫做独立函数调用。下面的例子中, foo 还是直接不带任何修饰函数引用进行调用的,因此此处只能使用默认绑定,无法应用其他规则。

// case 1
function foo(){
	console.log(this); // 全局对象, global or window
}

foo(); // 默认绑定,独立函数调用

当处于非严格模式下的时候 this 会被默认绑定在全局对象,而在严格模式下,则不会默认绑定在全局对象下。这里有一个微妙的细节,举个栗子

function baz() {
	console.log(this); // global or window
  bar();
}

// baz->bar
function bar() { 
  "use strict"
	console.log(this) // undefined 因为是在严格模式下,所以不默认绑定
  foo(); // 严格模式下,调用非严格模式下代码。
}

// baz->bar->foo
function foo() {
	// foo 是非严格模式下代码,严格模式下调用不影响默认绑定
	console.log(this) // global or window
}

baz();

严格模式下, this 不会默认绑定到全局对象上。虽然我们常说 this 的绑定规则取决于调用位置,但在严格模式下,调用非严格模式下代码,却不影响 this 默认绑定于全局对象,例如上面例子中 bar 调用 foo 。 再举一个更加清晰的例子:

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

(function () {
  'use strict';
  foo(); // output: global or window
})();

隐式绑定

考虑调用位置中是否有上下文对象,或者被某个对象所拥有或者包含。当调用位置使用其他对象上下文来引用函数时,都可以说函数被调用时的对象所拥有或者包含。 说起来有点绕,那么举个栗子:

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

a = 'global'

var obj = {
  a: 'object',
  foo: foo
}

foo() // global
obj.foo() // object

在这里,我们可以看到 foo 的声明,或者是之后当做 obj 的某个属性被加入到 obj 之中时, foo 这个函数严格意义上都不属于 obj 对象。 当通过 obj.foo 的方式进行调用时,函数引用存在上下文对象,即,这个函数的调用是通过某个对象进行发起的,函数调用中的 this 就会被隐式绑定到这个上下文对象中。在这个例子中,foo 函数的调用就是通过 obj 对象发起。

请注意,这个对象属性引用链只有最近一层在调用位置中起作用,即 obj.obj2.obj3.foo() 实际上相当于 this 会就近绑定在 obj3.foo() 上。

我们再来看另外一个例子,可以帮助更好理解声明和调用,从而帮助理解隐式绑定。

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

a = 'global'

var obj = {
  a: 'object',
  foo: foo
}

var bar = obj.foo

bar(); // global

在这个例子中, barobj.foo 的一个引用,实则是引用了 foo 这个函数,在实际调用的过程中,是直接通过 bar() 进行调用,也就是说,这是一个不带任何前缀的调用,即独立函数调用,因此这里 this 实际上是应用了默认绑定,绑定到全局对象上。这种情况也被称之为隐式丢失,即被隐式绑定的函数丢失了绑定对象。 (个人认为,也不用纠结于隐式丢失这种概念,只要分清楚调用位置即可)

还有一种情况,参数传递,这种发生在回调函数中居多。例如:

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

a = 'global'

var obj = {
  a: 'obj1',
  foo: foo
}

function run(fn) {
	var a = 'run'
  fn(); // 独立函数调用
}

run(obj.foo) // global

var runner = {
  a: 'runner',
  run: run
}

runner.run(obj.foo); // global

还是那句话,只要分清楚调用位置即可。 在这 case 中, obj.foo 是被当做函数参数被传递至 run 中执行。参数传递本身就是一种隐式赋值,那么这种情况就和我们的上面 bar 的例子一样了。 实际上,在执行中,是通过 fn() 进行独立函数调用,所以还是应用了默认绑定。同理,定时器等也可以通过调用位置的方式推断出来。

runner.run 为何执行之后也是 global 呢? 这个留给读者去寻找答案了。

显式绑定

上两节说到 JavaScript 引擎对 this 的一些自动绑定行为,有些时候我们会因为 this 绑定感到疑惑,我们期望通过我们自己手动来固定 this 的绑定,以期获得我们预期之内的效果。

说到显式绑定,这里就要提到 JavaScript 函数中的 callapply 方法。JavaScript 对内置的绝大多数函数以及我们创建的函数,都可以使用这两个方法。 对于 this 的绑定,二者并没有多大的差别,第一个函数参数都是传入将要绑定的 this 的对象,只是在使用上略微有点差别:

func.call(thisObj, arg1, arg2 ...)

func.apply(thisObj, [arg1, arg2...]) 

但是这里又有一个问题,当 func.call(thisObj) 显式绑定了 this 对象之后,就不能再去修改它的 this。但是我们可以自己实现一个简易的辅助绑定函数 bind:

function bind(fn, thisObj){
	return function(){
		return fn.apply(thisObj, args);
	}
}

由于这种绑定方式比较常见且实用,因此,ES5 中提供了一个内置的 Function.prototype.bind 方法,它只需要调用 fn = func.bind(obj)bind 会返回一个硬绑定的新函数,并指定 this 绑定在 obj ,确保了回调函数使用指定的 this 对象。

例外情况

当函数并不关心 this ,但是有需要一个 this 对象来进行占位,这个时候 null 是一个不错的选择。 但是指定的 this 对象是 null or undefined 时,这些值在实际绑定的时候会被忽略,最后应用的是默认绑定规则。但是,这样就会引出一些不可预计的后果,如修改到全局对象。

所以,在这种情况下,推荐使用一个空对象来进行占位会更加合适,如 Object.create(null) 或者是直接 {} 都可以。 但是,更加安全的方式前者,因为前一种并不会创建 Object.prototype 的委托,所以它比 {} 更空。

new 绑定

当使用 new 来调用某个函数的时候,JavaScript 会构造一个新对象并把其绑定到函数调用中的 this 上。要弄清楚 new 绑定,那首先就要弄清楚 new 操作符是什么。

在《Javascript 高级程序设计》(红宝书)创建对象章节中,说明了当 new 执行时,会执行如下步骤:

  • 在内存中创建一个新对象
  • 在这个新对象内部的 [[prototype]] 特性被赋值为构造函数prototype 属性,进行 [[prototype]] 链接
  • 构造函数内部的 this 被赋值为这个新对象
  • 执行构造函数内部代码
  • 如果构造函数返回一个非空对象,则返回该对象,都则则返回刚刚创建的新对象

翻译成代码语言即:

function myNew(func, ...args) {

    // 在内存中创建一个新对象
    const obj = Object.create()
		
    // 在这个新对象内部的 [[ prototype ]] 特性被赋值为构造函数的 prototype 属性
    obj.__proto__ = func.prototype
		
    // 构造函数内部的 this 被赋值为这个新对象
    // 执行构造函数内部代码
    let result = func.apply(obj, args)
    
    // 如果构造函数返回一个非空对象,则返回该对象,都则则返回刚刚创建的新对象
    return result instanceof Object ? result : obj
}

看完了上述的代码和 new 操作符的描述以及代码, 我们。可以看到 new 是可以影响函数调用时 this 绑定行为的方法,当使用 new 来调用函数时,会构造一个新的对象并将它绑定到函数调用中的 this 上。

实际上不存在所谓的构造函数,只有对于函数的构造调用。—— 《You Don't Know JS》

在 《YDKJS》中,作者提出了其实不存在“构造函数”,只是函数的“构造调用”方式而已。个人比较支持这种说法,其实在《Javascript 高级程序设计》 中,也只是说明了构造函数也是普通函数,任何函数用 new 作为操作符调用就是构造函数。因为在 JavaScript 中的 new 其实并不会属于某个类,也不会实例化一个类。 ES6 中的类仅仅是封装了 ES5.1 构造函数加原型继承的语法糖而已,并不是面向类语言中的类的概念。所以,按照《YDKJS》的说法比较不会产生不必要的歧义。

绑定优先级?

至此,已经看到了 this 的几种绑定原则。那么区分识别 this 的绑定规则的关键就是找到其调用位置,再进行根据上述提到的四个规则来最终判断。而就优先级而言:

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

具体而言,可以根据如下顺序来进行判断:

  • 当函数使用 new 进行构造调用时,那么这个 this 绑定的是新创建的对象。

    var bar = new foo()
    
  • 当函数是通过 call or apply or bind 进行显式绑定的话,this 会被显式绑定到指定的对象上。

    foo.call(thisObj, ...args)
    
  • 当函数是在某个上下文对象中调用,或者存在被某个对象所调用时,执行的是隐式绑定,this 会被绑定在那个上下文对象中。

    obj1.foo()
    
  • 如果上述条件都不满足,或者是进行独立函数调用,则会执行默认绑定。在非严格模式下,会被绑定在全局对象上。

    var bar = foo()
    

本文把令人头疼的 this 机制都大体梳理了一遍,同时介绍了几种 this 的绑定机制:

  • 默认绑定(Default Binding)
  • 隐式绑定(Implicit Binding)
  • 显式绑定(Explicit Binding)
  • New 操作符绑定(New Binding)

以及这几种规则的优先级顺序。这四条规则,基本上把所有正常函数的都已覆盖了。但,ES6 中介绍了一种无法应用这些规则的特殊函数类型——箭头函数。下一篇文章将单独讲述这个特殊的胖箭头。

Reference:

js 五种绑定彻底弄懂this,默认绑定、隐式绑定、显式绑定、new绑定、箭头函数绑定详解

JavaScript中的this绑定规则-case

红宝书 - chap8.2 创建对象

You Don't Know JS: this & Object Prototypes —— Chapter 2: this All Makes Sense Now!