this 指向

作为 JS 最复杂的几个机制之一,this 的指向往往是很难判断的,经常会出现因为 this 指向不对而导致的问题

以前我一直不明白 this 的机制到底是什么,直到看了《你不知道的 JS》才算明白一些

在弄清 this 的指向之前,要先知道为什么要用 this,因为 this 这种难以判断的东西应该少用才是,为什么那么常用呢?

const obj = {
  name: 'Tom',
  age: 16,
  setInfo(attribute, value) {
    this.attribute = value
  },
  getInfo(attribute) {
    console.log(this.attribute)
  },
}
obj.setInfo('name', 'Lisa')
obj.getInfo('name')

上面的例子实现了一个设置并且获取对象属性的功能,在 setInfogetInfo 方法中可以通过 this 获取该对象实例

如果不使用 this 呢?

const obj = {
  name: 'Tom',
  age: 16,
  setInfo(attribute, value) {
    obj.attribute = value
  },
  getInfo(attribute) {
    console.log(obj.attribute)
  },
}
obj.setInfo('name', 'Lisa')
obj.getInfo('name')

不使用 this 同样能实现效果,唯一的不同是将 this 替换成了该对象的名称,这样做的缺点非常明显:耦合性太强,复用性也低,如果我们事先不知道对象的名称该怎么办呢?

function animal(name, age) {
  this.name = name
  this.age = age
}
animal.prototype.setInfo = function (attribute, value) {
  this[attribute] = value
}
animal.prototype.getInfo = function (attribute) {
  console.log(this[attribute])
}

let cat = new animal('Tom', 6)
let mouse = new animal('Jerry', 4)

cat.getInfo('name') //'Tom'
mouse.getInfo('name') //'Jerry'

在实际项目中往往需要创建多个对象实例,这里我用组合模式实现该效果,可以看看到,在这种情况下是无法确定究竟是哪个对象调用 setInfogetInfo

这个时候 this 的作用就体现出来了

默认绑定

默认绑定指的是没有人为的指定 this 的指向,这时候 this 指向全局对象 window

var a = 1
function func() {
  console.log(this.a)
}
func()

上述代码中,func 中的 this 指向的就是 this

但其实我个人认为默认绑定是一种隐式绑定:

window.a = 1
function func() {
  console.log(this.a)
}
window.func()

因为afunc都是在全局声明的,所以它们都属于window对象,只是在调用的时候可以省略掉window而已,这样this指向的实际就是调用它的对象window

Notice

注意:这里我是用var声明a的,如果替换成let或者const,那么输出的就会是undefined,因为它们的顶层对象不是window

隐式绑定

const obj = {
  a: 1,
  foo: foo,
}
function foo() {
  console.log(this.a)
}
obj.foo()

在上述例子中,obj包含对foo的引用,因为foo是作为obj的属性被调用的(虽然foo实际上不属于obj),所以this指向的是obj

隐式绑定经常会发生绑定丢失的问题,看下面的例子:

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

表面上我将obj.foo赋给bar,那么调用bar输出的应该是obja,但实际上只是将obj.foo这个函数本身给了bar而已,bar是作为全局对象的属性被调用的,所以bar输出的是全局对象的a

绑定丢失同样会发生在函数传参过程中:

function foo() {
  console.log(this.a)
}
var obj = {
  a: 1,
  foo: foo,
}
var a = 2
function bar(func) {
  func()
}
bar(obj.foo)

bar仍然会输出 2,虽然obj.foo确实是传入了bar,但是在bar的内部,执行的并不是obj.foo(),在传参过程中会发生隐式赋值,将obj.foo赋给func,所以这其实和上一种情况是相同的,只是没有那么明显,如果你知道函数传参是按值传递,而不是按引用传递的话,那应该很快就能理解

不光是我们自己定义的函数会发生这种情况,js内置函数同样会出现这个问题:

function foo() {
  console.log(this.a)
}
var obj = {
  a: 1,
  foo: foo,
}
var a = 2
setTimeout(obj.foo, 200)

还是输出 2,因为js中内置的setTimeout函数的实现类似于下面的代码:

function setTimeout(func, time) {
  //time毫秒之后
  func()
}

所以仍然发生了绑定丢失,那么有什么方法可以解决这个问题呢?

显式绑定

我们知道隐式绑定会发生绑定丢失

强制绑定可以避免这种情况,可以使用call或者apply来实现显式绑定:

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

callapply的作用是一样的,唯一的不同是参数形式的不同:

  • apply 接收两个参数,第一个参数是 this 指向,第二个参数是一个数组,用来存储传入函数的参数
  • call 接受若干个参数,第一个参数是 this 指向,后面的参数全部都是传入函数的参数,用逗号分隔

