Organisation der Entwicklung von React-Großanwendungen

Dieser Beitrag basiert auf einer Reihe zur Modernisierung des jQuery-Frontends mit React. Um die Gründe für das Schreiben dieses Materials besser zu verstehen, wird empfohlen, den ersten Artikel dieser Reihe zu lesen. Es ist heutzutage sehr einfach, die Entwicklung einer kleinen React-Anwendung zu organisieren oder von vorne zu beginnen. Besonders bei Verwendung der Create-React-App . Einige Projekte benötigen höchstwahrscheinlich nur wenige Abhängigkeiten (z. B. um den Anwendungsstatus zu verwalten und das Projekt zu internationalisieren) und einen Ordner , der mindestens ein Verzeichnis enthält







srccomponents... Ich glaube, dass dies die Struktur ist, mit der die meisten React-Projekte beginnen. In der Regel sind Programmierer jedoch mit zunehmender Anzahl von Projektabhängigkeiten mit einer Zunahme der Anzahl von Komponenten, Reduzierern und anderen wiederverwendbaren Mechanismen konfrontiert, die in ihrer Zusammensetzung enthalten sind. Manchmal wird alles sehr unangenehm und schwierig zu handhaben. Was tun, wenn beispielsweise nicht mehr klar ist, warum bestimmte Abhängigkeiten erforderlich sind und wie sie zusammenpassen? Oder was ist, wenn das Projekt so viele Komponenten angesammelt hat, dass es schwierig wird, die richtige unter ihnen zu finden? Was tun, wenn ein Programmierer eine bestimmte Komponente finden muss, deren Name vergessen wurde?



Dies sind nur einige Beispiele für die Fragen, auf die wir bei der Überarbeitung des Frontends in Karify Antworten finden mussten . Wir wussten, dass die Anzahl der Abhängigkeiten und Projektkomponenten eines Tages außer Kontrolle geraten könnte. Dies bedeutete, dass wir alles so planen mussten, dass wir mit dem Wachstum des Projekts sicher weiter daran arbeiten konnten. Diese Planung beinhaltete die Vereinbarung der Datei- und Ordnerstruktur und der Codequalität. Dies beinhaltete eine Beschreibung der Gesamtarchitektur des Projekts. Und vor allem musste es so gestaltet werden, dass all dies von neuen Programmierern, die zum Projekt kommen, leicht wahrgenommen werden kann, damit sie das Projekt für die Aufnahme in die Arbeit nicht zu lange studieren müssen, um alle seine Abhängigkeiten und den Stil seines Codes zu verstehen.



Zum Zeitpunkt dieses Schreibens verfügt unser Projekt über ungefähr 1200 JavaScript-Dateien. 350 davon sind Komponenten. Der Code ist zu 80% einheitlich getestet. Da wir uns weiterhin an die Vereinbarungen halten, die wir im Rahmen der zuvor erstellten Projektarchitektur getroffen haben und arbeiten, haben wir beschlossen, dass es gut ist, all dies mit der Öffentlichkeit zu teilen. So ist dieser Artikel entstanden. Hier werden wir über die Organisation der Entwicklung einer groß angelegten React-Anwendung sprechen und darüber, welche Lehren wir aus den Erfahrungen bei der Arbeit daran gezogen haben.



Wie organisiere ich Dateien und Ordner?



Wir haben erst nach mehreren Phasen des Projekts eine Möglichkeit gefunden, unsere React-Front-End-Materialien bequem zu organisieren. Zunächst wollten wir die Projektmaterialien in demselben Repository hosten, in dem der jQuery-basierte Frontend-Code gespeichert war. Aufgrund der Anforderungen an die Ordnerstruktur, die das von uns verwendete Backend-Framework an das Projekt stellt, hat diese Option bei uns jedoch nicht funktioniert. Als nächstes haben wir darüber nachgedacht, den Frontend-Code in ein separates Repository zu verschieben. Anfangs hat dieser Ansatz gut funktioniert, aber im Laufe der Zeit haben wir darüber nachgedacht, andere Client-Teile des Projekts zu erstellen, beispielsweise ein Frontend, das auf React Native basiert. Dies brachte uns zum Nachdenken über die Komponentenbibliothek. Aus diesem Grund haben wir das neue Repository in zwei separate Repositorys aufgeteilt. Eine war für eine Komponentenbibliothek und die andere für das neue React-Frontend.Obwohl uns diese Idee zunächst erfolgreich erschien, führte ihre Umsetzung zu einer ernsthaften Komplikation des Codeüberprüfungsverfahrens. Die Beziehung zwischen den Änderungen in unseren beiden Repositories ist unklar geworden. Aus diesem Grund haben wir uns entschlossen, den Code erneut in einem einzigen Repository zu speichern, aber jetzt war es ein Mono-Repository.



