作為一名 JS 開發人員,這是讓我徹夜難眠的原因

已發表: 2022-03-11

JavaScript 是一種奇怪的語言。 雖然受到 Smalltalk 的啟發,但它使用了類似 C 的語法。 它結合了過程、功能和麵向對象編程 (OOP) 範式的各個方面。 它有許多(通常是多餘的)方法來解決幾乎任何可以想像的編程問題,並且對於哪些是首選方法沒有強烈的意見。 它是弱類型和動態類型,具有迷宮般的類型強制方法,即使是經驗豐富的開發人員也會絆倒。

JavaScript 也有它的缺陷、陷阱和有問題的特性。 新程序員會在其中的一些更困難的概念上苦苦掙扎——想想異步性、閉包和提升。 具有其他語言經驗的程序員合理地假設具有相似名稱和外觀的事物在 JavaScript 中將以相同的方式工作,並且通常是錯誤的。 數組並不是真正的數組。 this有什麼關係,原型是什麼, new實際做了什麼?

ES6 類的問題

到目前為止,最嚴重的違規者是 JavaScript 最新版本 ECMAScript 6 (ES6) 的新版本: classes 。 坦率地說,一些圍繞類的討論令人擔憂,並揭示了對該語言實際工作方式的根深蒂固的誤解:

“有了類,JavaScript 終於成為真正的面向對象語言了!”

要么:

“類讓我們從思考 JavaScript 損壞的繼承模型中解放出來。”

甚至:

“類是在 JavaScript 中創建類型的更安全、更簡單的方法。”

這些陳述並沒有打擾我,因為它們暗示原型繼承存在問題。 讓我們擱置這些論點。 這些陳述讓我感到困擾,因為它們都不是真的,它們展示了 JavaScript 的“一切為了每個人”的語言設計方法的後果:它削弱了程序員對語言的理解,而不是它所能實現的。 在我繼續之前,讓我們舉例說明。

JavaScript Pop Quiz #1:這些代碼塊之間的本質區別是什麼?

 function PrototypicalGreeting(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } PrototypicalGreeting.prototype.greet = function() { return `${this.greeting}, ${this.name}!` } const greetProto = new PrototypicalGreeting("Hey", "folks") console.log(greetProto.greet())
 class ClassicalGreeting { constructor(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } greet() { return `${this.greeting}, ${this.name}!` } } const classyGreeting = new ClassicalGreeting("Hey", "folks") console.log(classyGreeting.greet())

這裡的答案是沒有。 它們有效地做同樣的事情,只是是否使用了 ES6 類語法的問題。

誠然,第二個例子更具表現力。 僅出於這個原因,您可能會爭辯說class是該語言的一個很好的補充。 不幸的是,這個問題有點微妙。

JavaScript 小測驗 #2:以下代碼有什麼作用?

 function Proto() { this.name = 'Proto' return this; } Proto.prototype.getName = function() { return this.name } class MyClass extends Proto { constructor() { super() this.name = 'MyClass' } } const instance = new MyClass() console.log(instance.getName()) Proto.prototype.getName = function() { return 'Overridden in Proto' } console.log(instance.getName()) MyClass.prototype.getName = function() { return 'Overridden in MyClass' } console.log(instance.getName()) instance.getName = function() { return 'Overridden in instance' } console.log(instance.getName())

正確的答案是它打印到控制台:

 > MyClass > Overridden in Proto > Overridden in MyClass > Overridden in instance

如果您回答錯誤,您將不了解實際的class是什麼。 這不是你的錯。 就像Array一樣, class不是語言特性,它是語法晦澀難懂。 它試圖隱藏原型繼承模型和隨之而來的笨拙習語,這意味著 JavaScript 正在做一些它沒有做的事情。

您可能聽說過 JavaScript 中引入了class ,以使來自 Java 等語言的經典 OOP 開發人員更適應 ES6 類繼承模型。 如果您這些開發人員中的一員,那麼該示例可能會讓您感到震驚。 這應該。 它表明 JavaScript 的class關鍵字沒有附帶任何類應提供的保證。 它還展示了原型繼承模型的主要區別之一:原型是對象實例,而不是類型

原型與類

基於類和基於原型的繼承之間最重要的區別在於,類定義了一種可以在運行時實例化的類型,而原型本身就是一個對象實例。

