趣談js的bind牌膠水

hanmin發表於2018-05-14

前言

今天聊一聊js中的bind方法,主要從三個維度來闡述:why——>what——>how。文章雖經個人多次校驗,對語言表述、程式碼書寫等進行了認真稽核,但仍免不了有疏漏之處,如若發現,還望指出,鄙人將審而改之,如若有不爽之處,還望輕噴,理性交流,共同進步也。

Why ???——> 為什麼會誕生bind?

1. 背景講解bind誕生的原因:

bind是ECMAscript5新增的一個方法,ECMAscript是js的程式語言實現(詳情可閱相關資料),ECMAscript5是當前主流瀏覽器的通用支援版本,這個版本的出現很大程度上是為了解決js這門語言在誕生到發展過程中出現的大量問題而提出的解決方案版本,在早期,js定位為“網頁小助手”語言,只負責做簡單的校驗表單欄位小活,一度還淪為廣告彈框專屬語言,因為其尷尬的定位,所以js充滿各種意想不到的坑,大家一直也不怎麼重視它,直到基於Ajax技術的Gmail專案誕生(Gmail專案不是直接原因,這裡只是藉機聊下js歷史),大家才發現利用js可以做出這麼多牛逼的互動,一時間,各大公司蜂擁而至,大公司的專案往往預示著專案的複雜和多人協作,當專案一複雜後大家發現js的缺點就暴露出來了,js雖然在其名裡面包含了Java,但其命名只屬於取巧沾光,Java物件導向程式設計的特性可沒被js吸收,js語言更具函數語言程式設計特性,函式為js語言的一等公民,當函式越寫越多之時,管理他們的藝術就被提上了檯面,為了複雜專案開發的規範化、統一化,js迫切需要引入物件導向的相關思想,但物件導向屬於語言靈魂層次,js作為函數語言程式設計使用了這麼多年,不可能想改就改靈魂層次的東西,為了兼顧函數語言程式設計的靈活和麵向物件程式設計的規範,js開發的相關組織做了很多努力,其中一個努力就是創造出了bind、call、apply三個媒婆,這三個媒婆的共同作用就是為js的一等公民Function函式找個門當戶對的人家(指明Function函式的this指向)。

2. 程式碼講解bind誕生的原因:

我定義了一個類:

  var Xiaoming = {
    name: '小明',
    sayHi() {
      console.log('hello ' + this.name);
    }
  }
複製程式碼

如果我想使用這個類的sayHi功能,一開始,我想到的是直接拿來就用:

  var Xiaoming = {
    name: '小明',
    sayHi() {
      console.log('hello ' + this.name);
    }
  }

  var sayHi = Xiaoming.sayHi;
  sayHi(); // hello 
複製程式碼

不出意外,將會輸出hello(在嚴格模式下將會直接報出cannot read property 'name'錯誤),原因就是如果直接拿來用,這裡的this將會隱式指向到全域性window物件,而全域性物件中並沒有name屬性。在js中,當沒有明確指定this的情況下,置於全域性環境下的函式的this將會是window(注:瀏覽器環境下為window,node環境下為global,其它宿主環境本篇不做解釋,本篇文章涉及的宿主環境都是瀏覽器)。

function func() {
  console.log(this.toString());  // [object Window]
}

func();
複製程式碼

window是全域性環境下this的最終歸屬(如果你無家可歸,你的家就是這片天地),如果我們想給這些無家可歸的可憐函式找一個歸屬,我們需要一箇中介來牽線搭橋,bind就是那個中介之一,bind在js中充當粘合劑的作用,他負責把指定的類和Function函式強力的貼上在一起:

  var Xiaoming = {
    name: '小明',
    sayHi() {
      console.log('hello ' + this.name);
    }
  }

  var Jack = {
    name: '傑克'
  };

  var sayHello = Xiaoming.sayHi.bind(Jack);
  sayHello(); // hello 傑克
複製程式碼

當我們用bind貼上劑把sayHi方法和Jack類貼上在一起時,sayHello函式的this就指向Jack類了,所以輸出的結果就是hello 傑克

What ???——> 什麼是bind?

1. 漢語釋義:

vt. 綁;約束;裝訂;包紮;凝固 vi. 結合;裝訂;有約束力;過緊

在漢語釋義中,bind的大體意思就是繫結、結合,我個人給其在js中的定義為膠水(注意膠水二字!)。當我想給一個函式換一個新宿主之時,我就取出“bind牌膠水”把想用的函式和它的新宿主貼上在一起,然後再呼叫這個用“bind牌膠水”貼上的擁有新宿主的新函式。

2. MDN釋義:
  • English:

The bind( ) method creates a new function that, when called, has its this keyword set to the provided value, with a given sequence of arguments preceding any provided when the new function is called.

  • 中文:

bind( )方法建立了一個新函式,當新函式被呼叫之時,將其this指向到指定的值,同時會通過bind傳入一串預設引數序列供新函式使用。

在這段定義中我抽出了幾個細節:

1. 建立了一個新函式 ——> 這句話很關鍵,這也是我把bind定義為膠水的原因,這句話以下幾點須注意:
  • 使用bind,它不會破壞原先的宿主(意即:不是把函式從原先的宿主中刪除掉):
  var Xiaoming = {
    name: '小明',
    sayHi() {
      console.log('hello ' + this.name);
    }
  }

  var Jack = {
  name: '傑克'
};

  var sayHello = Xiaoming.sayHi.bind(Jack);
  console.log(xiaoming); // {name: "小明", sayHi: ƒ}
複製程式碼
  • 使用bind,會建立一個新函式,意即:把指定的函式從原先的宿主中“複製”一份成新函式,然後通過“bind牌膠水”把指定的宿主和新函式貼上在一起。這個新函式的this將會指向到指定宿主,而且和之前的舊宿主撇清了關係,實現了和指定宿主的結合。注意:1、不是把函式繫結到指定宿主上;2、這裡的繫結是“按址繫結”,不是copy了一份指定宿主,所以當這個貼上的指定宿主發生改變時,使用“bind牌膠水”貼上的新函式也會受影響:
var Xiaoming = {
  name: '小明',
  sayHi() {
    console.log('hello ' + this.name);
  }
}

var Jack = {
  name: '傑克'
};

var sayHello = Xiaoming.sayHi.bind(Jack);
sayHello(); // hello 傑克
Jack.name = '皆可'; // 改變新宿主的name屬性
sayHello(); // hello 皆可    <—— 當新宿主發生改變時,對應的輸出也會受影響

/* 並沒有把函式繫結到新宿主上 */
Jack.sayHi(); // error: Uncaught TypeError: Jack.sayHi is not a function
Jack.sayHello(); // error: Uncaught TypeError: Jack.sayHello is not a function
複製程式碼
2. 一串預設引數序列供新函式使用

“bind牌膠水”的主要作用是給指定函式繫結指定this,第一個引數即指定的新宿主,其後的剩餘引數為預設引數,既然第一個引數已經達到了目的,為什麼還要在其後加一些預設引數呢?這裡要注意引數的預設二字,預設表示預先設定給新函式的引數,通過bind預設的引數將會比新函式自己設定的引數預先使用。看程式碼:

    var obj1 = {
      name: 'han',
      sayHi(word1, word2) {
        console.log('hello' + this.name + ',' + word1 + ',' + word2);
      }
    };
    
    var obj2 = {
      name: '李'
    };
    var func = obj1.sayHi.bind(obj2, '早上好');
    func('good morning'); // hello 李,早上好, good morning
    func(); // hello 李,早上好, undefined
複製程式碼

通過程式碼我們發現這個預設引數和預設引數有點類似(但其實完全不是一回事!),因為通過bind預設的引數總是先被呼叫,而使用新函式時自定義的引數總是等預設引數呼叫後再被呼叫,類似的概念(先進先出)。這個預設引數的設計,我個人覺得略顯尷尬,可能是因為js的函式之前沒有預設引數的設定導致的吧(不甚瞭解)?這裡用預設引數個人覺得會更合適。

How ???——> 怎麼使用bind?

1. 在事件繫結中使用:

