Skalieren Sie Ihre Redux App mit Enten

Wie skaliert Ihre Front-End-Anwendung? Wie stellen Sie sicher, dass der von Ihnen geschriebene Code in 6 Monaten gewartet werden kann?

Redux eroberte 2015 die Welt der Front-End-Entwicklung im Sturm und etablierte sich als Standard - auch über den Rahmen von React hinaus.

In der Firma, in der ich arbeite, haben wir kürzlich die Umgestaltung einer ziemlich großen React-Codebasis abgeschlossen und Redux anstelle von Reflux hinzugefügt.

Wir haben es getan, weil es ohne eine gut strukturierte Anwendung und ein gutes Regelwerk unmöglich gewesen wäre, vorwärts zu kommen.

Die Codebasis ist mehr als zwei Jahre alt und Reflux war von Anfang an da. Wir mussten Code ändern, der seit mehr als einem Jahr nicht mehr berührt wurde und ziemlich durcheinander mit den React-Komponenten war.

Basierend auf der Arbeit, die wir an dem Projekt geleistet haben, habe ich dieses Repo zusammengestellt und unseren Ansatz bei der Organisation unseres Redux-Codes erläutert.

Wenn Sie sich mit Redux und den Rollen von Aktionen und Reduzierern vertraut machen, beginnen Sie mit sehr einfachen Beispielen. Die meisten heute verfügbaren Tutorials gehen nicht zum nächsten Level. Wenn Sie jedoch mit Redux etwas erstellen, das komplizierter ist als eine Aufgabenliste, benötigen Sie eine intelligentere Methode, um Ihre Codebasis im Laufe der Zeit zu skalieren.

Jemand hat einmal gesagt, dass das Benennen von Dingen eine der schwierigsten Aufgaben in der Informatik ist. Ich konnte nicht mehr zustimmen. Das Strukturieren von Ordnern und das Organisieren von Dateien ist jedoch eine knappe Sekunde.

Lassen Sie uns untersuchen, wie wir in der Vergangenheit mit der Code-Organisation umgegangen sind.

Funktion gegen Funktion

Es gibt zwei etablierte Ansätze zur Strukturierung von Anwendungen: Funktion zuerst und Merkmal zuerst .

Links unten sehen Sie eine Ordnerstruktur mit der ersten Funktion. Auf der rechten Seite sehen Sie einen Feature-First-Ansatz.

Funktion zuerst bedeutet, dass Ihre Verzeichnisse der obersten Ebene nach dem Zweck der darin enthaltenen Dateien benannt sind. Sie haben also: Container , Komponenten , Aktionen , Reduzierungen usw.

Dies skaliert überhaupt nicht. Wenn Ihre App wächst und Sie weitere Funktionen hinzufügen, fügen Sie Dateien in denselben Ordnern hinzu. Sie müssen also in einen einzelnen Ordner scrollen, um Ihre Datei zu finden.

Das Problem besteht auch darin, die Ordner miteinander zu koppeln. Für einen einzelnen Fluss durch Ihre App sind wahrscheinlich Dateien aus allen Ordnern erforderlich.

Ein Vorteil dieses Ansatzes besteht darin, dass er - in unserem Fall - die Reaktion von Redux isoliert. Wenn Sie also die Statusverwaltungsbibliothek ändern möchten, wissen Sie, welche Ordner Sie berühren müssen. Wenn Sie die Ansichtsbibliothek ändern, können Sie Ihre Redux-Ordner intakt halten.

Feature-First bedeutet, dass die Verzeichnisse der obersten Ebene nach den Hauptfunktionen der App benannt sind: Produkt , Warenkorb , Sitzung .

Dieser Ansatz lässt sich viel besser skalieren, da jede neue Funktion mit einem neuen Ordner geliefert wird. Sie haben jedoch keine Trennung zwischen den React-Komponenten und Redux. Eine davon langfristig zu ändern, ist eine sehr schwierige Aufgabe.

Außerdem haben Sie Dateien, die keiner Funktion angehören. Sie erhalten einen gemeinsamen oder freigegebenen Ordner , da Sie Code für viele Funktionen in Ihrer App wiederverwenden möchten.

