解析 JavaScript 的 this 机制

408 阅读6分钟

定义

经典的定义是这个:

this 是函数被调用时的一个内部参数,指向函数被调用所处的位置或对象(又称对象上下文)。

但是我们将会看见这种定义是有一定误导性的,this 指向的对象的确可以称作是对象上下文,但这个对象上下文真的不应该称作函数被调用时所处的位置。

this 的四种绑定方式

所谓绑定,就是确认 this 指向对象的机制。这样的机制共有四种。

默认绑定

在以最普通的方式调用函数时,就会发生默认绑定,如果代码运行在非严格模式下,this 默认绑定绑定在全局作用域上,而非被调用函数所处的位置,所以我们说经典定义是有误导性的;而在严格模式下,this 会变成 undefined,这段代码就会报错。

function foo() {
  var a = 3
  console.log('foo', this.a) // foo 1
  
  function bas() {
    var a = 4
    console.log('bas', this.a) // bas 1
  }
  
  bas()
}

function baz() {
  var a = 2
  console.log('baz', this.a) // baz 1
  foo()
}

// 即使这里给函数对象赋属性值也没用
var a = 1
baz.a = 2
foo.a = 3

baz()

隐式绑定

你不知道的 JavaScript 把这种方式称为隐式绑定,但我们将看到,由于这种方法实际制定了上下文对象,所以其实一点儿也不“隐式”。


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

var obj = {
  a: 2,
  foo
}

var a = 1

foo() // 1
obj.foo() // 2

obj.foo() 一样,将函数作为某个对象的属性调用,就会将这个函数中的 this 绑定为该对象。值得一提的是,在 JavaScript 中,我们说对象具有某个属性而且这个属性的值是另一个对象的时候,我们的意思其实是对象的某个属性保存了对另一个对象的引用

在利用回调函数的时候,我们常常会以为我们做了隐式绑定,但其实发生的却是默认绑定,考虑:

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


function doFoo(cb) {
  cb()
}

var obj = {
  a: 2,
  foo
}

var a = 1

doFoo(obj.foo)

obj.foo 看起来像是一个隐式绑定,但是在 doFoo 中我们可以清楚地看到,我们其实是普通调用了 foo(传递的其实就是函数的引用!),所以发生的是默认绑定。

记住,如果要使用隐式绑定,一定要严格按照 上下文对象.函数名 的方式调用一个函数。

显式绑定

ECMAScript 5 之后,下面三个方法均定义在 Function.prototype 上,所以可以对任何一个函数调用该三个方法。

call

这个方法的作用是调用一个函数,并将 call 的参数作为这个函数 this:

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

var obj = {
  a: 2
}

function bar() {
  foo.call(obj)
}

var a = 1

bar() // 2 显式绑定
foo() // 1 默认绑定

如果我们想要使得某个函数的 this 指向不变,我们就可以像 bar 一样写一个 wrapper,通过这个 wrapper 来调用原本想要调用的那个函数。

call 方法和隐式绑定有什么区别呢?区别在于现在我们不需要对象有一个属性指向我们要调用的那个函数了。

callapply 的区别在于对函数参数的处理方法不同。

bind

我们知道了三种在函数执行时绑定 this 的方法,似乎想要知道函数的 this 到底指向哪,我们必须知道函数是如何被调用的。等等,我们真的没有办法得到一个 this 的指向不会随着调用方式而改变的函数吗?答案是有(除了上面讲的 wrapper 之外),就是 bind()

bind 方法返回一个新的函数,这个函数的 this 指向是固定的,就是作为 bind 方法的参数的那个对象,而且指向不会因为函数调用方式而改变。

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

var obj = {
  a: 2
}

// 这差不多是最简单的实现了
function bind(fn, obj) {
  return function() {
    fn.call(obj)
  }
}

var a = 1
var bar = bind(foo, obj)

bar() // 2 显式绑定
foo() // 1 默认绑定

其实和 wrapper 是一个道理,都是利用作用域机制保存了对函数和对象的引用,然后利用 call 方法,不同之处在于 bind 返回了一个函数,所以这里有个闭包。

另外 bind 还可以用于实现偏函数,见 MDN。

new

在 js 中我们有构造函数的概念,它能够帮助我们创建一个对象:

function Apple(color) {
  this.color = color
}

var greenApple = new Apple('green')
console.log(greenApple.color) // green

但是 js 中并没有 C++ 或者是 Java 中那样真正的构造函数,因为 js 中连类都没有,“几乎一切”都是对象!真正发生的事情是:new 创建了一个新的对象,并以这个对象作为上下文调用了 Apple 函数。我们这里只关心 this,实际上 new 还要设置原型链。

不同绑定方法的优先级

new 高于 显式绑定 高于 隐式绑定 高于 默认绑定

下面说明为什么 new 高于显式绑定。看 MDN 给出的 polyfill,ECMAScript 5 规定的 bind 其实和这个 polyfill 有不同之处。

if (!Function.prototype.bind) {
  Function.prototype.bind = function(oThis) {
    // 这个 this,按照隐式调用的原理,指向被绑定的函数
    // 首先要检查被绑定的的确是一个函数
    if (typeof this !== 'function') {
      throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable');
    }

    // bind 的第一个参数是被绑定的对象,将其他的参数作为数组 aArgs 保存下来,这些其他的参数就变成了绑定后的函数的默认参数
    // fToBind,上面说到
    // fNOP 作为一个函数对象的代表,是用来帮助判断是否是在 new 关键字的作用下调用绑定后函数的
    // fBound 是绑定过后的函数
    var aArgs   = Array.prototype.slice.call(arguments, 1),
        fToBind = this,
        fNOP    = function() {},
        fBound  = function() {
          // 注意这里 this 的指向不再是被绑定的函数了,而是调用位置的对象!this 在函数被执行时绑定,而这个返回的函数被执行的时候可能在 new 中
          // 如果 this 是 fNOP 的一个实例,说明被调用的位置是在 new 中,这时要以 new 创建的对象为对象上下文
          // 否则,我们还是以被绑定的对象作为对象上下文
          // 实际上最终帮助我们绑定 this 的是 apply
          return fToBind.apply(this instanceof fNOP
                 ? this
                 : oThis,
                 // 在实际调用函数的时候,我们要把默认参数和后来的参数拼接起来
                 aArgs.concat(Array.prototype.slice.call(arguments)));
        };

    // 维护原型关系
    // 这里的 this 指向的还是被绑定的函数
    if (this.prototype) {
      // 如果被绑定的函数有原型对象,就将 fNOP 的原型对象也设置为它
      fNOP.prototype = this.prototype; 
    }
    fBound.prototype = new fNOP();

    return fBound;
  };
}

箭头函数中的 this

箭头函数和普通的函数不同,箭头函数中没有 this,所以它利用了作用域原则,在自己的上层(即作用域链中)查找最近的 this。

var obj = {
  a: 2
}

function foo() {
  return (() =>{
    console.log(this.a)
  }).bind(obj)
}

function baz() {
  return function() {
    console.log(this.a)
  }.bind(obj)
}

var a = 1

foo()() // 1
foo.call(obj)() // 2
baz()() // 2

在箭头函数的例子中,我们虽然 bindobj,但因为箭头函数没有自己的 this,所以这个绑定没有用。箭头函数的 this 借用了 foo 的 this,而 foo 在全局中调用的时候,就打印出 1,用 call 显式绑定 obj 的时候,就输出 2。

参考

  • MDN
  • 《你不知道的 JavaScript 上册》第 2 篇第 2 章