【愣錘筆記】一篇小短文讓你徹底搞懂this、call、apply和bind

愣錘發表於2019-03-18

跟我左手右手一起慢動作,右手左手慢動作重複。額~貌似哪裡有點不對勁哎?讓我想想,右手?左手?慢動作??重複???重播???不對不對,是左手call,右手apply,一起來bind this。

額,這都能強扯一波,好吧,讓我吐血一波~~~說起了js中的this,確實是個有趣的話題,也是很多小夥伴一開始傻傻分不清的老命題了。算是老梗重提,再來聊聊this吧。

關於this,首先要提的一點就是,它指向一個物件,具體指向誰是由函式執行時所處的上下文決定的。這是最重要的一個概念,也是理解js中this的關鍵。所謂的上下文,可以理解為函式執行時的環境,例如一個函式在全域性中執行,那麼它的上下文就是這個全域性物件,客戶端中這個global物件就是window;函式作為物件的方法執行,那麼它的上下文就是該物件。

關於this的指向問題,我們可以大致分為如下幾種情景來討論:

  • 函式作為普通函式呼叫
  • ES5嚴格模式下的函式呼叫
  • 函式作為物件的一個方法呼叫
  • 構造器中的this(也就是常說的類中的this,但是要搞清楚js是沒有類的,是基於原型委託實現的繼承,類只是大家習慣性的叫法)
(1)函式作為普通函式呼叫:大家學習js,對函式應該是再熟悉不過了。函式可是js中的一等公民,人中呂布、馬中赤兔啊。

var name1 = 'hello this';
window.name2 = 'hello global';
function func () {
  console.log(this.name1); // 輸出:"hello this"
  console.log(this.name2); // 輸出:"hello global"
}
func();複製程式碼

這裡的程式碼大家自然一眼就知道結果了,結果寫在了上面的註釋裡。通過執行結果我們知道,普通函式在全域性呼叫中,this指向全域性物件。這裡我們定義了一個全域性變數name1,和一個window的屬性name2,所以this.name1和this.name2如我們的預期指向了這兩個值。值得一提的是:定義的全域性變數,是被作為全域性物件window的屬性存在的哦。此時我們列印看下window物件,看圖:

【愣錘筆記】一篇小短文讓你徹底搞懂this、call、apply和bind

(2)ES5嚴格模式下的函式呼叫:this不再指向全域性物件,而是undefined。

function strictFunc () {
  'use strict'
  console.log(this)
  console.log(this.name)
}
strictFunc()複製程式碼

我們先看下執行結果:

【愣錘筆記】一篇小短文讓你徹底搞懂this、call、apply和bind

可以看到,this列印出來的值是undefined,而this.name會直接報錯。由此說明,嚴格模式下,this已經不再指向全域性物件,而是undefined值。引用undefinednull值的屬性會報Uncaught TypeError錯,這點我們在日常開發中需要注意一下,以免因為一個錯誤導致後面的程式直接掛掉(這是js單執行緒的原因,一旦程式出錯,後面便不會再執行)。特別是我們在拿到一些不是我們決定的資料(例如後臺返回的)進行處理的時候,使用物件的屬性時最好判斷一下,這樣在極端情況下,也可以保證我們的程式繼續跑下去,而不至於直接掛掉:

obj && obj.name
// 而不是直接取值:
obj.name

或者用try/catch捕獲錯誤:
try {
    const { data } = await api.getArticleList()
} catch {

} finally {

}
複製程式碼

(3)函式作為物件的方法使用:this指向該物件

var obj = {
  name: 'xiaoming',
  getInfo () {
    return '姓名: ' + this.name;
  }
}
console.log(obj.getInfo()); // 姓名: xiaoming 複製程式碼

當物件當屬性的值是一個函式時,我們會稱這個函式是這個物件的一個方法。該方法中的this在執行時指向的是該物件。上面的例子的輸出結果也看的清清楚楚,然鵝,沒錯,就是鵝,現實有時候是會啪啪打臉的,打的響亮亮的、輕脆脆的、綠油油的~哎,我為什麼要說綠油油,毛病。下面我簡單改寫一個上面的程式碼:

// 還是這個obj,還是熟悉的味道
var obj = {
  name: 'xiaoming',
  getInfo () {
    return '姓名: ' + this.name;
  }
}
// 定義一個引入obj.getInfo的變數
var referenceGetInfo = obj.getInfo;
console.log(referenceGetInfo()); // 輸出:姓名:複製程式碼

最終我們沒有拿到預期的name值,打臉了吧,說好了的指向該物件的呢!果然我們男人都是騙子,都是大豬蹄子!

這是為什麼呢?我們知道js分為兩種資料型別:基本資料型別,如string、number、undefined、null等,引用型別,如object。而像陣列、函式等,本質都是物件,所以都是引用型別。函式名只不過是指向該函式在記憶體中位置的一個引用。所以,這裡var referenceGetInfo = obj.getInfo在賦值之後,referenceGetInfo也只是該函式的一個引用。在看referenceGetInfo 的呼叫位置,是在全域性中,所以是作為普通函式呼叫的。由此this指向window,所以沒有值。可以在getInfo函式中,增加如下驗證,結果必然是true