ES6 類的子類是另一種類型定義,它使用新的屬性和方法擴展父類,而這些屬性和方法又可以在運行時實例化。 原型的子對像是另一個對象實例,它將任何未在子對像上實現的屬性委託給父對象。

旁注:您可能想知道為什麼我提到了類方法,而不是原型方法。 那是因為 JavaScript 沒有方法的概念。 函數在 JavaScript 中是一等的,它們可以具有屬性,也可以是其他對象的屬性。

類構造函數創建類的實例。 JavaScript 中的構造函數只是一個簡單的舊函數,它返回一個對象。 JavaScript 構造函數的唯一特殊之處在於,當使用new關鍵字調用時,它將其原型分配為返回對象的原型。 如果這聽起來讓您感到有些困惑,那麼您並不孤單——確實如此,這也是為什麼對原型了解甚少的重要原因。

確切地說,原型的子代不是其原型的副本,也不是與原型具有相同形狀的對象。 子對像原型有一個活動引用,並且子對像上不存在的任何原型屬性都是對原型上同名屬性的單向引用。

考慮以下:

 let parent = { foo: 'foo' } let child = { } Object.setPrototypeOf(child, parent) console.log(child.foo) // 'foo' child.foo = 'bar' console.log(child.foo) // 'bar' console.log(parent.foo) // 'foo' delete child.foo console.log(child.foo) // 'foo' parent.foo = 'baz' console.log(child.foo) // 'baz'
注意:在現實生活中你幾乎永遠不會編寫這樣的代碼——這是一種糟糕的做法——但它簡潔地展示了這個原則。

在前面的示例中,雖然child.fooundefined ,但它引用parent.foo 。 一旦我們在child上定義了foochild.foo的值'bar' ,但是parent.foo保留了它的原始值。 一旦我們delete child.foo ,它就會再次引用parent.foo ,這意味著當我們更改父級的值時, child.foo引用新的值。

讓我們看看剛剛發生的事情(為了更清楚地說明,我們將假設這些是Strings而不是字符串文字,這裡的區別並不重要):

遍歷原型鏈以展示 JavaScript 如何處理丟失的引用。

this 在底層的工作方式,尤其是newthis的特性,是另一天的話題,但如果您想閱讀更多內容,Mozilla 有一篇關於 JavaScript 原型繼承鏈的詳盡文章。

關鍵的一點是原型沒有定義type ; 它們本身就是instances ,並且它們在運行時是可變的,具有所有暗示和需要。

還在我這兒? 讓我們回到剖析 JavaScript 類。

JavaScript 小測驗 #3:如何在類中實現隱私?

我們上面的原型和類屬性與其說是“封裝”,不如說是“搖搖晃晃地掛在窗外”。 我們應該解決這個問題,但如何解決?

這裡沒有代碼示例。 答案是你不能。

