應(yīng)該在JavaScript中使用Class嗎?

作者:FreewheelLee
來源:https://zhuanlan.zhihu.com/p/158956514
看似無處不在的OOP
OOP 即 面向?qū)ο缶幊?(Object Oriented Programming)毫無疑問是軟件設(shè)計和發(fā)展中的一大進步。事實上,一些編程語言如 Java 、C++ 就是基于 OOP 的核心概念 class 開發(fā)出來。
在高校的 CS 相關(guān)專業(yè)中,無論教授什么編程語言,OOP的學(xué)習(xí)是絕對不會被落下的。
同時,OOP在業(yè)界中也的確被大量使用,尤其是的后端服務(wù)領(lǐng)域、桌面軟件、移動APP開發(fā)等。
因此,OOP看起來在軟件行業(yè)無處不在,在這種有點教條主義的氛圍下,很多程序員甚至以為 class 是編程固有的概念 —— 然而并不是。
OOP 只是一套幫助開發(fā)者設(shè)計和編寫軟件的方法論,但并不代表它能解決所有領(lǐng)域的問題,也不是能在所有編程語言的任何場景下都適用。我們應(yīng)避免陷入這種教條主義。
JavaScript中使用Class的坑
ES6 之后,JavaScript 也引入了 class 關(guān)鍵字用于聲明一個類。但需要注意的是,這樣聲明出來的類其實在底層還是使用了 JavaScript 的函數(shù) 和 原型鏈 (來模擬類的行為)
看個例子:
class Person {
constructor (name) {
this.name = name
}
talk () {
console.log(`${this.name} says hello`)
}
}
上面的代碼在底層實現(xiàn)時,非常接近于
function Person (name) {
this.name = name
}
Person.prototype.talk = function () {
console.log(`${this.name} says hello`)
}
這邊可以注意到 talk 其實并不是一個Person類內(nèi)部封裝的方法,而只是一個常規(guī)的JavaScript函數(shù),賦值到了Person的原型上而已。因此,talk 函數(shù)里的 this 對應(yīng)的是調(diào)用時的上下文而不是定義時的上下文,這點跟 Java 和 C++ 的差別很大。
這種差異最明顯的影響是在別的對象試圖調(diào)用這個對象的talk時
const Grey = new Person('Grey')
const mockDomButton = {} // 模擬一個DOM上的按鈕對象
mockDomButton.onClick = Grey.talk; // 綁定點擊事件
mockDomButton.onClick() // 輸出的結(jié)果是 undefined says hello
上面這段模擬代碼輸出的結(jié)果并不是我們想要的。原因是 onClick 被調(diào)用時,其實是 talk 函數(shù)在執(zhí)行,且talk 函數(shù)的this 指向的是 mockDomButton 而不是 Grey ,mockDomButton 并沒有 name 屬性于是 輸出了 undefined says hello
這種“特殊”的表現(xiàn)讓很多 JavaScript 新手感到頭疼,尤其是那些從 Java 或者 C++ 背景過來的新手前端程序員。
解決這個問題的辦法當(dāng)然是有的,先介紹兩個仍然使用 class 的方案
方案一:
使用函數(shù)的 bind 方法
**
bind()**方法創(chuàng)建一個新的函數(shù),在bind()被調(diào)用時,這個新函數(shù)的this被指定為bind()的第一個參數(shù)
修改 Person.js 文件如下
class Person {
constructor (name) {
this.name = name
this.talk = this.talk.bind(this); // 在構(gòu)造器里顯式調(diào)用 bind 函數(shù)綁定 this
}
talk () {
console.log(`${this.name} says hello`)
}
}
再次運行上面的測試代碼,這次的輸出就是正確的了 —— Grey says hello
這種方案的缺點就是需要繁瑣地寫這種 bind 方法調(diào)用語句,當(dāng)這個類的方法很多時,會顯得構(gòu)造器非常臃腫,降低可讀性和編碼效率如

