重新認識JS的this、作用域、閉包、對象

9月4號,今日早讀文章由丁香園@相學(xué)長投稿分享。
正文從這開始~
日常開發(fā)中,我們經(jīng)常用到this。例如用Jquery綁定事件時,this指向觸發(fā)事件的DOM元素;編寫Vue、React組件時,this指向組件本身。對于新手來說,常會用一種意會的感覺去判斷this的指向。以至于當(dāng)遇到復(fù)雜的函數(shù)調(diào)用時,就分不清this的真正指向。
本文將通過兩道題去慢慢分析this的指向問題,并涉及到函數(shù)作用域與對象相關(guān)的點。最終給大家?guī)碚嬲睦碚摲治觯皇呛喓唵螁蔚囊痪湓捀爬ā?br> 相信若是對this稍有研究的人,都會搜到這句話:this總是指向調(diào)用該函數(shù)的對象。
然而箭頭函數(shù)并不是如此,于是大家就會遇到如下各式說法:
箭頭函數(shù)的this指向外層函數(shù)作用域中的this。

箭頭函數(shù)的this是定義函數(shù)時所在上下文中的this。

箭頭函數(shù)體內(nèi)的this對象,就是定義時所在的對象,而不是使用時所在的對象。

各式各樣的說法都有,乍看下感覺說的差不多。廢話不多說,憑著你之前的理解,來先做一套題吧(非嚴格模式下)。
/*** Question 1*/

var name = 'window'
var person1 = {
 name: 'person1',
 show1: function () {   
   console.log(this.name) 
}, 
show2: () => console.log(this.name), 
show3: function () {  
 return function () {     
    console.log(this.name)   
 } 
}, 
show4: function () {   
  return () => console.log(this.name) }
}

var person2 = { 
  name: 'person2'      
 }

person1.show1()     
person1.show1.call(person2)
person1.show2()
person1.show2.call(person2)
person1.show3()()
person1.show3().call(person2)
person1.show3.call(person2)()
person1.show4()()
person1.show4().call(person2)
person1.show4.call(person2)()

大致意思就是,有兩個對象person1,person2,然后花式調(diào)用person1中的四個show方法,預(yù)測真正的輸出。
你可以先把自己預(yù)測的答案按順序記在本子上,然后再往下拉看正確答案。
正確答案選下:

person1.show1() // person1
person1.show1.call(person2) // person2
person1.show2() // window
person1.show2.call(person2) // window
person1.show3()() // window
person1.show3().call(person2) // person2
person1.show3.call(person2)() // window
person1.show4()() // person1
person1.show4().call(person2) //  person1
person1.show4.call(person2)() // person2

對比下你剛剛記下的答案,是否有不一樣呢?讓我們嘗試來最開始那些理論來分析下。
person1.show1()與person1.show1.call(person2)好理解,驗證了誰調(diào)用此方法,this就是指向誰。
person1.show2()與person1.show2.call(person2)的結(jié)果用上面的定義解釋,就開始讓人不理解了。
它的執(zhí)行結(jié)果說明this指向的是window。那就不是所謂的定義時所在的對象。
如果說是外層函數(shù)作用域中的this,實際上并沒有外層函數(shù)了,外層就是全局環(huán)境了,這個說法也不嚴謹。
只有定義函數(shù)時所在上下文中的this這句話算能描述現(xiàn)在這個情況。
person1.show3是一個高階函數(shù),它返回了一個函數(shù),分步走的話,應(yīng)該是這樣:
var func = person3.show()func()

