apply、call、bind方法的区别和模拟实现

312 阅读7分钟

1 区别

这三个方法都是Function原型对象里面的方法(Function.prototype),都可以指定方法的第一个参数来改变某个函数或方法运行时的上下文(指向当前this的指向)的作用。bindapplycall方法不同的是,它返回一个新函数,等执行新函数时才会去改变上下文,常用于回调函数,而后两者是直接运行的,只是传参的方式不一样:apply的第2个参数是一个数组或类数组,call是正常传参。下面是一个例子:

var a = {
  value: 1
}
var value = 2
var b = function(a, b) {
  console.log(a + b + this.value)
}

b.call(a, 2, 3) //输出:6,this指向了a,等同于b.apply(a, [2, 3])
b(2, 3) // 输出:7,this指向了window
var c = b.bind(a, 2, 3) // 返回一个新函数
c() // 调用新函数再绑定this指向

注意:在严格模式下不指定this指向的话,this值为undefined

2 使用场景和用法

2.1 apply

  • 数组拼接
var array = [1, 2, 3, 4];
array.push.apply(array, [4, 5, 6]);
console.log(array);

数组拼接有一个concat方法,但是不改变原数组,只是返回了一个新数组。而使用apply就可以在原数组后面依次添加另一个数组里面的元素。

  • 求数组求最大值
var array = [41, 25, 23, 54, 58];
var max = Math.max.apply(null, array);
console.log(max);

有时候我们想求数组的最大值,而数组原型对象里面没有求最大值的方法,要么在原型里面添加一个max方法,要么就得循环数组依次比较,很是麻烦,而使用apply方法第二个参数的特性,可以把这个数组作为Math对象中max方法的参数,就可以很容易求出最大值。求最小值也是一样的方法。

2.2 call

  • 调用父构造函数,实现继承
//定义父类
function Person(name, age) {
    this.name = name;
    this.age = age;
    this.sayHi = function() {
        console.log('hello,' + 'name');
    };
}
//添加原型方法
Person.prototype.walk = () => { 
    console.log('i can walk');
 };
 //定义子类
 function Student(name, age, grades) {
     //调用父类构造函数
     Person.call(this, name, age);
     this.grades = grades;
 }
 
 var stu = new Student('laocao', 21, 100);

这样可以访问父类里面的属性和方法,但是访问不到原型里面的方法,要实现继承还需要两步:

Student.prototype = new Person();
Student.prototype.constructor = Student;

这里是设置了原型链,这样就可以通过原型来实现继承,关于原型以及原型链的知识具体可以参考我的一篇文章 原型链的介绍

  • 判断数据类型

判断数据类型常用为三种:typeofinstanceofObject.prototype.toString.call

前两者都有一定的缺陷,typeof不能精准判断对象类型,对于所有除函数的引用类型都返回object。而对于instanceof,它虽然可以判断对象类型,不能判断基本类型,而且所有引用类型都是Object的子类型,原因就是instanceof根据原型链来判断的,类似于这样:

A instanceof B
// 等价于
A.__proto === B.prototype

对于Object.prototype.toString.call,这是Object原型对象里面的一个方法,也就是说所有对象都会有toString方法,返回表示该对象的字符串。配合call,给call传递一个对象,就可以返回该对象的类型(如果时基本类型的值,会自动被JS引擎包装成基本类型包装对象)。

在这里插入图片描述

2.3 bind

  • 绑定回调函数的执行上下文

在没有箭头函数之前,我们如果需要在setTimeoutsetInterval回调函数中使用this,可以通过bind或者将this存到另一个变量中来绑定回调函数的执行上下文。

var a = {
  value: 1
}
var value = 2
var b = function() {
  console.log(this.value)
}
setTimeout(b.bind(a), 1000) // 1s后打印1

3 模拟实现apply、call和bind方法

3.1 实现call

call方法第一个参数的作用是为前面的方法绑定执行的上下文,那么如何绑定?

先看以下代码:

const name = 'window'
const person = {
	name: 'cao',
	sayHi () {
		console.log(this.name)
	}
}
person.sayHi() // cao

JS有一个特性,当使用new操作符去创建实例对象的时候,会将构造函数中的this指向它的调用者,即实例对象。(上面的字面量对象可以看作是new Object())。在上面的例子中,当创建字面量对象person的时候, this就指向了person这个实例对象,所以在sayHi方法中的this可以取到其他属性的值。

按这个思路,把call前面的方法添加到 call 第一个参数这个对象中的属性中去,执行这个属性中的方法,然后再删除这个属性不就可以了吗?

//(非严格模式)
Function.prototype.myCall = function (targetObj) {
	// 将调用call的方法或函数放到需要绑定上下文对象的属性中
	targetObj.fn = this // this即调用call的方法
	targetObj.fn() // 执行该方法
	delete targetObj.fn // 删除该属性
}

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

