Jako programista JS, właśnie to trzyma mnie w nocy
Opublikowany: 2022-03-11JavaScript to dziwaczny język. Choć inspirowany Smalltalk, używa składni podobnej do C. Łączy w sobie aspekty paradygmatów programowania proceduralnego, funkcjonalnego i obiektowego (OOP). Ma wiele, często nadmiarowych, podejść do rozwiązania prawie każdego możliwego problemu programistycznego i nie ma silnej opinii na temat tego, które są preferowane. Jest słabo i dynamicznie napisany, z labiryntowym podejściem do wymuszania pisania, które potyka nawet doświadczonych programistów.
JavaScript ma również swoje brodawki, pułapki i wątpliwe cechy. Nowi programiści zmagają się z niektórymi trudniejszymi koncepcjami — pomyśl o asynchroniczności, domknięciach i podnoszeniu. Programiści z doświadczeniem w innych językach rozsądnie zakładają, że rzeczy o podobnych nazwach i wyglądzie będą działać w ten sam sposób w JavaScript i często się mylą. Tablice tak naprawdę nie są tablicami; o co z this
, co to jest prototyp i co właściwie robi new
?
Problem z klasami ES6
Zdecydowanie najgorszym przestępcą jest nowość w najnowszej wersji JavaScript, ECMAScript 6 (ES6): classs . Niektóre rozmowy na zajęciach są naprawdę alarmujące i ujawniają głęboko zakorzenione niezrozumienie tego, jak ten język faktycznie działa:
„JavaScript jest wreszcie prawdziwym językiem zorientowanym obiektowo, teraz, gdy ma klasy!”
Lub:
„Klasy uwalniają nas od myślenia o modelu niedziałającego dziedziczenia JavaScript”.
Lub nawet:
„Klasy to bezpieczniejsze i łatwiejsze podejście do tworzenia typów w JavaScript”.
Te stwierdzenia nie przeszkadzają mi, ponieważ sugerują, że coś jest nie tak z prototypowym dziedziczeniem; odłóżmy na bok te argumenty. Te stwierdzenia nie dają mi spokoju, ponieważ żadne z nich nie są prawdziwe i pokazują konsekwencje podejścia JavaScript „wszystko dla każdego” do projektowania języka: częściej paraliżuje rozumienie języka przez programistę, niż pozwala. Zanim przejdę dalej, zilustrujmy.
JavaScript Pop Quiz nr 1: Jaka jest zasadnicza różnica między tymi blokami kodu?
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())
Odpowiedź brzmi: nie istnieje . Działają skutecznie to samo, pozostaje tylko pytanie, czy użyto składni klasy ES6.
To prawda, że drugi przykład jest bardziej wyrazisty. Już z tego powodu możesz argumentować, że class
jest miłym dodatkiem do języka. Niestety problem jest nieco bardziej subtelny.
JavaScript Pop Quiz #2: Co robi poniższy kod?
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())
Prawidłowa odpowiedź jest taka, że wypisuje do konsoli:
> MyClass > Overridden in Proto > Overridden in MyClass > Overridden in instance
Jeśli odpowiedziałeś niepoprawnie, nie rozumiesz, czym właściwie jest class
. To nie twoja wina. Podobnie jak Array
, class
nie jest funkcją języka, jest obskurantyzmem składniowym . Próbuje ukryć prototypowy model dziedziczenia i niezdarne idiomy, które się z nim wiążą, i sugeruje, że JavaScript robi coś, czym nie jest.
Być może powiedziano Ci, że class
została wprowadzona do JavaScriptu, aby programiści klasyczni OOP wywodzący się z języków takich jak Java byli wygodniejsi z modelem dziedziczenia klas ES6. Jeśli jesteś jednym z tych programistów, ten przykład prawdopodobnie Cię przeraził. Powinno. Pokazuje, że słowo kluczowe JavaScript w class
nie zawiera żadnej gwarancji, jaką ma zapewnić klasa. Pokazuje również jedną z kluczowych różnic w modelu dziedziczenia prototypów: Prototypy to instancje obiektów , a nie typy .
Prototypy a klasy
Najważniejsza różnica między dziedziczeniem opartym na klasie i prototypie polega na tym, że klasa definiuje typ , który można utworzyć w czasie wykonywania, podczas gdy sam prototyp jest instancją obiektu.
Potomek klasy ES6 to kolejna definicja typu , która rozszerza rodzica o nowe właściwości i metody, które z kolei mogą być tworzone w czasie wykonywania. Potomkiem prototypu jest kolejna instancja obiektu, która deleguje do rodzica wszelkie właściwości, które nie są zaimplementowane w potomku.
Uwaga na marginesie: Możesz się zastanawiać, dlaczego wspomniałem o metodach klasowych, ale nie o metodach prototypowych. To dlatego, że JavaScript nie posiada koncepcji metod. Funkcje są pierwszorzędne w JavaScript i mogą mieć właściwości lub być właściwościami innych obiektów.
Konstruktor klasy tworzy instancję klasy. Konstruktor w JavaScript to po prostu stara funkcja, która zwraca obiekt. Jedyną wyjątkową rzeczą w konstruktorze JavaScript jest to, że po wywołaniu ze słowem kluczowym new
przypisuje on swój prototyp jako prototyp zwracanego obiektu. Jeśli brzmi to dla ciebie trochę dezorientująco, nie jesteś sam — tak jest i to w dużej mierze dlatego prototypy są słabo rozumiane.
Aby to podkreślić, dziecko prototypu nie jest kopią swojego prototypu ani nie jest obiektem o takim samym kształcie jak jego prototyp. Dziecko ma żywe odniesienie do prototypu, a każda właściwość prototypu, która nie istnieje w dziecku, jest jednokierunkowym odwołaniem do właściwości o tej samej nazwie w prototypie.
Rozważ następujące:
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'
W poprzednim przykładzie, choć child.foo
było undefined
, odwoływało się do parent.foo
. Gdy tylko zdefiniowaliśmy foo
na child
, child.foo
miało wartość 'bar'
, ale parent.foo
zachowało swoją pierwotną wartość. Gdy delete child.foo
to ponownie odwołuje się do parent.foo
, co oznacza, że kiedy zmieniamy wartość rodzica, child.foo
odnosi się do nowej wartości.
Spójrzmy na to, co się właśnie wydarzyło (dla lepszej ilustracji, będziemy udawać, że są to Strings
, a nie literały ciągów, różnica nie ma tutaj znaczenia):
Sposób, w jaki to działa pod maską, a zwłaszcza osobliwości new
i this
, to temat na inny dzień, ale Mozilla ma dokładny artykuł o łańcuchu dziedziczenia prototypów JavaScript, jeśli chcesz przeczytać więcej.
Kluczowym wnioskiem jest to, że prototypy nie definiują type
; same są instances
i można je zmieniać w czasie wykonywania, ze wszystkim, co implikuje i pociąga za sobą.
Nadal ze mną? Wróćmy do analizowania klas JavaScript.
JavaScript Pop Quiz #3: Jak zaimplementować prywatność w klasach?
Powyższe właściwości prototypu i klasy nie są tak bardzo „zamknięte”, jak „niebezpiecznie zwisające przez okno”. Powinniśmy to naprawić, ale jak?
Brak przykładów kodu tutaj. Odpowiedź brzmi: nie możesz.
JavaScript nie ma żadnego pojęcia o prywatności, ale ma zamknięcia:
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!"
Czy rozumiesz, co się właśnie stało? Jeśli nie, nie rozumiesz zamknięć. W porządku, naprawdę – nie są tak onieśmielające, jak są robione, są bardzo przydatne i powinieneś poświęcić trochę czasu, aby się o nich dowiedzieć.
JavaScript Pop Quiz #4: Jaki jest odpowiednik powyższego przy użyciu słowa kluczowego class
?
Przepraszam, to kolejne podchwytliwe pytanie. Możesz zrobić w zasadzie to samo, ale wygląda to tak:
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!"
Daj mi znać, jeśli wygląda to łatwiej lub jaśniej niż w SecretiveProto
. Moim zdaniem jest nieco gorzej — przełamuje idiomatyczne użycie deklaracji class
w JavaScript i nie działa tak, jak można by się spodziewać po, powiedzmy, Javie. Wyjaśnią to:

JavaScript Pop Quiz #5: Co robi SecretiveClass::looseLips()
?
Dowiedzmy Się:
try { liar.looseLips() } catch(e) { // ReferenceError: secret is not defined }
Cóż… to było niezręczne.
JavaScript Pop Quiz #6: Które preferują doświadczeni programiści JavaScript — prototypy czy klasy?
Zgadłeś, to kolejne podchwytliwe pytanie — doświadczeni programiści JavaScript zwykle unikają obu, kiedy tylko mogą. Oto dobry sposób na zrobienie powyższego z idiomatycznym JavaScriptem:
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()
Nie chodzi tylko o unikanie nieodłącznej brzydoty dziedziczenia czy wymuszanie enkapsulacji. Zastanów się, co jeszcze możesz zrobić z secretFactory
i leaker
, czego nie możesz łatwo zrobić z prototypem lub klasą.
Po pierwsze, możesz go zdestrukturyzować, ponieważ nie musisz się martwić o kontekst this
:
const { spillTheBeans } = secretFactory() spillTheBeans() // Favor composition over inheritance, (...)
To całkiem miłe. Oprócz unikania new
i this
wygłupów, pozwala nam na używanie naszych obiektów zamiennie z modułami CommonJS i ES6. Ułatwia też składowanie:
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)
Klienci blackHat
nie muszą się martwić o to, skąd pochodzi exfiltrate
, a spyFactory
nie musi grzebać w Function::bind
żonglującej kontekstem ani głęboko zagnieżdżonych właściwościach. Pamiętaj, że nie musimy się this
zbytnio przejmować w prostym synchronicznym kodzie proceduralnym, ale powoduje to wszelkiego rodzaju problemy w kodzie asynchronicznym, których lepiej unikać.
Przy odrobinie namysłu spyFactory
może zostać przekształcone w wysoce wyrafinowane narzędzie szpiegowskie, które poradzi sobie z wszelkiego rodzaju celami infiltracji – lub innymi słowy, fasadą.
Oczywiście możesz to zrobić również z klasą, a raczej zestawem klas, z których wszystkie dziedziczą po abstract class
lub interface
… z wyjątkiem tego, że JavaScript nie ma żadnego pojęcia o abstrakcjach ani interfejsach.
Wróćmy do przykładu programu powitalnego, aby zobaczyć, jak zaimplementowalibyśmy go z fabryką:
function greeterFactory(greeting = "Hello", name = "World") { return { greet: () => `${greeting}, ${name}!` } } console.log(greeterFactory("Hey", "folks").greet()) // Hey, folks!
Być może zauważyłeś, że te fabryki stają się coraz bardziej zwięzłe, ale nie martw się — robią to samo. Koła treningowe spadają, ludzie!
To już mniej schematyczne niż prototyp lub wersja klasowa tego samego kodu. Po drugie, skuteczniej zamyka swoje właściwości. Ponadto w niektórych przypadkach ma mniejszą ilość pamięci i wydajność (na pierwszy rzut oka może się to nie wydawać, ale kompilator JIT cicho pracuje za kulisami, aby zmniejszyć duplikację i wywnioskować typy).
Tak więc jest bezpieczniej, często jest szybszy i łatwiej jest napisać taki kod. Dlaczego znowu potrzebujemy zajęć? Och, oczywiście, wielokrotnego użytku. Co się stanie, jeśli chcemy nieszczęśliwych i entuzjastycznych wariantów witania? Cóż, jeśli używamy klasy ClassicalGreeting
, prawdopodobnie przeskoczymy bezpośrednio do wymyślania hierarchii klas. Wiemy, że będziemy musieli sparametryzować interpunkcję, więc zrobimy małą refaktoryzację i dodamy kilka dzieci:
// 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!!
To dobre podejście, dopóki ktoś nie pojawi się i poprosi o funkcję, która nie pasuje idealnie do hierarchii, a cała sprawa przestanie mieć sens. Przypnij szpilkę w tej myśli, gdy próbujemy napisać tę samą funkcjonalność z fabrykami:
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!!
Nie jest oczywiste, że ten kod jest lepszy, chociaż jest nieco krótszy. W rzeczywistości można argumentować, że trudniej jest czytać, a może jest to głupie podejście. Czy nie moglibyśmy mieć po prostu unhappyGreeterFactory
i enthusiasticGreeterFactory
?
Wtedy pojawia się twój klient i mówi: „Potrzebuję nowego witającego, który jest niezadowolony i chce, żeby dowiedział się o tym cały pokój!”
console.log(enthusiastic(unhappy(greeterFactory))().greet()) // HELLO, WORLD :(
Gdybyśmy musieli użyć tego entuzjastycznie nieszczęśliwego programu powitalnego więcej niż raz, moglibyśmy sobie to ułatwić:
const aggressiveGreeterFactory = enthusiastic(unhappy(greeterFactory)) console.log(aggressiveGreeterFactory("You're late", "Jim").greet())
Istnieją podejścia do tego stylu kompozycji, które działają z prototypami lub klasami. Na przykład możesz przemyśleć UnhappyGreeting
i EnthusiasticGreeting
Powitanie jako dekoratorów. Nadal wymagałoby to więcej schematu niż podejście w stylu funkcjonalnym zastosowane powyżej, ale to cena, jaką płacisz za bezpieczeństwo i hermetyzację prawdziwych klas.
Chodzi o to, że w JavaScript nie otrzymujesz automatycznego bezpieczeństwa. Frameworki JavaScript, które kładą nacisk na użycie class
, robią wiele „magii”, aby rozwiązać tego rodzaju problemy i wymuszać zachowanie klas. Zerknij kiedyś na kod źródłowy ElementMixin
firmy Polymer, ośmielę się. To arcymagiczne poziomy arkana JavaScript, i mam na myśli to bez ironii i sarkazmu.
Oczywiście możemy naprawić niektóre z omówionych powyżej problemów za pomocą Object.freeze
lub Object.defineProperties
z większym lub mniejszym skutkiem. Ale po co imitować formularz bez funkcji, ignorując narzędzia, które natywnie dostarcza nam JavaScript, a których możemy nie znaleźć w językach takich jak Java? Czy użyłbyś młotka z napisem „śrubokręt” do wkręcenia śruby, gdy twoja skrzynka narzędziowa miałaby prawdziwy śrubokręt siedzący tuż obok niego?
Znalezienie dobrych części
Programiści JavaScript często podkreślają dobre strony języka, zarówno potocznie, jak i w odniesieniu do książki o tym samym tytule. Staramy się unikać pułapek zastawionych przez bardziej wątpliwe wybory projektowe języka i trzymać się części, które pozwalają nam pisać czysty, czytelny, minimalizujący błędy i wielokrotnego użytku kod.
Istnieją rozsądne argumenty na temat tego, które części JavaScriptu kwalifikują się, ale mam nadzieję, że przekonałem Cię, że class
nie jest jedną z nich. Jeśli to się nie powiedzie, miejmy nadzieję, że zrozumiesz, że dziedziczenie w JavaScript może być mylącym bałaganem, a class
ani go nie naprawia, ani nie oszczędza konieczności zrozumienia prototypów. Dodatkowe uznanie, jeśli zorientowałeś się, że wzorce projektowe zorientowane obiektowo działają dobrze bez dziedziczenia klas lub ES6.
Nie mówię ci, żebyś całkowicie unikał class
. Czasami potrzebujesz dziedziczenia, a class
zapewnia czystszą składnię, aby to zrobić. W szczególności class X extends Y
jest znacznie ładniejsza niż stare podejście prototypowe. Poza tym wiele popularnych frameworków front-end zachęca do jego używania i prawdopodobnie powinieneś unikać pisania dziwnego niestandardowego kodu dla samej zasady. Po prostu nie podoba mi się, dokąd to zmierza.
W moich koszmarach cała generacja bibliotek JavaScript jest pisana przy użyciu class
, z oczekiwaniem, że będzie ona zachowywać się podobnie do innych popularnych języków. Odkryto zupełnie nowe klasy błędów (zamierzona gra słów). Wskrzeszają stare, które z łatwością mogłyby zostać na Cmentarzu Zniekształconego JavaScriptu, gdybyśmy nie beztrosko wpadli w pułapkę class
. Doświadczeni programiści JavaScript są nękani przez te potwory, ponieważ to, co popularne, nie zawsze jest dobre.
W końcu wszyscy poddajemy się frustracji i zaczynamy wymyślać na nowo koła w Rust, Go, Haskell lub kto wie co jeszcze, a następnie kompilujemy do Wasm for the web, a nowe frameworki i biblioteki internetowe rozrastają się w wielojęzyczną nieskończoność.
To naprawdę nie pozwala mi zasnąć w nocy.