使用call方法的前提是你已经知道函数需要多少个参数,如果事先不清楚要传多少个参数,最好用apply

但是这还不能够解决绑定丢失的问题,需要采用硬绑定:

function foo() {
  console.log(this.a)
}
var obj = {
  a: 1,
  foo: foo,
}
var a = 2
function bar() {
  foo.call(obj)
}
setTimeout(bar, 100)

硬绑定可以保证bar无论在哪里被调用都不可能修改内部的this

硬绑定是一种非常常用的模式,比如创建一个可以重复使用的函数:

function compare(newValue) {
  if (newValue < this.value) {
    console.log(this.value)
  } else {
    console.log(newValue)
  }
}
let obj = {
  value: 1,
}
let bar = compare.bind(obj)
let a = bar(2) //2

这里我没有使用call或者apply,因为ES5function提供了内置方法bind来绑定thisbind会返回一个新的函数,它会将this绑定到你制定的对象上,并调用原始函数

bind 的内部实现可以这样的代码理解:

if (!Function.prototype.bind) {
  Function.prototype.bind = function (oThis) {
    if (typeof this !== 'function') {
      // 可能的与 ECMAScript 5 内部的 IsCallable 函数最接近的东西
      throw new TypeError('Function.prototype.bind - what ' + 'is trying to be bound is not callable')
    }

    var aArgs = Array.prototype.slice.call(arguments, 1),
      fToBind = this,
      fNOP = function () {}, //用作做中间桥梁的空函数(原型继承)
      fBound = function () {
        return fToBind.apply(
          this instanceof fNOP && oThis //如果后面又对返回的fBound函数进行new操作了,那oThis为空,new绑定覆盖原绑定
            ? this
            : oThis,
          aArgs.concat(Array.prototype.slice.call(arguments))
        )
      }
    fNOP.prototype = this.prototype //因为返回的apply方法只继承构造方法,所以得连上原函数的prototype
    fBound.prototype = new fNOP() //利用原型式继承

    return fBound
  }
}

这是MDN上关于bind方法的描述,可以说是最接近ES5bind实现方法了:

  1. 首先判断调用者是否为函数
  2. 传入 othisothis 指的就是调用 bind 时指定的 this 指向目标
  3. Array.prototype.slice.call( arguments, 1 )将传入的第一个参数作为预设参数
  4. aArgs.concat( Array.prototype.slice.call( arguments ) )将剪切下来的第一个参数和 arguments 连接
  5. 将调用者的 prototype 指向 fNOPprototype

函数里面的函数

但我们需要注意的是,this 的改变之作用于当前函数,而不会影响到内部的普通函数!

var a = 1
var d = {
  a: 2,
}
function b() {
  console.log(this)
  function c() {
    console.log(this)
    console.log(this.a)
  }
  c()
}
b() // window window 1
b.call(d) // d window 1

可以看到,虽然我们使用 callb 中的 this 指向了 d,但不会影响内部 c 函数的指向,c 实际上还是在 window 下执行的,因此 this 仍然指向 window

new 绑定

看下面的代码:

function foo(name, age) {
  this.name = name
  this.age = age
}
foo.prototype.getInfo = function (attribute) {
  console.log(this[attribute])
}
let b = new foo('Tom', 16)
b.getInfo('name') //Tom

在一些后端语言中,使用new初始化一个类的时候会调用类中的构造函数,但在js中原理是完全不一样的,在js中只有被new调用的函数才能叫作构造函数,构造函数不属于也不会实例化某个类,严格的来说它甚至不能叫做构造函数,new foo()只是对foo函数的构造调用而已,而 foo 只是一个被 new 调用的普通函数

那么new操作符究竟做了些什么呢?

  1. 创建一个全新的空对象
  2. 设置原型链,将新对象的_proto_属性指向构造函数的prototype
  3. 将构造函数的this指向新对象
  4. 如果是值类型,返回新对象,如果是引用类型,返回这个引用类型的对象

同样地,new绑定也可以改变this的指向

软绑定

硬绑定可以解决隐式绑定中绑定丢失的问题,但是这样会造成许多问题,比如硬绑定之后无法再改变this的指向,使得函数的灵活性大大降低

如果可以给默认绑定一个全局对象和undefined以外的值,就可以实现和硬绑定一样的效果,同时又可以解决无法手动修改this指向的问题

if (!Function.prototype.softBind) {
  Function.prototype.softBind = function (obj) {
    var fn = this
    var curried = [].slice.call(arguments, 1)
    var bound = function () {
      return fn.apply(!this || this === (window || global) ? obj : this, curried.concat.apply(curried, arguments))
    }
    bound.prototype = Object.create(fn.prototype)
    return bound
  }
}