Wir haben uns für ein Mono-Repository entschieden, weil wir im Projekt eine Trennung zwischen der Bibliothek der Komponenten und der Frontend-Anwendung einführen wollten. Der Unterschied zwischen unserem Mono-Repository und anderen ähnlichen Repositorys besteht darin, dass wir keine Pakete in unserem Repository veröffentlichen mussten. In unserem Fall waren Pakete nur ein Mittel zur Gewährleistung der Modularität der Entwicklung und ein Instrument zur Trennung von Bedenken. Es ist besonders nützlich, verschiedene Pakete für verschiedene Varianten Ihrer Anwendung zu haben, da Sie so unterschiedliche Abhängigkeiten für jede einzelne definieren und unterschiedliche Skripte mit jeder anwenden können.



Wir richten unser Mono-Repository mithilfe von Garnarbeitsbereichen mit der folgenden Konfiguration in der Stammdatei ein package.json:



"workspaces": [
    "app/*",
    "lib/*",
    "tool/*"
]


Jetzt fragen sich einige von Ihnen vielleicht, warum wir die Paketordner einfach nicht verwendet haben, genauso wie in anderen Monorepositorys. Dies liegt hauptsächlich daran, dass wir die Anwendung und die Komponentenbibliothek trennen wollten. Außerdem wussten wir, dass wir einige unserer eigenen Tools erstellen mussten. Als Ergebnis kamen wir zu der obigen Ordnerstruktur. So spielen diese Ordner in einem Projekt ab:



  • app: Alle Pakete in diesem Ordner beziehen sich auf Frontend-Anwendungen wie das Karify-Frontend und einige andere interne Frontends. Hier werden auch unsere Storybook- Materialien gespeichert .
  • lib: -, , . , , . , , typography, media primitive.
  • tool: , , Node.js. , , , . , , webpack, , ( « »).


Alle unsere Pakete haben unabhängig vom Ordner, in dem sie gespeichert sind, einen Unterordner srcund optional einen Ordner bin. Die srcPaketverzeichnisse, die in Verzeichnissen appund gespeichert sind lib, können einige der folgenden Unterordner enthalten:



  • actions: Enthält Funktionen zum Erstellen von Aktionen, deren Rückgabewerte an Versandfunktionen von reduxoder übergeben werden können useReducer.
  • components: Enthält Ordner mit Komponenten mit Code, Übersetzungen, Komponententests, Snapshots und Historien (falls zutreffend für eine bestimmte Komponente).
  • constants: In diesem Ordner werden Werte gespeichert, die in verschiedenen Umgebungen unverändert bleiben. Hier werden auch Dienstprogramme gespeichert.
  • fetch: Hier werden Typdefinitionen für die Verarbeitung der von unserer API empfangenen Daten sowie der entsprechenden asynchronen Aktionen zum Empfang dieser Daten gespeichert.
  • helpers: , .
  • reducers: , redux useReducer.
  • routes: , react-router history.
  • selectors: , redux-, , API.


Diese Ordnerstruktur ermöglicht es uns, wirklich modularen Code zu schreiben, da sie ein klares System zur Aufteilung der Verantwortlichkeiten zwischen den verschiedenen durch unsere Abhängigkeiten definierten Konzepten schafft. Dies hilft uns, das Repository nach Variablen, Funktionen und Komponenten zu durchsuchen und darüber hinaus unabhängig davon, ob derjenige, der sie sucht, über ihre Existenz Bescheid weiß oder nicht. Darüber hinaus hilft es uns, die Mindestmenge an Inhalten in separaten Ordnern zu speichern, was wiederum die Arbeit mit ihnen erleichtert.