JavaScript 沒有任何隱私的概念,但它確實有閉包:

 function SecretiveProto() { const secret = "The Class is a lie!" this.spillTheBeans = function() { console.log(secret) } } const blabbermouth = new SecretiveProto() try { console.log(blabbermouth.secret) } catch(e) { // TypeError: SecretiveClass.secret is not defined } blabbermouth.spillTheBeans() // "The Class is a lie!"

你明白剛剛發生了什麼嗎? 如果不是,你不了解閉包。 沒關係,真的 - 它們並不像他們想像的那樣令人生畏,它們非常有用,你應該花一些時間來了解它們。

JavaScript Pop Quiz #4:使用class關鍵字與上面的等效項是什麼?

對不起,這是另一個技巧問題。 你可以做基本相同的事情,但它看起來像這樣:

 class SecretiveClass { constructor() { const secret = "I am a lie!" this.spillTheBeans = function() { console.log(secret) } } looseLips() { console.log(secret) } } const liar = new SecretiveClass() try { console.log(liar.secret) } catch(e) { console.log(e) // TypeError: SecretiveClass.secret is not defined } liar.spillTheBeans() // "I am a lie!"

讓我知道這是否比SecretiveProto更容易或更清晰。 在我個人看來,情況更糟——它破壞了 JavaScript 中class聲明的慣用用法,而且它不像你期望的那樣工作,比如來自 Java。 這將通過以下方式明確:

JavaScript 小測驗 #5: SecretiveClass::looseLips()做什麼?

讓我們來了解一下:

 try { liar.looseLips() } catch(e) { // ReferenceError: secret is not defined }

嗯……這很尷尬。

JavaScript Pop Quiz #6:有經驗的 JavaScript 開發人員更喜歡哪個——原型還是類?

你猜對了,這是另一個技巧問題——有經驗的 JavaScript 開發人員往往會盡可能避免這兩個問題。 這是使用慣用 JavaScript 完成上述操作的好方法:

 function secretFactory() { const secret = "Favor composition over inheritance, `new` is considered harmful, and the end is near!" const spillTheBeans = () => console.log(secret) return { spillTheBeans } } const leaker = secretFactory() leaker.spillTheBeans()

這不僅僅是為了避免繼承固有的醜陋,或者強制封裝。 想想你還可以用secretFactoryleaker做些什麼,而用原型或類做不到這些。

一方面,您可以對其進行解構,因為您不必擔心this的上下文:

 const { spillTheBeans } = secretFactory() spillTheBeans() // Favor composition over inheritance, (...)

這很不錯。 除了避免newthis愚蠢之外,它還允許我們將我們的對象與 CommonJS 和 ES6 模塊互換使用。 它還使合成更容易一些:

 function spyFactory(infiltrationTarget) { return { exfiltrate: infiltrationTarget.spillTheBeans } } const blackHat = spyFactory(leaker) blackHat.exfiltrate() // Favor composition over inheritance, (...) console.log(blackHat.infiltrationTarget) // undefined (looks like we got away with it)

blackHat的客戶不必擔心exfiltrate是從哪裡來的, spyFactory也不必弄亂Function::bind上下文雜耍或深度嵌套的屬性。 請注意,在簡單的同步過程代碼中我們不必太擔心this ,但它會導致異步代碼中的各種問題,最好避免。

稍加思考, spyFactory就可以發展成一個高度複雜的間諜工具,可以處理各種滲透目標——或者換句話說,一個門面。

當然你也可以用一個類,或者更確切地說,一個分類的類,所有這些類都繼承自一個abstract classinterface ……除了 JavaScript 沒有任何抽像或接口的概念。

讓我們回到 greeter 示例,看看我們如何使用工廠實現它:

 function greeterFactory(greeting = "Hello", name = "World") { return { greet: () => `${greeting}, ${name}!` } } console.log(greeterFactory("Hey", "folks").greet()) // Hey, folks!

您可能已經註意到,隨著我們的發展,這些工廠變得越來越簡潔,但別擔心——它們做同樣的事情。 訓練輪即將脫落,伙計們!

這已經比相同代碼的原型或類版本更少樣板。 其次,它更有效地實現了對其特性的封裝。 此外,在某些情況下,它的內存和性能佔用較低(乍一看可能不像,但 JIT 編譯器正在悄悄地在幕後工作以減少重複和推斷類型)。

所以它更安全,通常更快,並且更容易編寫這樣的代碼。 為什麼我們又需要上課? 哦,當然,可重用性。 如果我們想要不快樂和熱情的迎賓變體會發生什麼? 好吧,如果我們使用ClassicalGreeting類,我們可能會直接跳入夢想類層次結構。 我們知道我們需要參數化標點符號,所以我們將進行一些重構並添加一些子元素:

 // Greeting class class ClassicalGreeting { constructor(greeting = "Hello", name = "World", punctuation = "!") { this.greeting = greeting this.name = name this.punctuation = punctuation } greet() { return `${this.greeting}, ${this.name}${this.punctuation}` } } // An unhappy greeting class UnhappyGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, " :(") } } const classyUnhappyGreeting = new UnhappyGreeting("Hello", "everyone") console.log(classyUnhappyGreeting.greet()) // Hello, everyone :( // An enthusiastic greeting class EnthusiasticGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, "!!") } greet() { return super.greet().toUpperCase() } } const greetingWithEnthusiasm = new EnthusiasticGreeting() console.log(greetingWithEnthusiasm.greet()) // HELLO, WORLD!!

這是一個很好的方法,直到有人出現並要求一個不完全適合層次結構的功能並且整個事情不再有意義。 當我們嘗試使用工廠編寫相同的功能時,請記住這一點:

 const greeterFactory = (greeting = "Hello", name = "World", punctuation = "!") => ({ greet: () => `${greeting}, ${name}${punctuation}` }) // Makes a greeter unhappy const unhappy = (greeter) => (greeting, name) => greeter(greeting, name, ":(") console.log(unhappy(greeterFactory)("Hello", "everyone").greet()) // Hello, everyone :( // Makes a greeter enthusiastic const enthusiastic = (greeter) => (greeting, name) => ({ greet: () => greeter(greeting, name, "!!").greet().toUpperCase() }) console.log(enthusiastic(greeterFactory)().greet()) // HELLO, WORLD!!

這段代碼是否更好並不明顯,即使它有點短。 實際上,您可能會爭辯說它更難閱讀,也許這是一種遲鈍的方法。 難道我們不能只有一個unhappyGreeterFactory和一個enthusiasticGreeterFactory的GreeterFactory 嗎?

然後你的客戶過來說:“我需要一個不開心的新迎賓員,希望整個房間都知道!”

 console.log(enthusiastic(unhappy(greeterFactory))().greet()) // HELLO, WORLD :(

如果我們需要不止一次地使用這個熱情不快的問候語,我們可以讓自己更容易:

 const aggressiveGreeterFactory = enthusiastic(unhappy(greeterFactory)) console.log(aggressiveGreeterFactory("You're late", "Jim").greet())

有一些方法可以使用原型或類來處理這種組合風格。 例如,您可以將UnhappyGreetingEnthusiasticGreeting重新考慮為裝飾器。 它仍然需要比上面使用的函數式方法更多的樣板,但這就是您為真實類的安全性和封裝付出的代價。

問題是,在 JavaScript 中,你並沒有獲得那種自動的安全性。 強調class使用的 JavaScript 框架做了很多“魔術”來解決這類問題並強制類表現自己。 有時間看一下 Polymer 的ElementMixin源代碼,我敢。 這是 JavaScript 奧秘的大嚮導級別,我的意思是沒有諷刺或諷刺。

當然,我們可以使用Object.freezeObject.defineProperties來修復上面討論的一些問題,以達到或多或少的效果。 但是,為什麼要在沒有函數的情況下模仿表單,而忽略 JavaScript 為我們提供的在 Java 等語言中可能找不到的工具呢? 當您的工具箱旁邊有真正的螺絲刀時,您會使用標有“螺絲刀”的錘子來驅動螺絲嗎?

尋找好的零件

JavaScript 開發人員經常強調該語言的優點,無論是口語化還是參考同名書籍。 我們試圖避免其更可疑的語言設計選擇所設置的陷阱,並堅持讓我們編寫乾淨、可讀、錯誤最小化、可重用代碼的部分。

關於 JavaScript 的哪些部分符合條件存在合理的爭論,但我希望我已經說服你class不是其中之一。 如果做不到這一點,希望您了解 JavaScript 中的繼承可能會令人困惑,並且class既不能修復它,也不能讓您不必理解原型。 如果您發現面向對象的設計模式在沒有類或 ES6 繼承的情況下也能正常工作,那麼您將獲得額外的榮譽。

我不是要你完全避免class 。 有時您需要繼承,而class為此提供了更簡潔的語法。 特別是, class X extends Y比舊的原型方法好得多。 除此之外,許多流行的前端框架都鼓勵使用它,您應該避免僅在原則上編寫奇怪的非標準代碼。 我只是不喜歡這是怎麼回事。

在我的噩夢中,整整一代的 JavaScript 庫都是使用class編寫的,期望它的行為與其他流行語言相似。 發現了全新的錯誤類別(雙關語)。 如果我們沒有不小心掉入class陷阱,那些很容易被留在畸形 JavaScript 墓地中的舊的已經復活了。 有經驗的 JavaScript 開發人員被這些怪物所困擾,因為流行的東西並不總是好的東西。

最終,我們都沮喪地放棄了,開始用 Rust、Go、Haskell 或誰知道還有什麼重新發明輪子,然後編譯為 Web 的 Wasm,新的 Web 框架和庫激增為多語言的無窮無盡。

它確實讓我徹夜難眠。