Bei Upsun haben wir an der Konvertierung unseres Kunden-Dashboards (Console) von JavaScript zu TypeScript gearbeitet. Theoretisch ist dies so einfach wie das Hinzufügen einiger Pakete, eine kleine Änderung an unserem Build-Skript und das Ändern von Tausenden von Dateierweiterungen. Aber wie man so schön sagt: In der Theorie gibt es keinen Unterschied zwischen Theorie und Praxis. In der Praxis gibt es einen.
Die Umstellung auf TypeScript dauert nun schon über anderthalb Jahre an, und wir haben dabei viel gelernt und viele unserer eigenen Strategien entwickelt. Wir wussten von Anfang an, dass es sich um ein monumentales Unterfangen handeln würde, und viele der Herausforderungen, mit denen wir konfrontiert wurden, waren zu erwarten, aber es gab auch ein paar unerwartet komplexe Hürden auf dem Weg. Und obwohl die Arbeit noch nicht abgeschlossen ist, ernten wir schon jetzt die Früchte, weil wir Fehler früher im Entwicklungsprozess finden und mehr Vertrauen in unseren Code haben.
Jede einigermaßen komplexe Anwendung - mehrere Entwickler, Kundenbetreuung, Zahlungsabwicklung usw. - wird von der gleichen Umstellung profitieren. Wenn Sie also eine ähnliche Umstellung in Erwägung ziehen, kann Ihnen unsere Erfahrung helfen, den Weg, der vor Ihnen liegt, ein wenig leichter zu bewältigen.
Bei einem bestehenden JavaScript (JS)-Projekt besteht der erste Schritt darin, TypeScript (TS) mit Ihrer Codebasis zum Laufen zu bringen. TS kann nicht direkt in Browsern ausgeführt werden, daher ist eine Konvertierung in JavaScript erforderlich. Die Einzelheiten hängen von Ihrer Einrichtung ab, aber wenn Sie bereits einen Build-Schritt in Ihrer Deployment-Pipeline haben, sollte es nicht schwierig sein, diesen hinzuzufügen. Projekte, die bei Upsun gehostet werden, haben Zugang zu Hooks, die diesen Prozess automatisieren können, was den Konvertierungsprozess noch einfacher macht.
Im Großen und Ganzen beinhaltet dieser manuelle Prozess das Hinzufügen des TS-Pakets (und anderer unterstützender Pakete für Linting, Testen usw.) zu Ihrem Projekt und das Konfigurieren Ihres Bundlers für die Handhabung von TS-Dateien. Für die Upsun-Konsole erfordert dies das Hinzufügen und Ändern einiger Regeln in unserer Webpack-Konfiguration; für andere Bundler kann der Prozess sogar noch einfacher sein. Sie müssen auch eine Konfigurationsdatei für TS hinzufügen, um es über Ihr Projekt und Ihre Einstellungen zu informieren.
Die wichtigste Entscheidung, die Sie bei der Konfiguration von TS treffen müssen, ist die Frage, wie streng es sein soll - mehrere Einstellungen beeinflussen dies. Sind sie ausgeschaltet, funktioniert Ihr TS-Code im Wesentlichen wie JS, d. h., grundlegende Typinformationen werden abgeleitet, aber Sie werden nicht in den Genuss der vollen Leistungsfähigkeit der Sprache kommen.
Mit diesem lockeren Ansatz zu beginnen, würde die anfängliche Reibung bei der Konvertierung von Dateien in TS verringern, aber es hat auch einige erhebliche Nachteile. Wenn Sie von Anfang an einen strikten Ansatz wählen, wird der Compiler - und Ihre integrierte Entwicklungsumgebung - nicht nur die Typen im Auge behalten, sondern auch darauf hinweisen, wann und wo Typinformationen angegeben werden müssen. Dies kann im Vorfeld etwas mehr Aufwand bedeuten, aber wenn Sie warten, bis der gesamte Code migriert ist, bevor Sie diese Schalter aktivieren, werden Sie mit einer umfangreichen Fehlermeldung begrüßt, die überwältigend sein kann.
Sobald TS eingerichtet und ein erster Ansatz gewählt wurde, steht man vor der schwierigen Entscheidung, wo man mit der Konvertierung beginnen soll. Wir hatten Erfolg mit mehreren unterschiedlichen Philosophien. Die erste besteht darin, Leaf-Dateien zu identifizieren und zu migrieren. Dabei handelt es sich um Dateien, die nicht von Ihrem anderen Code abhängig sind. Sie importieren nur externe Bibliotheken, wenn überhaupt. Der TS-Compiler kann aus JS-Dateien einige Typen ableiten, aber alles, was über die absoluten Grundlagen hinausgeht, wird nicht typisiert, genauer gesagt, es wird den gefürchteten any-Typ
haben - mehr dazu weiter unten. Wenn eine TS-Datei nicht von JS-Code abhängt - was bei diesen Dateien der Fall sein wird -, müssen Sie sich nicht in den Kaninchenbau begeben, um komplexe Typen ohne den Nutzen des Compilers herauszufinden und manuell Typen hinzuzufügen, die später automatisch abgeleitet werden. Einfache Komponenten der Benutzeroberfläche (UI) und Utility-Funktionen fallen oft in diese Kategorie.
Der andere Konvertierungsansatz, den wir verwendet haben, ist die Konvertierung von Dateien, auf denen viele andere Dateien beruhen. Wir wissen, dass ihre Typinformationen weit verbreitet sein werden. Je früher wir also sicherstellen, dass sie korrekt sind, desto einfacher werden all diese Konvertierungen sein. Beispiele sind Datenspeicher (z. B. Redux) und API-Kommunikation. Diese zentralen Dateien können andere lokale Dateien importieren, so dass Sie entscheiden müssen, ob Sie diese Dateien ebenfalls in einem frühen Stadium des Prozesses konvertieren oder auf einen späteren Zeitpunkt verschieben wollen. Unser Leitprinzip war es, logische Haltepunkte zu finden, so dass jede Arbeitseinheit relativ in sich geschlossen bleibt.
Die andere Seite des Zauns sind externe Pakete. Auch Blattdateien können diese importieren. Immer mehr Gemeinschaftspakete enthalten direkt Typinformationen, aber viele tun das immer noch nicht. In diesen Fällen gibt es mehrere Möglichkeiten. Viele beliebte Pakete haben externe Typen, die über Definitely-Typed verfügbar sind. Diese Typen stimmen nicht immer perfekt mit dem tatsächlichen Code überein, aber sie sind in der Regel völlig ausreichend und ein wunderbarer Teil der Open-Source-Gemeinschaft.
Gelegentlich kann es vorkommen, dass Sie eine weniger verbreitete Bibliothek verwenden, für die keine Typinformationen verfügbar sind. In diesen Fällen können Sie einfach Ihre eigenen Typen bereitstellen. Für die Upsun-Konsole speichern wir diese in ./types
und teilen sie dem Compiler mit dieser Konfiguration mit:
{ "compilerOptions": { "paths": { "*": ["types/*"] } } }
Vorhandene Typen können mit demselben Mechanismus überschrieben werden, und Sie können Ihre Typen über Definitely-Typed an die Gemeinschaft zurückgeben.
Es wäre zu kurz gegriffen zu sagen, dass man am besten gar keine Typinformationen hinzufügt, aber Tatsache ist, dass der TS-Compiler sehr leistungsfähig ist und in der Regel richtig liegt, wenn es um die Ableitung von Typen geht. Wir lassen ihn so oft wie möglich seine Arbeit machen und geben nur dann manuell Typen an, wenn es nötig ist. Diese Situationen lassen sich in zwei Kategorien einteilen:
Die erste ist die, in der es keine Typinformationen gibt, z. B. Funktionsargumente, das Array.reduce-Anfangsarg
, API-Antworten und JSON.parse
. In jedem dieser Fälle müssen wir dem Compiler mitteilen, womit er es zu tun hat. In diesen Fällen sind wir für die Genauigkeit voll verantwortlich. Der Compiler wird uns warnen, wenn er den angegebenen Typ nicht für sinnvoll hält, aber ansonsten vertraut er darauf, dass wir wissen, was wir tun.
Die andere Kategorie ist die Angabe eines Typs als Vertrag. Wenn ein Typ geändert wird - z. B. durch die Änderung des Rückgabewerts einer Funktion -, wird dies möglicherweise erst bemerkt, wenn ein Fehler nachgelagert entdeckt wird, was mehrere Importe entfernt sein kann, wenn überhaupt ein Fehler ausgelöst wird. Um solche Situationen zu vermeiden, geben wir manchmal manuell an, welcher Typ von einer Funktion zurückgegeben werden soll - auch wenn er leicht abgeleitet werden kann -, so dass bei einer Änderung des Rückgabetyps garantiert ein Fehler ausgelöst wird, und zwar genau dort, wo die Änderung vorgenommen wurde.
TypeScript bringt messbare Vorteile für komplexe JS-Anwendungen. Allerdings gibt es auch einige Funktionen, die leicht missbraucht werden können. Es sei darauf hingewiesen, dass diese nicht ausschließlich böse sind und wir sie gelegentlich verwenden, aber sie sollten gut verstanden und nur verwendet werden, wenn es wirklich notwendig ist.
Der erste ist der Typ any
. any
war früher ein unvermeidlicher Teil des Typsystems, der jeden möglichen Typ repräsentieren sollte. In der Praxis deaktiviert er effektiv die Typüberprüfung und alle damit verbundenen Vorteile. Wir können ihn nun größtenteils durch den unbekannten
Typ ersetzen. unknown
prüft weiterhin Typen, macht aber keine Annahmen über den zugrunde liegenden Typ. Um eine Operation mit einem unbekannten
Typ durchzuführen, müssen wir zuerst beweisen, dass er gültig ist. Es kommt immer noch selten vor, dass wir zu any
greifen, aber es wirft immer die Frage auf, ob es nicht eine bessere Option gibt.
Die nächste Funktion, die zu Problemen führen kann, ist der as-Operator
. as
ist ein wichtiger Teil der Sprache, aber er legt die Verantwortung für die Genauigkeit auf den Entwickler. Er wird verwendet, um einen Typ in einen anderen umzuwandeln. Auch hier wird sich der Compiler beschweren, wenn er die Konvertierung nicht für sinnvoll hält - weil sich die Typen nicht ausreichend überschneiden -, aber es gibt eine Ausweichmöglichkeit, indem man zuerst as unknown
castet. Wie bei jedem anderen Verfahren auch
, gibt es Zeiten, in denen dies erforderlich ist, aber wir greifen nur dazu, wenn es keine andere Möglichkeit gibt.
Der letzte Punkt ist kein Merkmal, aber etwas, vor dem man sich in Acht nehmen sollte: Komplexität. Das Typsystem ist unglaublich leistungsfähig, aber es ist möglich, Dinge zu implementieren, die schwer zu verstehen sind - sowohl für andere als auch für Ihr zukünftiges Ich. Bedingte Typen, gemappte Typen und rekursive Typen können in diese Kategorie fallen. Wie bei den oben genannten Vorbehalten ist dies bei der Konvertierung in TS oft unvermeidlich, weshalb wir versuchen, diese Komplexität zu isolieren und sorgfältig zu verwalten. Wenn möglich, implementieren wir einen komplexen Typ als Blackbox-Utility. Wir versuchen, den Problemraum und die Einschränkungen von Anfang an so zu definieren, dass der Code eine Sache gut macht und nicht häufig, wenn überhaupt, aktualisiert werden muss. Gründliches und durchdachtes Kommentieren trägt ebenfalls dazu bei, auf künftige Änderungen vorbereitet zu sein.
Zum Abschluss dieses Beitrags wollen wir unseren Blickwinkel erweitern und die Auswirkungen einer solchen Migration auf andere Aspekte der Entwicklung betrachten, angefangen beim Git-Workflow.
Der erste Schritt bei der Konvertierung einer Datei von JS nach TS besteht darin, die Dateierweiterung zu ändern. Git ist normalerweise in der Lage zu erkennen, dass es sich immer noch um dieselbe Datei handelt, und der gesamte Dateiverlauf wird beibehalten. Wenn jedoch zu viele Änderungen an der umbenannten Datei vorgenommen werden, bevor sie übertragen wird, sieht Git sie nicht mehr als dieselbe Datei an. Statt einer Umbenennung wird dann die alte Datei gelöscht und eine völlig neue Datei hinzugefügt. Das Schlimmste daran ist, dass die Historie der Datei bei diesem Vorgang verloren geht. Um dies zu umgehen, verwenden wir zwei Schritte: Im ersten wird nur die Erweiterung der betreffenden Dateien geändert. Anschließend werden die umbenannten Dateien übertragen.
Wenn Sie vor dem Commit lokal ein Linting oder eine Typüberprüfung durchführen, müssen Sie dies hier umgehen, da die umbenannten Dateien diese Prüfungen möglicherweise nicht bestehen. Dies kann mit dem Git Commit-Flag -no-verify
erreicht werden, das die Ausführung des Pre-Commit-Hooks überspringt. Sobald die umbenannten Dateien übertragen wurden, fügen wir die Typen hinzu, wo es nötig ist, und beheben alle Fehler, die aufgetaucht sind.
Häufig erfordert das Hinzufügen von Typinformationen zu einer Datei Änderungen an anderen Dateien - sowohl an denen, die sie importiert, als auch an denen, die sie importieren. Dies kann auf bereits existierende Fehler zurückzuführen sein, die vorher nicht sichtbar waren. Die Notwendigkeit, undefinierte
Typen explizit zu behandeln, ist etwas, das uns häufig begegnet. Wenn Sie die prop-types-Bibliothek
für JS-React-Komponenten verwenden, wird sie durch TS-Typen ersetzt, die in der Regel viel granularer sind, und dieser Wechsel macht manchmal weitere Änderungen erforderlich.
Es kann eine Herausforderung sein, all diese Änderungen zu identifizieren, bevor man mit einer individuellen Migration beginnt, so dass der Umfang einer Änderung im Laufe der Zeit wachsen kann. Auch hier ist ein umsichtiges Vorgehen erforderlich, um die richtigen Grenzen für eine bestimmte Migration zu finden, und wir stellen oft fest, dass wir eine Migration in zwei oder mehr aufteilen müssen.
Möglicherweise entdecken Sie während des Prozesses auch grundlegende Probleme mit Ihrem Code, die nicht einmal mit Typinformationen zu tun haben. Wir haben die Erfahrung gemacht, dass es besser ist, diese Probleme im Vorfeld zu beheben und separat zu übertragen, damit der Umfang der Migrationsarbeiten relativ gering bleibt, anstatt sie im Rahmen der Migration zu lösen. Im Endeffekt bedeutet dies, dass wir oft bis zu vier Schritte für jede Migration durchführen:
Was den letzten Punkt betrifft, müssen Sie möglicherweise Ihre anderen Entwicklungswerkzeuge anpassen, um TS zu unterstützen. Wenn Sie eslint verwenden, müssen Sie wahrscheinlich @typescript-eslint/parser
und @typescript-eslint/eslint-plugin
hinzufügen und zusätzliche Lint-Regeln konfigurieren, die auf Ihre TS-Dateien angewendet werden. Einige von ihnen nutzen sogar die Vorteile der Typprüfung und stellen eine großartige Ergänzung zur Fehlerprüfung durch den TS-Compiler dar.
Möglicherweise müssen Sie auch einige Änderungen an Ihrem Test-Runner vornehmen, damit er TS-Dateien unterstützt. Bei Jest muss TS vor der Ausführung nach JS transpiliert werden, aber Vitest unterstützt TS von Haus aus. Das schwierigere Problem beim Schreiben von Tests in TS ist, dass die Typisierung nicht annähernd so streng sein muss wie bei Ihrem Produktionscode, und es ist nicht praktikabel, die gleichen Standards einzuhalten. Zum Beispiel muss ein ganzer komplexer Typ gespottet werden, weil eine Funktion ihn erwartet, obwohl ein Test nur einen Teil davon betrifft.
In diesen Fällen ist es viel wahrscheinlicher, dass wir uns auf schnelle und schmutzige Tricks wie Typecasting mit as
verlassen. Es ist auch möglich, Konfigurationsüberschreibungen für TS, eslint und viele andere Werkzeuge zu erstellen, die speziell für Tests geeignet sind. Sie müssen immer noch darauf achten, dass Sie keinen Hack verwenden, um ein legitimes Problem zu überspielen, aber es ist ein lohnender Kompromiss, um zu vermeiden, dass Sie Ihre Tests streng typisieren müssen.
Bei einem so großen Unterfangen wie der Konvertierung Ihrer gesamten Codebasis wäre es ideal, wenn Sie alle anderen Arbeiten einstellen könnten, bis die Migration abgeschlossen ist. Leider ist dies nur selten praktikabel. Dennoch ist es von Vorteil, wenn Sie, wenn möglich, jeweils einen größeren Refactor in den Vordergrund stellen können. Neue Funktionen, Fehlerkorrekturen und andere alltägliche Aufgaben müssen immer noch untergebracht werden, aber die gleichzeitige Durchführung anderer großer Projekte führt zu einer exponentiellen Zunahme der Gesamtkomplexität.
Wir haben auch gelernt, funktionale Änderungen zu minimieren, wenn wir Typen hinzufügen. Wenn Sie eine gründliche Untersuchung Ihrer gesamten Codebasis durchführen, werden Sie zweifellos auf technische Schulden und andere Probleme stoßen, die Sie gerne beheben würden. Diese sollten am besten dokumentiert und auf einen späteren Zeitpunkt verschoben werden, da sonst der Umfang und die Komplexität außer Kontrolle geraten und auch der Zeitaufwand für die vollständige Umstellung zunimmt. Im Allgemeinen versuchen wir, überschaubare Teile von Änderungen zu isolieren, die sich schnell in TS umwandeln lassen. Sie sind leichter zu verstehen und zu überprüfen und führen zu weniger Konflikten beim Zusammenführen.
Als wir Anfang 2022 mit diesem Prozess begannen, hatten wir etwas mehr als 100.000 Zeilen JS-Code für die Konsole. Eineinhalb Jahre später sind wir bei etwa 70.000 Zeilen JS und TS angelangt. Außerdem haben wir viele großartige Funktionen hinzugefügt, und es wird noch mehr kommen! Zu diesem Zeitpunkt beginnt unsere gesamte Neuentwicklung als TS.
Auf dem Weg dorthin gab es einige Herausforderungen. Wir mussten all unsere vorhandenen Werkzeuge an die neue Sprache anpassen, das Team schulen und uns damit abfinden, dass Dinge, die in JS einfach - oder leicht zu ignorieren - waren, nun einen strengeren Ansatz erfordern. Aber die Vorteile haben die Nachteile bei weitem aufgewogen. Wir haben mehr Vertrauen in den Code, den wir in TS ausführen. Wir haben zahlreiche Unzulänglichkeiten in unserem älteren Code gefunden und behoben. Wir können Probleme viel früher im Entwicklungsprozess erkennen - in der Regel bevor sie in die Produktion gelangen.
Eine Migration dieser Größenordnung ist nichts, was man auf die leichte Schulter nimmt, aber es ist ein erreichbares Ziel. Wir haben zwar noch einen weiten Weg vor uns, bis dieses Projekt abgeschlossen ist, aber TS ist jetzt reibungslos in unseren Arbeitsablauf integriert, und ob wir nun neue Funktionen hinzufügen oder alte konvertieren, es ist eine großartige Ergänzung der Upsun-Konsole.