Als wir mit der Anwendung dieser Ordnerstruktur begannen, standen wir vor der Herausforderung, eine konsistente Anwendung einer solchen Struktur sicherzustellen. Wenn Sie mit verschiedenen Paketen arbeiten, möchte der Entwickler möglicherweise verschiedene Ordner in den Ordnern dieser Pakete erstellen und die Dateien in diesen Ordnern auf unterschiedliche Weise organisieren. Obwohl dies nicht immer schlecht ist, würde ein derart unorganisierter Ansatz zu Verwirrung führen. Um die obige Struktur systematisch anzuwenden, haben wir einen sogenannten "Dateisystem-Linter" erstellt. Wir werden jetzt darüber sprechen.



Wie stellen Sie sicher, dass der Styleguide angewendet wird?



Wir haben uns in unserem Projekt um eine einheitliche Struktur der Dateien und Ordner bemüht. Wir wollten dasselbe für den Code erreichen. Zu diesem Zeitpunkt hatten wir bereits erfolgreiche Erfahrungen mit der Lösung eines ähnlichen Problems in der jQuery-Version des Projekts, aber wir mussten viel verbessern, insbesondere was CSS betrifft. Aus diesem Grund haben wir uns entschlossen, einen Styleguide von Grund auf neu zu erstellen und ihn mit einem Linter zu verwenden. Regeln, die mit einem Linter nicht durchgesetzt werden konnten, wurden während der Codeüberprüfung kontrolliert.



Das Einrichten eines Linter in einem Mono-Repository erfolgt auf die gleiche Weise wie in jedem anderen Repository. Dies ist gut, da Sie damit das gesamte Repository in einem einzigen Lauf auschecken können. Wenn Sie mit Lintern nicht vertraut sind, empfehlen wir Ihnen , einen Blick auf ESLint und Stylelint zu werfen . Wir verwenden sie genau.



Der JavaScript-Linter hat sich in folgenden Situationen als besonders nützlich erwiesen:



  • Sicherstellen der Verwendung von Komponenten, die unter Berücksichtigung der Zugänglichkeit von Inhalten erstellt wurden, anstelle ihrer HTML-Gegenstücke. Bei der Erstellung des Styleguides haben wir verschiedene Regeln für die Zugänglichkeit von Links, Schaltflächen, Bildern und Symbolen eingeführt. Dann mussten wir diese Regeln im Code durchsetzen und sicherstellen, dass wir sie in Zukunft nicht vergessen würden. Wir haben dies mit der React / Forbid- Elements- Regel von eslint-plugin-react gemacht .


Hier ist ein Beispiel dafür, wie es aussieht:



'react/forbid-elements': [
    'error',
    {
        forbid: [
            {
                element: 'img',
                message: 'Use "<Image>" instead. This is important for accessibility reasons.',
            },
        ],
    },
],






Neben JavaScript und CSS haben wir auch einen eigenen "Dateisystem-Linter". Er sorgt für eine einheitliche Nutzung der von uns gewählten Ordnerstruktur. Da dies ein Tool ist, das wir selbst erstellt haben, können wir es jederzeit entsprechend ändern, wenn wir zu einer anderen Ordnerstruktur wechseln. Hier sind Beispiele für die Regeln, die wir beim Arbeiten mit Dateien und Ordnern steuern:



  • Überprüfen der Ordnerstruktur der Komponenten: Stellen Sie sicher, dass immer eine Datei index.tsund eine .tsxDatei mit demselben Namen wie der Ordner vorhanden sind.
  • Dateiüberprüfung package.json: Stellen Sie sicher, dass es eine solche Datei pro Paket gibt und dass die Eigenschaft privateso eingestellt ist true, dass eine versehentliche Veröffentlichung des Pakets verhindert wird.


Welches Typsystem sollten Sie wählen?