方案二:
使用類屬性+箭頭函數(shù)的方式來定義方法
即
class Person {
constructor(name) {
this.name = name
}
talk = () => {
console.log(`${this.name} says hello`)
}
}
這種語法是 ES2017 才引入的,它等效于
class Person {
constructor(name) {
this.name = name
this.talk = () => {
console.log(`${this.name} says hello`)
}
}
}
運行測試代碼,依然能成功輸出 Grey says hello
但是,這種方案也有缺點 —— 由于它等效于函數(shù)定義放在了構(gòu)造器內(nèi),所以
一、這個方法不在原型鏈上,即 Person.prototype.talk 的值是undefined ,所以這個類的子類并不能使用 super.talk() 調(diào)用到父類這個方法,所以下面這段代碼會報錯
class Student extends Person {
talk = () => {
super.talk(); // 報錯
console.log("student talk hi");
}
}
const student = new Student('Tom');
student.talk();
二、每次創(chuàng)建一個 Person 實例都會創(chuàng)建一個 talk 函數(shù),造成性能浪費 (僅僅是用來與方案一對比)
const Grey = new Person('Grey')
const Tom = new Person('Tom')
console.log(Grey.talk === Tom.talk); // 輸出 false
在 JavaScript 中使用類居然有上面這么多坑,那何不試試其他方案?
首先,我們回到源頭想想什么是類,我們想利用類達到什么目的:
大多數(shù)時候,我們定義的類 其實是 創(chuàng)建對象的藍圖(模板) —— 我們先規(guī)劃好一個類的模樣,之后通過 new 的方式創(chuàng)建出許許多多的對象,每個對象都符合我們想要的格式(即屬性,方法)
在 JavaScript 中,我們還有其他方案可以達到這個目的
工廠函數(shù)(factory functions)
const PersonFactory = (name) => {
return {
talk: () => {
console.log(`${name} says Hello`)
}
}
}
PersonFactory 是個簡單的工廠函數(shù),它返回一個對象,這個對象擁有一個 talk 方法
(p.s. 我更新了一下代碼,看起來可讀性更高一點,想看原版代碼的可以查看歷史記錄)
const Grey = PersonFactory('Grey'); // 使用工廠函數(shù)生成對象
const mockDomButton = {} // 模擬一個DOM上的按鈕對象
mockDomButton.onClick = Grey.talk; // 綁定點擊事件
mockDomButton.onClick() // 輸出的結(jié)果是 Grey says Hello
由于JavaScript的閉包特性,name已經(jīng)被封裝在了函數(shù)里,所以上面的測試代碼可以正常運作。而且更贊的是,這個方案中,name甚至自動成為了私有的變量,不怕被更改(上面的那些 class 方案里 name 都可以被公共訪問的)
而且相比之下,工廠函數(shù)的代碼更簡潔易讀,也不需要考慮 this 的繁瑣問題。
因此,如果只是為了給對象創(chuàng)建繪制藍圖(模板),工廠函數(shù)是比類更合適的方案。
繼承
類的另一個特征是繼承機制,子類可以繼承(分享)來自父類的屬性和方法。
如果僅僅是共享屬性和方法,使用組合(composition)也可以很容易實現(xiàn)
const Workable = {
inOffice: true
}
const WorkablePersonFactory = (name) => (
Object.assign(
{},
Workable,
PersonFactory(name)
)
)
// 或者
const WorkablePersonFactory = (name) => (
{
... Workable,
...PersonFactory(name),
}
)
上面的代碼意圖十分明顯,可讀性很高,這也是組合模式的一個優(yōu)點。
當(dāng)然,對于某些更復(fù)雜的類使用場景,工廠函數(shù)并不能替代類。
關(guān)注代碼表達性而不是死守教條主義
在 JavaScript 的現(xiàn)實場景中,尤其是前端代碼,我們很少真正用到類繼承,大多數(shù)時候,工廠函數(shù)就能完成我們的目標(biāo)。
以React為例,官方這幾年推崇 Hooks 的意圖也很明顯 —— 擺脫JavaScript class 帶來的復(fù)雜性,擁抱函數(shù)式風(fēng)格。
由于 JavaScript 實現(xiàn)的特殊性,在 JavaScript 應(yīng)用中使用 class 對于一些程序員來說有許多坑,于此同時,大多數(shù)場景下其他替代方案如 工廠函數(shù) 可能更契合JavaScript的特性,反而帶來更好的效果。
當(dāng)然,并不是一桿子打死 JavaScript 的 class,在一些特別適合 OOP 的場景中,依然鼓勵使用 class?。
總之,不要被教條主義所束縛,牢記編寫程序最重要的兩點是:
真正將需求轉(zhuǎn)化成了代碼 寫出可讀的,容易維護的,方便理解的代碼
個人體驗
在我的工作負責(zé)的幾個項目中,其中一個Nodejs項目,我發(fā)現(xiàn)了大量的不必要的 class ,在constructor中充斥著大量的 bind 語句,而且這些 class 的方法之間并沒有太多關(guān)系,很多class也沒有內(nèi)部狀態(tài);更像是為了聲明這些函數(shù)是屬于同一個模塊而已 —— 也就是說根本不必要以 class 的形式組織代碼。
于是,在日常任務(wù)完成之余,我就花了點時間,把這些class方法全部重構(gòu)成普通的 function,再利用 ES6 module 的形式重新組織這些代碼。現(xiàn)在這些代碼干凈、清晰了很多。
(關(guān)于 Nodejs 的 module 以及如何在 Nodejs 中使用 ES6 module,歡迎閱讀我的另一篇文章 )
FreewheelLee:[搬運] JavaScript 模塊化:CommonJS vs AMD vs ES6:
補充:
本文的討論的場景主要是基于業(yè)務(wù)開發(fā)的上下文,不包括底層庫、工具庫開發(fā)等場景。
1. bind 以外的其他方案
感謝 @賀師俊 大佬的提醒
class fields或者autobind decorator都有很多問題,而且這兩者還不是最終標(biāo)準(zhǔn),建議不要用
讀者們可以參考
2. 關(guān)于 工廠函數(shù) 的舉例
首先這個例子主要是針對這種場景 ——在 JavaScript 給創(chuàng)建某類對象定制一個標(biāo)準(zhǔn),以便可以用這個?模板?創(chuàng)建許多對象
這個例子的確還不夠亮眼,那我再舉個更實際的例子吧
function httpClientFactory(baseUrl) {
return {
baseUrl: baseUrl,
listUsers: () => {
return axios.get(`${baseUrl}/users`)
},
getUser: (id) => {
return axios.get(`${baseUrl}/users/${id}`)
},
createUser: (user) => {
return axios.post(`${baseUrl}/users`, user);
},
listBooks: () => {
return axios.get(`${baseUrl}/books`)
},
getBook: (bookName) => {
return axios.get(`${baseUrl}/books/${bookName}`)
},
createBook: (book) => {
return axios.post(`${baseUrl}/books`, book)
}
}
}
const httpClient = httpClientFactory("https://your-endpoints/api");
httpClient.getUser("123");
httpClient.getBook("JavaScript Is Interesting");
console.log("The httpClient's baseUrl is " + httpClient.baseUrl);
對比
class HttpClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.listUsers = this.listUsers.bind(this);
this.getUser = this.getUser.bind(this);
this.createUser = this.createUser.bind(this);
this.listBooks = this.listBooks.bind(this);
this.getBook = this.listUsers.bind(this);
this.createBook = this.createBook.bind(this);
}
listUsers() {
return axios.get(`${this.baseUrl}/users`)
}
getUser(id) {
return axios.get(`${this.baseUrl}/users/${id}`)
}
createUser(user) {
return axios.post(`${this.baseUrl}/users`, user);
}
listBooks() {
return axios.get(`${this.baseUrl}/books`)
}
getBook(bookName) {
return axios.get(`${this.baseUrl}/books/${bookName}`)
}
createBook(book) {
return axios.post(`${this.baseUrl}/books`, book)
}
}
const httpClient = new HttpClient("https://your-endpoints/api");
httpClient.getUser("123");
httpClient.getBook("JavaScript Is Interesting");
console.log("The httpClient's baseUrl is " + httpClient.baseUrl);
感受一下代碼的整潔程度
(彩蛋:bind 語句復(fù)制粘貼導(dǎo)致的bug你們發(fā)現(xiàn)了嗎?)
3. 注意使用 class 的初衷
太多開發(fā)者一上來就寫個class的原因通常是因為 他/她 是從OOP背景過來的 —— 在Java,你不能光禿禿地定義一個常量,一個函數(shù)或者一個表達式,你得先有個類,然后在類里定義一個靜態(tài)不可變的屬性 (public static final 三連) 才能產(chǎn)生一個常量,類似的,也只能在類里定義一個(靜態(tài)或者非靜態(tài))的方法才能讓函數(shù)有容身之地 (為了防杠,我謹慎加一條 —— Java 8 的 functional interface 開始可以讓函數(shù)單獨出來走兩步了,但前提還是要有interface)
如果你想好好寫 native JavaScript,那么你通常不需要一個類
// xxx.js
import _ from 'lodash';
export const BOOK_NAME_PREFIX = "JS_"; // 定義常量
export const DEFAULT_USER_AGE = 18;
export const convertVarToObject = function (v) { // 定義一個工具方法,將傳入的值包裝返回一個對象
// ...
}
const privateSecret = "zhimakaimen"; // 不export的常量自然變成模塊私有的
function privateFunc(){ // 同樣可以定義模塊私有的函數(shù)
// ...
}
export default { // 可以export出自定義的對象(包含自定義的屬性)
render: xxx,
property: yyy,
}
直接在 js module 里定義常量、函數(shù),然后 export 出來給其他模塊用,這么簡單直接不香嗎?(js module 里也可以定義私有的變量、常量、函數(shù)等)
再次推薦閱讀 這篇文章,好好理解 js 模塊,別再像 Java 那樣只用 class 來組織所有代碼了。
FreewheelLee:[搬運] JavaScript 模塊化:CommonJS vs AMD vs ES6:
4. 使用 class 的心智負擔(dān)
業(yè)務(wù)代碼中,現(xiàn)在大家寫 JavaScript class 相信已經(jīng)不會再直接訪問 prototype 了,而是使用 class 關(guān)鍵字 —— 而 class 關(guān)鍵字的底層實現(xiàn)仍然是 prototype,仍然要考慮 this 的復(fù)雜性,在復(fù)雜的繼承場景中甚至仍然得理解 prototype chaining
也就是說,一個新手接觸/維護一個由大量類構(gòu)成的項目時,他要么趕緊精通理解JavaScript class,要么就很可能掉進坑里。
我在個人體驗里談到的那個Nodejs項目,實習(xí)生新增一個方法后忘記加bind語句,然后程序一直報錯 ReferenceError: XXX is not defined, 他一頭霧水 —— ”明明方法定義就在那兒啊!“
當(dāng)然這是因為實習(xí)生的基礎(chǔ)問題,他需要更多學(xué)習(xí)歷練,但話說回來**這樣的心智負擔(dān)真的有必要嗎?為什么不讓程序更簡單明了一點?**僅僅是為了讓代碼看起來更 OOP 嗎?
這個油管視頻 https://www.youtube.com/watch?v=Tllw4EPhLiQ (有條件的讀者可以看看) 里說?在 JavaScript添加 class 關(guān)鍵字?就好像
giving clean needles to meth addicts
簡單來說,JavaScript 并不擅長玩 OOP class 這一套,它有自己非常擅長且自然而然的風(fēng)格(函數(shù)式),如果你想好好學(xué) JavaScript 且正宗地用好 JavaScript ,我個人十分建議,把你花在 JavaScript OOP上的時間用來先搞清楚 JavaScript function 和 閉包 (React 開發(fā)者學(xué)好 Hooks)—— 然后再去學(xué) class、prototype 等知識
牢記JavaScript的一個特性 —— Functions are first-class in JavaScript 函數(shù)是一等公民
5. 工廠函數(shù)會每次都重復(fù)生成函數(shù)(影響性能)嗎?
可以參考這個回答
brambles:現(xiàn)代瀏覽器生成一個 JS 函數(shù)的開銷多大?React hooks 的設(shè)計頻繁生成新函數(shù)對性能有影響嗎?www.zhihu.com
另外,可以簡單回想一下,在我們?nèi)粘I(yè)務(wù)開發(fā)中,真的有需要創(chuàng)建那么多類對象嗎?
你寫的類里被 new 過幾次?真的每次 new 都有必要嗎?如果沒有,往上看第 3 點。
6. @賀師俊 賀大提到另一個點
class具有更高的聲明性和靜態(tài)可分析性,也跟platform api更為一致,同時在現(xiàn)代引擎里也有更好的優(yōu)化
感謝賀大的指出,底層庫的開發(fā)我本人經(jīng)歷不多,目前接觸更多是還是業(yè)務(wù)代碼為主。
至于引擎的代碼優(yōu)化,我持保留意見,之前在研究React Hooks的時候,不記得在哪看到過React的官方開發(fā)者認為在未來 Functional Component 的優(yōu)化有比 Class Component 更好的趨勢(原句和原文我暫時找不到了,找到了再補充回來,有讀者看到過也可以評論給我,謝謝) —— 更新:找到了 https://zh-hans.reactjs.org/docs/hooks-intro.html#classes-confuse-both-people-and-machines

》聲明:文章著作權(quán)歸作者所有,如有侵權(quán),請聯(lián)系小編刪除。
