Hvordan ES6-klasser virkelig fungerer, og hvordan man bygger din egen

Den 6. udgave af ECMAScript (eller ES6 for kort) revolutionerede sproget og tilføjede mange nye funktioner, inklusive klasser og klassebaseret arv. Den nye syntaks er let at bruge uden at forstå detaljerne og gør for det meste, hvad du ville forvente, men hvis du er som mig, er det ikke helt tilfredsstillende. Hvordan fungerer den tilsyneladende magiske syntaks faktisk under hætten? Hvordan interagerer det med andre funktioner på sproget? Er det muligt at efterligne klasser uden at bruge klassesyntaxen? Her vil jeg besvare disse spørgsmål i en detaljeret detalje.

Men først skal du forstå, hvad der kom foran dem, og Javascript's underliggende objektmodel for at forstå klasser.

Objektmodel

Javascript-objektmodellen er ret enkel. Hvert objekt er kun en kortlægning af strenge og symboler til egenskabsbeskrivelser. Hver egenskabsbeskrivelse rummer på sin side enten et getter / setter-par til beregne egenskaber eller en dataværdi for almindelige dataegenskaber.

Når du udfører koden foo [bar], konverterer den bar til en streng, hvis den ikke allerede er en streng eller symbol, så søger den nøgle op mellem foos egenskaber og returnerer værdien af ​​den tilsvarende egenskab (eller kalder dens getter-funktion som anvendelig). For bogstavelige strengnøgler, der er gyldige identifikatorer, er der den kortfattede syntaks foo.bar, der svarer til foo ["bar"]. Indtil videre, så enkel.

Prototype arv

Javascript har det, der kaldes prototypisk arv, hvilket lyder skræmmende, men er faktisk enklere end traditionel klassebaseret arv, når du først får fat på det. Hvert objekt kan have en implicit pointer til et andet objekt, kaldet dens prototype. Når du prøver at få adgang til en egenskab på et objekt, hvor der ikke findes nogen egenskab med denne nøgle, søger den i stedet nøglen på prototypeobjektet og returnerer prototypens egenskab for den nøgle, hvis den findes. Hvis den ikke findes på prototypen, kontrollerer den rekursivt prototypens prototype og så videre, helt op i kæden, indtil en egenskab findes eller et objekt uden en prototype er nået.

Hvis du tidligere har brugt Python, er attributopslagsprocessen den samme. I Python bliver hver attribut først slået op i eksemplet ordbog. Hvis den ikke er der, kontrollerer runtime derefter klassen ordbog, derefter superklassen ordbog osv. Helt op i arvehierarkiet. I Javascript er processen den samme bortset fra at der ikke er nogen sondring mellem typeobjekter og instansobjekter - ethvert objekt kan være prototypen til ethvert andet objekt. I den virkelige verden bruger folk naturligvis sjældent denne kendsgerning og organiserer i stedet deres kode i klasselignende hierarkier, fordi det er lettere at styre på den måde, hvorfor Javascript tilføjede klassesyntax i første omgang.

Intern slots

Hvis alt et objekt består af er en kortlægning af nøgler til egenskaber, hvor er prototypen gemt? Svaret er, at ud over egenskaber har objekter også interne metoder og interne slots, der bruges til at implementere speciel semantik på sprogniveau. Interne slots kan ikke fås direkte adgang fra Javascript-kode på nogen måde, men i nogle tilfælde er der måder at indirekte få adgang til dem. For eksempel er objektprototyper repræsenteret ved [[Prototype]] -spalten, som kan læses og skrives vha. Object.getPrototypeOf () og Object.setPrototypeOf (). Efter konventionen er interne slots og metoder skrevet i [[dobbelt firkantede parenteser]] for at skelne dem fra almindelige egenskaber.

Gamle stil klasser

I tidlige versioner af Javascript var det almindeligt at simulere klasser ved hjælp af kode som følgende.

Hvor kom dette fra? Hvor kom prototypen fra? Hvad gør nyt? Som det viser sig, ønskede selv de tidligste versioner af Javascript ikke at være for ukonventionelle, så de inkluderede nogle syntaks, der lader dig kode ting, der var kinda-sorta-lignende klasser.

I tekniske termer defineres funktioner i Javascript ved hjælp af de to interne metoder [[Opkald]] og [[Opbyg]]. Ethvert objekt med en [[Opkald]] -metode kaldes en funktion, og enhver funktion, der desuden har en [[Konstruktion]] -metode kaldes en konstruktør¹. Metoden [[Opkald]] bestemmer, hvad der sker, når du påberåber et objekt som en funktion, f.eks. foo (args), mens [[Construct]] bestemmer, hvad der sker, når du påberåber det som et nyt udtryk, dvs. ny foo eller new foo (args).