Das Beste aus zwei Welten

Obwohl nicht im Umfang dieses Artikels, möchte ich diese einzige Idee ansprechen: Trennen Sie immer State Management-Dateien von UI-Dateien.

Denken Sie langfristig an Ihre Anwendung. Stellen Sie sich vor, was mit der Codebasis passiert, wenn Sie von React zu einer anderen Bibliothek wechseln . Oder überlegen Sie , wie Ihre Codebasis ReactNative parallel zur Webversion verwenden würde .

Unser Ansatz geht von der Notwendigkeit aus, den React-Code in einem einzigen Ordner - Ansichten genannt - und den Redux-Code in einem separaten Ordner - Redux genannt - zu isolieren.

Diese Aufteilung der ersten Ebene gibt uns die Flexibilität, die beiden getrennten Teile der App völlig unterschiedlich zu organisieren.

Innerhalb des Ansichtsordners bevorzugen wir einen Funktions-First-Ansatz bei der Strukturierung von Dateien. Dies fühlt sich im Kontext von React sehr natürlich an: Seiten , Layouts , Komponenten, Enhancer usw.

Um nicht verrückt nach der Anzahl der Dateien in einem Ordner zu werden, haben wir möglicherweise eine funktionsbasierte Aufteilung in jeden dieser Ordner.

Dann im Redux-Ordner…

Geben Sie Re-Enten

Jedes Feature der Anwendung sollte separaten Aktionen und Reduzierungen zugeordnet werden. Daher ist es sinnvoll, einen Feature-First-Ansatz zu wählen.

Der ursprüngliche modulare Ansatz von ducks ist eine nette Vereinfachung für Redux und bietet eine strukturierte Möglichkeit, jede neue Funktion in Ihrer App hinzuzufügen.

Wir wollten jedoch ein wenig untersuchen, was passiert, wenn die App skaliert. Wir haben festgestellt, dass eine einzelne Datei für ein Feature zu überladen und auf lange Sicht schwer zu warten ist.

So wurden Re-Enten geboren. Die Lösung bestand darin, jedes Feature in einen Entenordner aufzuteilen .

duck/ ├── actions.js ├── index.js ├── operations.js ├── reducers.js ├── selectors.js ├── tests.js ├── types.js ├── utils.js

Ein Entenordner MUSS:

  • Enthält die gesamte Logik für die Behandlung nur EINES Konzepts in Ihrer App, z. B. Produkt , Warenkorb , Sitzung usw.
  • haben eine index.jsDatei, die nach den ursprünglichen Entenregeln exportiert.
  • Bewahren Sie Code mit ähnlichem Zweck in derselben Datei auf, z. B. Reduzierungen , Selektoren und Aktionen
  • enthalten die Tests in Bezug auf die Ente.

In diesem Beispiel haben wir keine Abstraktion verwendet, die auf Redux basiert. Beim Erstellen von Software ist es wichtig, mit der geringsten Anzahl von Abstraktionen zu beginnen. Auf diese Weise stellen Sie sicher, dass die Kosten Ihrer Abstraktionen die Vorteile nicht überwiegen.

Wenn Sie sich davon überzeugen müssen, dass Abstraktionen schlecht sein können, sehen Sie sich diesen großartigen Vortrag von Cheng Lou an.

Mal sehen, was in jeder Datei steckt.

Typen

Die Typen - Datei enthält die Namen der Aktionen , die Sie in Ihrer Anwendung Dispatching. Als bewährte Methode sollten Sie versuchen, die Namen anhand der Funktion zu erfassen, zu der sie gehören. Dies hilft beim Debuggen komplexerer Anwendungen.

const QUACK = "app/duck/QUACK"; const SWIM = "app/duck/SWIM"; export default { QUACK, SWIM };

Aktionen

Diese Datei enthält alle Funktionen des Aktionserstellers.

import types from "./types"; const quack = ( ) => ( { type: types.QUACK } ); const swim = ( distance ) => ( { type: types.SWIM, payload: { distance } } ); export default { swim, quack };

