call、apply、bind 实现原理

这篇文章主要探究 call、apply、bind 的实现原理,对于使用方法不做太多概述。如果有不太了解的同学可以查看JavaScript MDN 官方文档 call apply bind 详解

前言

我们都知道 call、apply、bind 三个方法是用来改变 this 指向的,但是也只局限于会用状态。具体的实现原理,却没有思考过。所以有必要研究一下。在明白起原理的情况下,才能用的更好。

具体实现 call

众所周知这三个方法都是提供给 函数使用的,函数通过这三个方法调用把 this 传入进去就可以改变其 this 指向。通过这个特性我们可以推断出: 这三个方法都是写在 Function 对象的原型上的。也就是 Function.prototype

下边我们先在这个原型上写一个自己的 call 方法就叫 newCall:

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

let obj = {
  name: '渣渣辉'
}

Function.prototype.newCall = function (obj) {
  console.log(this) // 这里的this指向的是 person 函数
  console.log(obj) // {name: '渣渣辉'}

}

person.newCall(obj)

运行上边的代码,我们会发现 newCall 原型方法里的 this 指向的是 person 函数, 因为 newCall 这个方法是定义在 Function 对象原型上的,并且是 person 函数调用了 newCall 方法,这就印证了那句话,谁调用了这个方法 this 就指向谁。然后只要在 newCall 方法中想办法执行 this 指向的这个 person 函数就行。

然后我们可以再 obj 这个对象上添加一个属性,然后把 this 指向这个属性,然后在执行这个属性就可以了: 代码如下

function person () {
  console.log(this.name)  // 渣渣辉
}

let obj = {
  name: '渣渣辉'
}

Function.prototype.newCall = function (obj) {
  console.log(this)
  console.log(obj)
  // 在 obj 对象上添加一个属性 fn, 并且绑定 this (也就是 person) person 函数是 引用数据类型,是存在堆空间,栈空间通过指针指向 堆空间
  obj.fn = this
  // 执行 fn 函数
  obj.fn()
  // 防止作用域污染,使用完之后删除
  delete obj.fn
}

person.newCall(obj)

这时候 顶部的 person 函数中 console.log(this.name) 执行的结果就是 渣渣辉

基本的 call 的操作已经完成了,但是还需要完善一下:

  1. call() 调用的时候 第一个参数一般是 this 或者一个对象,或者是 null 或者直接省略不传,这种情况下this 的值将会被绑定为全局对象。
  2. call(this, 1, 2, 3, 4) 接受一个参数列表

根据上边的特性再完善一下 newCall 方法:

function person (a, b, c, d) {
  console.log(this.name) // 渣渣辉
  console.log(a, b, c, d) // 1, 2, 3, 4
}

let obj = {
  name: '渣渣辉'
}

Function.prototype.newCall = function (obj) {
  // 如果参数为 为 null ,则默认为 window, 即访问全局作用域对象
  var obj = obj || window
  // 在 obj 对象上添加一个属性 fn, 并且绑定 this (也就是 person) person 函数是 引用数据类型,是存在堆空间,栈空间通过指针指向 堆空间
  obj.fn = this
  // 截取作用域对象参数后面的参数
  var args = [...arguments].slice(1)
  // 执行 fn 函数
  obj.fn(...args)
  // 防止作用域污染,使用完之后删除
  delete obj.fn
}

// person.newCall(obj)
person.newCall(obj, 1, 2, 3, 4)

现在离成功又进了一步,但是有时候被call 调用的那个函数也是有返回值的,所以还需要完善一下

function person (a, b, c, d) {
  console.log(this.name) // 渣渣辉
  console.log(a, b, c, d) // 1, 2, 3, 4
  // 有返回值
  return {
    name: this.name,
    a: a, b: b, c: c, d: d
  }
}

let obj = {
  name: '渣渣辉'
}

Function.prototype.newCall = function (obj) {
  // 如果参数为 为 null ,则默认为 window, 即访问全局作用域对象
  var obj = obj || window
  // 在 obj 对象上添加一个属性 fn, 并且绑定 this (也就是 person) person 函数是 引用数据类型,是存在堆空间,栈空间通过指针指向 堆空间
  obj.fn = this
  // 截取作用域对象参数后面的参数
  var args = [...arguments].slice(1)
  // 执行 fn 函数
  var result = obj.fn(...args)
  // 防止作用域污染,使用完之后删除
  delete obj.fn
  // 执行完成后返回结果
  return result
}