Heutzutage ist die Antwort auf die Frage im Titel dieses Abschnitts für viele wahrscheinlich recht einfach. Sie müssen nur TypeScript verwenden . In einigen Fällen kann die Implementierung von TypeScript unabhängig von der Größe des Projekts die Entwicklung verlangsamen. Wir glauben jedoch, dass dies ein angemessener Preis für die Verbesserung der Qualität und Genauigkeit des Codes ist.



Leider war das Prop-Typ- System zu der Zeit, als wir mit der Arbeit an dem Projekt begannen, noch sehr weit verbreitet.... Zu Beginn unserer Arbeit war dies genug für uns, aber als das Projekt wuchs, vermissten wir die Möglichkeit, Typen für Entitäten zu deklarieren, die keine Komponenten sind. Wir haben gesehen, dass dies uns helfen wird, beispielsweise Reduzierer und Selektoren zu verbessern. Die Einführung eines anderen Typisierungssystems in ein Projekt würde jedoch viel Code-Refactoring erfordern, um die gesamte Codebasis einzugeben.



Am Ende haben wir unser Projekt immer noch mit Typunterstützung ausgestattet, aber den Fehler gemacht, zuerst Flow auszuprobieren.... Es schien uns, dass Flow einfacher in das Projekt zu integrieren war. Obwohl dies der Fall war, hatten wir regelmäßig alle möglichen Probleme mit Flow. Dieses System ließ sich nicht sehr gut in unsere IDE integrieren, manchmal wurden aus unbekannten Gründen einige Fehler nicht erkannt, und das Erstellen generischer Typen war ein wahrer Albtraum. Aus diesen Gründen haben wir alles auf TypeScript migriert. Wenn wir damals wüssten, was wir heute wissen, würden wir sofort TypeScript wählen.



Aufgrund der Richtung, in die sich TypeScript in den letzten Jahren entwickelt hat, war dieser Übergang für uns recht einfach. Der Übergang von TSLint zu ESLint war für uns besonders nützlich .



Wie teste ich den Code?



Als wir mit der Arbeit an dem Projekt begannen, war uns nicht klar, welche Testwerkzeuge wir wählen sollten. Wenn ich jetzt darüber nachdenken würde, würde ich sagen, dass es für Unit- und Integrationstests am besten ist, Scherz bzw. Zypresse zu verwenden . Diese Tools sind gut dokumentiert und einfach zu bedienen. Das einzige Schade ist, dass Cypress die Fetch-API nicht unterstützt . Das Schlimme ist, dass die API dieses Tools nicht für die Verwendung des Konstrukts async / await ausgelegt ist . Nachdem wir mit der Verwendung von Zypressen begonnen hatten, haben wir dies nicht sofort verstanden. Ich hoffe aber, dass sich die Situation in naher Zukunft verbessern wird.



Zunächst war es für uns schwierig, den besten Weg zu finden, um Unit-Tests zu schreiben. Im Laufe der Zeit haben wir Ansätze wie Snapshot- Tests , Test-Renderer und flache Renderer ausprobiert . Wir haben Testing Library ausprobiert . Am Ende haben wir ein flaches Rendering durchgeführt, mit dem die Ausgabe der Komponente getestet wurde, und mit Test-Rendering die interne Logik der Komponenten getestet.



Wir glauben, dass die Testbibliothek eine gute Lösung für kleine Projekte ist. Die Tatsache, dass dieses System auf DOM-Rendering basiert, hat einen großen Einfluss auf die Benchmark-Leistung. Darüber hinaus glauben wir dieser KritikSchnappschuss-Tests mit Oberflächen-Rendering sind für sehr "tiefe" Komponenten irrelevant. Für uns erwiesen sich Schnappschüsse als sehr nützlich, um alle möglichen Optionen für die Ausgabe von Komponenten zu überprüfen. Der Komponentencode sollte jedoch nicht zu kompliziert sein. Sie sollten sich bemühen, das Lesen zu vereinfachen. Dies kann toJSONerreicht werden, indem die Komponenten klein gemacht und eine Methode für die Komponenteneingaben definiert werden, die für den Snapshot nicht relevant sind.



