應(yīng)該在JavaScript中使用Class嗎
看似無處不在的OOP
OOP 即 面向?qū)ο缶幊?(Object Oriented Programming)毫無疑問是軟件設(shè)計和發(fā)展中的一大進(jìn)步。事實上,一些編程語言如 Java 、C++ 就是基于 OOP 的核心概念 class 開發(fā)出來。
在高校的 CS 相關(guān)專業(yè)中,無論教授什么編程語言,OOP的學(xué)習(xí)是絕對不會被落下的。
同時,OOP在業(yè)界中也的確被大量使用,尤其是的后端服務(wù)領(lǐng)域、桌面軟件、移動APP開發(fā)等。
因此,OOP看起來在軟件行業(yè)無處不在,在這種有點(diǎn)教條主義的氛圍下,很多程序員甚至以為 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)用時的上下文而不是定義時的上下文」,這點(diǎn)跟 Java 和 C++ 的差別很大。
這種差異最明顯的影響是在別的對象試圖調(diào)用這個對象的talk時
const?Grey?=?new?Person('Grey')
const?mockDomButton?=?{}?//?模擬一個DOM上的按鈕對象
mockDomButton.onClick?=?Grey.talk;?//?綁定點(diǎn)擊事件
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`)
??}
}
再次運(yùn)行上面的測試代碼,這次的輸出就是正確的了 —— Grey says hello
這種方案的缺點(diǎn)就是需要繁瑣地寫這種 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`)
????}
??}
}
運(yùn)行測試代碼,依然能成功輸出 Grey says hello
但是,這種方案也有缺點(diǎn) —— 由于它等效于函數(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ù),造成性能浪費(fèi) (僅僅是用來與方案一對比)
const?Grey?=?new?Person('Grey')
const?Tom?=?new?Person('Tom')
console.log(Grey.talk?===?Tom.talk);?//??輸出?false
在 JavaScript 中使用類居然有上面這么多坑,那何不試試其他方案?
首先,我們回到源頭想想什么是類,我們想利用類達(dá)到什么目的:
大多數(shù)時候,我們定義的類 其實是 創(chuàng)建對象的藍(lán)圖(模板) —— 我們先規(guī)劃好一個類的模樣,之后通過 new 的方式創(chuàng)建出許許多多的對象,每個對象都符合我們想要的格式(即屬性,方法)
在 JavaScript 中,我們還有其他方案可以達(dá)到這個目的
工廠函數(shù)(factory functions)
const?PersonFactory?=?(name)?=>?{
??return?{
????talk:?()?=>?{
??????console.log(`${name}?says?Hello`)
????}
??}
}
PersonFactory 是個簡單的工廠函數(shù),它返回一個對象,這個對象擁有一個 talk 方法
(p.s. 我更新了一下代碼,看起來可讀性更高一點(diǎn),想看原版代碼的可以查看歷史記錄)
const?Grey?=?PersonFactory('Grey');?//?使用工廠函數(shù)生成對象
const?mockDomButton?=?{}?//?模擬一個DOM上的按鈕對象
mockDomButton.onClick?=?Grey.talk;?//?綁定點(diǎn)擊事件
mockDomButton.onClick()?//?輸出的結(jié)果是?Grey?says?Hello
由于JavaScript的「閉包」特性,name已經(jīng)被封裝在了函數(shù)里,所以上面的測試代碼可以正常運(yùn)作。而且更贊的是,這個方案中,name甚至自動成為了「私有的變量」,不怕被更改(上面的那些 class 方案里 name 都可以被公共訪問的)
而且相比之下,工廠函數(shù)的代碼更簡潔易讀,也不需要考慮 this 的繁瑣問題。
因此,「如果只是為了給對象創(chuàng)建繪制藍(lán)圖(模板),工廠函數(shù)是比類更合適的方案」。
繼承
類的另一個特征是繼承機(jī)制,子類可以繼承(分享)來自父類的屬性和方法。
如果僅僅是共享屬性和方法,使用組合(composition)也可以很容易實現(xiàn)
const?Workable?=?{
??inOffice:?true
}
const?WorkablePersonFactory?=?(name)?=>?(
??Object.assign(
????{},
????Workable,
????PersonFactory(name)
??)
)
//?或者
const?WorkablePersonFactory?=?(name)?=>?(
????{
?????...?Workable,
?????...PersonFactory(name),
????}
)
上面的代碼意圖十分明顯,可讀性很高,這也是組合模式的一個優(yōu)點(diǎn)。
當(dāng)然,對于某些更復(fù)雜的類使用場景,工廠函數(shù)并不能替代類。
關(guān)注代碼表達(dá)性而不是死守教條主義
在 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」 。
總之,不要被教條主義所束縛,牢記編寫程序最重要的兩點(diǎn)是:
真正將需求轉(zhuǎn)化成了代碼 寫出可讀的,容易維護(hù)的,方便理解的代碼
沒想到這篇文章有這么高的閱讀量,以及部分爭議。統(tǒng)一回復(fù)一下吧。
「本文的討論的場景主要是基于業(yè)務(wù)開發(fā)的上下文,不包括底層庫、工具庫開發(fā)等場景?!?/strong>
1. bind 以外的其他方案
感謝
@賀師俊
大佬的提醒
?class fields或者autobind decorator都有很多問題,而且這兩者還不是最終標(biāo)準(zhǔn),建議不要用
?
讀者們可以參考
關(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)了嗎?)
注意使用 class 的初衷
太多開發(fā)者一上來就寫個class的原因通常是因為 他/她 是從OOP背景過來的 —— 在Java,你不能光禿禿地定義一個常量,一個函數(shù)或者一個表達(dá)式,你得先有個類,然后在類里定義一個靜態(tài)不可變的屬性 (public static final 三連) 才能產(chǎn)生一個常量,類似的,也只能在類里定義一個(靜態(tài)或者非靜態(tài))的方法才能讓函數(shù)有容身之地 (為了防杠,我謹(jǐn)慎加一條 —— Java 8 的 functional interface 開始可以讓函數(shù)單獨(dú)出來走兩步了,但前提還是要有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 來組織所有代碼了。
JavaScript 模塊化:CommonJS vs AMD vs ES6:https://zhuanlan.zhihu.com/p/158683510
使用 class 的心智負(fù)擔(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
也就是說,一個新手接觸/維護(hù)一個由大量類構(gòu)成的項目時,他要么趕緊精通理解JavaScript class,要么就很可能掉進(jìn)坑里。
我在個人體驗里談到的那個Nodejs項目,實習(xí)生新增一個方法后忘記加bind語句,然后程序一直報錯 ReferenceError: XXX is not defined, 他一頭霧水 —— ”明明方法定義就在那兒??!“
當(dāng)然這是因為實習(xí)生的基礎(chǔ)問題,他需要更多學(xué)習(xí)歷練,但話說回來**這樣的心智負(fù)擔(dān)真的有必要嗎?為什么不讓程序更簡單明了一點(diǎn)?**僅僅是為了讓代碼看起來更 OOP 嗎?
這個油管視頻 https://www.youtube.com/watch?v=Tllw4EPhLiQ (有條件的讀者可以看看) 里說 「在 JavaScript添加 class 關(guān)鍵字」 就好像
?giving clean needles to meth addicts
?
給(xi du的)癮君子送來一些干凈的針頭 (太犀利了?。ㄓ锌鋸埑煞?狗頭護(hù)體)
簡單來說,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ù)是一等公民」
工廠函數(shù)會每次都重復(fù)生成函數(shù)(影響性能)嗎?
可以參考這個回答
https://www.zhihu.com/answer/943385371
另外,可以簡單回想一下,在我們?nèi)粘I(yè)務(wù)開發(fā)中,真的有需要創(chuàng)建那么多類對象嗎?
你寫的類里被 new 過幾次?真的每次 new 都有必要嗎?如果沒有,往上看第 3 點(diǎn)。
@賀師俊
賀大提到另一個點(diǎn)
?class具有更高的聲明性和靜態(tài)可分析性,也跟platform api更為一致,同時在現(xiàn)代引擎里也有更好的優(yōu)化
?
感謝賀大的指出,底層庫的開發(fā)我本人經(jīng)歷不多,目前接觸更多是還是業(yè)務(wù)代碼為主。
至于引擎的代碼優(yōu)化,我持保留意見,之前在研究React Hooks的時候,不記得在哪看到過React的官方開發(fā)者認(rèn)為在未來 Functional Component 的優(yōu)化有比 Class Component 更好的趨勢(原句和原文我暫時找不到了,找到了再補(bǔ)充回來,有讀者看到過也可以評論給我,謝謝) —— 更新:找到了 https://zh-hans.reactjs.org/docs/hooks-intro.html#classes-confuse-both-people-and-machines

后記
挺意外這篇文章有這么大的關(guān)注度,多謝大家的支持和討論。
其實我個人還是有點(diǎn)耿耿于懷的,雖然文章整體表達(dá)了我的觀點(diǎn),但感覺并沒有完全把 JavaScript class 的所有坑介紹清楚(僅提了比較常見的 bind 問題),其實還有 prototype 的機(jī)制差異、prototype chain 等問題,但是限于篇幅就沒寫出來。
接下來我會繼續(xù)寫一篇后續(xù)的相關(guān)的文章,接著討論 JavaScript 和 OOP 碰撞的另一簇火花 —— 原來不使用 class ,JavaScript 依然能借鑒前人OOP的最佳實踐和經(jīng)驗!、
分享前端好文,點(diǎn)亮?在看?
