從這兩道題重新理解,JS的this、作用域、閉包、物件

腹黑的可樂發表於2023-01-03

日常開發中,我們經常用到this。例如用Jquery繫結事件時,this指向觸發事件的DOM元素;編寫Vue、React元件時,this指向元件本身。對於新手來說,常會用一種意會的感覺去判斷this的指向。以至於當遇到複雜的函式呼叫時,就分不清this的真正指向。

本文將透過兩道題去慢慢分析this的指向問題,並涉及到函式作用域與物件相關的點。最終給大家帶來真正的理論分析,而不是簡簡單單的一句話概括。

相信若是對this稍有研究的人,都會搜到這句話:this總是指向呼叫該函式的物件

然而箭頭函式並不是如此,於是大家就會遇到如下各式說法:

  1. 箭頭函式的this指向外層函式作用域中的this。
  2. 箭頭函式的this是定義函式時所在上下文中的this。
  3. 箭頭函式體內的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)()

大致意思就是,有兩個物件person1person2,然後花式呼叫person1中的四個show方法,預測真正的輸出。

你可以先把自己預測的答案按順序記在本子上,然後再往下拉看正確答案。


正確答案選下:

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)好理解,驗證了誰呼叫此方法,this就是指向誰

person1.show2()person1.show2.call(person2)的結果用上面的定義解釋,就開始讓人不理解了。

它的執行結果說明this指向的是window。那就不是所謂的定義時所在的物件。

如果說是外層函式作用域中的this,實際上並沒有外層函式了,外層就是全域性環境了,這個說法也不嚴謹。

只有定義函式時所在上下文中的this這句話算能描述現在這個情況。

person1.show3是一個高階函式,它返回了一個函式,分步走的話,應該是這樣:

var func = person3.show()

func()

從而導致最終呼叫函式的執行環境是window,但並不是window物件呼叫了它。所以說,this總是指向呼叫該函式的物件,這句話還得補充一句:在全域性函式中,this等於window

person1.show3().call(person2)person1.show3.call(person2)() 也好理解了。前者是透過person2呼叫了最終的列印方法。後者是先透過person2呼叫了person1的高階函式,然後再在全域性環境中執行了該列印方法。

person1.show4()()person1.show4().call(person2)都是列印person1。這好像又印證了那句:箭頭函式體內的this物件,就是定義時所在的物件,而不是使用時所在的物件。因為即使我用過person2去呼叫這個箭頭函式,它指向的還是person1。

然而person1.show4.call(person2)()的結果又是person2。this值又發生改變,看來上述那句描述又走不通了。一步步來分析,先透過person2執行了show4方法,此時show4第一層函式的this指向的是person2。所以箭頭函式輸出了person2的name。也就是說,箭頭函式的this指向的是誰呼叫箭頭函式的外層function,箭頭函式的this就是指向該物件,如果箭頭函式沒有外層函式,則指向window。這樣去理解show2方法,也解釋的通。

這句話就對了麼?在我們學習的過程中,我們總是想以總結規律的方法去總結結論,並且希望結論越簡單越容易描述就越好。實際上可能會錯失真理。

下面我們再做另外一個相似的題目,透過建構函式來建立一個物件,並執行相同的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)()

同樣的,按照之前的理解,再次預計列印結果,把答案記下來,再往下拉看正確答案。


參考 前端進階面試題詳細解答

正確答案選下:

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

我們發現與之前字面量宣告的相比,show2方法的輸出產生了不一樣的結果。為什麼呢?雖然說構造方法Person是有自己的函式作用域。但是對於personA來說,它只是一個物件,在直觀感受上,它跟第一道題中的person1應該是一模一樣的。 JSON.stringify(new Person('person1')) === JSON.stringify(person1)也證明了這一點。

說明建構函式建立物件與直接用字面量的形式去建立物件,它是不同的,建構函式建立物件,具體做了什麼事呢?我引用紅寶書中的一段話。

