bind为什么可以永久绑定this

3,310 阅读6分钟

bind用来干嘛的?bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。前置知识:

  • 闭包:
    • 外部持有对函数原始定义作用域的引用,即为闭包(例如常见的异步回调函数的场景)
    • 常驻内存,不污染全局,不会被垃圾回收器回收(例如IIFE模块机制)
  • this:
    • ES6以前,在函数调用的时候发生this绑定,既不指向函数自身也不指向函数的词法作用域
    • ES6新增一种箭头函数,不遵循函数调用发生的绑定逻辑,而是根据外层(函数或者全局)作用域(词法作用域)来决定

为什么需要bind?

经典的面试题:

var name = 'window',obj = { name: 'Jacky', whatIsYourName: whatIsYourName }

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

obj.whatIsYourName() // 'Jacky'

var test = obj.whatIsYourName
test() // 'window',虽然test是obj.whatIsYourName的一个引用,但是实际上,它引用的是whatIsYourName函数本身。

在react收集dom属性的回调函数时,类似onClick之类的,需要修改为onclick,其中需要重新赋值给一个临时变量,类似var test = obj.whatIsYourName,在复杂的应用中,赋值过程中很有可能就丢失了this的指向,导致找不到变量的问题,所以bind是必须的。

再看另一道面试题:

var name = 'window',obj = { name: 'Jacky', whatIsYourName: whatIsYourName }

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

function passParams(fn){ 
  // fn.bind(obj)() 'Jacky'
  fn()
}

passParams(obj.whatIsYourName) // 'window'

传入fn参数执行导致this丢失,出现在各种异步回调函数中,是非常常见的,通过bind解决丢失问题。

bind的实现

Function.prototype.apply = function(ctx,...args){
  const context = ctx || global
  const hash = +new Date() // 避免重名
  context[hash] = this // 缓存this,调用aplly后删掉
  const result = context[hash](...args) // 利用扩展运算符,call和apply没有差别
  delete context[hash]
  return result
}

Function.prototype.bind = function(context, ...args){
  // 错误处理
  if(typeof this !== 'function'){
    throw new TypeError('invalid invoked!')
  }
  // 闭包中self字段记录调用bind的函数,args记录预定义的参数
  var self = this
  return function F(...rest){
    // 返回函数的执行结果
    // 判断函数是作为构造函数还是普通函数
    // 构造函数this instanceof F返回true,将绑定函数的this指向该实例,可以让实例获得来自绑定函数的值。
    // 当作为普通函数时,this 指向 window,此时结果为 false,将绑定函数的 this 指向 context
    if(this instanceof F){
      // return new self(...args, ...rest)
      return New(self)(rest.concat(args))
    }
    return self.apply(context, rest.concat(args))
  }
}

// new操作符一般经历以下四个步骤
// 1.创建一个新的对象;
// 2.将构造函数的作用域赋值给新对象(因此this就指向了这个新对象)
// 3.执行函数中的代码(为这个对象添加新的属性)
// 4.返回新的对象
function New(f){
  return function(...args){
    var o = { __proto__: f.prototype }
    f.apply(o, ...args)
    return o
  }
}

通过debugger查看bind的运行过程

在执行到var test = obj.whatIsYourName.bind(obj)的时候,test作为外部引用,可以访问bind函数闭包缓存的context、args和this变量:

当第一个test执行bind完毕后,test就是一个闭包,其中的变量context、args和this已被锁定在了test中,接下来执行test()实际执行的是self.apply(context, rest.concat(args)),把闭包中缓存的数据传入,apply中用context[hash]动态记录了self函数,也就是最起初被bind的函数function whatIsYourName(){ console.log(this.name) },通过const result = context[hash](...args)调用实际被bind的函数

简化一下apply中的调用,其实就是利用闭包把目标函数function whatIsYourName(){ console.log(this.name) }和调用目标函数的contextobj = { name: 'Jacky', whatIsYourName: whatIsYourName }缓存了下来:

var obj = { name: 'Jacky', whatIsYourName: whatIsYourName }

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

Function.prototype.apply = function(ctx,...args){
  const context = ctx || global
  const hash = +new Date() // 避免重名
  context[hash] = this // 缓存this,调用aplly后删掉
  const result = context[hash](...args) // 利用扩展运算符,call和apply没有差别
  delete context[hash]
  return result
}

function apply(){
    var context = {
        '1611578238466': whatIsYourName,
        name: 'Jacky',
        whatIsYourName: whatIsYourName
    }
    context['1611578238466']() // 'Jacky'
}

跟第一道面试题中所示的一样,在目标contextobj = { name: 'Jacky', whatIsYourName: whatIsYourName }上动态挂载一个不重复的key,引用this,再利用动态挂载的key调用目标函数function whatIsYourName(){ console.log(this.name) },这样目标函数中的this就只想了context中,从而改变了this的指向。接下来看看第二次bind执行的过程var test1 = test.bind(window)

其中context是传入的window对象,this指向的是调用者test,也就是上一次bind的返回值,写作f F(...rest)的一个匿名函数,然后返回一个新的匿名函数f F(...rest)作为test1的值,需要注意的是,这个新的匿名函数中self指向的是上一个匿名函数,这是关键,接下来进入test1的执行环节:

在Watch中添加context和self的变量监听,可以清楚地看见,self指向上一次返回的匿名函数f F(...rest),context指向传入的参数window,进入apply的调用:

根据简化后的apply调用可以知道,实际的调用的是:

function apply(){
    var context = {
        '1611628513407': f F(...rest),
        ...其他的window属性
    }
    context['1611628513407']() // 执行上一次bind的结果
}

当执行上一次bind结果的时候,由于闭包的特性,test持有对函数原始定义作用域的引用,可以看见context和self变成了第一次bind的参数obj,self也变成第一次bind的调用者f whatIsYourName(),至此水落石出,接下来的逻辑自然也就是走的第一次bind的逻辑,并且以第一次bind的执行结果作为之后的返回值。

那么第二次传入的参数window到哪里去了呢?

答案是:window只是作为了一个挂载函数的中间商,本来如果像第一次bind的那样,传入一个类似function whatIsYourName(){ console.log(this.name) }的函数,在调用这个函数的时候,this就会指向window从而改变this,然而传入的是一个本就持有对函数原始定义作用域引用的闭包函数,自然也就没了window什么事了,回归到了第一次bind的逻辑。

总结一下:

  • call、apply之所以可以改变this,是利用了函数在调用时才发生this绑定,既不指向函数自身也不指向函数的词法作用域的特性,当函数引用有上下文对象时,隐式绑定规则会把函数中的this绑定到这个上下文对象,也就是apply()中的context对象。需要注意的是对象属性引用链中只有上一层或者说最后一层在调用中起作用。

  • bind之所以可以永久绑定this,是利用了闭包的特性,缓存了第一次bind时的context对象,以至于之后的bind不会改变第一次调用的逻辑。

文中的实现比较粗糙,没有考虑很多边界情况,各位看官将就着看,如果觉得有收获的话,不要吝啬您的一个小赞👍