從而導(dǎo)致最終調(diào)用函數(shù)的執(zhí)行環(huán)境是window,但并不是window對象調(diào)用了它。所以說,this總是指向調(diào)用該函數(shù)的對象,這句話還得補充一句:在全局函數(shù)中,this等于window。
person1.show3().call(person2) 與 person1.show3.call(person2)() 也好理解了。前者是通過person2調(diào)用了最終的打印方法。后者是先通過person2調(diào)用了person1的高階函數(shù),然后再在全局環(huán)境中執(zhí)行了該打印方法。
person1.show4()(),person1.show4().call(person2)都是打印person1。這好像又印證了那句:箭頭函數(shù)體內(nèi)的this對象,就是定義時所在的對象,而不是使用時所在的對象。因為即使我用過person2去調(diào)用這個箭頭函數(shù),它指向的還是person1。
然而person1.show4.call(person2)()的結(jié)果又是person2。this值又發(fā)生改變,看來上述那句描述又走不通了。一步步來分析,先通過person2執(zhí)行了show4方法,此時show4第一層函數(shù)的this指向的是person2。所以箭頭函數(shù)輸出了person2的name。也就是說,箭頭函數(shù)的this指向的是誰調(diào)用箭頭函數(shù)的外層function,箭頭函數(shù)的this就是指向該對象,如果箭頭函數(shù)沒有外層函數(shù),則指向window。這樣去理解show2方法,也解釋的通。
這句話就對了么?在我們學(xué)習(xí)的過程中,我們總是想以總結(jié)規(guī)律的方法去總結(jié)結(jié)論,并且希望結(jié)論越簡單越容易描述就越好。實際上可能會錯失真理。
下面我們再做另外一個相似的題目,通過構(gòu)造函數(shù)來創(chuàng)建一個對象,并執(zhí)行相同的4個show 方法。
/*** Question 2*/

var name = 'window'
function Person (name) { 
 this.name = name; this.show1 = function () { console.log(this.name)  }
 this.show2 = () => console.log(this.name)
 this.show3 = function () {   return function () {     console.log(this.name)   } } 
 this.show4 = function () {   return () => console.log(this.name) }
}
 var personA = new Person('personA')
 var personB = new Person('personB')

  personA.show1()
  personA.show1.call(personB)
  personA.show2()
  personA.show2.call(personB)
  personA.show3()()
  personA.show3().call(personB)
  personA.show3.call(personB)()
  personA.show4()()
  personA.show4().call(personB)
  personA.show4.call(personB)()

同樣的,按照之前的理解,再次預(yù)計打印結(jié)果,把答案記下來,再往下拉看正確答案。
正確答案選下:

personA.show1() // personA
personA.show1.call(personB) // personB
personA.show2() // personA
personA.show2.call(personB) // personA
personA.show3()() // window
personA.show3().call(personB) // personB
personA.show3.call(personB)() // window
personA.show4()() // personA
personA.show4().call(personB) // personA
personA.show4.call(personB)() // personB

我們發(fā)現(xiàn)與之前字面量聲明的相比,show2方法的輸出產(chǎn)生了不一樣的結(jié)果。為什么呢?雖然說構(gòu)造方法Person是有自己的函數(shù)作用域。但是對于person1來說,它只是一個對象,在直觀感受上,它跟第一道題中的person1應(yīng)該是一模一樣的。 JSON.stringify(new Person('person1')) === JSON.stringify(person1)也證明了這一點。
說明構(gòu)造函數(shù)創(chuàng)建對象與直接用字面量的形式去創(chuàng)建對象,它是不同的,構(gòu)造函數(shù)創(chuàng)建對象,具體做了什么事呢?我引用紅寶書中的一段話。
使用 new 操作符調(diào)用構(gòu)造函數(shù),實際上會經(jīng)歷一下4個步驟:
創(chuàng)建一個新對象;

將構(gòu)造函數(shù)的作用域賦給新對象(因此this就指向了這個新對象);

執(zhí)行構(gòu)造函數(shù)中的代碼(為這個新對象添加屬性);

返回新對象。

所以與字面量創(chuàng)建對象相比,很大一個區(qū)別是它多了構(gòu)造函數(shù)的作用域。我們用chrome查看這兩者的作用域鏈就能清晰的知道:


