bind 函数的实现原理

7,348 阅读6分钟

最近在看《你不知道的JavaScript》系列,看到这个地方的时候,第一眼没对上,没有确认过的眼神,所以就带着疑惑,深入解析一下,做了一份学习总结。

Function.prototype.bind

引用 MDN

bind() 方法创建一个新的函数, 当被调用时,将其 this 关键字设置为提供的值,在调用新函数时,在任何提供之前提供一个给定的参数序列。

语法:

  fun.bind(thisArg[, arg1[, arg2[, ...]]])

参数:

thisArg:当绑定函数被调用时,该参数会作为原函数运行时的 this 指向。当使用 new 调用绑定函数时,该参数无效。

arg1, arg2, ...:当绑定函数被调用时,这些参数将置于实参之前传递给被绑定的方法。

返回值:

返回由指定的 this 值和初始化参数改造的原函数拷贝

从上面的定义来看,bind 函数有哪些功能:

  • 改变原函数的 this 指向,即绑定 this

  • 返回原函数的拷贝

  • 注意,还有一点,当 new 调用绑定函数的时候,thisArg 参数无效。也就是 new 操作符修改 this 指向的优先级更高

bind 函数的实现

bind 函数的实现,需要了解 this 的绑定。this 绑定有 4 种绑定规则:

  • 默认绑定

  • 隐式绑定

  • 显式绑定

  • new 绑定

四种绑定规则的优先级从上到下,依次递增,默认绑定优先级最低,new 绑定最高。今天我们来讨论一下显式绑定。

显式绑定就是,运用 apply(...)call(...) 方法,在调用函数时,绑定 this,也即是可以指定调用函数中的 this 值。例如:

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

这是不是 bind 函数的功能之一,修改 this 的绑定?如果我们将上面的例子修改一下:

  
  Function.prototype.myBind = function(oThis) {
      if(typeof this !== 'function') {
          return;
      }
      var self = this,
          args = Array.prototype.slice.call(arguments, 1);
      return function() {
          return self.apply(oThis, args.concat(Array.prototype.slice.call(arguments)));
      }
  }
  function foo() {
      console.log(this.a);
  }
  var obj = { a: 2 };
  var bar = foo.myBind(obj);
  bar();      // 2

这便是一个简易版的 bind 函数了,已实现了原生 bind 函数的前两个功能点了。

但是,如果遇到 new 调用绑定函数(注意这里哈,是绑定之后的函数)的时候,结果会是怎样呢?

  
  function foo(name) {
      this.name = name;
  }
  var obj = {};
  var bar = foo.myBind(obj);
  bar('Jack');
  console.log(obj.name);      // Jack
  var alice = new bar('Alice');
  console.log(obj.name);      // Alice
  console.log(alice.name);    // undefined

我们发现,new 调用绑定函数,并不会更改 this 的指向,我们简易版能做的,只是永久绑定指定的 this

如何实现原生 bind 的第三个功能点呢?

实现之前,我们来了解一下,new 操作符在调用构造函数的时候,会进行一个什么样的过程:

  • 创建一个全新的对象

  • 这个对象被执行 [[Prototype]] 连接

  • 将这个对象绑定到构造函数中的 this

  • 如果函数没有返回其他对象,则 new 操作符调用的函数则会返回这个对象

这可以看出,在 new 执行过程中的第三步,会对函数调用的 this 进行修改。在我们简易版的 bind 函数里,原函数调用中的 this 永远执行指定的对象,而不能根据如果是 new 调用而绑定到 new 创建的对象。所以,我们要对原函数的调用进行判断,是否是 new 调用。我们再对简易版 bind 函数进行修改:

  
  Function.prototype.myBind = function(oThis) {
      if(typeof this !== 'function') {
          return;
      }
      var self = this,
          args = Array.prototype.slice.call(arguments, 1),
          fBound = function () {
              return self.apply(
                  // 检测是否是 new 创建
                  (this instanceof self ? this : oThis),
                  args.concat(Array.prototype.slice.call(arguments))
              );  
          };
      // 思考下为什么要链接原型?提示:如果不连接,上面的检测是否会成功
      if(this.prototype) {
          fBound.prototype = this.prototype;
      }
      return fBound;
  }
  // 测试
  function foo(name) {
      this.name = name;
  }
  var obj = {};
  var bar = foo.myBind(obj);
  bar('Jack');
  console.log(obj.name);  // Jack
  var alice = new bar('Alice');
  console.log(obj.name);  // Jack
  console.log(alice.name);    // Alice

经过修改之后,此时我们发现, myBind 函数已经实现原生 bind 函数的功能。在上述代码中,留下一个问题,在这里讲一下:

  • 首先,变量 bar 是绑定之后的函数,也就是 fBoundself 是原函数 foo 的引用。

  • 对于 fBound 函数中的 this 的指向,如果是 bar('Jack') 这样直接调用,this 指向全局变量或者 undefined (视是否在严格模式下)。但是如果是 new bar('Alice') ,根据上面给出的 new 执行过程,我们知道,fBound 函数中的 this 会指向 new 表达式返回的对象,即 alice

  • 捋清楚变量之后,我们接着分析。我们首先忽略掉原型连接,也即忽略 fBound.prototype = this.prototype 这行代码。

  • 如果是直接调用 bar('Jack')this instanceof self ? this : oThis 这句判断,根据上述变量分析,所以此判断为 false,绑定函数的 this 指向 oThis,也即是指定的 this 对象。

  • 如果是 new 调用绑定函数,此时绑定函数中的 this 是由 new 调用绑定函数返回的实例对象,这个对象的构造函数是 fBound,当我们忽略掉原型连接那行代码时,其原型对象并不等于原函数 self 的原型,所以 this instanceof self ? this : oThis 得到的值还是指定的对象,而不是 new 返回的对象。

  • 所以,知道为什么要在绑定的时候,绑定函数要与原函数进行原型连接了吧?每次绑定的时候,将绑定函数 fBound 的原型指向原函数的原型,如果 new 调用绑定函数,得到的实例的原型,也是原函数的原型。这样在 new 执行过程中,执行绑定函数的时候对 this 的判断就可以判断出是否是 new 操作符调用

好了,到这基本结束了。

哦,是么?

等等,在原型连接的时候,你们是否发现 fBound.prototype = this.prototype 这赋值是有问题的?

哦,对哦。

当绑定函数直接连接原函数的原型的时候,如果 fBound 的原型有修改时,是不是原函数的原型也会受到影响了?所以,为了解决这个问题,我们需要一个空函数,作为中间人。

  Function.prototype.bind = function(oThis) {
      if (typeof this !== 'function') {
        // closest thing possible to the ECMAScript 5
        // internal IsCallable function
        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
                   ? this
                   : oThis,
                   // 获取调用时(fBound)的传参.bind 返回的函数入参往往是这么传递的
                   aArgs.concat(Array.prototype.slice.call(arguments)));
          };
  ​
      // 维护原型关系
      if (this.prototype) {
        // Function.prototype doesn't have a prototype property
        fNOP.prototype = this.prototype; 
      }
      fBound.prototype = new fNOP();
  ​
      return fBound;
  };

上述代码是 MDN 提供 bind 函数的 Polyfill 方案,里面的细节我们都分析完毕了,到这基本理解 bind 函数实现的功能的背后了。

主要的知识点:

  • this 的绑定规则

  • new 操作符执行过程

  • 原型

参考书籍:

  • 《你不知道的 JavaScript》(上卷)