Notice how all the actions are represented by functions, even if they are not parametrized. A consistent approach is more than needed in a large codebase.

Operations

To represent chained operations you need a redux middleware to enhance the dispatch function. Some popular examples are: redux-thunk, redux-saga or redux-observable.

In our case, we use redux-thunk. We want to separate the thunks from the action creators, even with the cost of writing extra code. So we define an operation as a wrapper over actions.

If the operation only dispatches a single action — doesn’t actually use redux-thunk — we forward the action creator function. If the operation uses a thunk, it can dispatch many actions and chain them with promises.

import actions from "./actions"; // This is a link to an action defined in actions.js. const simpleQuack = actions.quack; // This is a thunk which dispatches multiple actions from actions.js const complexQuack = ( distance ) => ( dispatch ) => { dispatch( actions.quack( ) ).then( ( ) => { dispatch( actions.swim( distance ) ); dispatch( /* any action */ ); } ); } export default { simpleQuack, complexQuack };

Call them operations, thunks, sagas, epics, it’s your choice. Just find a naming convention and stick with it.

At the end, when we discuss the index, we’ll see that the operations are part of the public interface of the duck. Actions are encapsulated, operations are exposed.

Reducers

If a feature has more facets, you should definitely use multiple reducers to handle different parts of the state shape. Additionally, don’t be afraid to use combineReducers as much as needed. This gives you a lot of flexibility when working with a complex state shape.

import { combineReducers } from "redux"; import types from "./types"; /* State Shape { quacking: bool, distance: number } */ const quackReducer = ( state = false, action ) => { switch( action.type ) { case types.QUACK: return true; /* ... */ default: return state; } } const distanceReducer = ( state = 0, action ) => { switch( action.type ) { case types.SWIM: return state + action.payload.distance; /* ... */ default: return state; } } const reducer = combineReducers( { quacking: quackReducer, distance: distanceReducer } ); export default reducer;

In a large scale application, your state tree will be at least 3 level deep. Reducer functions should be as small as possible and handle only simple data constructs. The combineReducers utility function is all you need to build a flexible and maintainable state shape.

Check out the complete example project and look how combineReducers is used. Once in the reducers.js files and then in the store.js file, where we put together the entire state tree.

Selectors

Together with the operations, the selectors are part of the public interface of a duck. The split between operations and selectors resembles the CQRS pattern.

Selector functions take a slice of the application state and return some data based on that. They never introduce any changes to the application state.

function checkIfDuckIsInRange( duck ) { return duck.distance > 1000; } export default { checkIfDuckIsInRange };

Index

This file specifies what gets exported from the duck folder. It will:

  • export as default the reducer function of the duck.
  • export as named exports the selectors and the operations.
  • export the types if they are needed in other ducks.
import reducer from "./reducers"; export { default as duckSelectors } from "./selectors"; export { default as duckOperations } from "./operations"; export { default as duckTypes } from "./types"; export default reducer;

Tests

A benefit of using Redux and the ducks structure is that you can write your tests next to the code you are testing.

Testing your Redux code is fairly straight-forward:

import expect from "expect.js"; import reducer from "./reducers"; import actions from "./actions"; describe( "duck reducer", function( ) { describe( "quack", function( ) { const quack = actions.quack( ); const initialState = false; const result = reducer( initialState, quack ); it( "should quack", function( ) { expect( result ).to.be( true ) ; } ); } ); } );

Inside this file you can write tests for reducers, operations, selectors, etc.

I could write a whole different article about the benefits of testing your code, there are so many of them. Just do it!

So there it is

The nice part about re-ducks is that you get to use the same pattern for all your redux code.

The feature-based split for the redux code is much more flexible and scalable as your application codebase grows. And the function-based split for views works when you build small components that are shared across the application.

You can check out a full react-redux-example codebase here. Just keep in mind that the repo is still under active development.

How do you structure your redux apps? I’m looking forward to hearing some feedback on this approach I’ve presented.

If you found this article useful, click on the green heart below and I will know my efforts are not in vain.