Ved almindelige funktionsdefinitioner² vil opkald [[Construct]] implicit oprette et nyt objekt, hvis [[Prototype]] er prototypegenskab for konstruktorfunktionen, hvis denne egenskab findes og er objektværdigt, eller Object.prototype ellers. Det nyoprettede objekt er bundet til denne værdi i funktionens lokale miljø. Hvis funktionen returnerer et objekt, evaluerer det nye udtryk til det objekt, ellers evaluerer det nye udtryk til den implicit oprettede denne værdi.

Hvad angår prototypegenskab, oprettes det implicit, hver gang du definerer en almindelig funktion. Hver nydefinerede funktion har en egenskab med navnet "prototype" defineret på den med et nyligt oprettet objekt som dets værdi. Dette objekt har til gengæld en konstruktorejendom, der peger tilbage til den originale funktion. Bemærk, at denne prototypeegenskap ikke er den samme som [[Prototype]] -spalten. I det forrige kodeeksempel er Foo stadig kun en funktion, så dens [[Prototype]] er det foruddefinerede objekt Function.prototype.

Her er et diagram, der illustrerer den forrige kodeprøve med [[Prototype]] -forhold i sort- og egenskabsforhold i grønt og blåt.

diagram over prototypehierarki til forrige kodeprøve

[1] Du kan tænkes at have objekter med en [[Construct]] -metode og ingen [[Call]] -metode, men ECMAScript-specifikationen definerer ikke sådanne objekter. Derfor er alle konstruktører også funktioner.

[2] Med almindelige funktionsdefinitioner mener jeg funktioner defineret ved hjælp af det almindelige funktionsnøgleord og intet andet, snarere end => funktioner, generatorfunktioner, asyncfunktioner, metoder osv. Dette var naturligvis inden ES6 den eneste slags funktion definition.

Ny stil klasser

Med den baggrund ude af vejen, er det tid til at undersøge syntaks i klasse ES6. Den forrige kodeeksempel oversættes direkte til den nye syntaks som følger:

Som tidligere består hver klasse af en konstruktorfunktion og et prototypeobjekt, der henviser til hinanden via prototype- og konstruktoregenskaber. Imidlertid er rækkefølgen af ​​definition af de to omvendt. Med en gammel stilklasse definerer du konstruktorfunktionen, og prototypeobjektet oprettes til dig. Med en ny stilklasse bliver kroppen til klassedefinitionen indholdet af prototypegenstanden (undtagen for statiske metoder), og blandt dem definerer du en konstruktør. Slutresultatet er det samme på begge måder.

Så hvis syntaks i ES6-klassen kun er sukker til gamle "klasser", hvad er da poenget? Bortset fra at se meget pænere ud og tilføje sikkerhedskontrol, har den nye klassesyntax også funktionalitet, der var umulig før ES6, specifikt klassebaseret arv. Når du definerer en klasse med den nye syntaks, kan du valgfrit give en super klasse for klassen at arve fra som vist nedenfor:

Dette eksempel i sig selv kan stadig emuleres uden klassesyntaks, selvom den krævede kode er meget grimere.

Med klassebaseret arv er reglen enkel - hver del af parret har som sin prototype den tilsvarende del af superklassen. Så konstruktøren af ​​superklassen er [[Prototype]] for underklasse-konstruktøren, og prototypeobjektet i superklassen er [[Prototype]] for underklasse-prototypegenstanden. Her er et diagram, der illustrerer (kun [[Prototyper]] vises; egenskaber er udeladt for klarhed).

Der er ingen direkte og bekvem måde at konfigurere disse [[Prototype]] -forhold uden at bruge klassesyntax, men du kan indstille dem manuelt ved hjælp af Object.setPrototypeOf (), introduceret i ES5.

Imidlertid undgår eksemplet ovenfor især at gøre noget i konstruktørerne. Især undgår den super, et nyt stykke syntaks, der giver underklasser adgang til egenskaberne og konstruktøren af ​​superklassen. Dette er meget mere kompliceret og er faktisk umuligt at fuldt ud emulere i ES5, selvom det kan emuleres i ES6 uden at bruge klassesyntax eller super ved brug af Reflect.

Superklasse ejendomsadgang

Der er to anvendelser til superkaldelse af en superklassekonstruktør eller adgang til egenskaberne for superklassen. Det andet tilfælde er enklere, så vi dækker det først.

Den måde, super fungerer på, er, at hver funktion har en intern slot kaldet [[HomeObject]], som indeholder det objekt, som funktionen oprindeligt var defineret inden for, hvis den oprindeligt var defineret som en metode. For en klassedefinition er dette objekt prototypeobjektet for klassen, dvs. Foo.prototype. Når du får adgang til en egenskab via super.foo eller super ["foo"], svarer den til [[HomeObject]]. [[Prototype]]. Foo.