使用 new 運算子呼叫建構函式,實際上會經歷一下4個步驟:

  1. 建立一個新物件;
  2. 將建構函式的作用域賦給新物件(因此this就指向了這個新物件);
  3. 執行建構函式中的程式碼(為這個新物件新增屬性);
  4. 返回新物件。

所以與字面量建立物件相比,很大一個區別是它多了建構函式的作用域。我們用chrome檢視這兩者的作用域鏈就能清晰的知道:

personA的函式的作用域鏈從建構函式產生的閉包開始,而person1的函式作用域僅是global,於是導致this指向的不同。我們發現,要想真正理解this,先得知道到底什麼是作用域,什麼是閉包。

有簡單的說法稱閉包就是能夠讀取其他函式內部變數的函式。然而這是一種閉包現象的描述,而不是它的本質與形成的原因。

我再次引用紅寶書的文字(便於理解,文字順序稍微調整),來描述這幾個點:

...每個函式都有自己的執行環境(execution context,也叫執行上下文),每個執行環境都有一個與之關聯的變數物件,環境中定義的所有變數和函式都儲存在這個物件中。

...當執行流進入一個函式時,函式的環境就會被推入一個環境棧中。當程式碼在環境中執行時,會建立一個作用域鏈,來保證對執行環境中的所有變數和函式的有序訪問。函式執行之後,棧將環境彈出。

...函式內部定義的函式會將包含函式的活動物件新增到它的作用域鏈中。

具體來說,當我們 var func = personA.show3() 時,personAshow3函式的活動物件,會一直儲存在func的作用域鏈中。只要不銷燬func,那麼show3函式的活動物件就會一直儲存在記憶體中。(chrome的v8引擎對閉包的開銷會有最佳化)

而建構函式同樣也是閉包的機制,personAshow1方法,是建構函式的內部函式,因此執行了 this.show3 = function () { console.log(this.name) }時,已經把建構函式的活動物件推到了show3函式的作用域鏈中。

我們再回到this的指向問題。我們發現,單單是總結規律,或者用一句話概括,已經難以正確解釋它到底指向誰了,我們得追本溯源。

紅寶書中說道:

...this引用的是函式執行的環境物件(便於理解,貼上英文原版:It is a reference to the context object that the function is operating on)。
...每個函式被呼叫時都會自動獲取兩個特殊變數:this和arguments。內部在搜尋這個兩個變數時,只會搜尋到其活動物件為止,永遠不可能直接訪問外部函式中的這兩個變數。

我們看下MDN中箭頭函式的概念:

一個箭頭函式表示式的語法比一個函式表示式更短,並且不繫結自己的 thisargumentssupernew.target。...箭頭函式會捕獲其所在上下文的 this 值,作為自己的 this 值。

也就是說,普通情況下,this指向呼叫函式時的物件。在全域性執行時,則是全域性物件。

箭頭函式的this,因為沒有自身的this,所以this只能根據作用域鏈往上層查詢,直到找到一個繫結了this的函式作用域(即最靠近箭頭函式的普通函式作用域,或者全域性環境),並指向呼叫該普通函式的物件。

或者從現象來描述的話,即箭頭函式的this指向宣告函式時,最靠近箭頭函式的普通函式的this。但這個this也會因為呼叫該普通函式時環境的不同而發生變化。導致這個現象的原因是這個普通函式會產生一個閉包,將它的變數物件儲存在箭頭函式的作用域中

故而personAshow2方法因為建構函式閉包的關係,指向了建構函式作用域內的this。而

var func = personA.show4.call(personB)

func() // print personB

因為personB呼叫了personA的show4,使得返回函式func的作用域的this繫結為personB,進而呼叫func時,箭頭函式透過作用域找到的第一個明確的this為personB。進而輸出personB。

講了這麼多,可能還是有點繞。總之,想充分理解this的前提,必須得先明白js的執行環境、閉包、作用域、建構函式等基礎知識。然後才能得出清晰的結論。

我們平常在學習過程中,難免會更傾向於根據經驗去推導結論,或者直接去找一些通俗易懂的描述性語句。然而實際上可能並不是最正確的結果。如果想真正掌握它,我們就應該追本溯源的去研究它的內部機制。

相關文章