personA的函數(shù)的作用域鏈從構(gòu)造函數(shù)產(chǎn)生的閉包開始,而person1的函數(shù)作用域僅是global,于是導(dǎo)致this指向的不同。我們發(fā)現(xiàn),要想真正理解this,先得知道到底什么是作用域,什么是閉包。
有簡單的說法稱閉包就是能夠讀取其他函數(shù)內(nèi)部變量的函數(shù)。然而這是一種閉包現(xiàn)象的描述,而不是它的本質(zhì)與形成的原因。
我再次引用紅寶書的文字(便于理解,文字順序稍微調(diào)整),來描述這幾個點:
...每個函數(shù)都有自己的執(zhí)行環(huán)境(execution context,也叫執(zhí)行上下文),每個執(zhí)行環(huán)境都有一個與之關(guān)聯(lián)的變量對象,環(huán)境中定義的所有變量和函數(shù)都保存在這個對象中。
...當(dāng)執(zhí)行流進入一個函數(shù)時,函數(shù)的環(huán)境就會被推入一個環(huán)境棧中。當(dāng)代碼在環(huán)境中執(zhí)行時,會創(chuàng)建一個作用域鏈,來保證對執(zhí)行環(huán)境中的所有變量和函數(shù)的有序訪問。函數(shù)執(zhí)行之后,棧將環(huán)境彈出。
...函數(shù)內(nèi)部定義的函數(shù)會將包含函數(shù)的活動對象添加到它的作用域鏈中。
具體來說,當(dāng)我們 var func = personA.show3() 時,personA的show3函數(shù)的活動對象,會一直保存在func的作用域鏈中。只要不銷毀func,那么show3函數(shù)的活動對象就會一直保存在內(nèi)存中。(chrome的v8引擎對閉包的開銷會有優(yōu)化)
而構(gòu)造函數(shù)同樣也是閉包的機制,personA的show1方法,是構(gòu)造函數(shù)的內(nèi)部函數(shù),因此執(zhí)行了 this.show3 = function () { console.log(this.name) }時,已經(jīng)把構(gòu)造函數(shù)的活動對象推到了show3函數(shù)的作用域鏈中。
我們再回到this的指向問題。我們發(fā)現(xiàn),單單是總結(jié)規(guī)律,或者用一句話概括,已經(jīng)難以正確解釋它到底指向誰了,我們得追本溯源。
紅寶書中說道:
...this引用的是函數(shù)執(zhí)行的環(huán)境對象(便于理解,貼上英文原版:It is a reference to the context object that the function is operating on)。 ...每個函數(shù)被調(diào)用時都會自動獲取兩個特殊變量:this和arguments。內(nèi)部在搜索這個兩個變量時,只會搜索到其活動對象為止,永遠不可能直接訪問外部函數(shù)中的這兩個變量。
我們看下MDN中箭頭函數(shù)的概念:
一個箭頭函數(shù)表達式的語法比一個函數(shù)表達式更短,并且不綁定自己的 this,arguments,super或 new.target。...箭頭函數(shù)會捕獲其所在上下文的 this 值,作為自己的 this 值。
也就是說,普通情況下,this指向調(diào)用函數(shù)時的對象。在全局執(zhí)行時,則是全局對象。
箭頭函數(shù)的this,因為沒有自身的this,所以this只能根據(jù)作用域鏈往上層查找,直到找到一個綁定了this的函數(shù)作用域(即最靠近箭頭函數(shù)的普通函數(shù)作用域,或者全局環(huán)境),并指向調(diào)用該普通函數(shù)的對象。
或者從現(xiàn)象來描述的話,即箭頭函數(shù)的this指向聲明函數(shù)時,最靠近箭頭函數(shù)的普通函數(shù)的this。但這個this也會因為調(diào)用該普通函數(shù)時環(huán)境的不同而發(fā)生變化。導(dǎo)致這個現(xiàn)象的原因是這個普通函數(shù)會產(chǎn)生一個閉包,將它的變量對象保存在箭頭函數(shù)的作用域中。
故而personA的show2方法因為構(gòu)造函數(shù)閉包的關(guān)系,指向了構(gòu)造函數(shù)作用域內(nèi)的this。而
var func = personA.show4.call(personB)func() // print personB

因為personB調(diào)用了personA的show4,使得返回函數(shù)func的作用域的this綁定為personB,進而調(diào)用func時,箭頭函數(shù)通過作用域找到的第一個明確的this為personB。進而輸出personB。
講了這么多,可能還是有點繞。總之,想充分理解this的前提,必須得先明白js的執(zhí)行環(huán)境、閉包、作用域、構(gòu)造函數(shù)等基礎(chǔ)知識。然后才能得出清晰的結(jié)論。
我們平常在學(xué)習(xí)過程中,難免會更傾向于根據(jù)經(jīng)驗去推導(dǎo)結(jié)論,或者直接去找一些通俗易懂的描述性語句。然而實際上可能并不是最正確的結(jié)果。如果想真正掌握它,我們就應(yīng)該追本溯源的去研究它的內(nèi)部機制。
我上述所說也是我自己推導(dǎo)出的結(jié)果,即使它不一定正確,但這個推斷思路跟學(xué)習(xí)過程,我覺得可以跟大家分享分享。
最后,@相學(xué)長曾分享過:
【第983期】2017前端現(xiàn)狀--答題救不了前端新人

關(guān)于本文
作者:@相學(xué)長
原文:https://github.com/wuomzfx/blog/blob/master/this.md

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容