Med denne forståelse af, hvordan super fungerer bag kulisserne, kan du forudsige, hvordan det vil opføre sig selv under komplicerede og usædvanlige omstændigheder. For eksempel er en funktions [[HomeObject]] fast på definitionstidspunktet og ændres ikke, selvom du senere tildeler funktionen til andre objekter som vist nedenfor.

I ovenstående eksempel overtog vi en funktion, der oprindeligt var defineret i D.prototype og kopierede den til B.prototype. Da [[HomeObject]] stadig peger på D.prototype, ser superadgangen ud i [[Prototype]] til D.prototype, som er C.prototype. Resultatet er, at C's kopi af foo kaldes, selvom C ikke findes i B's prototype-kæde.

På samme måde betyder det faktum, at [[HomeObject]]. [[Prototype]] er slået op ved hver evaluering af superudtrykket, at det vil se ændringer til [[Prototype]] og returnere nye resultater, som vist nedenfor.

Som en sidebemærkning er super ikke begrænset til klassedefinitioner. Det kan også bruges fra en hvilken som helst funktion defineret i et objekt bogstaveligt ved hjælp af den nye metodes korthortsyntaks, i hvilket tilfælde [[HomeObject]] vil være det lukkende objekt bogstaveligt. Naturligvis vil [[Prototype]] af et objekt bogstaveligt altid være Object.prototype, så dette er ikke meget nyttigt, medmindre du manuelt tildeler prototypen igen, som det er gjort nedenfor.

Efterligne superegenskaber

Der er ingen måde at manuelt indstille [[HomeObject]] på vores metoder, men vi kan emulere den ved blot at gemme værdien og gøre opløsningen manuelt som vist nedenfor. Det er ikke så praktisk som bare at skrive super, men det fungerer i det mindste.

Bemærk, at vi er nødt til at bruge .call (dette) for at sikre, at supermetoden bliver kaldt med den rigtige denne værdi. Hvis metoden har en egenskab, der skygger Function.prototype.call af en eller anden grund, kunne vi i stedet bruge Function.prototype.call.call (foo, dette) eller Reflect.apply (foo, dette), som er mere pålidelige, men ordrette.

Super i statiske metoder

Du kan også bruge super fra statiske metoder. Statiske metoder er de samme som almindelige metoder, bortset fra at de defineres som egenskaber på konstruktørfunktionen i stedet for på prototypeobjektet.

super kan emuleres inden for statiske metoder, på samme måde som ved normale metoder. Den eneste forskel er, at [[HomeObject]] nu er konstruktørfunktionen snarere end prototypeobjektet.

Superkonstruktører

Når [[Construct]] -metoden til en almindelig konstruktorfunktion aktiveres, oprettes et nyt objekt implicit og bindes til denne værdi inde i funktionen. Underklassekonstruktører følger dog forskellige regler. Der oprettes ikke automatisk denne værdi og forsøg på at få adgang til dette resulterer i en fejl. I stedet skal du kalde konstruktøren af ​​superklassen via super (args). Resultatet af superklassekonstruktøren er derefter bundet til den lokale denne værdi, hvorefter du kan få adgang til den i underklassekonstruktøren som normalt.

Dette giver naturligvis problemer, hvis du vil oprette en gammel stilklasse, der kan fungere korrekt sammen med nye stilklasser. Der er ikke noget problem, når man klassificerer en gammel stilklasse med en ny stilklasse, da baseklasse-konstruktøren kun er en almindelig konstruktorfunktion uanset hvad. Underklassificering af en ny typeklasse med en gammel stilklasse fungerer imidlertid ikke ordentligt, da gamle stilkonstruktører altid er baskonstruktører og ikke har den specielle underklasse-konstruktøropførsel.

For at gøre udfordringen konkret, formoder vi, at vi har en ny stilklasse Base, hvis definition er ukendt og ikke kan ændres, og vi ønsker at underklasse den uden at bruge klassesyntax, mens vi forbliver kompatible med uanset hvilken kode i Base forventer en ægte underklasse.

Først og fremmest antager vi, at Base ikke bruger proxies eller ikke-bestemmende computereegenskaber, eller noget andet underligt som det, da vores løsning sandsynligvis får adgang til Base-egenskaberne et andet antal gange eller i en anden rækkefølge end en reel underklasse ville , og der er intet, vi kan gøre ved det.

