JavaScript 的這個難點,毀掉了多少程式設計師?
1. this 適合你嗎?
我看到許多文章在介紹 JavaScript 的 this 時都會假設你學過某種面向物件的程式語言,比如 Java、C++ 或 Python 等。但這篇文章面向的讀者是那些不知道 this 是什麼的人。我儘量不用任何術語來解釋 this 是什麼,以及 this 的用法。
也許你一直不敢解開 this 的祕密,因為它看起來挺奇怪也挺嚇人的。或許你只在 StackOverflow 說你需要用它的時候(比如在 React 裡實現某個功能)才會使用。
在深入介紹 this 之前,我們首先需要理解函數語言程式設計和麵向物件程式設計之間的區別。
2.
你可能不知道,JavaScript 同時擁有面向物件和函式式的結構,所以你可以自己選擇用哪種風格,或者兩者都用。
我在很早以前使用 JavaScript 時就喜歡函數語言程式設計,而且會像躲避瘟疫一樣避開面向物件程式設計,因為我不理解面向物件中的關鍵字,比如 this。我不知道為什麼要用 this。似乎沒有它我也可以做好所有的工作。
而且我是對的。
在某種意義上 。也許你可以只專注於一種結構並且完全忽略另一種,但這樣你只能是一個 JavaScript 開發者。為了解釋函式式和麵向物件之間的區別,下面我們通過一個數組來舉例說明,陣列的內容是 Facebook 的好友列表。
假設你要做一個 Web 應用,當用戶使用 Facebook 登入你的 Web 應用時,需要顯示他們的 Facebook 的好友資訊。你需要訪問 Facebook 並獲得使用者的好友資料。這些資料可能是 firstName、lastName、username、numFriends、friendData、birthday 和 lastTenPosts 等資訊。
const data = [
{
firstName: 'Bob',
lastName: 'Ross'
username: 'bob.ross',
numFriends: 125,
birthday: '2/23/1985',
lastTenPosts: ['What a nice day', 'I love Kanye West', ...],
},
...
]
假設上述資料是你通過 Facebook API 獲得的。現在需要將其轉換成方便你的專案使用的格式。我們假設你想顯示的好友資訊如下:
● 三篇隨機文章
● 距離生日的天數
3. 函式式方式
函式式的方式就是將整個陣列或者陣列中的某個元素傳遞給某個函式,然後返回你需要的資訊:
const fullNames = getFullNames(data)
// ['Ross, Bob', 'Smith, Joanna', ...]
首先我們有 Facebook API 返回的原始資料。為了將其轉換成需要的格式,首先要將資料傳遞給一個函式,函式的輸出是(或者包含)經過修改的資料,這些資料可以在應用中向用戶展示。
我們可以用類似的方法獲得隨機三篇文章,並且計算距離好友生日的天數。
函式式的方式是:將原始資料傳遞給一個函式或者多個函式,獲得對你的專案有用的資料格式。
4. 面向物件的方式
對於程式設計初學者和 JavaScript 初學者,面向物件的概念可能有點難以理解。其思想是,我們要將每個好友變成一個物件,這個物件能夠生成你一切開發者需要的東西。
你可以建立一個物件,這個物件對應於某個好友,它有 fullName 屬性,還有兩個函式 getThreeRandomPosts 和 getDaysUntilBirthday。
function initializeFriend(data) {
return {
fullName: `${data.firstName} ${data.lastName}`,
getThreeRandomPosts: function() {
// get three random posts from data.lastTenPosts
},
getDaysUntilBirthday: function() {
// use data.birthday to get the num days until birthday
}
};
}
const objectFriends = data.map(initializeFriend)
objectFriends[0].getThreeRandomPosts()
// Gets three of Bob Ross's posts
面向物件的方式就是為資料建立物件,每個物件都有自己的狀態,並且包含必要的資訊,能夠生成需要的資料。
5. 這跟 this 有什麼關係?
你也許從來沒想過要寫上面的 initializeFriend 程式碼,而且你也許認為,這種程式碼可能會很有用。但你也注意到,這並不是真正的面向物件。
其原因就是,上面例子中的 getThreeRandomPosts 或 getdaysUntilBirtyday 能夠正常工作的原因其實是閉包。因為使用了閉包,它們在 initializeFriend 返回之後依然能訪問 data。關於閉包的更多資訊可以看看這篇文章:作用域和閉包(https://github.com/getify/You-Dont-Know-JS/blob/master/scope%20%26%20closures/ch5.md)。
還有一個方法該怎麼處理?我們假設這個方法叫做 greeting。注意方法(與 JavaScript 的物件有關的方法)其實只是一個屬性,只不過屬性值是函式而已。我們想在 greeting 中實現以下功能:
function initializeFriend(data) {
return {
fullName: `${data.firstName} ${data.lastName}`,
getThreeRandomPosts: function() {
// get three random posts from data.lastTenPosts
},
getDaysUntilBirthday: function() {
// use data.birthday to get the num days until birthday
},
greeting: function() {
return `Hello, this is ${fullName}'s data!`
}
};
}
這樣能正常工作嗎?
不能!
我們新建的物件能夠訪問 initializeFriend 中的一切變數,但不能訪問這個物件本身的屬性或方法。當然你會問,
難道不能在 greeting 中直接用 data.firstName 和 data.lastName 嗎?
當然可以。但要是想在 greeting 中加入距離好友生日的天數怎麼辦?我們最好還是有辦法在 greeting 中呼叫 getDaysUntilBirthday。
這時輪到 this 出場了!
6. 終於——this 是什麼
this 在不同的環境中可以指代不同的東西。預設的全域性環境中 this 指代的是全域性物件(在瀏覽器中 this 是 window 物件),這沒什麼太大的用途。而在 this 的規則中具有實用性的是這一條:
如果在物件的方法中使用 this,而該方法在該物件的上下文中呼叫,那麼 this 指代該物件本身。
你會說“在該物件的上下文中呼叫”……是啥意思?
彆著急,我們一會兒就說。
所以,如果我們想從 greeting 中呼叫 getDaysUntilBirtyday 我們只需要寫 this.getDaysUntilBirthday,因為此時的 this 就是物件本身。
附註:不要在全域性作用域的普通函式或另一個函式的作用域中使用 this!this 是個面向物件的東西,它只在物件的上下文(或類的上下文)中有意義。
我們利用 this 來重寫 initializeFriend:
function initializeFriend(data) {
return {
lastTenPosts: data.lastTenPosts,
birthday: data.birthday,
fullName: `${data.firstName} ${data.lastName}`,
getThreeRandomPosts: function() {
// get three random posts from this.lastTenPosts
},
getDaysUntilBirthday: function() {
// use this.birthday to get the num days until birthday
},
greeting: function() {
const numDays = this.getDaysUntilBirthday()
return `Hello, this is ${this.fullName}'s data! It is ${numDays} until ${this.fullName}'s birthday!`
}
};
}
現在,在 initializeFriend 執行結束後,該物件需要的一切都位於物件本身的作用域之內了。我們的方法不需要再依賴於閉包,它們只會用到物件本身包含的資訊。
好吧,這是 this 的用法之一,但你說過 this 在不同的上下文中有不同的含義。那是什麼意思?為什麼不一定會指向物件自己?
有時候,你需要將 this 指向某個特定的東西。一種情況就是事件處理函式。比如我們希望在使用者點選好友時開啟好友的 Facebook 首頁。我們會給物件新增下面的 onClick 方法:
function initializeFriend(data) {
return {
lastTenPosts: data.lastTenPosts,
birthday: data.birthday,
username: data.username,
fullName: `${data.firstName} ${data.lastName}`,
getThreeRandomPosts: function() {
// get three random posts from this.lastTenPosts
},
getDaysUntilBirthday: function() {
// use this.birthday to get the num days until birthday
},
greeting: function() {
const numDays = this.getDaysUntilBirthday()
return `Hello, this is ${this.fullName}'s data! It is ${numDays} until ${this.fullName}'s birthday!`
},
onFriendClick: function() {
window.open(`https://facebook.com/${this.username}`)
}
};
}
注意我們在物件中添加了 username 屬性,這樣 onFriendClick 就能訪問它,從而在新視窗中開啟該好友的 Facebook 首頁。現在只需要編寫 HTML:
<button id="Bob_Ross">
<!-- A bunch of info associated with Bob Ross -->
</button>
還有 JavaScript:
const bobRossObj = initializeFriend(data[0])
const bobRossDOMEl = document.getElementById('Bob_Ross')
bobRossDOMEl.addEventListener("onclick", bobRossObj.onFriendClick)
在上述程式碼中,我們給 Bob Ross 建立了一個物件。然後我們拿到了 Bob Ross 對應的 DOM 元素。然後執行 onFriendClick 方法來開啟 Bob 的 Facebook 主頁。似乎沒問題,對吧?
有問題!
哪裡出錯了?
注意我們呼叫 onclick 處理程式的程式碼是 bobRossObj.onFriendClick。看到問題了嗎?要是寫成這樣的話能看出來嗎?
bobRossDOMEl.addEventListener("onclick", function() {
window.open(`https://facebook.com/${this.username}`)
})
現在看到問題了嗎?如果把事件處理程式寫成 bobRossObj.onFriendClick,實際上是把 bobRossObj.onFriendClick 上儲存的函式拿出來,然後作為引數傳遞。它不再“依附”在 bobRossObj 上,也就是說,this 不再指向 bobRossObj。它實際指向全域性物件,也就是說 this.username 不存在。似乎我們沒什麼辦法了。
輪到繫結上場了!
7. 明確繫結 this
我們需要明確地將 this 繫結到 bobRossObj 上。我們可以通過 bind 實現:
const bobRossObj = initializeFriend(data[0])
const bobRossDOMEl = document.getElementById('Bob_Ross')
bobRossObj.onFriendClick = bobRossObj.onFriendClick.bind(bobRossObj)
bobRossDOMEl.addEventListener("onclick", bobRossObj.onFriendClick)
之前,this 是按照預設的規則設定的。但使用 bind 之後,我們明確地將 bobRossObj.onFriendClick 中的 this 的值設定為 bobRossObj 物件本身。
到此為止,我們看到了為什麼要使用 this,以及為什麼要明確地繫結 this。最後我們來介紹一下,this 實際上是箭頭函式。
8. 箭頭函式
你也許注意到了箭頭函式最近很流行。人們喜歡箭頭函式,因為很簡潔、很優雅。而且你還知道箭頭函式和普通函式有點區別,儘管不太清楚具體區別是什麼。
簡而言之,兩者的區別在於:
在定義箭頭函式時,不管 this 指向誰,箭頭函式內部的 this 永遠指向同一個東西。
嗯……這貌似沒什麼用……似乎跟普通函式的行為一樣啊?
我們通過 initializeFriend 舉例說明。假設我們想新增一個名為 greeting 的函式:
function initializeFriend(data) {
return {
lastTenPosts: data.lastTenPosts,
birthday: data.birthday,
username: data.username,
fullName: `${data.firstName} ${data.lastName}`,
getThreeRandomPosts: function() {
// get three random posts from this.lastTenPosts
},
getDaysUntilBirthday: function() {
// use this.birthday to get the num days until birthday
},
greeting: function() {
function getLastPost() {
return this.lastTenPosts[0]
}
const lastPost = getLastPost()
return `Hello, this is ${this.fullName}'s data!
${this.fullName}'s last post was ${lastPost}.`
},
onFriendClick: function() {
window.open(`https://facebook.com/${this.username}`)
}
};
}
這樣能執行嗎?如果不能,怎樣修改才能執行?
答案是不能。因為 getLastPost 沒有在物件的上下文中呼叫,因此getLastPost 中的 this 按照預設規則指向了全域性物件。
你說沒有“在物件的上下文中呼叫”……難道它不是從 initializeFriend 返回的內部呼叫的嗎?如果這還不叫“在物件的上下文中呼叫”,那我就不知道什麼才算了。
我知道“在物件的上下文中呼叫”這個術語很模糊。也許,判斷函式是否“在物件的上下文中呼叫”的好方法就是檢查一遍函式的呼叫過程,看看是否有個物件“依附”到了函式上。
我們來檢查下執行 bobRossObj.onFriendClick() 時的情況。“給我物件 bobRossObj,找到其中的 onFriendClick 然後呼叫該屬性對應的函式”。
我們同樣檢查下執行 getLastPost() 時的情況。“給我名為 getLastPost 的函式然後執行。”看到了嗎?我們根本沒有提到物件。
好了,這裡有個難題來測試你的理解程度。假設有個函式名為 functionCaller,它的功能就是呼叫一個函式:
functionCaller(fn) {
fn()
}
如果呼叫 functionCaller(bobRossObj.onFriendClick) 會怎樣?你會認為 onFriendClick 是“在物件的上下文中呼叫”的嗎?this.username有定義嗎?
我們來檢查一遍:“給我 bobRosObj 物件然後查詢其屬性 onFriendClick。取出其中的值(這個值碰巧是個函式),然後將它傳遞給 functionCaller,取名為 fn。然後,執行名為 fn 的函式。”注意該函式在呼叫之前已經從 bobRossObj 物件上“脫離”了,因此並不是“在物件的上下文中呼叫”的,所以 this.username 沒有定義。
這時可以用箭頭函式解決這個問題:
function initializeFriend(data) {
return {
lastTenPosts: data.lastTenPosts,
birthday: data.birthday,
username: data.username,
fullName: `${data.firstName} ${data.lastName}`,
getThreeRandomPosts: function() {
// get three random posts from this.lastTenPosts
},
getDaysUntilBirthday: function() {
// use this.birthday to get the num days until birthday
},
greeting: function() {
const getLastPost = () => {
return this.lastTenPosts[0]
}
const lastPost = getLastPost()
return `Hello, this is ${this.fullName}'s data!
${this.fullName}'s last post was ${lastPost}.`
},
onFriendClick: function() {
window.open(`https://facebook.com/${this.username}`)
}
};
}
上述程式碼的規則是:
在定義箭頭函式時,不管 this 指向誰,箭頭函式內部的 this 永遠指向同一個東西。
箭頭函式是在 greeting 中定義的。我們知道,在 greeting 內部的 this 指向物件本身。因此,箭頭函式內部的 this 也指向物件本身,這正是我們需要的結果。
9. 結論
this 有時很不好理解,但它對於開發 JavaScript 應用非常有用。本文當然沒能介紹 this 的所有方面。一些沒有涉及到的話題包括:
● call 和 apply;● 使用 new 時 this 會怎樣;
● 在 ES6 的 class 中 this 會怎樣。
我建議你首先問問自己在這些情況下的 this,然後在瀏覽器中執行程式碼來檢驗你的結果。
想學習更多關 於thi