const name = 'window'
const person = {
	name: 'person'
}

sayHi() // window
sayHi.myCall(person) // person

下一步,就是需要实现传参了,call 方法可是不只一个参数, 而且参数都是不确定的,那该怎么办?

我们可以使用函数自带的arguments对象来处理剩余参数,并将参数传入call前面的方法中。

Function.prototype.myCall = function (targetObj) {
	var args = []
	targetObj.fn = this
	// 将第一个参数后面的剩余参数放入一个数组中
	for (var i = 1; i < arguments.length; i++) {
		args.push('arguments['+ i +']')
	}
	// 执行后 args为 ["arguments[1]", "arguments[2]", "arguments[3]"],但是里面的arguments是无法解析的,被当做字符串了
	// 使用eval可以计算字符串里面的JS代码
	eval('targetObj.fn('+ args +')')
	delete targetObj.fn
}

function sayHi (age1,age2) {
	console.log(this.name,age1,age2) 
}

const person = {
	name: 'person'
}

sayHi.myCall(person,22,21) // person 22 21

在传参的解决方案中,我采用了eval来把数组参数转化成了多个参数,但是有一定的性能问题,如果没有要求的话,可以采用ES6中的展开运算符...来把数组中的参数拆分传递。

// 使用展开运算符和slice方法把传入的参数放入数组
var args = [...arguments].slice(1)

// 调用call前面的函数
targetObj.fn(...args)

方法实现到这里,还有一些细节的问题:如果第一个参数为 null 或者undefined(没传值)呢 ?如果target.fn()有返回值呢 ?接下来我们需要对传递的参数和返回值进行判断了:

Function.prototype.myCall = function (targetObj) {
  var args = []
  var result
  // 如果没有传递参数,默认绑定到window
  targetObj = targetObj || window
  targetObj.fn = this
  // 将第一个参数后面的剩余参数放入一个数组中
  for (var i = 1; i < arguments.length; i++) {
    args.push('arguments[' + i + ']')
  }
  // 使用eval可以执行字符串里面的JS代码
  result = eval('targetObj.fn(' + args + ')')
  delete targetObj.fn
  return result
}

var username = 'Jimmy'
var userInfo = { username: 'Tom' }
var testFn = function (age, gender) {
  if (age && gender) {
    console.log(this.username + ' ' + age + ' ' + gender)
  } else {
    console.log(this.username)
  }
}

testFn.myCall(userInfo, 12, 'male') // Tom 12 male
testFn.myCall() // Jimmy


3.2 实现apply

applycall的作用完全一致,除了后面的参数是一个数组,那么这里就不多写了。少了将数组转化为单个参数的麻烦,直接上改动后的代码:

Function.prototype.myApply = function () {
  var targetObj = arguments[0]
  var array = arguments[1]
  var result

  targetObj = targetObj || window
  targetObj.fn = this
  //判断第二个参数是不是 null or undefiend
  result = array == null ? targetObj.fn(array) : targetObj.fn(...array)

  delete targetObj.fn
  return result
}
var username = 'Jimmy'
var userInfo = { username: 'Tom' }
var testFn = function (age, gender) {
  if (age && gender) {
    console.log(this.username + ' ' + age + ' ' + gender)
  } else {
    console.log(this.username)
  }
}

testFn.myApply(userInfo, [12, 'male']) // Tom 12 male
testFn.myApply() // Jimmy

3.3 实现bind

bind的实现我查找的是MDN的poly fill,个人觉得比大多数人的实现要简洁而且容易理解,我稍微改了一下:

Function.prototype.myBind = function () {
  // 先获取调用bind的函数以及要绑定的上下文对象
  var thatFunc = this,
    thatArg = arguments[0]
   // 获取初始传入bind的参数
  var args = Array.prototype.slice.call(arguments, 1)
  // 判断调用bind的函数的类型
  if (typeof thatFunc !== 'function') {
    throw new TypeError(
      'Function.prototype.bind - ' +
        'what is trying to be bound is not callable'
    )
  }
  // 返回一个新的函数
  return function () {
    var funcArgs = args.concat(Array.prototype.slice.call(arguments))
    return thatFunc.apply(thatArg, funcArgs)
  }
}

4 总结

  • call,aplly,bind都可以改变函数运行时的上下文(this)
  • 如果对传入的参数不确定,推荐使用apply
  • 对于有明确规定的参数,推荐使用call,当然这也是最常用的
  • 对于想先绑定一个新函数,不立马执行的,推荐bind

5 参考

【1】Github issues:JavaScript深入之call和apply的模拟实现

【2】掘金:call、apply有什么区别?call,aplly和bind的内部是如何实现的?

【3】MDN:Function.prototype.bind()