前端戰五渣學JavaScript——call、apply以及bind

戈德斯文發表於2019-03-28

寫這篇部落格之前,我想先說下今天(2019年3月28日)一直關注的一件事吧(出於湊熱鬧的心情——尷尬)。在昨天,全球最大交友網站Github上悄然出現一個名為996.ICU的文件專案,整個專案沒有程式碼,只是列了一些《勞動法》的條款和最近表明實行996工作制的公司。本來以為是一個小打小鬧的抱怨,結果今天中午再看的時候star數已經有30k以上,並且issues達到5000+。下午更是勢如破竹,在Github的star排行榜上,一路過五關斬六將,截止目前,這個出現不到24小時的專案,坐擁63k的star,並且排行榜第21名。為什麼一個這麼簡單的專案會異軍突起,伴著屠榜的架勢,一發不可收拾。也許這只是觸動了被強行996工作的朋友們,以及無休止的加班沒有回報的程式設計師們心中那最敏感的神經,可能迫於生計問題,現實生活中只能忍氣吞聲,但當出現一個虛擬的世界可以讓你盡情發洩的時候,心中的苦水傾瀉而出,造就了這個怪異的專案。我們不是不能接受996,是要實行996工作制公司得付的出相應的報酬,這讓員工感覺自己的付出是有回報的,既沒有相應的酬勞,又沒有自己的時間,怨氣只會越攢越多。我們現在能做什麼:一、儘量不去996的公司,讓996的公司無人可招;二、提高自己的技術水平,讓自己擁有議價的主導權,非要實行996,能談出你可以接受的薪酬。以上是我個人看法,不喜勿噴。(還是那句。。。錢給到位,住公司都行)

What is this?

What is this?這是什麼?this是什麼?(黑人問號臉)
今天的主題(??)是call、apply以及bind,這裡這個以及我覺得用的很好,後面我會解釋為什麼不把bindcall、apply歸為一類。

this物件是在執行時基於函式的執行環境繫結的(拋開箭頭函式)
當函式被作為某個物件的方法呼叫時,this等於那個物件
this等於最後呼叫函式的物件

讓我們來for example ⬇️

var name = 'Jack Sparrow';

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

sayWhoAmI(); // Jack Sparrow

var onePiece = {
  name: 'Monkey·D·Luffy',
  sayWhoAmI: function () {
    console.log(this.name)
  }
};

onePiece.sayWhoAmI(); // Monkey·D·Luffy
複製程式碼

上面的程式碼我們可以看出,不管定義在哪的sayWhoAmI()方法,函式體是一樣的,onePiece.sayWhoAmI()根據上面說的可以理解:
∵(因為,下同)呼叫方法的最後那個物件就是onePiece
∴(所以,下同)thisonePiecethis.name就是onePiece.name
但是為什麼全域性定義的sayWhoAmI方法輸出的是Jack Sparrow,那我換種寫法可能大家就明白了 ⬇️

var name = 'Jack Sparrow';

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

- sayWhoAmI(); // Jack Sparrow
+ window.sayWhoAmI(); // Jack Sparrow
複製程式碼

這樣是不是清晰明瞭了
∵ 在全域性宣告的變數或者函式,都是在window或者globle這個物件裡的
∴ 在window全域性下宣告的sayWhoAmI可以輸出同是window全域性下宣告的name

小進階

簡單的我們已經明白了,現在我們來看看加入return的方法,我覺得算是有點難度的了,大佬請飄過 ⬇️

var area = 'East Ocean';

var onePiece = {
  area: 'New World',
  tellMeWhereAreYou: function () {
    return function () {
      console.log(this.area);
    }
  }
};

onePiece.tellMeWhereAreYou()(); // East Ocean
// 如果看不懂這裡為什麼執行兩次,或者不明白為什麼輸出的全域性變數
// 那我引入一箇中間變數,讓過程多一步就能看懂了
var grandLine = onePiece.tellMeWhereAreYou();
// 這時候的 grandLine = function() { console.log(this.area); },等於onePiece.tellMeWhereAreYou();返回的函式
// 因為grandLine是一個全域性變數,所以this.area返回的是East Ocean
grandLine(); // East Ocean
複製程式碼

上面我覺得用了言簡意賅的方法解釋了一下這個問題,因為這個涉及到閉包的知識,以及函式的活動物件,不明白的可以看我的另一篇部落格《前端戰五渣學JavaScript——閉包》,如果還不懂,還想更深入的瞭解可以自行翻閱《JavaScript高階程式設計》有關閉包的7.2章節,弄明白7.2章節中的兩張圖。