// person.newCall(obj)
// person.newCall(obj, 1, 2, 3, 4)
var res = person.newCall(obj, 1, 2, 3, 4)
console.log(res)  // {name: "渣渣辉", a: 1, b: 2, c: 3, d: 4}

到此,newCall 方法基本上已经完成了, 上边的写法中obj.fn(...args) 使用了 ES6 的扩展运算符,但是 call 方法在没有扩展运算符的年代就已经实现了,所以我们有必要在探究一下, 最终找到一个方法 eval() 方法运行 js 然后得到结果,最终版本如下

Function.prototype.newCall = function (obj) {
  // 如果参数为 为 null ,则默认为 window, 即访问全局作用域对象
  var obj = obj || window
  // 在 obj 对象上添加一个属性 fn, 并且绑定 this (也就是 person) person 函数是 引用数据类型,是存在堆空间,栈空间通过指针指向 堆空间
  obj.fn = this
  
  // ES 5 的实现方法 ===============
  var args = []
  for (var i = 1; i < arguments.length; i++) {
    args.push('arguments['+ i +']')
  }
  var result = eval('obj.fn('+ args +')')
  // ===================

  //ES 6 ================
  // 截取作用域对象参数后面的参数
  // var args = [...arguments].slice(1)
  // // 执行 fn 函数
  // var result = obj.fn(...args)
  // ===================

  // 防止作用域污染,使用完之后删除
  delete obj.fn
  // 执行完成后返回结果
  return result
}

apply

说完了call 再说下 apply ,其实这两个方法功能基本上都是相同的,不同的地方是传参方式不同,
call 接收一个参数列表作为参数, apply 方法接受的是一个包含多个参数的数组。差别不大我们直接把 实现的 newCall 方法拿过来改改就好了,所以这里不在过多叙述实现过程了,直接上代码:


Function.prototype.newApply = function (obj, arr) {
  var obj = obj || window
  var result
  obj.fn = this
  if (!arr) {
    obj.fn()
  } else {
    // ES 5 实现方式 =========
    var args = []
    for (var i = 0; i < arr.length; i++) {
      args.push('arr['+ i +']')
    }
    result = eval('obj.fn('+ args +')')
    // =========

    // ES6 =========
    // result = obj.fn(...arr)
    // =========
  }
  delete obj.fn
  return result
}

bind

  1. bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被bind的第一个参数指定,其余的参数将作为新函数的参数供调用时使用
  2. bind() 绑定的函数 支持 new 实例化

完整代码如下:

Function.prototype.newBind = function (obj) {
  var self = this
  // 截取绑定时的参数
  var arge1 = Array.prototype.slice.call(arguments, 1)
  // 定义中专构造函数,用于通过原型连接绑定后的函数和调用 bind 的函数
  var F = function () {}
  // 定义返回的新函数
  var newF = function () {
    // 截取调用时的参数
    var arge2 = Array.prototype.slice.call(arguments)
    // 参数合并
    var argeSum = arge1.concat(arge2)
    // 支持 new 实例化, 判断是否使用 new 关键字
    // 改变作用域,注:aplly/call是立即执行函数,即绑定会直接调用
    if (this instanceof F) {
      return self.apply(this, argeSum)
    } else {
      return self.apply(obj, argeSum)
    }
  }
  // 将调用函数的原型赋值到中专函数的原型上
  F.prototype = self.prototype
  // 通过原型的方式继承调用函数的原型
  newF.prototype = new F
  return newF
}

这是《JavaScript Web Application》一书中对 bind() 的实现:通过设置一个中转构造函数 F,使绑定后的函数与调用 bind() 的函数处于同一原型链上,用 new 操作符调用绑定后的函数,返回的对象也能正常使用 instanceof,因此这是最严谨的 bind() 实现

然后我们来验证一下

function person (a, b, c, d) {
  console.log(this.name) // 渣渣辉
  console.log(a, b, c, d) // 1, 2, 3, 4
  // 有返回值
  return {
    name: this.name,
    a: a, b: b, c: c, d: d
  }
}

let obj = {
  name: '渣渣辉'
}


person.newBind(obj, 1, 2, 3)(4)
var bb = person.newBind(this, '点赞', '收藏')
var aa = new bb('充电')

// ==================

var people = {
  name: '张三',
  getName: function (age) {
    return this.name + age
  }
}

var temp = people.getName
var context = temp.newBind(people)
console.log(context('18岁')) // 张三18岁

都能正确运行。