在改變this指向的方法中,存在著三個方法:bind、call、apply,bind屬於“靜態繫結”,作為膠水,bind只負責貼上函式,不負責貼上之後的函式的執行,但call和apply卻不是,他們給函式繫結this後還把繫結後的函式給當場執行了。因為這個特性,我們在給事件繫結函式時只能使用bind來進行this的繫結(因為給事件繫結的函式不需要我們手動執行,它是在事件被觸發時由JS 內部自動執行的),看程式碼:

  <button id="btn">Click Me</button>
複製程式碼
  var obj = {
    thing: '搞點事情'
  };
  function onBtnClick() {
    console.dir('我被點選了,我想' + this.thing);
  }
  var btn = document.getElementById('btn');
  btn.addEventListener('click', onBtnClick.bind(obj)); // 我想搞點事情 
複製程式碼

2. 給迷失的函式找回自我:

在js程式設計中,經常會出現使用var that = this;的hack黑魔法來給函式找回自我,具體場景如下:

  var obj = {
    datas: ['jack'],
    resolveDatas: function() {
      var that = this;
      this.datas.forEach(function(val) {
        that.name = val;
        console.log(this); // Window {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, frames: Window, …}
        console.log(that); // {datas: Array(1), resolveDatas: ƒ, name: "jack"}
      });
      console.log(this.name); // jack
    }
  }
  obj.resolveDatas();
複製程式碼

在上面的程式碼中,當在內部函式內部使用匿名函式時,this將會指向到全域性window物件,為了避免這個問題,在函式內部通過var that = this宣告瞭一個變數,然後在forEach的匿名函式中呼叫,為什麼要這樣使用?這是因為在沒出現ES6的箭頭函式之前,js存在著一個“任性this”,關於js中this的複雜度,在《你不知道的js(上卷)》中寫到:

this 關鍵字是js 中最複雜的機制之一。它是一個很特別的關鍵字,被自動定義在 所有函式的作用域中。但是即使是非常有經驗的js 開發者也很難說清它到底指向 什麼。 任何足夠先進的技術都和魔法無異。 ——Arthur C. Clarke

關於js中this的指向黑魔法問題這裡只略提,具體可查閱相關權威資料。在上面的程式碼中,我們發現一個啼笑皆非的現實:好好的一個函式,咋包了一層函式後就找不到設想中的那個this了呢?本以為自己把this指向到了當前的obj物件,一到用的時候就直接“認賊作父”了,把this指向到了window物件,what the hell?,如何避免這種悲劇?讓我們有請“bind牌膠水”隆重登場,作為專業的“this硬繫結”方法,bind用起來妥妥的:

  var obj = {
    datas: ['jack'],
    resolveDatas: function() {
      this.datas.forEach((function(val) {
        this.name = val;
        console.log(this); // {datas: Array(1), resolveDatas: ƒ, name: "jack"}
      }).bind(this));
      console.log(this.name); // jack
    }
  }
  obj.resolveDatas();
複製程式碼

在上面的程式碼中,我們通過“bind牌膠水”把真正想用的物件貼上給了匿名函式,從而讓匿名函式能夠堅持自我,但在這裡個人覺得這種硬繫結是一種笨拙的hack方法,因為針對這種詭異問題竟然要用膠水進行“修補”,個人覺得其實很low,所以不予提倡,Es6提出箭頭函式才是專業應對該問題的合適方案:

  var obj = {
    datas: ['jack'],
    resolveDatas: function() {
      this.datas.forEach((val) => {
        this.name = val;
        console.log(this); // {datas: Array(1), resolveDatas: ƒ, name: "jack"}
      });
      console.log(this.name); // un
    }
  }
  obj.resolveDatas();
複製程式碼

關於bind的使用方,我這裡只列舉出了兩個,更多的使用場景還有很多,可以查閱相關資料。

後語

花了好幾天時間終於寫完了這篇文章,希望相關內容能給大家帶來一些啟發和感悟。一般講bind的時候都會把call和apply放在一起聊,我本有此意,但考慮到自己的囉嗦話語,內容過長,所以還是分開講解,下一篇文章我來聊聊apply和call方法(因為這兩個方法就是孿生兄弟,所以一起講再合適不過了),然後到下篇再對bind、apply、call三者進行對比來闡述,若知後事如何,且聽下回分解!

相關文章