Javascript内功修炼-深入浅出call、bind、apply
Gwolf

写在前面

在上文中,提到了多数情况下的this 遵循的指向机制。在另外一些情况下 this 是不遵循这个机制的。改变 this 的指向,主要有两条路:

  1. 通过改变书写代码的方式做到(比如上一节提到的箭头函数)。
  2. 显式地调用一些方法来帮忙。

两条路都是面试经常考的。其中第一条路,因为比较简单,就先拿它开刀:

改变书写代码的方式,进而改变 this 的指向

唱反调的箭头函数

箭头函数在上文已经讲过。这里再强调一下:

1
2
3
4
5
6
7
8
9
10
11
12
var a = 1

var obj = {
a: 2,
// 声明位置
showA: () => {
console.log(this.a)
}
}

// 调用位置
obj.showA() // 1

当普通函数改写为箭头函数时,箭头函数的 this 会在书写阶段(即声明位置)就绑定到它父作用域的 this 上。无论后续如何调用它,都无法再为它指定目标对象 —— 因为箭头函数的 this 指向是静态的,“一次便是一生”。

构造函数里的 this

当使用构造函数去 new 一个实例的时候:

1
2
3
4
5
6
function Person(name) {
this.name = name
console.log(this)
}

var person = new Person('Gwolf')

构造函数里面的 this 会绑定到 new 出来的这个对象上:

显式地调用一些方法来帮忙

改变 this 指向,常用的是 call、 apply 和 bind 方法。

考虑到实际开发中改变 this 指向的场景非常多,所以这三种方法的使用在面试中考察的频率也比较高。最常见的考法,是问询三种方法的使用及区别。但很多时候,为了能够进一步试探你对 this 相关概念理解和掌握的深度, 面试官会考察你 call、apply 和 bind 的实现机制,甚至可能会要求你手写代码。

因此,针对 call、 apply 和 bind,不仅要会用、会辨析,更要对其原理知根知底。接下来,将这三种方法的考察方式汇聚到两道题里面,若能掌握这两个问题,就可以做到举一反三,知一解百。

call、apply 和 bind 是干嘛的?如何使用?它们之间有哪些区别?

call、apply 和 bind 之间的区别比较大,前者在改变 this 指向的同时,也会把目标函数给执行掉;后者则只负责改造 this,不作任何执行操作。

call 和 apply 之间的区别,则体现在对入参的要求上。前者只需要将目标函数的入参逐个传入即可,后者则希望入参以数组形式被传入。

进阶编码题:模拟实现一个 call/apply/bind 方法

这三种方法在实现层面上非常相似,以 call 方法的实现为例,带大家深入理解一下这类方法的模拟思路:

call 方法的模拟

在实现 call 方法之前,先来看一个 call 的调用示范:

1
2
3
4
5
6
7
8
9
var me = {
name: 'Gwolf'
}

function showName() {
console.log(this.name)
}

showName.call(me) // Gwolf

结合 call 表现出的特性,首先至少能想到以下两点:

  • call 是可以被所有的函数继承的,所以 call 方法应该被定义在 Function.prototype 上
  • call 方法做了两件事:

结合这两点,一步一步来实现 call 方法。首先,改变 this 的指向:

showName 在 call 方法调用后,表现得就像是 me 这个对象的一个方法一样。

所以最直接的一个联想是,如果能把 showName 直接塞进 me 对象里就好了,像这样:

1
2
3
4
5
6
7
8
var me = {
name: 'Gwolf',
showName: function() {
console.log(this.name)
}
}

me.showName()

但是这样做有一个问题,因为在 call 方法里,me 是一个入参:

1
showName.call(me) // Gwolf

用户在传入 me 这个对象的时候, 想做的仅仅是让 call 把 showName 里的 this 给改掉,而不想给 me 对象新增一个 showName 方法。所以说在执行完 me.showName 之后,还要记得把它给删掉。遵循这个思路,来模拟一下 call 方法(注意看注释):

1
2
3
4
5
6
7
8
Function.prototype.myCall = function(context) {
// step1: 把函数挂到目标对象上(这里的 this 就是要改造的的那个函数)
context.func = this
// step2: 执行函数
context.func()
// step3: 删除 step1 中挂到目标对象上的函数,把目标对象”完璧归赵”
delete context.func
}

到这里,已经实现了 “改变 this 的指向” 这个功能点。现在的 myCall 还需要具备读取函数入参的能力,类比于 call 的这种调用形式:

1
2
3
4
5
6
7
8
9
var me = {
name: 'Chris'
}

function showFullName(surName) {
console.log(`${this.name} ${surName}`)
}

showFullName.call(me, 'Lee') // Chris Lee

读取函数入参,具体来说其实是读取 call 方法的第二个到最后一个入参。要做到这一点,可以借助数组的扩展符(注意阅读注释,如果对 ‘…’ 这个符号感到陌生,需要补习一下 ES6 中扩展运算符相关的知识):

1
2
3
4
5
6
// '...'这个扩展运算符可以帮助把一系列的入参变为数组
function readArr(...args) {
console.log(args)
}

readArr(1,2,3) // [1,2,3]

把这个逻辑用到的 myCall 方法里:

1
2
3
4
Function.prototype.myCall = function(context, ...args) {
...
console.log('入参是', args)
}

就能通过 args 这个数组拿到想要的入参了。把 args 数组代表的目标入参重新展开,传入目标方法里,就大功告成了:

1
2
3
4
5
6
7
8
Function.prototype.myCall = function(context, ...args) {
// step1: 把函数挂到目标对象上(这里的 this 就是要改造的的那个函数)
context.func = this
// step2: 执行函数,利用扩展运算符将数组展开
context.func(...args)
// step3: 删除 step1 中挂到目标对象上的函数,把目标对象”完璧归赵”
delete context.func
}

以上,就成功模拟了一个 call 方法出来,apply和bind也有类似的实现思路,这里就不一一赘述了。