那麼現在問題來了,我怎麼才能讓這個函式輸出我物件內部的area: 'New World' ⬇️

var area = 'East Ocean';

var onePiece = {
  area: 'New World',
  tellMeWhereAreYou: function () {
    var that = this;
    // 我們通過宣告一個變數來儲存this所指向的物件,然後再閉包中,就是返回的函式中使用
    // 一個典型的閉包結構就完成了
    return function () {
      console.log(that.area);
    }
  }
};

onePiece.tellMeWhereAreYou()(); // New World
複製程式碼

可能大家之前工作中會用到中間變數來儲存this的這種方法,而且我感覺也不難,那我就跳過了。

我們現在應該大體搞明白了this指向的問題了。但是我們就是變態,我們有病,我們終於搞明白了this的指向問題,那我們現在又想改變this指向,?人生處處是艱難啊

這時候我們就需要用到標題中提到的callapply

Apply nothing and just call me

call()方法與apply()方法的作用相同,它們的區別僅在於接收引數的方式不同。————————《JavaScript高階程式設計》

書裡面說的很清楚,它們兩個的作用是一樣的,只是接收引數的方式不同,那到底有什麼區別呢,聽我我細細道來

瘋狂打call

call()方法可以指定一個this的值(第一個引數),並且分別傳入引數(第一個引數後面的就是需要傳入函式的引數,需要一個一個傳)

call()方法到底有什麼用呢,自然是解決我們剛才提出來的改變this指向,怎麼用呢???⬇️

var first = '大黑刀·夜',
    second = '二代鬼徹',
    third = '初代鬼徹',
    fourth = '時雨';

var zoro = {
  first: '和道一文字',
  second: '三代鬼徹',
  third: '雪走',
  fourth: '秋水'
};

function sayYourWeapon(num, num2) {
  console.log(`這是我${num}得到的刀"${this[num]}"`)
  console.log(`這是我${num2}得到的刀"${this[num2]}"`)
}

sayYourWeapon('first', 'third'); // 這是我first得到的刀"大黑刀·夜";這是我third得到的刀"初代鬼徹"
sayYourWeapon.call(zoro, 'first', 'fourth'); // 這是我first得到的刀"和道一文字";這是我fourth得到的刀"秋水"
複製程式碼

上面這段程式碼很明顯的改變了this的指向,如果我直接呼叫sayYourWeapon()必然輸出的是全域性全域性變數firstthird的值,而我後面通過sayYourWeapon.call(zoro, 'first', 'fourth')中的call()方法
∵ 改變了函式中的this值,就是傳入的zoro,把this值從全域性物件改成了zoro物件
∴ 後面輸出的也都是物件zoro中的'first', 'fourth'的值

apply所有配置

apply()方法可以指定一個this的值(第一個引數),並且傳入引數陣列(引數需要在一個陣列或者類陣列中)

我們應該已經是知道了call()方法怎麼用了,那我們熟悉apply()就簡單多了,我們可以把上面的例子改一下⬇️

var first = '大黑刀·夜',
  second = '二代鬼徹',
  third = '初代鬼徹',
  fourth = '時雨';

var zoro = {
  first: '和道一文字',
  second: '三代鬼徹',
  third: '雪走',
  fourth: '秋水'
};

function sayYourWeapon(num, num2) {
  console.log(`這是我${num}得到的刀"${this[num]}"`)
  console.log(`這是我${num2}得到的刀"${this[num2]}"`)
}

sayYourWeapon('first', 'third'); // 這是我first得到的刀"大黑刀·夜";這是我third得到的刀"初代鬼徹"
- sayYourWeapon.call(zoro, 'first', 'fourth'); // 這是我first得到的刀"和道一文字";這是我fourth得到的刀"秋水"
+ sayYourWeapon.apply(zoro, ['first', 'fourth']); // 這是我first得到的刀"和道一文字";這是我fourth得到的刀"秋水"
複製程式碼

可以看到,我全篇就只是把call改成了apply,並且把之前'first', 'fourth'這麼傳進去的引數改成了['first', 'fourth']一個陣列。如果我們是在一個函式當中使用,那我們還可以直接使用arguments這個類陣列物件⬇️

