Clean-Code-Konzepte angepasst für TypeScript. Inspiriert von clean-code-javascript.
- Einführung
- Variablen
- Funktionen
- Objekte und Datenstrukturen
- Klassen
- SOLID
- Testen
- Asynchronität
- Fehlerbehandlung
- Formatierung
- Kommentare
- Übersetzungen
Software-Entwicklungs-Prinzipien, aus Robert C. Martins Buch Clean Code (* affiliate link), angepasst für TypeScript. Dies ist kein Style Guide. Es ist ein Leitfaden zur Erstellung von lesbarer, wiederverwendbarer und refaktorierbarer Software in TypeScript.
Nicht jedes Prinzip hierin muss strikt befolgt werden, und noch weniger werden sie allgemein anerkannt sein. Dies hier sind Richtlinien und nichts weiter. Aber sie sind solche, die über viele Jahre kollektiver Erfahrung von den Autoren von Clean Code.
Unser Handwerk der Softwareentwicklung ist etwas mehr als 50 Jahre alt und wir lernen immer noch viel. Wenn die Softwarearchitektur so alt ist wie die Architektur selbst, dann werden wir vielleicht härtere Regeln haben, denen wir folgen können. Für den Moment sollen diese Richtlinien als Prüfstein dienen, um die Qualität des TypeScript-Codes zu beurteilen, den du und dein Team produzieren.
Und noch etwas: Wenn du diese Richtlinien kennst, wirst du nicht sofort ein besserer Softwareentwickler! Und wenn du jahrelang mit ihnen arbeitest, bedeutet das nicht, dass du keine Fehler machen wirst. Jedes Stück Code beginnt als erster Entwurf - wie nasser Ton - der in seine endgültige Form gebracht wird. Schließlich meißeln wir die Unvollkommenheiten weg, wenn wir es mit unseren Kollegen überprüfen. Mach dich nicht selbst fertig für deine ersten Entwürfe, die verbessert werden müssen. Verprügle stattdessen den Code!
Unterscheide die Namen so, dass der Leser weiß, was die Unterschiede bieten.
Schlecht:
functionbetween<T>(a1: T,a2: T,a3: T): boolean{returna2<=a1&&a1<=a3;}Gut:
functionbetween<T>(value: T,left: T,right: T): boolean{returnleft<=value&&value<=right;}Wenn du es nicht aussprechen kannst, kannst du nicht darüber diskutieren, ohne wie ein Idiot zu klingen.
Schlecht:
typeDtaRcrd102={genymdhms: Date;modymdhms: Date;pszqint: number;};Gut:
typeCustomer={generationTimestamp: Date;modificationTimestamp: Date;recordId: number;};Schlecht:
functiongetUserInfo(): User;functiongetUserDetails(): User;functiongetUserData(): User;Gut:
functiongetUser(): User;Wir werden mehr Code lesen als wir jemals schreiben werden. Es ist wichtig, dass der Code, den wir schreiben, lesbar und durchsuchbar sein muss. Indem wir Variablen, die für das Verständnis unseres Programms von Bedeutung sind, nicht gut benennen, schaden wir unseren Lesern. Mache deine Namen durchsuchbar. Tools wie ESLint können helfen, unbenannte Konstanten und Variablen (auch bekannt als Magic Strings und Magic Numbers) zu identifizieren.
Schlecht:
// What the heck is 86400000 for?setTimeout(restart,86400000);Gut:
// Declare them as capitalized named constants.constMILLISECONDS_PER_DAY=24*60*60*1000;// 86400000setTimeout(restart,MILLISECONDS_PER_DAY);Schlecht:
declareconstusers: Map<string,User>;for(constkeyValueofusers){// iterate through users map}Gut:
declareconstusers: Map<string,User>;for(const[id,user]ofusers){// iterate through users map}Explizit ist besser als implizit. Klarheit ist der König.
Schlecht:
constu=getUser();consts=getSubscription();constt=charge(u,s);Gut:
constuser=getUser();constsubscription=getSubscription();consttransaction=charge(user,subscription);Wenn dein Klassen-/Typ-/Objektname dir etwas sagt, dann wiederhole das nicht in deinem Variablennamen.
Schlecht:
typeCar={carMake: string;carModel: string;carColor: string;};functionprint(car: Car): void{console.log(`${car.carMake}${car.carModel} (${car.carColor})`);}Gut:
typeCar={make: string;model: string;color: string;};functionprint(car: Car): void{console.log(`${car.make}${car.model} (${car.color})`);}Standardargumente sind oft sauberer als Kurzschlüsse.
Schlecht:
functionloadPages(count?: number){constloadCount=count!==undefined ? count : 10;// ...}Gut:
functionloadPages(count: number=10){// ...}Enums können dir helfen, den Sinn des Codes zu dokumentieren. Zum Beispiel, wenn es darum geht, dass die Werte unterschiedlich sind, anstatt den genauen Wert von denen.
Schlecht:
constGENRE={ROMANTIC: "romantic",DRAMA: "drama",COMEDY: "comedy",DOCUMENTARY: "documentary",};projector.configureFilm(GENRE.COMEDY);classProjector{// declaration of ProjectorconfigureFilm(genre){switch(genre){caseGENRE.ROMANTIC: // some logic to be executed}}}Gut:
enumGENRE{ROMANTIC,DRAMA,COMEDY,DOCUMENTARY,}projector.configureFilm(GENRE.COMEDY);classProjector{// declaration of ProjectorconfigureFilm(genre){switch(genre){caseGENRE.ROMANTIC: // some logic to be executed}}}Die Begrenzung der Anzahl von Funktionsparametern ist unglaublich wichtig. Es macht das Testen deiner Funktion einfacher. Mehr als drei Parameter führen zu einer kombinatorischen Explosion, bei der du tonnenweise verschiedene Fälle mit jedem einzelnen Argument testen musst.
Ein oder zwei Argumente sind der Idealfall. Drei sollten wenn möglich vermieden werden. Alles, was darüber hinausgeht, sollte auf keinen Fall angewandt werden. Normalerweise, wenn du mehr als zwei Argumente hast, dann versucht deine Funktion zu viel zu machen. In den Fällen, in denen das nicht der Fall ist, reicht meistens ein übergeordnetes Objekt als Argument aus.
Ziehe die Verwendung von Objektliteralen in Betracht, wenn du feststellst, dass du viele Argumente brauchst.
Um deutlich zu machen, welche Eigenschaften die Funktion erwartet, kannst du die destructuring-Syntax verwenden. Dies hat ein paar Vorteile:
- Wenn sich jemand die Funktionssignatur ansieht, ist sofort klar, welche Eigenschaften verwendet werden.
- Es kann verwendet werden, um benannte Parameter zu simulieren.
- Die Destrukturierung klont auch die angegebenen primitiven Werte des in die Funktion übergebenen Argumentobjekts. Dies kann helfen, Seiteneffekte zu vermeiden. Hinweis: Objekte und Arrays, die vom Argument-Objekt destrukturiert werden, werden NICHT geklont.
- TypeScript warnt dich vor unbenutzten Eigenschaften, was ohne Destrukturierung unmöglich wäre.
Schlecht:
functioncreateMenu(title: string,body: string,buttonText: string,cancellable: boolean){// ...}createMenu("Foo","Bar","Baz",true);Gut:
functioncreateMenu(options: {title: string;body: string;buttonText: string;cancellable: boolean;}){// ...}createMenu({title: "Foo",body: "Bar",buttonText: "Baz",cancellable: true,});Du kannst die Lesbarkeit weiter verbessern, indem du type aliases verwendest:
typeMenuOptions={title: string;body: string;buttonText: string;cancellable: boolean;};functioncreateMenu(options: MenuOptions){// ...}createMenu({title: "Foo",body: "Bar",buttonText: "Baz",cancellable: true,});Dies ist bei weitem die wichtigste Regel in der Softwareentwicklung. Wenn Funktionen mehr als eine Sache machen, sind sie schwieriger zu verwalten, zu testen und zu verstehen. Wenn du eine Funktion auf nur eine Aktion isolieren kannst, kann sie leicht refaktorisiert werden und dein Code wird sich viel sauberer lesen. Wenn du nichts anderes aus diesem Leitfaden mitnimmst als das, wirst du vielen Entwicklern voraus sein.
Schlecht:
functionemailActiveClients(clients: Client[]){clients.forEach((client)=>{constclientRecord=database.lookup(client);if(clientRecord.isActive()){email(client);}});}Gut:
functionemailActiveClients(clients: Client[]){clients.filter(isActiveClient).forEach(email);}functionisActiveClient(client: Client){constclientRecord=database.lookup(client);returnclientRecord.isActive();}Schlecht:
functionaddToDate(date: Date,month: number): Date{// ...}constdate=newDate();// It's hard to tell from the function name what is addedaddToDate(date,1);Gut:
functionaddMonthToDate(date: Date,month: number): Date{// ...}constdate=newDate();addMonthToDate(date,1);Wenn du mehr als eine Abstraktionsebene hast, macht deine Funktion normalerweise zu viel. Das Aufteilen von Funktionen führt zu Wiederverwendbarkeit und einfacherem Testen.
Schlecht:
functionparseCode(code: string){constREGEXES=[/* ... */];conststatements=code.split(" ");consttokens=[];REGEXES.forEach((regex)=>{statements.forEach((statement)=>{// ...});});constast=[];tokens.forEach((token)=>{// lex...});ast.forEach((node)=>{// parse...});}Gut:
constREGEXES=[/* ... */];functionparseCode(code: string){consttokens=tokenize(code);constsyntaxTree=parse(tokens);syntaxTree.forEach((node)=>{// parse...});}functiontokenize(code: string): Token[]{conststatements=code.split(" ");consttokens: Token[]=[];REGEXES.forEach((regex)=>{statements.forEach((statement)=>{tokens.push(/* ... */);});});returntokens;}functionparse(tokens: Token[]): SyntaxTree{constsyntaxTree: SyntaxTree[]=[];tokens.forEach((token)=>{syntaxTree.push(/* ... */);});returnsyntaxTree;}Gib dein Bestes, um doppelten Code zu vermeiden. Doppelter Code ist schlecht, weil er bedeutet, dass es mehr als eine Stelle gibt, an der du etwas ändern kannst, wenn du eine Logik ändern musst.
Stell dir vor, du betreibst ein Restaurant und behältst den Überblick über dein Inventar: all deine Tomaten, Zwiebeln, Knoblauch, Gewürze, etc. Wenn du mehrere Listen hast, auf denen du dies festhältst, dann müssen alle aktualisiert werden, wenn du ein Gericht mit Tomaten darin servierst. Wenn du nur eine Liste hast, gibt es nur einen Ort zum Aktualisieren!
Oftmals hast du doppelten Code, weil du zwei oder mehr leicht unterschiedliche Dinge hast, die viel gemeinsam haben, aber ihre Unterschiede zwingen dich dazu, zwei oder mehr separate Funktionen zu haben, die viel vom Gleichen machen. Doppelten Code zu entfernen bedeutet, eine Abstraktion zu schaffen, die diese verschiedenen Dinge mit nur einer Funktion/Modul/Klasse behandeln kann.
Die richtige Abstraktion zu finden ist kritisch, deshalb solltest du den SOLID Prinzipien folgen. Schlechte Abstraktionen können schlimmer sein als doppelter Code, also sei vorsichtig! Wenn du also eine gute Abstraktion machen kannst, dann tu es! Wiederhole dich nicht, sonst wirst du dich dabei ertappen, dass du jedes Mal, wenn du eine Sache ändern willst, mehrere Stellen aktualisieren musst.
Schlecht:
functionshowDeveloperList(developers: Developer[]){developers.forEach((developer)=>{constexpectedSalary=developer.calculateExpectedSalary();constexperience=developer.getExperience();constgithubLink=developer.getGithubLink();constdata={ expectedSalary, experience, githubLink,};render(data);});}functionshowManagerList(managers: Manager[]){managers.forEach((manager)=>{constexpectedSalary=manager.calculateExpectedSalary();constexperience=manager.getExperience();constportfolio=manager.getMBAProjects();constdata={ expectedSalary, experience, portfolio,};render(data);});}Gut:
classDeveloper{// ...getExtraDetails(){return{githubLink: this.githubLink,};}}classManager{// ...getExtraDetails(){return{portfolio: this.portfolio,};}}functionshowEmployeeList(employee: (Developer|Manager)[]){employee.forEach((employee)=>{constexpectedSalary=employee.calculateExpectedSalary();constexperience=employee.getExperience();constextra=employee.getExtraDetails();constdata={ expectedSalary, experience, extra,};render(data);});}Du kannst auch einen Union-Typ oder eine gemeinsame Elternklasse hinzufügen, wenn das zu deiner Abstraktion passt.
classDeveloper{// ...}classManager{// ...}typeEmployee=Developer|Manager;functionshowEmployeeList(employee: Employee[]){// ...}Du solltest kritisch gegenüber Code-Duplizierung sein. Manchmal gibt es einen Kompromiss zwischen doppeltem Code und erhöhter Komplexität durch die Einführung unnötiger Abstraktion. Wenn zwei Implementierungen aus zwei verschiedenen Modulen ähnlich aussehen, aber in verschiedenen Domänen leben, kann die Duplizierung akzeptabel sein und dem Extrahieren des gemeinsamen Codes vorgezogen werden. Der extrahierte gemeinsame Code führt in diesem Fall eine indirekte Abhängigkeit zwischen den beiden Modulen ein.
Schlecht:
typeMenuConfig={title?: string;body?: string;buttonText?: string;cancellable?: boolean;};functioncreateMenu(config: MenuConfig){config.title=config.title||"Foo";config.body=config.body||"Bar";config.buttonText=config.buttonText||"Baz";config.cancellable=config.cancellable!==undefined ? config.cancellable : true;// ...}createMenu({body: "Bar"});Gut:
typeMenuConfig={title?: string;body?: string;buttonText?: string;cancellable?: boolean;};functioncreateMenu(config: MenuConfig){constmenuConfig=Object.assign({title: "Foo",body: "Bar",buttonText: "Baz",cancellable: true,},config);// ...}createMenu({body: "Bar"});Oder du kannst den Spread-Operator verwenden:
functioncreateMenu(config: MenuConfig){constmenuConfig={title: "Foo",body: "Bar",buttonText: "Baz",cancellable: true, ...config,};// ...}Der Spread-Operator und Object.assign() sind sich sehr ähnlich. Der Hauptunterschied besteht darin, dass "Spreading" neue Eigenschaften definiert, während Object.assign() sie festlegt. Ausführlicher wird der Unterschied in diesem Thread erklärt.
Alternativ kannst du auch eine Destrukturierung mit Standardwerten verwenden:
typeMenuConfig={title?: string;body?: string;buttonText?: string;cancellable?: boolean;};functioncreateMenu({ title ="Foo", body ="Bar", buttonText ="Baz", cancellable =true,}: MenuConfig){// ...}createMenu({body: "Bar"});Um Seiteneffekte und unerwartetes Verhalten durch die explizite Übergabe von undefined oder null Werten zu vermeiden, kannst du dem TypeScript Compiler sagen, dass er dies nicht zulassen soll. Siehe die --strictNullChecks-Option in TypeScript.
Flags sagen deinem Benutzer, dass diese Funktion mehr als eine Sache macht. Funktionen sollten nur eine Sache machen. Teile deine Funktionen auf, wenn sie verschiedene Codepfade basierend auf einem Boolean verfolgen.
Schlecht:
functioncreateFile(name: string,temp: boolean){if(temp){fs.create(`./temp/${name}`);}else{fs.create(name);}}Gut:
functioncreateTempFile(name: string){createFile(`./temp/${name}`);}functioncreateFile(name: string){fs.create(name);}Eine Funktion erzeugt einen Seiteneffekt, wenn sie etwas anderes macht, als einen Wert aufzunehmen und einen anderen Wert oder Werte zurückzugeben. Ein Nebeneffekt könnte das Schreiben in eine Datei, das Ändern einer globalen Variable oder das versehentliche Überweisen deines gesamten Geldes an einen Fremden sein.
Nun, du brauchst gelegentlich Seiteneffekte in einem Programm. Wie im vorherigen Beispiel, musst du vielleicht in eine Datei schreiben. Was du machen willst, ist, den Ort, an dem du dies tust, zu zentralisieren. Du solltest nicht mehrere Funktionen und Klassen haben, die in eine bestimmte Datei schreiben. Habe einen Dienst, der das macht. Einen und nur einen.
Der wichtigste Punkt ist, die üblichen Fallstricke zu vermeiden, wie das Teilen von Zuständen zwischen Objekten ohne jegliche Struktur, die Verwendung von veränderbaren Datentypen, die von jedem geschrieben werden können, und die fehlende Zentralisierung der Seiteneffekte. Wenn du das schaffst, wirst du glücklicher sein als die große Mehrheit der anderen Programmierer.
Schlecht:
// Global variable referenced by following function.letname="Robert C. Martin";functiontoBase64(){name=btoa(name);}toBase64();// If we had another function that used this name, now it'd be a Base64 valueconsole.log(name);// expected to print 'Robert C. Martin' but instead 'Um9iZXJ0IEMuIE1hcnRpbg=='Gut:
constname="Robert C. Martin";functiontoBase64(text: string): string{returnbtoa(text);}constencodedName=toBase64(name);console.log(name);Browser und Node.js verarbeiten nur JavaScript, daher muss jeder TypeScript-Code vor dem Ausführen oder Debuggen kompiliert werden. In JavaScript sind einige Werte unveränderlich (immutable) und andere veränderbar (mutable). Objekte und Arrays sind zwei Arten von veränderbaren Werten, daher ist es wichtig, sie sorgfältig zu behandeln, wenn sie als Parameter an eine Funktion übergeben werden. Eine JavaScript-Funktion kann die Eigenschaften eines Objekts oder den Inhalt eines Arrays ändern, was leicht zu Fehlern an anderer Stelle führen kann.
Nehmen wir an, es gibt eine Funktion, die einen Array-Parameter akzeptiert, der einen Warenkorb darstellt. Wenn die Funktion eine Änderung in diesem Warenkorb-Array vornimmt - z. B. indem sie einen Artikel zum Kauf hinzufügt -, dann wird jede andere Funktion, die dasselbe Warenkorb-Array verwendet, von dieser Änderung betroffen sein. Das mag toll sein, kann aber auch schlecht sein. Stellen wir uns eine schlechte Situation vor:
Der Benutzer klickt auf den "Purchase" Button, der eine purchase Funktion aufruft, die eine Netzwerkanfrage stellt und das cart Array an den Server sendet. Aufgrund einer schlechten Netzwerkverbindung muss die Funktion purchase die Anfrage immer wieder neu versuchen. Was passiert, wenn der Nutzer in der Zwischenzeit versehentlich auf einen Artikel klickt, den er eigentlich gar nicht haben will, bevor die Netzwerkanfrage gestartet wird? Wenn das passiert und die Netzwerkanfrage beginnt, sendet die purchase Funktion den versehentlich hinzugefügten Artikel, weil das Array cart geändert wurde.
Eine gute Lösung wäre, wenn die Funktion addItemToCart immer cart klont, ihn bearbeiten und den Klon zurückgeben würde. Das würde sicherstellen, dass Funktionen, die noch den alten Warenkorb verwenden, nicht von den Änderungen betroffen sind.
Zwei Vorbehalte sind bei diesem Ansatz zu erwähnen:
- Es kann Fälle geben, in denen du das Eingabeobjekt tatsächlich ändern möchtest, aber wenn du diese Programmierpraxis anwendest, wirst du feststellen, dass diese Fälle ziemlich selten sind. Die meisten Dinge können so refaktorisiert werden, dass sie keine Seiteneffekte haben! (siehe pure function)
- Das Klonen von großen Objekten kann sehr teuer in Bezug auf die Performance sein. Glücklicherweise ist dies in der Praxis kein großes Problem, da es großartige Bibliotheken gibt, die es ermöglichen, dass diese Art von Programmieransatz schnell und nicht so speicherintensiv ist, wie es für dich wäre, wenn du Objekte und Arrays manuell klonen würdest.
Schlecht:
functionaddItemToCart(cart: CartItem[],item: Item): void{cart.push({ item,date: Date.now()});}Gut:
functionaddItemToCart(cart: CartItem[],item: Item): CartItem[]{return[...cart,{ item,date: Date.now()}];}Globals zu verunreinigen ist eine schlechte Praxis in JavaScript, da du mit einer anderen Bibliothek kollidieren könntest und der Benutzer deiner API nichts davon mitbekommt, bis er in der Produktion eine Ausnahme bekommt. Lass uns über ein Beispiel nachdenken: Was wäre, wenn du JavaScript's native Array-Methode um eine diff-Methode erweitern wolltest, die den Unterschied zwischen zwei Arrays anzeigen könnte? Du könntest deine neue Funktion in den Array.prototype schreiben, aber sie könnte mit einer anderen Bibliothek kollidieren, die versucht, das Gleiche zu tun. Was wäre, wenn diese andere Bibliothek einfach diff benutzen würde, um den Unterschied zwischen dem ersten und letzten Element eines Arrays zu finden? Deshalb wäre es viel besser, nur Klassen zu verwenden und einfach das Array global zu erweitern.
Schlecht:
declare global {interfaceArray<T>{diff(other: T[]): Array<T>;}}if(!Array.prototype.diff){Array.prototype.diff=function<T>(other: T[]): T[]{consthash=newSet(other);returnthis.filter((elem)=>!hash.has(elem));};}Gut:
classMyArray<T>extendsArray<T>{diff(other: T[]): T[]{consthash=newSet(other);returnthis.filter((elem)=>!hash.has(elem));}}Bevorzuge diesen Stil der Programmierung, wenn du kannst.
Schlecht:
constcontributions=[{name: "Uncle Bobby",linesOfCode: 500,},{name: "Suzie Q",linesOfCode: 1500,},{name: "Jimmy Gosling",linesOfCode: 150,},{name: "Gracie Hopper",linesOfCode: 1000,},];lettotalOutput=0;for(leti=0;i<contributions.length;i++){totalOutput+=contributions[i].linesOfCode;}Gut:
constcontributions=[{name: "Uncle Bobby",linesOfCode: 500,},{name: "Suzie Q",linesOfCode: 1500,},{name: "Jimmy Gosling",linesOfCode: 150,},{name: "Gracie Hopper",linesOfCode: 1000,},];consttotalOutput=contributions.reduce((totalLines,output)=>totalLines+output.linesOfCode,0);Schlecht:
if(subscription.isTrial||account.balance>0){// ...}Gut:
functioncanActivateService(subscription: Subscription,account: Account){returnsubscription.isTrial||account.balance>0;}if(canActivateService(subscription,account)){// ...}Schlecht:
functionisEmailNotUsed(email: string): boolean{// ...}if(isEmailNotUsed(email)){// ...}Gut:
functionisEmailUsed(email: string): boolean{// ...}if(!isEmailUsed(email)){// ...}Das scheint eine unmögliche Aufgabe zu sein. Wenn man das zum ersten Mal hört, sagen die meisten Leute: "Wie soll ich etwas ohne eine if-Anweisung machen?" Die Antwort ist, dass du Polymorphismus verwenden kannst, um die gleiche Aufgabe in vielen Fällen zu erreichen. Die zweite Frage ist in der Regel: "Nun, das ist toll, aber warum sollte ich das tun wollen?" Die Antwort ist ein früheres Clean Code Konzept, das wir gelernt haben: eine Funktion sollte nur eine Sache tun. Wenn du Klassen und Funktionen hast, die if Anweisungen haben, sagst du deinem Benutzer, dass deine Funktion mehr als eine Sache macht. Denke daran, nur eine Sache zu tun.
Schlecht:
classAirplane{privatetype: string;// ...getCruisingAltitude(){switch(this.type){case"777": returnthis.getMaxAltitude()-this.getPassengerCount();case"Air Force One": returnthis.getMaxAltitude();case"Cessna": returnthis.getMaxAltitude()-this.getFuelExpenditure();default: thrownewError("Unknown airplane type.");}}privategetMaxAltitude(): number{// ...}}Gut:
abstractclassAirplane{protectedgetMaxAltitude(): number{// shared logic with subclasses ...}// ...}classBoeing777extendsAirplane{// ...getCruisingAltitude(){returnthis.getMaxAltitude()-this.getPassengerCount();}}classAirForceOneextendsAirplane{// ...getCruisingAltitude(){returnthis.getMaxAltitude();}}classCessnaextendsAirplane{// ...getCruisingAltitude(){returnthis.getMaxAltitude()-this.getFuelExpenditure();}}TypeScript ist ein strenges syntaktisches Superset von JavaScript und fügt der Sprache eine optionale statische Typüberprüfung hinzu. Bevorzuge es immer, die Typen von Variablen, Parametern und Rückgabewerten zu spezifizieren, um die volle Leistung der TypeScript Features zu nutzen. Es macht das Refactoring einfacher.
Schlecht:
functiontravelToTexas(vehicle: Bicycle|Car){if(vehicleinstanceofBicycle){vehicle.pedal(currentLocation,newLocation("texas"));}elseif(vehicleinstanceofCar){vehicle.drive(currentLocation,newLocation("texas"));}}Gut:
typeVehicle=Bicycle|Car;functiontravelToTexas(vehicle: Vehicle){vehicle.move(currentLocation,newLocation("texas"));}Moderne Browser machen eine Menge Optimierungen unter der Haube zur Laufzeit. Wenn du optimierst, verschwendest du in vielen Fällen nur deine Zeit. Es gibt gute Ressourcen, um zu sehen, wo es an Optimierung mangelt. Nimm diese in der Zwischenzeit ins Visier, bis sie behoben sind, wenn sie es können.
Schlecht:
// On old browsers, each iteration with uncached `list.length` would be costly// because of `list.length` recomputation. In modern browsers, this is optimized.for(leti=0,len=list.length;i<len;i++){// ...}Gut:
for(leti=0;i<list.length;i++){// ...}Toter Code ist genau so schlimm wie doppelter Code. Es gibt keinen Grund, ihn in deiner Codebase zu behalten. Wenn er nicht aufgerufen wird, entferne ihn! Er wird immer noch sicher in deiner Versionsgeschichte sein, wenn du ihn noch brauchst.
Schlecht:
functionoldRequestModule(url: string){// ...}functionrequestModule(url: string){// ...}constreq=requestModule;inventoryTracker("apples",req,"www.inventory-awesome.io");Gut:
functionrequestModule(url: string){// ...}constreq=requestModule;inventoryTracker("apples",req,"www.inventory-awesome.io");Verwende Generatoren und Iterables, wenn du mit Sammlungen von Daten arbeitest, die wie ein Stream verwendet werden. Dafür gibt es einige gute Gründe:
- entkoppelt den Aufrufer von der Generatorimplementierung in dem Sinne, dass der Aufrufer entscheidet, auf wie viele Items er zugreift
- Lazy Execution, Items werden on-demand gestreamt
- eingebaute Unterstützung für die Iteration von Items mit der
for-ofSyntax - Iterables erlauben die Implementierung optimierter Iteratormuster
Schlecht:
functionfibonacci(n: number): number[]{if(n===1)return[0];if(n===2)return[0,1];constitems: number[]=[0,1];while(items.length<n){items.push(items[items.length-2]+items[items.length-1]);}returnitems;}functionprint(n: number){fibonacci(n).forEach((fib)=>console.log(fib));}// Print first 10 Fibonacci numbers.print(10);Gut:
// Generates an infinite stream of Fibonacci numbers.// The generator doesn't keep the array of all numbers.function*fibonacci(): IterableIterator<number>{let[a,b]=[0,1];while(true){yielda;[a,b]=[b,a+b];}}functionprint(n: number){leti=0;for(constfiboffibonacci()){if(i++===n)break;console.log(fib);}}// Print first 10 Fibonacci numbers.print(10);Es gibt Bibliotheken, die es erlauben, mit Iterables auf ähnliche Weise wie mit nativen Arrays zu arbeiten, indem Methoden wie map, slice, forEach etc. verkettet werden. Siehe itiriri für ein Beispiel der fortgeschrittenen Manipulation mit Iterables (oder itiriri-async für die Manipulation von asynchronen Iterables).
importitiririfrom"itiriri";function*fibonacci(): IterableIterator<number>{let[a,b]=[0,1];while(true){yielda;[a,b]=[b,a+b];}}itiriri(fibonacci()).take(10).forEach((fib)=>console.log(fib));TypeScript unterstützt die Getter/Setter-Syntax. Getter und Setter zu verwenden, um auf Daten von Objekten zuzugreifen, die Verhalten kapseln, könnte besser sein, als einfach nach einer Eigenschaft auf einem Objekt zu suchen. "Warum?", magst du fragen. Nun, hier ist eine Liste von Gründen:
- Wenn du mehr tun willst, als nur eine Objekteigenschaft zu erhalten, musst du nicht jeden Accessor in deiner Codebase nachschlagen und ändern.
- Macht das Hinzufügen einer Validierung einfach, wenn du ein
setmachst. - Verkapselt die interne Repräsentation.
- Einfaches Hinzufügen von Logging und Fehlerbehandlung beim Holen und Setzen.
- Du kannst die Eigenschaften deines Objekts "lazy" laden, z.B. wenn du es von einem Server holst.
Schlecht:
typeBankAccount={balance: number;// ...};constvalue=100;constaccount: BankAccount={balance: 0,// ...};if(value<0){thrownewError("Cannot set negative balance.");}account.balance=value;Gut:
classBankAccount{privateaccountBalance: number=0;getbalance(): number{returnthis.accountBalance;}setbalance(value: number){if(value<0){thrownewError("Cannot set negative balance.");}this.accountBalance=value;}// ...}// Now `BankAccount` encapsulates the validation logic.// If one day the specifications change, and we need extra validation rule,// we would have to alter only the `setter` implementation,// leaving all dependent code unchanged.constaccount=newBankAccount();account.balance=100;TypeScript unterstützt public(default), protected und private Accessoren auf Klassenmitglieder.
Schlecht:
classCircle{radius: number;constructor(radius: number){this.radius=radius;}perimeter(){return2*Math.PI*this.radius;}surface(){returnMath.PI*this.radius*this.radius;}}Gut:
classCircle{constructor(privatereadonlyradius: number){}perimeter(){return2*Math.PI*this.radius;}surface(){returnMath.PI*this.radius*this.radius;}}TypeScripts Typsystem erlaubt es dir, einzelne Eigenschaften auf einer Schnittstelle/Klasse als readonly zu markieren. Dies erlaubt es dir, auf funktionale Weise zu arbeiten (eine unerwartete Mutation ist schlecht). Für fortgeschrittenere Szenarien gibt es einen eingebauten Typ Readonly, der einen Typ T nimmt und alle seine Eigenschaften als readonly markiert, indem er gemappte Typen verwendet (siehe gemappte Typen).
Schlecht:
interfaceConfig{host: string;port: string;db: string;}Gut:
interfaceConfig{readonlyhost: string;readonlyport: string;readonlydb: string;}Im Fall von Array kannst du ein schreibgeschütztes Array erstellen, indem du ReadonlyArray<T> verwendest. Sie erlauben keine Änderungen wie push() und fill(), können aber Funktionen wie concat() und slice() verwenden, die den Wert nicht verändern.
Schlecht:
constarray: number[]=[1,3,5];array=[];// errorarray.push(100);// array will be updatedGut:
constarray: ReadonlyArray<number>=[1,3,5];array=[];// errorarray.push(100);// errorDas Deklarieren von Nur-Lese-Argumenten in TypeScript 3.4 ist ein bisschen einfacher.
functionhoge(args: readonlystring[]){args.push(1);// error}Bevorzuge const-assertions für literalische Werte.
Schlecht:
constconfig={hello: "world",};config.hello="world";// value is changedconstarray=[1,3,5];array[0]=10;// value is changed// writable objects is returnedfunctionreadonlyData(value: number){return{ value };}constresult=readonlyData(100);result.value=200;// value is changedGut:
// read-only objectconstconfig={hello: "world",}asconst;config.hello="world";// error// read-only arrayconstarray=[1,3,5]asconst;array[0]=10;// error// You can return read-only objectsfunctionreadonlyData(value: number){return{ value }asconst;}constresult=readonlyData(100);result.value=200;// errorVerwende type, wenn du eine Vereinigung oder Kreuzung brauchst. Verwende ein Interface, wenn du extends oder implements brauchst. Es gibt keine strikte Regel, verwende die, die für dich funktioniert. Für eine detailliertere Erklärung siehe diese Antwort über die Unterschiede zwischen type und interface in TypeScript.
Schlecht:
interfaceEmailConfig{// ...}interfaceDbConfig{// ...}interfaceConfig{// ...}//...typeShape={// ...};Gut:
typeEmailConfig={// ...};typeDbConfig={// ...};typeConfig=EmailConfig|DbConfig;// ...interfaceShape{// ...}classCircleimplementsShape{// ...}classSquareimplementsShape{// ...}Die Größe der Klasse wird an ihrer Verantwortung gemessen. Nach dem Single Responsibility Prinzip sollte eine Klasse klein sein.
Schlecht:
classDashboard{getLanguage(): string{/* ... */}setLanguage(language: string): void{/* ... */}showProgress(): void{/* ... */}hideProgress(): void{/* ... */}isDirty(): boolean{/* ... */}disable(): void{/* ... */}enable(): void{/* ... */}addSubscription(subscription: Subscription): void{/* ... */}removeSubscription(subscription: Subscription): void{/* ... */}addUser(user: User): void{/* ... */}removeUser(user: User): void{/* ... */}goToHomePage(): void{/* ... */}updateProfile(details: UserDetails): void{/* ... */}getVersion(): string{/* ... */}// ...}Gut:
classDashboard{disable(): void{/* ... */}enable(): void{/* ... */}getVersion(): string{/* ... */}}// split the responsibilities by moving the remaining methods to other classes// ...Kohäsion definiert den Grad, in dem die Klassenmitglieder miteinander in Beziehung stehen. Idealerweise sollten alle Felder innerhalb einer Klasse von jeder Methode verwendet werden. Wir sagen dann, dass die Klasse maximal kohäsiv ist. In der Praxis ist dies jedoch nicht immer möglich und auch nicht ratsam. Du solltest es jedoch bevorzugen, dass die Kohäsion hoch ist.
Kopplung bezieht sich darauf, wie verwandt oder abhängig zwei Klassen zueinander sind. Man sagt, dass Klassen niedrig gekoppelt sind, wenn Änderungen in einer von ihnen keine Auswirkungen auf die andere haben.
Gutes Softwaredesign hat hohe Kohäsion und niedrige Kopplung.
Schlecht:
classUserManager{// Bad: each private variable is used by one or another group of methods.// It makes clear evidence that the class is holding more than a single responsibility.// If I need only to create the service to get the transactions for a user,// I'm still forced to pass and instance of `emailSender`.constructor(privatereadonlydb: Database,privatereadonlyemailSender: EmailSender){}asyncgetUser(id: number): Promise<User>{returnawaitdb.users.findOne({ id });}asyncgetTransactions(userId: number): Promise<Transaction[]>{returnawaitdb.transactions.find({ userId });}asyncsendGreeting(): Promise<void>{awaitemailSender.send("Welcome!");}asyncsendNotification(text: string): Promise<void>{awaitemailSender.send(text);}asyncsendNewsletter(): Promise<void>{// ...}}Gut:
classUserService{constructor(privatereadonlydb: Database){}asyncgetUser(id: number): Promise<User>{returnawaitthis.db.users.findOne({ id });}asyncgetTransactions(userId: number): Promise<Transaction[]>{returnawaitthis.db.transactions.find({ userId });}}classUserNotifier{constructor(privatereadonlyemailSender: EmailSender){}asyncsendGreeting(): Promise<void>{awaitthis.emailSender.send("Welcome!");}asyncsendNotification(text: string): Promise<void>{awaitthis.emailSender.send(text);}asyncsendNewsletter(): Promise<void>{// ...}}Wie schon in Design Patterns von der "Gang of Four" gesagt, solltest du Komposition gegenüber Vererbung bevorzugen, wo du kannst. Es gibt viele gute Gründe, Vererbung zu verwenden und viele gute Gründe, Komposition zu verwenden. Der Hauptpunkt für diese Maxime ist, dass wenn dein Verstand instinktiv zu Vererbung greift, versuche zu überlegen, ob Komposition dein Problem besser modellieren könnte. In einigen Fällen kann sie das.
Du fragst dich dann vielleicht: "Wann sollte ich Vererbung verwenden?" Das hängt von deinem Problem ab, aber dies ist eine anständige Liste, wann Vererbung mehr Sinn macht als Komposition:
- Deine Vererbung repräsentiert eine "is-a"-Beziehung und nicht eine "hat-a"-Beziehung (Mensch → Tier vs. Benutzer → BenutzerDetails).
- Du kannst Code aus den Basisklassen wiederverwenden (Menschen können sich wie alle Tiere bewegen).
- Du willst globale Änderungen an abgeleiteten Klassen vornehmen, indem du eine Basisklasse änderst. (Ändere den Kalorienverbrauch aller Tiere, wenn sie sich bewegen).
Schlecht:
classEmployee{constructor(privatereadonlyname: string,privatereadonlyemail: string){}// ...}// Bad because Employees "have" tax data. EmployeeTaxData is not a type of EmployeeclassEmployeeTaxDataextendsEmployee{constructor(name: string,email: string,privatereadonlyssn: string,privatereadonlysalary: number){super(name,email);}// ...}Gut:
classEmployee{privatetaxData: EmployeeTaxData;constructor(privatereadonlyname: string,privatereadonlyemail: string){}setTaxData(ssn: string,salary: number): Employee{this.taxData=newEmployeeTaxData(ssn,salary);returnthis;}// ...}classEmployeeTaxData{constructor(publicreadonlyssn: string,publicreadonlysalary: number){}// ...}Dieses Muster ist sehr nützlich und wird häufig in vielen Bibliotheken verwendet. Es erlaubt deinem Code, ausdrucksstark und weniger langatmig zu sein. Aus diesem Grund solltest du die Methodenverkettung nutzen und dir ansehen, wie sauber dein Code sein wird.
Schlecht:
classQueryBuilder{privatecollection: string;privatepageNumber: number=1;privateitemsPerPage: number=100;privateorderByFields: string[]=[];from(collection: string): void{this.collection=collection;}page(number: number,itemsPerPage: number=100): void{this.pageNumber=number;this.itemsPerPage=itemsPerPage;}orderBy(...fields: string[]): void{this.orderByFields=fields;}build(): Query{// ...}}// ...constqueryBuilder=newQueryBuilder();queryBuilder.from("users");queryBuilder.page(1,100);queryBuilder.orderBy("firstName","lastName");constquery=queryBuilder.build();Gut:
classQueryBuilder{privatecollection: string;privatepageNumber: number=1;privateitemsPerPage: number=100;privateorderByFields: string[]=[];from(collection: string): this {this.collection=collection;returnthis;}page(number: number,itemsPerPage: number=100): this {this.pageNumber=number;this.itemsPerPage=itemsPerPage;returnthis;}orderBy(...fields: string[]): this {this.orderByFields=fields;returnthis;}build(): Query{// ...}}// ...constquery=newQueryBuilder().from("users").page(1,100).orderBy("firstName","lastName").build();Wie es in Clean Code heißt: "Es sollte nie mehr als einen Grund für eine Klasse geben, sich zu ändern". Es ist verlockend, eine Klasse mit einer Menge Funktionalität vollzupacken, wie wenn du nur einen Koffer mit auf deinen Flug nehmen kannst. Das Problem dabei ist, dass deine Klasse nicht konzeptionell zusammenhängend sein wird und es viele Gründe gibt, sie zu ändern. Es ist wichtig, dass du die Zeit, die du brauchst, um eine Klasse zu ändern, minimierst. Es ist wichtig, denn wenn zu viel Funktionalität in einer Klasse ist und du einen Teil davon änderst, kann es schwierig sein zu verstehen, wie sich das auf andere abhängige Module in deiner Codebasis auswirkt.
Schlecht:
classUserSettings{constructor(privatereadonlyuser: User){}changeSettings(settings: UserSettings){if(this.verifyCredentials()){// ...}}verifyCredentials(){// ...}}Gut:
classUserAuth{constructor(privatereadonlyuser: User){}verifyCredentials(){// ...}}classUserSettings{privatereadonlyauth: UserAuth;constructor(privatereadonlyuser: User){this.auth=newUserAuth(user);}changeSettings(settings: UserSettings){if(this.auth.verifyCredentials()){// ...}}}Wie Bertrand Meyer sagt: "Software-Entitäten (Klassen, Module, Funktionen, etc.) sollten offen für Erweiterungen, aber geschlossen für Änderungen sein." Was bedeutet das aber? Dieses Prinzip besagt im Grunde, dass du den Benutzern erlauben solltest, neue Funktionalitäten hinzuzufügen, ohne den bestehenden Code zu verändern.
Schlecht:
classAjaxAdapterextendsAdapter{constructor(){super();}// ...}classNodeAdapterextendsAdapter{constructor(){super();}// ...}classHttpRequester{constructor(privatereadonlyadapter: Adapter){}asyncfetch<T>(url: string): Promise<T>{if(this.adapterinstanceofAjaxAdapter){constresponse=awaitmakeAjaxCall<T>(url);// transform response and return}elseif(this.adapterinstanceofNodeAdapter){constresponse=awaitmakeHttpCall<T>(url);// transform response and return}}}functionmakeAjaxCall<T>(url: string): Promise<T>{// request and return promise}functionmakeHttpCall<T>(url: string): Promise<T>{// request and return promise}Gut:
abstractclassAdapter{abstractasyncrequest<T>(url: string): Promise<T>;// code shared to subclasses ...}classAjaxAdapterextendsAdapter{constructor(){super();}asyncrequest<T>(url: string): Promise<T>{// request and return promise}// ...}classNodeAdapterextendsAdapter{constructor(){super();}asyncrequest<T>(url: string): Promise<T>{// request and return promise}// ...}classHttpRequester{constructor(privatereadonlyadapter: Adapter){}asyncfetch<T>(url: string): Promise<T>{constresponse=awaitthis.adapter.request<T>(url);// transform response and return}}Dies ist ein beängstigender Begriff für ein sehr einfaches Konzept. Er ist formal definiert als "Wenn S ein Subtyp von T ist, dann können Objekte vom Typ T durch Objekte vom Typ S ersetzt werden (d.h. Objekte vom Typ S können Objekte vom Typ T ersetzen), ohne dass sich irgendeine der wünschenswerten Eigenschaften des Programms (Korrektheit, ausgeführte Aufgabe, etc.) ändert." Das ist eine noch gruseligere Definition.
Die beste Erklärung dafür ist, wenn du eine Elternklasse und eine Kindklasse hast, dann können die Elternklasse und die Kindklasse austauschbar verwendet werden, ohne falsche Ergebnisse zu erhalten. Das könnte immer noch verwirrend sein, also lass uns einen Blick auf das klassische Quadrat-Rechteck Beispiel werfen. Mathematisch gesehen ist ein Quadrat ein Rechteck, aber wenn du es mit der "is-a" Beziehung über Vererbung modellierst, kommst du schnell in Schwierigkeiten.
Schlecht:
classRectangle{constructor(protectedwidth: number=0,protectedheight: number=0){}setColor(color: string): this {// ...}render(area: number){// ...}setWidth(width: number): this {this.width=width;returnthis;}setHeight(height: number): this {this.height=height;returnthis;}getArea(): number{returnthis.width*this.height;}}classSquareextendsRectangle{setWidth(width: number): this {this.width=width;this.height=width;returnthis;}setHeight(height: number): this {this.width=height;this.height=height;returnthis;}}functionrenderLargeRectangles(rectangles: Rectangle[]){rectangles.forEach((rectangle)=>{constarea=rectangle.setWidth(4).setHeight(5).getArea();// BAD: Returns 25 for Square. Should be 20.rectangle.render(area);});}constrectangles=[newRectangle(),newRectangle(),newSquare()];renderLargeRectangles(rectangles);Gut:
abstractclassShape{setColor(color: string): this {// ...}render(area: number){// ...}abstractgetArea(): number;}classRectangleextendsShape{constructor(privatereadonlywidth=0,privatereadonlyheight=0){super();}getArea(): number{returnthis.width*this.height;}}classSquareextendsShape{constructor(privatereadonlylength: number){super();}getArea(): number{returnthis.length*this.length;}}functionrenderLargeShapes(shapes: Shape[]){shapes.forEach((shape)=>{constarea=shape.getArea();shape.render(area);});}constshapes=[newRectangle(4,5),newRectangle(4,5),newSquare(5)];renderLargeShapes(shapes);ISP besagt, dass "Clients nicht gezwungen werden sollten, von Schnittstellen abhängig zu sein, die sie nicht nutzen". Dieses Prinzip ist sehr verwandt mit dem Single Responsibility Principle. Was es wirklich bedeutet ist, dass du deine Abstraktionen immer so gestalten solltest, dass die Clients, die die exponierten Methoden nutzen, nicht den ganzen Kuchen abbekommen. Das bedeutet auch, dass du den Clients die Last auferlegst, Methoden zu implementieren, die sie eigentlich nicht brauchen.
Schlecht:
interfaceSmartPrinter{print();fax();scan();}classAllInOnePrinterimplementsSmartPrinter{print(){// ...}fax(){// ...}scan(){// ...}}classEconomicPrinterimplementsSmartPrinter{print(){// ...}fax(){thrownewError("Fax not supported.");}scan(){thrownewError("Scan not supported.");}}Gut:
interfacePrinter{print();}interfaceFax{fax();}interfaceScanner{scan();}classAllInOnePrinterimplementsPrinter,Fax,Scanner{print(){// ...}fax(){// ...}scan(){// ...}}classEconomicPrinterimplementsPrinter{print(){// ...}}Dieses Prinzip besagt zwei wesentliche Dinge:
- High-Level Module sollten nicht von Low-Level Modulen abhängen. Beide sollten von Abstraktionen abhängen.
- Abstraktionen sollten nicht von Details abhängen. Details sollten von Abstraktionen abhängen.
Das kann am Anfang schwer zu verstehen sein, aber wenn du mit Angular gearbeitet hast, hast du eine Implementierung dieses Prinzips in Form von Dependency Injection (DI) gesehen. Obwohl es sich nicht um identische Konzepte handelt, hält DIP High-Level-Module davon ab, die Details der Low-Level-Module zu kennen und diese einzurichten. Dies kann durch DI erreicht werden. Ein großer Vorteil davon ist, dass es die Kopplung zwischen den Modulen reduziert. Kopplung ist ein sehr schlechtes Entwicklungsmuster, weil es deinen Code schwer zu refaktorisieren macht.
DIP wird normalerweise durch die Verwendung eines Inversion of Control (IoC) Containers erreicht. Ein Beispiel für einen mächtigen IoC-Container für TypeScript ist InversifyJs.
Schlecht:
import{readFileasreadFileCb}from"fs";import{promisify}from"util";constreadFile=promisify(readFileCb);typeReportData={// ..};classXmlFormatter{parse<T>(content: string): T{// Converts an XML string to an object T}}classReportReader{// BAD: We have created a dependency on a specific request implementation.// We should just have ReportReader depend on a parse method: `parse`privatereadonlyformatter=newXmlFormatter();asyncread(path: string): Promise<ReportData>{consttext=awaitreadFile(path,"UTF8");returnthis.formatter.parse<ReportData>(text);}}// ...constreader=newReportReader();constreport=awaitreader.read("report.xml");Gut:
import{readFileasreadFileCb}from"fs";import{promisify}from"util";constreadFile=promisify(readFileCb);typeReportData={// ..};interfaceFormatter{parse<T>(content: string): T;}classXmlFormatterimplementsFormatter{parse<T>(content: string): T{// Converts an XML string to an object T}}classJsonFormatterimplementsFormatter{parse<T>(content: string): T{// Converts a JSON string to an object T}}classReportReader{constructor(privatereadonlyformatter: Formatter){}asyncread(path: string): Promise<ReportData>{consttext=awaitreadFile(path,"UTF8");returnthis.formatter.parse<ReportData>(text);}}// ...constreader=newReportReader(newXmlFormatter());constreport=awaitreader.read("report.xml");// or if we had to read a json reportconstreader=newReportReader(newJsonFormatter());constreport=awaitreader.read("report.json");Testen ist wichtiger als Ausliefern. Wenn du keine oder nur unzureichende Tests hast, kannst du nicht sicher sein, dass du nichts kaputt gemacht hast, wenn du den Code auslieferst. Die Entscheidung darüber, was eine angemessene Menge ist, liegt bei deinem Team, aber eine 100%ige Abdeckung (alle Anweisungen und Verzweigungen) ist der Weg, um ein sehr hohes Vertrauen und den Seelenfrieden der Entwickler zu erreichen. Das bedeutet, dass du zusätzlich zu einem guten Testframework auch ein gutes Abdeckungswerkzeug verwenden musst.
Es gibt keine Ausrede, keine Tests zu schreiben. Es gibt viele gute JS-Testframeworks mit Typisierungsunterstützung für TypeScript, also finde eines, das dein Team bevorzugt. Wenn du eines gefunden hast, das für dein Team funktioniert, dann strebe an, für jedes neue Feature/Modul, das du einführst, immer Tests zu schreiben. Wenn deine bevorzugte Methode Test Driven Development (TDD) ist, ist das großartig, aber der Hauptpunkt ist, sicherzustellen, dass du deine Abdeckungsziele erreichst, bevor du ein Feature einführst oder ein bestehendes überarbeitest.
- Du darfst keinen produktiven Code schreiben, es sei denn, es geht darum, einen fehlgeschlagenen Unit-Test zu bestehen.
- Du darfst nicht mehr von einem Unit-Test schreiben, als zum Scheitern ausreicht, und; Kompilierungsfehler sind Fehler.
- Du darfst nicht mehr Produktionscode schreiben, als nötig ist, um den einen fehlgeschlagenen Unit-Test zu bestehen.
Saubere Tests sollten den Regeln folgen:
- Fast: Schnelle Tests sollten schnell sein, weil wir sie häufig ausführen wollen.
- Independent: Unabhängige Tests sollten nicht voneinander abhängen. Sie sollten die gleiche Ausgabe liefern, egal ob sie unabhängig voneinander oder alle zusammen in beliebiger Reihenfolge ausgeführt werden.
- Repeatable: Wiederholbar Tests sollten in jeder Umgebung wiederholbar sein und es sollte keine Ausrede geben, warum sie fehlschlagen.
- Self-Validating: Selbst-validierend sollte ein Test entweder mit Passed oder Failed antworten. Du musst keine Logdateien vergleichen, um festzustellen, ob ein Test bestanden wurde.
- Timely: Zeitnahe Unit-Tests sollten vor dem Produktionscode geschrieben werden. Wenn du Tests nach dem Produktionscode schreibst, könnte es dir zu schwer fallen, Tests zu schreiben.
Tests sollten auch dem Single Responsibility Principle folgen. Mache nur eine Assert pro Unit Test.
Schlecht:
import{assert}from"chai";describe("AwesomeDate",()=>{it("handles date boundaries",()=>{letdate: AwesomeDate;date=newAwesomeDate("1/1/2015");assert.equal("1/31/2015",date.addDays(30));date=newAwesomeDate("2/1/2016");assert.equal("2/29/2016",date.addDays(28));date=newAwesomeDate("2/1/2015");assert.equal("3/1/2015",date.addDays(28));});});Gut:
import{assert}from"chai";describe("AwesomeDate",()=>{it("handles 30-day months",()=>{constdate=newAwesomeDate("1/1/2015");assert.equal("1/31/2015",date.addDays(30));});it("handles leap year",()=>{constdate=newAwesomeDate("2/1/2016");assert.equal("2/29/2016",date.addDays(28));});it("handles non-leap year",()=>{constdate=newAwesomeDate("2/1/2015");assert.equal("3/1/2015",date.addDays(28));});});Wenn ein Test fehlschlägt, ist sein Name der erste Hinweis darauf, was falsch gelaufen sein könnte.
Schlecht:
describe("Calendar",()=>{it("2/29/2020",()=>{// ...});it("throws",()=>{// ...});});Gut:
describe("Calendar",()=>{it("should handle leap year",()=>{// ...});it("should throw when format is invalid",()=>{// ...});});Callbacks sind nicht sauber und verursachen exzessive Mengen an Verschachtlungen (die Callback-Hölle). Es gibt Hilfsprogramme, die bestehende Funktionen im Callback-Stil in eine Version umwandeln, die Versprechen zurückgibt (für Node.js siehe util.promisify, für allgemeine Zwecke siehe pify, es6-promisify).
Schlecht:
import{get}from"request";import{writeFile}from"fs";functiondownloadPage(url: string,saveTo: string,callback: (error: Error,content?: string)=>void){get(url,(error,response)=>{if(error){callback(error);}else{writeFile(saveTo,response.body,(error)=>{if(error){callback(error);}else{callback(null,response.body);}});}});}downloadPage("https://en.wikipedia.org/wiki/Robert_Cecil_Martin","article.html",(error,content)=>{if(error){console.error(error);}else{console.log(content);}});Gut:
import{get}from"request";import{writeFile}from"fs";import{promisify}from"util";constwrite=promisify(writeFile);functiondownloadPage(url: string,saveTo: string): Promise<string>{returnget(url).then((response)=>write(saveTo,response));}downloadPage("https://en.wikipedia.org/wiki/Robert_Cecil_Martin","article.html").then((content)=>console.log(content)).catch((error)=>console.error(error));Promises unterstützen ein paar Hilfsmethoden, die helfen, den Code übersichtlicher zu gestalten:
| Muster | Beschreibung |
|---|---|
Promise.resolve(value) | Konvertiert einen Wert in ein aufgelöstes versprechen. |
Promise.reject(error) | Konvertiert einen Fehler in ein abgelehntes Versprechen. |
Promise.all(promises) | Gibt ein neues Versprechen zurück, das mit einem Array von Erfüllungswerten für die übergebenen Versprechen erfüllt wird oder mit dem Grund des ersten abgelehnten Versprechens zurückgewiesen wird. |
Promise.race(promises) | Gibt ein neues Versprechen zurück, das mit dem Ergebnis/Fehler des ersten erledigten Versprechens aus dem Array der übergebenen Versprechen erfüllt/abgelehnt wird. |
Promise.all ist besonders nützlich, wenn es notwendig ist, Aufgaben parallel laufen zu lassen. Promise.race macht es einfacher, Dinge wie Timeouts für Promises zu implementieren.
Mit der async/await Syntax kannst du Code schreiben, der viel sauberer und verständlicher ist als verkettete Versprechen. Innerhalb einer Funktion, der das Schlüsselwort async vorangestellt ist, hast du die Möglichkeit, der JavaScript-Laufzeit zu sagen, dass sie die Ausführung des Codes auf das Schlüsselwort await (wenn es auf ein Versprechen angewendet wird) pausieren soll.
Schlecht:
import{get}from"request";import{writeFile}from"fs";import{promisify}from"util";constwrite=util.promisify(writeFile);functiondownloadPage(url: string,saveTo: string): Promise<string>{returnget(url).then((response)=>write(saveTo,response));}downloadPage("https://en.wikipedia.org/wiki/Robert_Cecil_Martin","article.html").then((content)=>console.log(content)).catch((error)=>console.error(error));Gut:
import{get}from"request";import{writeFile}from"fs";import{promisify}from"util";constwrite=promisify(writeFile);asyncfunctiondownloadPage(url: string): Promise<string>{constresponse=awaitget(url);returnresponse;}// somewhere in an async functiontry{constcontent=awaitdownloadPage("https://en.wikipedia.org/wiki/Robert_Cecil_Martin");awaitwrite("article.html",content);console.log(content);}catch(error){console.error(error);}Ausgelöste Fehler sind eine gute Sache! Sie bedeuten, dass die Laufzeitumgebung erfolgreich erkannt hat, dass etwas in deinem Programm schief gelaufen ist und es dich wissen lässt, indem sie die Ausführung der Funktion auf dem aktuellen Stack stoppt, den Prozess (in Node) beendet und dich in der Konsole mit einem Stacktrace benachrichtigt.
Sowohl JavaScript als auch TypeScript erlauben es dir, ein beliebiges Objekt zu werfen. Ein Promise kann auch mit einem beliebigen Grundobjekt verworfen werden. Es ist ratsam, die throw Syntax mit einem Error Typ zu verwenden. Das liegt daran, dass dein Fehler in höherem Code mit einer catch Syntax abgefangen werden könnte. Es wäre sehr verwirrend, dort eine String-Meldung zu fangen und würde das Debugging schmerzhafter machen. Aus dem gleichen Grund solltest du Promises mit Error Typen ablehnen.
Schlecht:
functioncalculateTotal(items: Item[]): number{throw"Not implemented.";}functionget(): Promise<Item[]>{returnPromise.reject("Not implemented.");}Gut:
functioncalculateTotal(items: Item[]): number{thrownewError("Not implemented.");}functionget(): Promise<Item[]>{returnPromise.reject(newError("Not implemented."));}// or equivalent to:asyncfunctionget(): Promise<Item[]>{thrownewError("Not implemented.");}Der Vorteil der Verwendung von Error Typen ist, dass sie von der Syntax try/catch/finally unterstützt werden und implizit alle Fehler die Eigenschaft stack haben, was sehr mächtig für das Debugging ist. Es gibt auch andere Alternativen, die throw-Syntax nicht zu verwenden und stattdessen immer eigene Fehlerobjekte zurückzugeben. TypeScript macht dies noch einfacher. Betrachte das folgende Beispiel:
typeResult<R>={isError: false;value: R};typeFailure<E>={isError: true;error: E};typeFailable<R,E>=Result<R>|Failure<E>;functioncalculateTotal(items: Item[]): Failable<number,"empty">{if(items.length===0){return{isError: true,error: "empty"};}// ...return{isError: false,value: 42};}Eine detaillierte Erklärung dieser Idee findest du im Originalbeitrag.
Wenn du nichts mit einem gefangenen Fehler machst, hast du nicht die Möglichkeit, den Fehler zu beheben oder darauf zu reagieren. Den Fehler auf der Konsole zu protokollieren (console.log) ist nicht viel besser, da er oft in einem Meer von Dingen, die auf der Konsole ausgegeben werden, verloren gehen kann. Wenn du ein Stück Code in ein try/catch verpackst, bedeutet das, dass du denkst, dass dort ein Fehler auftreten könnte und deshalb solltest du einen Plan haben, oder einen Codepfad erstellen, für den Fall, dass er auftritt.
Schlecht:
try{functionThatMightThrow();}catch(error){console.log(error);}// or even worsetry{functionThatMightThrow();}catch(error){// ignore error}Gut:
import{logger}from"./logging";try{functionThatMightThrow();}catch(error){logger.log(error);}Aus dem gleichen Grund solltest du gefangene Fehler von try/catch nicht ignorieren.
Schlecht:
getUser().then((user: User)=>{returnsendEmail(user.email,"Welcome!");}).catch((error)=>{console.log(error);});Gut:
import{logger}from"./logging";getUser().then((user: User)=>{returnsendEmail(user.email,"Welcome!");}).catch((error)=>{logger.log(error);});// or using the async/await syntax:try{constuser=awaitgetUser();awaitsendEmail(user.email,"Welcome!");}catch(error){logger.log(error);}Formatierung ist subjektiv. Wie bei vielen Regeln hier gibt es keine feste Regel, die du befolgen musst. Der wichtigste Punkt ist, dass du dich NICHT über die Formatierung streiten solltest. Es gibt tonnenweise Tools, um dies zu automatisieren. Benutze eins! Es ist eine Verschwendung von Zeit und Geld für Ingenieure, sich über die Formatierung zu streiten. Die allgemeine Regel, die man befolgen sollte, ist konsistente Formatierungsregeln.
Für TypeScript gibt es ein mächtiges Werkzeug namens ESLint. Es ist ein statisches Analysetool, das dir helfen kann, die Lesbarkeit und Wartbarkeit deines Codes dramatisch zu verbessern. Es gibt fertige ESLint-Konfigurationen, die du in deinen Projekten referenzieren kannst:
- ESLint Config Airbnb - Airbnb Style Guide
- ESLint Base Style Config - ein Set von essentiellen ESLint Regeln für JS, TS und React
- ESLint + Prettier - Lint-Regeln für Prettier Code-Formatierer
Siehe auch diese großartige TypeScript StyleGuide and Coding Conventions Quelle.
Wenn du nach Hilfe bei der Migration von TSLint zu ESLint suchst, kannst du dir dieses Projekt ansehen: https://github.com/typescript-eslint/tslint-to-eslint-config
Die Großschreibung verrät dir viel über deine Variablen, Funktionen, etc. Diese Regeln sind subjektiv, also kann dein Team wählen, was immer sie wollen. Der Punkt ist, egal was ihr alle wählt, seid einfach konsequent.
Schlecht:
constDAYS_IN_WEEK=7;constdaysInMonth=30;constsongs=["Back In Black","Stairway to Heaven","Hey Jude"];constArtists=["ACDC","Led Zeppelin","The Beatles"];constdiscography=getArtistDiscography("ACDC");constbeatlesSongs=SONGS.filter((song)=>isBeatlesSong(song));functioneraseDatabase(){}functionrestore_database(){}typeanimal={/* ... */};typeContainer={/* ... */};Gut:
constDAYS_IN_WEEK=7;constDAYS_IN_MONTH=30;constSONGS=["Back In Black","Stairway to Heaven","Hey Jude"];constARTISTS=["ACDC","Led Zeppelin","The Beatles"];functioneraseDatabase(){}functionrestoreDatabase(){}typeAnimal={/* ... */};typeContainer={/* ... */};Verwende bevorzugt PascalCase für Klassen-, Interface-, Typ- und Namensraumnamen. Verwende bevorzugt camelCase für Variablen, Funktionen und Klassenmitglieder. Verwende bevorzugt SNAKE_CASE für Konstanten.
Wenn eine Funktion eine andere aufruft, halte diese Funktionen vertikal dicht in der Quelldatei. Idealerweise solltest du den Aufrufer direkt über dem Aufrufer halten. Wir neigen dazu, Code von oben nach unten zu lesen, wie eine Zeitung. Deshalb solltest du dafür sorgen, dass sich dein Code auch so liest.
Schlecht:
classPerformanceReview{constructor(privatereadonlyemployee: Employee){}privatelookupPeers(){returndb.lookup(this.employee.id,"peers");}privatelookupManager(){returndb.lookup(this.employee,"manager");}privategetPeerReviews(){constpeers=this.lookupPeers();// ...}review(){this.getPeerReviews();this.getManagerReview();this.getSelfReview();// ...}privategetManagerReview(){constmanager=this.lookupManager();}privategetSelfReview(){// ...}}constreview=newPerformanceReview(employee);review.review();Gut:
classPerformanceReview{constructor(privatereadonlyemployee: Employee){}review(){this.getPeerReviews();this.getManagerReview();this.getSelfReview();// ...}privategetPeerReviews(){constpeers=this.lookupPeers();// ...}privatelookupPeers(){returndb.lookup(this.employee.id,"peers");}privategetManagerReview(){constmanager=this.lookupManager();}privatelookupManager(){returndb.lookup(this.employee,"manager");}privategetSelfReview(){// ...}}constreview=newPerformanceReview(employee);review.review();Mit sauberen und einfach zu lesenden Import-Anweisungen kannst du schnell die Abhängigkeiten des aktuellen Codes sehen. Achte darauf, dass du die folgenden guten Praktiken für import-Anweisungen anwendest:
- Importanweisungen sollten alphabetisch geordnet und gruppiert werden.
- Unbenutzte Importe sollten entfernt werden.
- Benannte Importe müssen alphabetisch geordnet sein (z.B.
import{A, B, C} from 'foo';) - Importquellen müssen innerhalb von Gruppen alphabetisch geordnet werden, z.B.:
import * as foo from 'a'; import * as bar from 'b'; - Bevorzuge die Verwendung von
import typeanstelle vonimport, wenn du nur Typen aus einer Datei importierst, um Abhängigkeitszyklen zu vermeiden, da diese Importe zur Laufzeit gelöscht werden - Gruppen von Importen werden durch Leerzeilen abgegrenzt.
- Gruppen müssen folgende Reihenfolge einhalten:
- Polyfills (d.h.
import 'reflect-metadata';) - Node builtin Module (z.B.
import fs from 'fs';) - externe Module (z.B.
import{query } from 'itiriri';) - interne Module (z.B.
import{UserService } from 'src/services/userService';) - Module aus einem übergeordneten Verzeichnis (z.B.
import foo from '../foo'; import qux from '../../foo/qux';) - Module aus dem gleichen oder einem Geschwisterverzeichnis (z.B.
import bar from './bar'; import baz from './bar/baz';)
- Polyfills (d.h.
Schlecht:
import{TypeDefinition}from"../types/typeDefinition";import{AttributeTypes}from"../model/attribute";import{Customer,Credentials}from"../model/types";import{ApiCredentials,Adapters}from"./common/api/authorization";importfsfrom"fs";import{ConfigPlugin}from"./plugins/config/configPlugin";import{BindingScopeEnum,Container}from"inversify";import"reflect-metadata";Gut:
import"reflect-metadata";importfsfrom"fs";import{BindingScopeEnum,Container}from"inversify";importtype{AttributeTypes}from"../model/attribute";importtype{Customer,Credentials}from"../model/types";importtype{TypeDefinition}from"../types/typeDefinition";import{ApiCredentials,Adapters}from"./common/api/authorization";import{ConfigPlugin}from"./plugins/config/configPlugin";Erstelle hübschere Importe, indem du die Pfade und baseUrl Eigenschaften im CompilerOptions Abschnitt in der tsconfig.json definierst.
Dadurch werden lange relative Pfade beim Importieren vermieden.
Schlecht:
import{UserService}from"../../../services/UserService";Gut:
import{UserService}from"@services/UserService";// tsconfig.json ... "compilerOptions": { ... "baseUrl": "src","paths": {"@services": ["services/*"]}...}...Die Verwendung eines Kommentars ist ein Hinweis darauf, dass man sich ohne ihn nicht ausdrücken kann. Der Code sollte die einzige Quelle der Wahrheit sein.
Don’t comment bad code—rewrite it. — Brian W. Kernighan and P. J. Plaugher
Kommentare sind eine Entschuldigung, keine Voraussetzung. Guter Code dokumentiert sich meistens selbst.
Schlecht:
// Check if subscription is active.if(subscription.endDate>Date.now){}Gut:
constisSubscriptionActive=subscription.endDate>Date.now;if(isSubscriptionActive){/* ... */}Versionskontrolle existiert aus einem bestimmten Grund. Lass alten Code in deiner Historie.
Schlecht:
typeUser={name: string;email: string;// age: number;// jobPosition: string;};Gut:
typeUser={name: string;email: string;};Denke daran, Versionskontrolle zu benutzen! Es gibt keinen Grund für toten Code, kommentierten Code und besonders Journal-Kommentare. Benutze git log um die Historie zu erhalten!
Schlecht:
/** * 2016-12-20: Removed monads, didn't understand them (RM) * 2016-10-01: Improved using special monads (JP) * 2016-02-03: Added type-checking (LI) * 2015-03-14: Implemented combine (JR) */functioncombine(a: number,b: number): number{returna+b;}Gut:
functioncombine(a: number,b: number): number{returna+b;}Sie fügen normalerweise nur Lärm hinzu. Lass die Funktionen und Variablennamen zusammen mit der richtigen Einrückung und Formatierung deinem Code die visuelle Struktur geben. Die meisten IDEs unterstützen Code-Folding-Features, die es dir ermöglichen, Codeblöcke zu komprimieren/expandieren (siehe Visual Studio Code folding regions).
Schlecht:
////////////////////////////////////////////////////////////////////////////////// Client class////////////////////////////////////////////////////////////////////////////////classClient{id: number;name: string;address: Address;contact: Contact;////////////////////////////////////////////////////////////////////////////////// public methods////////////////////////////////////////////////////////////////////////////////publicdescribe(): string{// ...}////////////////////////////////////////////////////////////////////////////////// private methods////////////////////////////////////////////////////////////////////////////////privatedescribeAddress(): string{// ...}privatedescribeContact(): string{// ...}}Gut:
classClient{id: number;name: string;address: Address;contact: Contact;publicdescribe(): string{// ...}privatedescribeAddress(): string{// ...}privatedescribeContact(): string{// ...}}Wenn du feststellst, dass du Notizen im Code für spätere Verbesserungen hinterlassen musst, mache das mit // TODO Kommentaren. Die meisten IDEs haben spezielle Unterstützung für diese Art von Kommentaren, so dass du schnell die gesamte Liste der Todos durchgehen kannst.
Behalte jedoch im Hinterkopf, dass ein TODO Kommentar keine Entschuldigung für schlechten Code ist.
Schlecht:
functiongetActiveSubscriptions(): Promise<Subscription[]>{// ensure `dueDate` is indexed.returndb.subscriptions.find({dueDate: {$lte: newDate()}});}Gut:
functiongetActiveSubscriptions(): Promise<Subscription[]>{// TODO: ensure `dueDate` is indexed.returndb.subscriptions.find({dueDate: {$lte: newDate()}});}Diese Prinzipien sind auch in anderen Sprachen verfügbar:
Das Original in Englisch: labs42io/clean-code-typescript
Brasilianisches Portugiesisch: vitorfreitas/clean-code-typescript
Chinesisch:
Französisch: ralflorent/clean-code-typescript
Deutsch: mheob/clean-code-typescript
Italienisch: Kornil/clean-code-typescript
Japanisch: MSakamaki/clean-code-typescript
Koreanisch: 738/clean-code-typescript
Russisch: Real001/clean-code-typescript
Spanisch: 3xp1o1t/clean-code-typescript
Türkisch: ozanhonamlioglu/clean-code-typescript
Ukrainisch: KirillPd/clean-code-typescript
Vietnamesisch: hoangsetup/clean-code-typescript
Referenzen werden hinzugefügt, sobald die Übersetzungen abgeschlossen sind. Schau dir diese Diskussion für weitere Details und Fortschritte an. Du kannst einen unverzichtbaren Beitrag zur Clean Code Community leisten, indem du dies in deine Sprache übersetzt.