我们可以看到软绑定的代码其实和ES5内置的bind方法有些相似,它会检查调用时的this,如果this绑定到全局或者undefined,那就把指定的默认对象obj绑定到this,否则不会修改this,此外,这段代码还支持可选的函数柯里化

箭头函数的 this 指向

上面几种绑定方式都可应用于普通函数中,但是箭头函数是一个例外

箭头函数是 ES6 定义的一个特殊函数类型,它不会根据上面所述的几种规则改变 this 指向,而是根据外层作用域来决定 this

this.name = 'Jerry'
const obj = {
  name: 'Tom',
  func: function () {
    return () => {
      console.log(this.name)
    }
  },
}
let b = obj.func()
b() //Tom

结果非常让人惊讶,如果想看待普通函数一样看待箭头函数的话,那么因为将obj.func()赋给 b 这个操作已经丢失了对obj的引用,那么this指向的就应该是window而不是obj,但事实是,this的指向根本没有改变,这是为什么呢?

因为在func内部创建箭头函数的时候,箭头函数会捕获调用时functhis,由于functhis是绑定在obj上的,所以b中的this也会绑定到obj上,这和硬绑定有一些相似,同样地,箭头函数中的this无法被修改,即使是使用new也不行

为什么会造成这种结果?因为箭头函数本身无法绑定 this!也就是说他根本没有自己的 this!它会捕获其所在(即定义的位置)上下文的 this 值,作为自己的 this 值,它的 this 在它定义的时候就已经确定了

不过要注意的是,这里所说的上下文表示的是作用域,而不是某个父级对象:

var a = 1
var b = {
  a: 2,
  c: {
    a: 3,
    d: () => {
      console.log(this.a)
    },
  },
}
b.c.d() // 1

可以看到虽然箭头函数 d 的父对象 c 为对象 b 的属性,但对象 b 并不是一个作用域,因此 this 仍然指向 window

var a = 1
var b = {
  a: 2,
  c: {
    a: 3,
    d() {
      return () => {
        console.log(this.a)
      }
    },
  },
}
b.c.d()() // 3

在普通函数内的箭头函数,this 会指向普通函数 d,而 d 中的 this 指向 c,因此输出 3

虽然箭头函数无法直接修改 this 绑定,但是其本身的机制可以解决很多防止 this 丢失的问题:

this.a = 2
const obj = {
  a: 1,
  func: function () {
    let that = this
    setTimeout(function () {
      console.log(that.a)
    }, 100)
  },
}
obj.func()

这是在箭头函数之前经常用来解决 this 丢失的方法,在函数内把 this 存进一个变量中,这样 setTimeout 便不会使用默认绑定,如果是箭头函数呢?也能达到同样的效果,箭头函数不需要创建一个变量来储存 this,听起来蛮不错的,当然它也不是没有缺点,比如可读性较低,混合使用箭头函数和普通函数也会使得代码更加难维护等等。

间接修改箭头函数 this 指向

我们虽然不能直接修改箭头函数 this 指向,但却可以通过间接修改的形式达到目的:

var a = 1
var d = {
  a: 2,
}
function b() {
  console.log(this)
  var c = () => {
    console.log(this)
    console.log(this.a)
  }
  c()
}
b() // window window 1
b.call(d) // d d 2

如果我们套一层普通函数在箭头函数外,由于在这个例子中,内部的箭头函数的 this 是默认指向 b 的,因此只要改变函数 bthis 指向,就可以间接地改变内部箭头函数的指向了。

类中的箭头函数

class A {
  constructor() {
    this.b = 1
  }
  c = () => {
    console.log(this.b)
  }
  d() {
    console.log(this.b)
  }
}

new A().c() // 1
new A().d() // 1

在类中的箭头函数,this 和普通函数一样仍然指向该类。

但我们要注意的是,在类中声明箭头函数和普通函数的行为是不一样的:

A.prototype.c // undefined
A.prototype.d // d(){console.log(this.b)}

箭头函数并没有被定义到原型上,因为箭头函数不是方法,它们是匿名函数表达式,所以将它们添加到类中的唯一方法是赋值给属性。ES 类以完全不同的方式处理方法和属性。

方法被添加到类的原型中,这正是我们需要它们的地方——这意味着它们只定义一次,而不是每个实例定义一次。

类属性语法是为相同的属性分配给每一个实例的语法糖。实际上,类属性是这样工作的:

class A {
  constructor() {
    this.b = 1
    this.c = () => {
      console.log(this.b)
    }
  }
  d() {
    console.log(this.b)
  }
}

换句话说,每次 A 的实例对象被创建时,都会重新定义 c

const a = new A()
const b = new A()
a.c === b.c // false
a.d === b.d // true

这违背了使用类或共享原型的目的,因此不要盲目地在类中使用箭头函数