Hallo CSS-Tricksters! Bryan Hughes hat sich freundlicherweise ein Konzept aus einem bestehenden Beitrag von ihm über die Konvertierung zu TypeScript genommen und es in diesem Beitrag noch ein paar Schritte weiterentwickelt, um die Erstellung wiederverwendbarer Basisklassen zu erläutern. Obwohl dieser Beitrag die Lektüre des anderen nicht erfordert, lohnt es sich auf jeden Fall, ihn anzuschauen, da er die schwierige Aufgabe der Neufassung einer Codebasis und des Schreibens von Unit-Tests zur Unterstützung dieses Prozesses behandelt.
Johnny-Five ist eine Bibliothek für IoT und Robotik für Node.js. Johnny-Five erleichtert die Interaktion mit Hardware, indem es einen ähnlichen Ansatz verfolgt, wie ihn jQuery vor einem Jahrzehnt für das Web verfolgt hat: Es normalisiert die Unterschiede zwischen verschiedenen Hardwareplattformen. Ähnlich wie jQuery bietet Johnny-Five Abstraktionen auf höherer Ebene, die die Arbeit mit der Plattform erleichtern.
Johnny-Five unterstützt eine breite Palette von Plattformen über IO Plugins, von der Arduino-Familie über Tessel bis hin zu Raspberry Pi und vielen mehr. Jedes IO Plugin implementiert eine standardisierte Schnittstelle für die Interaktion mit Hardware-Peripheriegeräten auf niedrigerer Ebene. Raspi IO, das ich vor fünf Jahren entwickelt habe, ist das IO-Plugin, das die Unterstützung für den Raspberry Pi implementiert.
Immer wenn es mehrere Implementierungen gibt, die einem Ding entsprechen, besteht die Möglichkeit, Code zu teilen. IO Plugins sind keine Ausnahme, allerdings haben wir bisher keinen Code zwischen IO Plugins geteilt. Wir haben uns kürzlich als Gruppe entschieden, dies zu ändern. Das Timing war glücklich, da ich ohnehin vorhatte, Raspi IO in TypeScript neu zu schreiben, also habe ich zugestimmt, diese Aufgabe zu übernehmen.
Ziele einer Basisklasse
Für diejenigen, die mit der klassenbasierten Vererbung zur Code-Wiederverwendung möglicherweise nicht vertraut sind, hier eine kurze Einführung dazu, was ich meine, wenn ich von „Erstellung einer Basisklasse zur Code-Wiederverwendung“ spreche.
Eine Basisklasse bietet Struktur und gemeinsamen Code, den andere Klassen *erweitern* können. Werfen wir einen Blick auf eine vereinfachte TypeScript-Beispielbasisklasse, die wir später erweitern werden
abstract class Automobile {
private _speed: number = 0;
private _direction: number = 0;
public drive(speed: number): void {
this._speed = speed;
}
public turn(direction: number): void {
if (direction > 90 || direction < -90) {
throw new Error(`Invalid direction "${direction}"`);
}
this._direction = direction;
}
public abstract getNumDoors(): number;
}
Hier haben wir eine Basisklasse namens Automobile erstellt. Diese bietet einige grundlegende Funktionen, die allen Arten von Automobilen gemeinsam sind, sei es ein Auto, ein Pickup usw. Beachten Sie, wie diese Klasse als abstrakt markiert ist und wie es eine abstrakte Methode namens getNumDoors gibt. Diese Methode hängt vom spezifischen Automobiltyp ab. Indem wir sie als abstrakte Klasse definieren, sagen wir, dass wir eine andere Klasse benötigen, die diese Klasse erweitert und diese Methode implementiert. Dies können wir mit diesem Code tun
class Sedan extends Automobile {
public getNumDoors(): number {
return 4;
}
}
const myCar = new Sedan();
myCar.getNumDoors(); // prints 4
myCar.drive(35); // sets _speed to 35
myCar.turn(20); // sets _direction to 20
Wir *erweitern* die Klasse Automobile, um eine viertürige Limousinenklasse zu erstellen. Wir können nun alle Methoden beider Klassen aufrufen, als wären sie eine einzige Klasse.
Für dieses Projekt erstellen wir eine Basisklasse namens AbstractIO, die jeder IO-Plugin-Autor erweitern kann, um ein plattformspezifisches IO-Plugin zu implementieren.
Erstellung der abstrakten IO-Basisklasse
Abstract IO ist die Basisklasse, die ich erstellt habe, um die IO-Plugin-Spezifikation für alle IO-Plugin-Autoren zu implementieren, und ist heute auf npm verfügbar.
Jeder Autor hat seinen eigenen einzigartigen Stil, einige bevorzugen TypeScript, andere reines JavaScript. Das bedeutet, dass diese Basisklasse in erster Linie dem Schnittpunkt von TypeScript-Best Practices und JavaScript-Best Practices entsprechen muss. Das Finden des Schnittpunkts von Best Practices ist jedoch nicht immer offensichtlich. Um dies zu veranschaulichen, betrachten wir zwei Teile der IO-Plugin-Spezifikation: die Eigenschaft MODES und die Methode digitalWrite, die beide in IO-Plugins erforderlich sind.
Die Eigenschaft MODES ist ein Objekt, bei dem jeder Schlüssel einen menschenlesbaren Namen für einen bestimmten Modustyp hat, wie z. B. INPUT, und der Wert die numerische Konstante ist, die dem Modustyp zugeordnet ist, wie z. B. 0. Diese numerischen Werte erscheinen an anderen Stellen in der API, z. B. in der Methode pinMode. In TypeScript möchten wir eine enum verwenden, um diese Werte darzustellen, aber JavaScript unterstützt dies nicht. In JavaScript besteht die beste Praxis darin, eine Reihe von Konstanten zu definieren und sie in einem Objekt-"Container" zu platzieren.
Wie lösen wir diese scheinbare Spaltung zwischen Best Practices? Wir erstellen eine mit der anderen! Wir beginnen mit der Definition einer enum in TypeScript und definieren jede Einstellung mit einem expliziten Initialisierungswert für jeden Wert
export enum Mode {
INPUT = 0,
OUTPUT = 1,
ANALOG = 2,
PWM = 3,
SERVO = 4,
STEPPER = 5,
UNKNOWN = 99
}
Jeder Wert ist sorgfältig so gewählt, dass er explizit der MODES-Eigenschaft entspricht, wie sie in der Spezifikation definiert ist, die als Teil der Abstract IO-Klasse mit folgendem Code implementiert wird
public get MODES() {
return {
INPUT: Mode.INPUT,
OUTPUT: Mode.OUTPUT,
ANALOG: Mode.ANALOG,
PWM: Mode.PWM,
SERVO: Mode.SERVO,
UNKNOWN: Mode.UNKNOWN
}
}
Damit haben wir schöne Enums, wenn wir in der TypeScript-Welt sind, und können die numerischen Werte komplett ignorieren. Wenn wir uns in der reinen JavaScript-Welt befinden, haben wir immer noch die Eigenschaft MODES, die wir auf typische JavaScript-Weise weitergeben können, sodass wir keine numerischen Werte direkt verwenden müssen.
Aber was ist mit Methoden in der Basisklasse, die abgeleitete Klassen überschreiben müssen? In der TypeScript-Welt würden wir eine abstrakte Klasse verwenden, wie wir es im obigen Beispiel getan haben. Cool! Aber was ist mit der JavaScript-Seite? Abstrakte Methoden werden herauskompiliert und existieren im ausgegebenen JavaScript nicht einmal, sodass wir nichts haben, um sicherzustellen, dass Methoden dort überschrieben werden. Ein weiterer Widerspruch!
Leider konnte ich keine Möglichkeit finden, beide Sprachen zu berücksichtigen, daher habe ich mich dafür entschieden, den JavaScript-Ansatz zu verwenden: Eine Methode in der Basisklasse erstellen, die eine Ausnahme auslöst
public digitalWrite(pin: string | number, value: number): void {
throw new Error(`digitalWrite is not supported by ${this.name}`);
}
Es ist nicht ideal, da der TypeScript-Compiler uns nicht warnt, wenn wir die abstrakte Basis-Methode nicht überschreiben. Es funktioniert jedoch in TypeScript (wir erhalten die Ausnahme immer noch zur Laufzeit) und ist außerdem eine Best Practice für JavaScript.
Gewonnene Erkenntnisse
Bei der Erarbeitung des besten Designs für TypeScript und reines JavaScript habe ich festgestellt, dass die beste Lösung für Diskrepanzen darin besteht:
- Verwenden Sie eine gemeinsame Best Practice, die von jeder Sprache geteilt wird
- Wenn das nicht funktioniert, versuchen Sie, die TypeScript-Best Practice "intern" zu verwenden und sie einer separaten reinen JavaScript-Best Practice "extern" zuzuordnen, wie wir es für die
MODES-Eigenschaft getan haben. - Und wenn dieser Ansatz nicht funktioniert, greifen Sie auf die reine JavaScript-Best Practice zurück, wie wir es für die
digitalWrite-Methode getan haben, und ignorieren Sie die TypeScript-Best Practice.
Diese Schritte haben gut funktioniert, um Abstract IO zu erstellen, aber dies sind lediglich meine Erkenntnisse. Wenn Sie bessere Ideen haben, würde ich mich freuen, sie in den Kommentaren zu hören!
Ich habe festgestellt, dass abstrakte Klassen in reinen JavaScript-Projekten nicht nützlich sind, da JavaScript kein solches Konzept hat. Das bedeutet, dass sie hier ein Anti-Pattern sind, obwohl abstrakte Klassen eine TypeScript-Best Practice sind. TypeScript bietet auch keine Mechanismen zur Gewährleistung der Typsicherheit beim Überschreiben von Methoden, wie z. B. das Schlüsselwort override in C#. Die Lektion hier ist, dass Sie so tun sollten, als gäbe es abstrakte Klassen nicht, wenn Sie sich auch an reine JavaScript-Benutzer richten.
Ich habe auch gelernt, dass es wirklich wichtig ist, eine einzige Quelle der Wahrheit für Schnittstellen zu haben, die über Module hinweg gemeinsam genutzt werden. TypeScript verwendet Duck Typing als Kernmuster seines Typsystems, und das ist innerhalb eines jeden TypeScript-Projekts sehr nützlich. Das Synchronisieren von Typen über separate Module hinweg mit Duck Typing erwies sich jedoch als mehr Aufwand für die Wartung, als eine Schnittstelle aus einem Modul zu exportieren und in ein anderes zu importieren. Ich musste kleine Änderungen an Modulen veröffentlichen, bis sie ihre Typen aufeinander abgestimmt hatten, was zu übermäßiger Versionsnummernänderung führte.
Die letzte Lektion, die ich gelernt habe, ist keine neue: Holen Sie sich während des gesamten Designprozesses Feedback von den Nutzern der Bibliothek. In diesem Wissen habe ich zwei Hauptrunden des Feedbacks eingerichtet. Die erste Runde fand persönlich beim Johnny-Five-Kollaborateur-Gipfel statt, wo wir als Gruppe brainstormten. Die zweite Runde war ein Pull Request, den ich an mich selbst geöffnet habe, obwohl ich der einzige Kollaborateur von Abstract IO mit der Berechtigung war, Pull Requests zu mergen. Der Pull Request erwies sich als unschätzbar wertvoll, als wir die Details als Gruppe durchgingen. Der Code am Anfang des PR hatte wenig Ähnlichkeit mit dem Code, als er gemerged wurde, was gut ist!
Zusammenfassung
Die Erstellung von Basisklassen für die Nutzung durch sowohl TypeScript- als auch reine JavaScript-Benutzer ist ein weitgehend unkomplizierter Prozess, aber es gibt einige Fallstricke. Da TypeScript eine Obermenge von JavaScript ist, teilen die beiden meist die gleichen Best Practices. Wenn Unterschiede in den Best Practices auftreten, ist ein wenig Kreativität erforderlich, um den besten Kompromiss zu finden.
Ich konnte meine Ziele mit Abstract IO erreichen und eine Basisklasse implementieren, die sowohl für TypeScript- als auch für reine JavaScript-IO-Plugin-Autoren gut funktioniert. Es stellt sich heraus, dass es (meistens) möglich ist, das Glück und das Vermögen zu haben!
Der Autor kennt sich offensichtlich nicht gut mit TypeScript aus. Eine Enum wird zu einem JavaScript-Objekt kompiliert, was genau das ist, was er in der anderen Funktion tut. Diese Funktion und das zusätzliche Objekt sind überflüssig. Sie könnten einfach das "Mode"-Objekt verwenden.
Es gibt einige eigenartige Dinge in der IO-Plugin-Spezifikation bezüglich des Einfrierens des Objekts und dessen, was eingeschlossen werden kann/nicht eingeschlossen werden kann, die mich daran gehindert haben, den von Ihnen vorgeschlagenen Ansatz zu verfolgen, den ich der Kürze halber im Artikel weggelassen habe. Sie können die Enum in den meisten Anwendungen direkt verwenden, sollten sich aber bewusst sein, dass dies zu einem komplexeren Objekt führt als dem, das ich in meinem Code habe.
Es ist wichtig zu bedenken, dass die Enums zu mehr als nur dem Roh-Objekt kompiliert werden, das ich oben gezeigt habe, um auch die umgekehrte Suche nach Enum-Namen usw. zu unterstützen, worauf die Benutzer ebenfalls achten sollten.