Derefter bliver spørgsmålet, hvordan man indstiller konstruktørens opkaldskæde. Som med almindelige superegenskaber, kan vi nemt få superklasse-konstruktøren ved hjælp af Object.getPrototypeOf (homeObject) .constructor. Men hvordan man påberåber sig det? Heldigvis kan vi bruge Reflect.construct () til manuelt at påberåbe sig den interne [[Construct]] -metode for enhver konstruktorfunktion.

Der er ingen måde at efterligne den særlige opførsel af denne binding, men vi kan bare ignorere dette og bruge en lokal variabel til at gemme den "rigtige" denne værdi, kaldet $ dette i eksemplet nedenfor.

Bemærk afkastet $ dette; linje ovenfor. Husk, at hvis en konstruktørfunktion returnerer et objekt, vil objektet blive brugt som værdien af ​​det nye udtryk i stedet for den implicit oprettede denne værdi.

Så fuldført mission? Ikke helt. Obj-værdien i ovenstående eksempel er faktisk ikke et eksempel på Child, dvs. den har ikke Child.prototype i sin prototypekæde. Dette skyldes, at Base's konstruktør ikke vidste noget om Child og derfor returnerede et objekt, der blot var en almindelig forekomst af Base (dens [[Prototype]] er Base.prototype).

Så hvordan løses dette problem for rigtige klasser? [[Construct]], og som udvidelse Reflect.construct, tager faktisk tre parametre. Den tredje parameter, newTarget, er en henvisning til konstruktøren, der oprindeligt blev påberåbt i det nye udtryk, og dermed konstruktøren af ​​den nederste (mest afledte) klasse i arvehierarkiet. Når kontrolstrømmen når konstruktøren af ​​baseklassen, vil det implicit oprettede dette objekt have newTarget som dets [[Prototype]].

Derfor kan vi gøre Base til at konstruere et eksempel på Child ved at påberåbe sig konstruktøren via Reflect.construct (konstruktør, args, Child). Dette er dog stadig ikke helt rigtigt, fordi det går i stykker, når nogen anden underklasser barn. I stedet for at hardkode børneklassen, er vi nødt til at passere newTarget uændret. Heldigvis kan det tilgås inden for konstruktører ved hjælp af den specielle new.target-syntaks. Dette fører til den endelige løsning nedenfor:

Sidste berøring

Dette dækker al den vigtigste funktionalitet i klasser, men der er et par andre mindre forskelle, for det meste sikkerhedskontrol tilføjet til den nye klassesyntaks. F.eks. Er prototypegenskab, der automatisk tilføjes til funktionsdefinitioner, skrivbar som standard, men prototypegenskaberne for klassekonstruktører kan ikke skrives. Vi kan nemt gøre vores ikke-skrivbare såvel ved at kalde Object.defineProperty (). Alternativt kan du bare ringe til Object.freeze (), hvis du ønsker, at det hele skal være uforanderligt.

En anden ny beskyttelse er, at klassekonstruktører vil kaste en TypeError, hvis du prøver at [[Ring]] dem i stedet for at konstruere dem med nye. Vores konstruktør herover kaster også en TypeError, men kun indirekte, fordi new.target er udefineret, når funktionen er [[Opkald]] redigeret og Reflect.construct () kaster en TypeError, hvis du eksplicit passerer udefineret som det sidste argument. Da TypeError her er tilfældigt, er den resulterende fejlmeddelelse temmelig forvirrende. Det kan være nyttigt at tilføje en eksplicit kontrol for new.target, der kaster en fejl med en mere nyttig fejlmeddelelse.

Uanset hvad, håber jeg, at du nød dette indlæg og lærte så meget som jeg gjorde i processen med at forske på det. Ovenstående teknikker er sjældent nyttige i den virkelige verden kode, men det er stadig vigtigt at forstå, hvordan tingene fungerer under hætten, i tilfælde af at du har en usædvanlig brugssag, som kræver at man når frem til den sorte magi, eller mere sandsynligt, at du er fast ved debug nogen andens sort magi.

P. S. Hvis du som mig irriterer dig over Medium's gigantiske aflukkelige banner på bunden af ​​skærmen, der opfordrer dig til at tilmelde dig, eller den generelle søgen hos websteder for at gøre at læse deres indhold så vanskeligt og irriterende som muligt, vil jeg varmt anbefale at tjekke Kill Klæbrig. Det er et simpelt Javascript-kodestykke, du kan bogmærke, der sletter alle “klæbrige” elementer på siden. Det lyder enkelt, men at surfe med Kill Sticky ændrer livet. Og da det kun er en bogmærke, behøver du ikke at bekymre dig om ved et uheld at dræbe vigtige sideelementer for godt, som du ville gjort med et uBlock-filter. I værste fald kan du altid bare opdatere siden.