console.log(this === window)複製程式碼

(4)構造器函式中的this:指向該構造器返回的新物件

說起構造器函式,可能感覺會有些生硬,其實就是我們常說的定義類時的那個函式。例如,下面這個最常見的一個類(構造器函式):

// 定義Person類
var Person = function (name, sex) {
  this.name = name;
  this.sex = sex;
}
// 定義Person類的原型物件
Person.prototype = {
  constructor: Person,
  getName: function () {
    return '我叫:' + this.name;
  },
  getSex: function () {
    return '性別:' + this.sex;
  }
}
// 例項化一個p1
var p1 = new Person('愣錘', '男');
// 呼叫p1的方法
console.log(p1.getName()); // 我叫:愣錘
console.log(p1.getSex()); // 性別:男複製程式碼

構造器函式本是也是一個函式,如果直接呼叫該函式,那它和普通函式沒什麼區別。但是通過new呼叫之後,那它就成為了構造器函式。構造器函式在例項化時會返回一個新建立的物件,並將this指向該物件。所以this.name的值是"愣錘"。另外這裡再提一點,如果你擔心使用者使用類時忘記加new,可以通過如下方式,強制使用new呼叫:

var Person = function (name, sex) {
  // 在構造器中增加如下這一行,其餘不變
  if (!(this instanceof Person)) return new Person(name, sex);
  this.name = name;
  this.sex = sex;
}複製程式碼

該行程式碼判斷了當前的this是否是Person類的例項,如果不是則強制返回一個通過new初始化的類。以為如果使用者忘記使用new初始化類,那麼此時的構造器函式是作為普通函式呼叫的,this在非嚴格模式下指向window,肯定不會是Person類的例項,所以我們直接強制返回new初始化。這也是我們在開發類庫時可以使用的一個小技巧。

弄明白了js中的this的指向,下面我們再聊聊如何改變this的指向。在js中,改變this指向方法,常見的有如下幾種:

  • Function.prototype.call()
  • Function.prototype.apply()
  • Function.prororype.bind()
  • 除此之外,還有eval()、with()等

(1)call()方法和apply()方法都是ES3中就存在的方法,可以改變函式的this指向,兩者的功能完全一樣,所以這裡放在一起說。唯一的區別是兩者呼叫時傳入的引數不同,後面會仔細介紹。

// 還是熟悉的味道,還是那個obj
var obj = {
  name: 'xiaoming',
  getInfo (sex) {
    return '姓名: ' + this.name + '性別:' + this.sex || '未知';
  }
}
// 定義另一個obj物件
var otherObj = {
  name: '狗子你變了,你再也不是我認識的那個二狗了!'
}

console.log(obj.getInfo.call(otherObj, '女')); 
// 姓名: 狗子你變了,你再也不是我認識的那個二狗了!性別:女複製程式碼

我們通過callobj.getInfo方法放在ohterObj這個物件執行,輸出了ohterObj.name的值,由此驗證了call可以函式this的指向。call()方法接收多個引數: 

  • 第一個引數為可選引數,即this指向的新的上下文物件。如果不傳該引數,則指向全域性物件。若不傳入第一個引數且該方法(getInfo)使用嚴格模式,this值且undefined,和普通函式的嚴格模式一樣,從undefined上取值會報錯。
  • 後面的所有引數都是作為引數傳遞給方法呼叫

apply()方法和call的功能一樣,只不過傳入的引數不一樣:

  • 第一個引數為可選引數,和上面?call的一樣
  • 第二個引數是一個引數陣列/類陣列,陣列包含的所有引數都會作為引數傳遞給該方法呼叫

用法很簡單,和call一樣就不多介紹了。但是這裡提到了類陣列概念,說一下什麼是類陣列,可以理解為本身不是陣列,但是卻可以像陣列一樣擁有length屬性(例如函式的arguments物件)。我們沒有確切的辦法判斷一個物件是不是類陣列,所以這裡我們只能使用js中的鴨子型別來判斷。何為鴨子型別:如果它走起路來像鴨子,叫聲也像鴨子,我們便認為它就是鴨子。

鴨子型別是js中很重要的一個概念,因為我們此時並不真正關心它是不是鴨子,我們只是想聽到鴨子叫/或者看到鴨子走,即我們要的只是它擁有鴨子的行為,至於它是不是鴨子,無所謂呀!!!

所以只要一個物件能擁有陣列的行為,我們就可以把它作為陣列使用。下面引入underscore中的類陣列判斷方法說明:

var isArrayLike = function(collection) {

var length = getLength(collection);
  return typeof length == 'number' && length >= 0 && length <= MAX_ARRAY_INDEX;
};複製程式碼

