bind 函式的實現原理

陳永森發表於2018-07-04

最近在看《你不知道的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》(上卷)



相關文章