var first = '大黑刀·夜',
    second = '二代鬼徹',
    third = '初代鬼徹',
    fourth = '時雨';

  var zoro = {
    first: '和道一文字',
    second: '三代鬼徹',
    third: '雪走',
    fourth: '秋水'
  };

  function sayYourWeapon(num, num2) {
    console.log(`這是我${num}得到的刀"${this[num]}"`)
    console.log(`這是我${num2}得到的刀"${this[num2]}"`)
  }

  function mySayYourWeapon(num, num2) {
    sayYourWeapon.apply(zoro, arguments) // 我們自己宣告一個函式,並且在裡面呼叫apply,這是我們只需要傳入arguments這個引數,而不需要想call那樣一個一個傳進去了
  }

  sayYourWeapon('first', 'fourth'); // 這是我first得到的刀"大黑刀·夜";這是我fourth得到的刀"時雨"
  mySayYourWeapon('first', 'fourth'); // 這是我first得到的刀"和道一文字";這是我fourth得到的刀"秋水"
複製程式碼

羈bind祕密

文章開頭我說過這樣一句話⬇️

call、apply以及bind,這裡這個以及我覺得用的很好

現在我們就來聊聊這個‘以及’的內涵
我為什麼說‘以及’呢,因為bindcall、apply這兩個方法的使用有一丟丟的不一樣。上面我們一個函式呼叫.call()或者.apply()方法,方法會立即執行,如果函式有返回值會獲得返回值,但是bind不一樣
bind()方法不會立即執行目標函式,而是返回一個原函式的拷貝,並且擁有指定this值和初始函式(為什麼是指定的,當然是我們自己傳進去的啦)

什麼叫原函式的拷貝呢,那我們先來看一下⬇️

function a() {}

console.log(typeof a.bind() === 'function'); // 返回是true,先證明a.bind()是一個函式
console.log(a.bind()); // 輸出function a() {},跟原函式一樣
console.log(a.bind() == a); // false
console.log(a.bind() === a); // false 不管是 === 還是 == 都是false,證明是拷貝出來一份而不是原先的那個函式
複製程式碼

上面解釋了‘原函式的拷貝’這個問題,那接下來我們看看bind()怎麼使用

結印準備

bind()方法在傳參上跟call是一樣的,第一個引數是需要繫結的物件,後面一次傳入函式需要的引數,如下⬇️

var name = 'Jack Sparrow';

var onePiece = {
  name: 'Monkey·D·Luffy'
};

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

var mySayWhoAmI = sayWhoAmI.bind(onePiece)

sayWhoAmI(); // Jack Sparrow
mySayWhoAmI(); // Monkey·D·Luffy
複製程式碼

一個簡單的實現,本來輸出的是全域性變數'Jack Sparrow',後來經過bind以後繫結上了物件onePiece,所以輸出的就是物件onePiece中的nodeMonkey·D·Luffy。

那我們需要傳參的時候怎麼辦 ⬇️

var first = '大黑刀·夜',
  second = '二代鬼徹',
  third = '初代鬼徹',
  fourth = '時雨';

var zoro = {
  first: '和道一文字',
  second: '三代鬼徹',
  third: '雪走',
  fourth: '秋水'
};

function sayYourWeapon(num, num2) {
  console.log(`這是我${num}得到的刀"${this[num]}"`)
  console.log(`這是我${num2}得到的刀"${this[num2]}"`)
}

// 既然我們知道bind是返回一個函式,那我們宣告一個變數來接這個函式會看的直觀一些
var mySayYourWeapon = sayYourWeapon.bind(zoro, 'first', 'fourth'); // 傳入初始引數
var hisSayYourWeapon = sayYourWeapon.bind(zoro); // 只傳入目標物件

sayYourWeapon('first', 'third');
mySayYourWeapon(); // 因為我們當時bind繫結函式的時候已經傳入了目標物件zoro和指定的引數,所以這裡就不需要傳引數了
hisSayYourWeapon( 'first', 'fourth'); // 當然我們開始bind繫結函式的時候不傳入,在呼叫的時候再傳入引數也是可以的
複製程式碼

上面的程式碼我們可以發現mySayYourWeaponhisSayYourWeaponbind的時候一個傳入了初始的引數,一個沒有傳入,但是後續呼叫的時候可以再傳