underscore.js中對類陣列的判斷其實也是運用了鴨子型別的思想,即判斷如果該物件擁有length屬性且是number型別,並且length的值大於等於0小於等於一個陣列的最大元素個數,那我們就認定他是陣列。

好了,有的稍微扯遠了。

下面繼續apply的實際運用場景,例如柯里化函式:

// 定義一個柯里化函式
var currying = function () {
  var arrPro = Array.prototype,
  fn = arrPro.shift.call(arguments),
  args = arrPro.slice.call(arguments);
    return function () {
    var _args = arrPro.slice.call(arguments);
    return fn.apply(fn, args.concat(_args));
  }
}
// 定義一個返回a+b的函式var add = function (a, b) {
  return a + b;
}
// 將這個求和函式進行柯里化,使其第一項的值恆為5
var curryAdd = currying(add, 5);
var res = curryAdd(4);
console.log(res); // 9複製程式碼

我們在開發中apply方法和call方法是用的比較多的,例如這裡柯里化函式。特別是高階函式中,函式作為值返回的時候,會經常使用apply這些方法來繫結函式執行時的上下文物件。

我們再看一個更常見的函式節流吧:

// 去抖函式
function debounce (fn, delay) {
  var timer;
  return function () {
    var args = arguments;
    var _this = this;
    timer && clearTimeout(timer);
    timer = setTimeout(function () {
      fn.apply(_this, args);
    }, delay);
  }
}
// 呼叫,在瀏覽器視窗滾動的情況下,debounce裡的函式並不會被頻繁觸發,而是滾動結束500ms後觸發
window.addEventListener('scroll', debounce(function () {
  console.log('window scroll');
}, 500), false);複製程式碼

我們在這個去抖函式裡,在返回的函式裡,使用裡定時器,而定時器的第一個引數是一個函式,所以形成裡一個區域性的函式作用域。為了能保證我們的fn函式中的this的正確指向,我們通過apply改變它的指向。

所謂去抖函式,在一個函式被頻繁呼叫的時候,如果此次呼叫距離上一次的時間小於我們定下的delay 值,那麼取消本次呼叫。主要用來防止頻繁觸發的問題,從而提供程式執行效能。注意,上面只是一個函式去抖,真正在提升滾動效能的時候,我們更多的是會將去抖和節流結合起了使用。此處更多地在於演示apply的運用場景,不再多做節流去抖方面的說明。

call方法在v8的實現中,其實是作為apply方法的語法糖,由此,我們可以試著使用apply來模擬一個call方法(並非v8原始碼實現):

Function.prototype.call = function () {
  var ctx = Array.prototype.shift.apply(arguments);
  return this.apply(ctx, arguments);
}複製程式碼

我們知道call方法,第一個引數是上下文物件,所以我們的第一件事就是取出引數中的第一個引數ctx,然後把剩餘的引數使用apply的方式呼叫。so,就是這樣。

(2)說完了call和apply,下面我們再說一下ES5引入的新方法:Function.prototype.bind

該方法返回一個新的函式,並將該函式的this繫結到指定的上下文環境。接收多個引數:

  • 第一個引數為this繫結到的新上下文環境
  • 後面的引數會作為引數傳遞給該函式

用法很簡單,相信大家都會用:

// 還是那個熟悉的狗子,哦不對,還是那個熟悉的物件
var obj = {
  name: 'xiaoming',
  getInfo (sex, hobby) {
    return '姓名: ' + this.name + ', 性別:' + (sex || '未知') + hobby;
  }
}
// 另外一個狗子,呸呸呸!另外一個物件
var obj2 = {
  name: '我已經不是你認識的狗子了'
}
// 輸出:姓名: 我已經不是你認識的狗子了, 性別:男, 興趣:打球
var newGetInfo = obj.getInfo.bind(obj2, '男');console.log(newGetInfo('打球'));複製程式碼

可以看到,bind()後返回了一個新函式,並把第一個引數後面的引數傳遞給了obj.getInfo方法,在執行newGetInfo('打球')時,又繼續把引數傳遞給了obj.getInfo方法。是不是發現它天然支援了函式柯里化,是不是感覺跟我們上面的柯里化函式功能一樣?

但是bind方法,是es5引入的,在es3是不支援的。這時候可能會說了,es5已經是主流了,大家也都已經大量使用es6及更高的語法,反正又babel等工具幫我們轉換成es5的。沒錯,但是我們還是要了解其實現的,比如寫一個bind方法的profill。做到知其然,知其所以然。

// 如果本身支援bind方法,則使用原生的bind方法,否則我們就實現一個使用
Function.prototype.bind = Function.prototype.bind || function () {
  var fn = this;  var ctx = arguments[0];
  var args = Array.prototype.slice.call(arguments, 1);
  return function () {
    var _args = Array.prototype.slice.call(arguments);
    return fn.apply(ctx, args.concat(_args));
  }
}複製程式碼

講到這,相信已經可以將this/call/apply方法搞清楚了。由此還引申出更多的函式節流/去抖/柯里化/反柯里化,還是可以繼續深入深究一下的。


相關文章