Um Unit-Tests nicht zu vergessen, haben wir den Schwellenwert für die Codeabdeckung durch Tests festgelegt... Mit Scherz ist dies sehr einfach und es gibt nicht viel zu überlegen. Es reicht aus, nur einen Indikator für die globale Codeabdeckung durch Tests zu setzen. Zu Beginn der Arbeit haben wir diese Zahl auf 60% festgelegt. Mit der Zeit haben wir die Testabdeckung unserer Codebasis auf 80% erhöht. Wir sind mit diesem Indikator zufrieden, da wir nicht der Meinung sind, dass es notwendig ist, mit Tests eine 100% ige Codeabdeckung anzustreben. Das Erreichen dieser Codeabdeckung mit Tests erscheint uns nicht realistisch.



Wie kann die Erstellung neuer Projekte vereinfacht werden?



Normalerweise ist der Beginn der Arbeit an der React-Anwendung sehr einfach : ReactDOM.render(<App />, document.getElementById(‘#root’));. Wenn Sie jedoch SSR (Server-Side Rendering, Server Rendering) unterstützen müssen, wird diese Aufgabe komplizierter. Wenn die Abhängigkeiten Ihrer Anwendung mehr als nur "Reagieren" umfassen, müssen Ihr Client- und Servercode möglicherweise unterschiedliche Parameter verwenden. Zum Beispiel verwenden wir reagieren-intl für die Internationalisierung, reagieren-redux für globales Status - Management , reagieren-Router für das Routing und Redux-Saga für asynchrone Aktionen verwalten . Diese Abhängigkeiten müssen angepasst werden. Das Konfigurieren dieser Abhängigkeiten kann schwierig sein.



Unsere Lösung für dieses Problem basierte auf den Entwurfsmustern " Strategie " und " Abstrakte Fabrik ". Wir haben zwei verschiedene Klassen erstellt (zwei verschiedene Strategien): eine für die Client-Konfiguration und eine für die Server-Konfiguration. Beide Klassen erhielten die Parameter der erstellten Anwendung, darunter Name, Logo, Reduzierungen, Routen, Standardsprache, Sagen (für Redux-Saga) usw. Reduzierungen, Routen und Sagen können aus verschiedenen Paketen unseres Mono-Repository entnommen werden. Diese Konfiguration wird dann verwendet, um den Redux-Speicher, die Sagas-Middleware und das Router-Verlaufsobjekt zu erstellen. Es wird auch zum Laden von Übersetzungen und zum Rendern der Anwendung verwendet. Hier sind zum Beispiel die Signaturen der Client- und Serverstrategien:



type BootstrapConfiguration = {
  logo: string,
  name: string,
  reducers: ReducersMapObject,
  routes: Route[],
  sagas: Saga[],
};
class AbstractBootstrap {
  configuration: BootstrapConfiguration;
  intl: IntlShape;
  store: Store;
  rootSaga: Task;
abstract public run(): void;
  abstract public render<T>(): T;
  abstract protected createIntl(): IntlShape;
  abstract protected createRootSaga(): Task;
  abstract protected createStore(): Store;
}
//   
class WebBootstrap extends AbstractBootstrap {
  constructor(config: BootstrapConfiguration);
  public render<ReactNode>(): ReactNode;
}
//   
class ServerBootstrap extends AbstractBootstrap {
  constructor(config: BootstrapConfiguration);
  public render<string>(): string;
}


Wir fanden diese Trennung von Strategien nützlich, da es je nach Umgebung, in der der Code ausgeführt wird, einige Unterschiede beim Einrichten von Speicher, Sagen, Internationalisierungsobjekten und Verlauf gibt. Beispielsweise wird ein Redux-Speicher auf dem Client mithilfe von Daten erstellt, die vom Server vorinstalliert wurden, und mithilfe der Redux-devtools-Erweiterung . Nichts davon wird auf dem Server benötigt. Ein weiteres Beispiel ist ein Internationalisierungsobjekt, das auf dem Client die aktuelle Sprache aus navigator.languages und auf dem Server aus dem HTTP-Header Accept-Language abruft .



Es ist wichtig anzumerken, dass wir vor langer Zeit zu dieser Entscheidung gekommen sind. Während Klassen in React-Anwendungen noch weit verbreitet waren, gab es keine einfachen Tools für das serverseitige Rendern von Anwendungen. Im Laufe der Zeit machte die React-Bibliothek einen Schritt in Richtung eines funktionalen Stils und Projekte wie Next.js erschienen . Wenn Sie nach einer Lösung für ein ähnliches Problem suchen, empfehlen wir Ihnen, aktuelle Technologien zu erforschen. Auf diese Weise können wir möglicherweise etwas finden, das einfacher und funktionaler ist als das, was wir verwenden.



Wie können Sie die Qualität Ihres Codes auf einem hohen Niveau halten?



Linters, Tests, Typprüfung - all dies wirkt sich positiv auf die Qualität des Codes aus. Ein Programmierer kann jedoch leicht vergessen, die entsprechenden Überprüfungen durchzuführen, bevor er Code in einen Zweig einfügt master. Am besten lassen Sie solche Überprüfungen automatisch ausführen. Einige Leute bevorzugen dies bei jedem Commit mit Git-Hooks.Dies ermöglicht Ihnen kein Commit, bis der Code alle Prüfungen bestanden hat. Wir glauben jedoch, dass das System bei diesem Ansatz die Arbeit des Programmierers zu stark beeinträchtigt. Schließlich kann die Arbeit an einem bestimmten Zweig mehrere Tage dauern, und an all diesen Tagen wird sie nicht als zum Senden an das Repository geeignet erkannt. Daher überprüfen wir Commits mithilfe des kontinuierlichen Integrationssystems. Es wird nur der Code der Zweige überprüft, die Zusammenführungsanforderungen zugeordnet sind. Auf diese Weise können wir vermeiden, dass Überprüfungen ausgeführt werden, die garantiert nicht bestanden werden, da wir meistens die Aufnahme der Ergebnisse unserer Arbeit in den Hauptcode des Projekts anfordern, wenn wir sicher sind, dass diese Ergebnisse alle Prüfungen bestehen können.



Der Ablauf der automatischen Codeüberprüfung beginnt mit der Installation von Abhängigkeiten. Anschließend werden die Typen überprüft, Linters ausgeführt, Komponententests ausgeführt, eine Anwendung erstellt und Zypressen-Tests ausgeführt. Fast alle diese Aufgaben werden parallel ausgeführt. Wenn bei einem dieser Schritte ein Fehler auftritt, schlägt der gesamte Checkout-Prozess fehl und der entsprechende Zweig kann nicht in den Hauptprojektcode aufgenommen werden. Hier ist ein Beispiel für ein funktionierendes Code-Überprüfungssystem.





Automatische Codeüberprüfung Die



Hauptschwierigkeit beim Einrichten dieses Systems bestand darin, die Ausführung von Überprüfungen zu beschleunigen. Diese Aufgabe ist weiterhin relevant. Wir haben viele Optimierungen durchgeführt und jetzt sind alle diese Überprüfungen in etwa 20 Minuten stabil. Vielleicht kann dieser Indikator durch Parallelisierung der Ausführung einiger Zypressentests verbessert werden, aber im Moment passt er zu uns.



Ergebnis



Die Entwicklung einer groß angelegten React-Anwendung zu organisieren, ist keine leichte Aufgabe. Um dies zu lösen, muss ein Programmierer viele Entscheidungen treffen und viele Tools konfigurieren. Gleichzeitig gibt es keine einzige richtige Antwort auf die Frage, wie solche Anwendungen entwickelt werden sollen.



Unser System passt bisher zu uns. Wir hoffen, dass das Sprechen darüber anderen Programmierern helfen wird, die mit den gleichen Aufgaben konfrontiert sind, mit denen wir konfrontiert waren. Wenn Sie unserem Beispiel folgen möchten, stellen Sie zunächst sicher, dass das, was hier besprochen wurde, für Sie und Ihr Unternehmen richtig ist. Streben Sie vor allem nach Minimalismus. Machen Sie Ihre Apps und Toolkits, mit denen sie erstellt wurden, nicht zu kompliziert.



Wie würden Sie sich der Aufgabe nähern, die Entwicklung eines großen React-Projekts zu organisieren?






All Articles