既然是初始化引數,那我們就可以預設引數一個,然後再傳一個——————偏函式(不知道自己理解的對不對,但是肯定是有這麼個功能,不懂的可以移步MDN web docs的Function.prototype.bind中的偏函式

印結完了,該出招了

影子模仿術

預設大家到這裡已經知道怎麼使用bind了,那我們接下來需要挑戰的就是,自己手寫一個bind方法,這個可以幫助我們更清楚的理解bind方法是怎麼運作的,並且面試的時候也可能會被問到哦~
下面我們來看從MDN web docs 的Function.prototype.bind中複製過來的實現,新增了我自己的理解和註釋,希望大家能看懂⬇️

// 判斷當前環境的Function物件的原型上有沒有bind這個方法,如果沒有,那我們就自己新增一個
if (!Function.prototype.bind) {
  /**
   * 新增bind方法
   * @param oThis 目標物件
   * @returns {function(): *} 返回的拷貝函式
   */
  Function.prototype.bind = function(oThis) {
    if (typeof this !== 'function') {
      // closest thing possible to the ECMAScript 5
      // 最接近ECMAScript 5的實現(貌似是這個意思)
      // internal IsCallable function
      // 內部IsCallable函式(?什麼鬼)
      // 如果當前this物件不是function,就丟擲錯誤,因為只有function才需要實現bind這個方法。。。畢竟是返回函式
      throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable');
    }
    // 宣告變數aArgs儲存arguments中除了第一個引數的其他引數的陣列,因為第一個引數不是函式需要的引數,而是需要繫結的目標物件
    // 這塊就用到了call的方法,因為arguments是類陣列物件,沒有slice這個方法,所以只能從Array那call過來一個使用
    var aArgs = Array.prototype.slice.call(arguments, 1);
    // 儲存原先的this物件,是在呼叫bind的時候沒有傳入目標物件,那就使用原先的this物件
    var fToBind = this;
    // 宣告空函式,在下面的原型中可以使用
    var fNOP = function() {};
    // 需要放回的拷貝函式的本體,從最後的return也知道,最後是返回的fBound這個方法
    var fBound  = function() {
        // this instanceof fBound === true時,說明返回的fBound被當做new的建構函式呼叫
        // 下面就涉及到剛才說的是bind時初始化引數,還是bind以後呼叫的時候再傳入引數
        return fToBind.apply(
          // 判斷原始this物件是不是fBound的例項,或者說this的原型鏈上有沒有fBound
          this instanceof fBound
            // 如果有,就使用原始的this 
          ? this
            // 如果沒有,就使用現在的傳入的this物件
          : oThis,
          // 獲取呼叫時(fBound)的傳參.bind 返回的函式入參往往是這麼傳遞的
          // 這一步就是為了保障在bind時候沒有傳入引數的時候,呼叫時候傳入的引數能使用上
          aArgs.concat(Array.prototype.slice.call(arguments)));
      };

    // 維護原型關係
    // 判斷原始this物件上有沒有prototype
    if (this.prototype) {
      // Function.prototype doesn't have a prototype property
      // 如果原始this物件上有prototype 就把fNOP的prototype改成this.prototype,fNOP就繼承自原始this了
      fNOP.prototype = this.prototype;
    }
    // 下行的程式碼使fBound.prototype是fNOP的例項,因此
    // 返回的fBound若作為new的建構函式,new生成的新物件作為this傳入fBound,新物件的__proto__就是fNOP的例項
    // 既然fNOP是繼承自原始this物件的,那這裡的這一步就是讓拷貝函式也擁有原始this物件的prototype,繼承自同一個地方,師出同門
    fBound.prototype = new fNOP();
    // 最後返回被拷貝出來的函式
    return fBound;
  };
}
複製程式碼

上面的程式碼中有我新增的註釋,方便大家能更好的理解,理解了上面的程式碼以後,bind方法算是瞭解的差不多了,其他實現原理上摸清楚了
可能上面的程式碼註釋有點多,看著很費勁,下面貼出沒有註釋的程式碼,方便大家複製貼上除錯

if (!Function.prototype.bind) {
  Function.prototype.bind = function(oThis) {
    if (typeof this !== 'function') {
      throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable');
    }
    var aArgs = Array.prototype.slice.call(arguments, 1);
    var fToBind = this;
    var fNOP = function() {};
    var fBound  = function() {
        return fToBind.apply(this instanceof fBound ? this : oThis, aArgs.concat(Array.prototype.slice.call(arguments)));
    };
    if (this.prototype) {
      fNOP.prototype = this.prototype;
    }
    fBound.prototype = new fNOP();
    return fBound;
  };
}
複製程式碼

這麼看來程式碼還不算很多就實現了bind方法

人的夢想,是不會完結的,沒錯吧?

可能 996.ICU 起不到本質上的作用,但是讓我們知道有一群可愛的人跟我們一樣在為生計奔波勞累著,讓我們知道我們的圈子不小,只是沒到團結的時候,敢折騰就不賴,人一定要夢想,趁著年輕,萬一實現了呢。

帶病寫部落格。。。

病

前端戰五渣學JavaScript——call、apply以及bind
年輕嘛,就是幹!

ps:部落格可以技術分享,也當記錄生活了,以後看見的話,沒準會說“當時是不是傻”,但是現在感覺perfect


我是前端戰五渣,一個前端界的小學生。

相關文章