Skalerer din Redux-app med ænder

Hvordan skaleres din front-end applikation? Hvordan sørger du for, at den kode, du skriver, kan vedligeholdes 6 måneder fra nu?

Redux tog verden med frontend-udvikling med storm i 2015 og etablerede sig som en standard - selv uden for React.

Hos det firma, hvor jeg arbejder, er vi for nylig færdige med at omlægge en forholdsvis stor React-kodebase og tilføje redux i stedet for reflux.

Vi gjorde det, fordi det ville have været umuligt at komme videre uden en velstruktureret applikation og et godt sæt regler.

Kodebasen er mere end to år gammel, og tilbagesvaling var der fra starten. Vi var nødt til at ændre kode, der ikke blev rørt på mere end et år og var temmelig sammenflettet med React-komponenterne.

Baseret på det arbejde, vi udførte på projektet, sammensatte jeg denne repo og forklarede vores tilgang til organisering af vores redux-kode.

Når du lærer om redux og rolle handlinger og reduktionsmidler, starter du med meget enkle eksempler. De fleste tilgængelige tutorials går ikke til det næste niveau. Men hvis du bygger noget med Redux, der er mere kompliceret end en todo-liste, skal du bruge en smartere måde at skalere din codebase over tid.

Nogen sagde engang, at navngivning af ting er et af de sværeste job inden for datalogi. Jeg kunne ikke være mere enig. Men at strukturere mapper og organisere filer er et tæt sekund.

Lad os undersøge, hvordan vi tidligere henvendte os til kodeorganisation.

Funktion vs funktion

Der er to etablerede tilgange til strukturering af applikationer: funktion første og funktion første .

En til venstre nedenfor kan du se en funktions-første mappestruktur. Til højre kan du se en funktion-første tilgang.

Function-first betyder, at dine topkataloger er opkaldt efter formålet med filerne indeni. Så du har: containere , komponenter , handlinger , reduktionsgear osv.

Dette skaleres slet ikke. Når din app vokser, og du tilføjer flere funktioner, tilføjer du filer i de samme mapper. Så du ender med at skulle rulle inde i en enkelt mappe for at finde din fil.

Problemet handler også om at koble mapperne sammen. En enkelt gennemstrømning gennem din app vil sandsynligvis kræve filer fra alle mapper.

En fordel ved denne fremgangsmåde er, at den isolerer - i vores tilfælde - Reager fra redux. Så hvis du vil ændre statsadministrationsbiblioteket, ved du hvilke mapper du har brug for at røre ved. Hvis du ændrer visningsbiblioteket, kan du holde dine redux-mapper intakte.

Feature-first betyder, at katalogerne på øverste niveau er opkaldt efter appens hovedfunktioner: produkt , indkøbskurv , session .

Denne tilgang skaleres meget bedre, fordi hver nye funktion leveres med en ny mappe. Men du har ingen adskillelse mellem React-komponenterne og redux. At ændre en af ​​dem på lang sigt er et meget vanskeligt job.

Derudover har du filer, der ikke hører til nogen funktion. Du ender med en fælles eller delt mappe , fordi du vil genbruge kode på tværs af mange funktioner i din app.

Det bedste fra to verdener

Selvom det ikke er omfattet af denne artikel, vil jeg røre ved denne eneste idé: altid adskille State Management-filer fra UI-filer.

Tænk på din ansøgning i det lange løb. Forestil dig, hvad der sker med kodebasen, når du skifter fra React til et andet bibliotek. Eller tænk, hvordan din codebase vil bruge ReactNative parallelt med webversionen.

Vores tilgang starter fra behovet for at isolere React-koden i en enkelt mappe - kaldet visninger - og redux-koden i en separat mappe - kaldet redux.

Denne split på første niveau giver os fleksibiliteten til at organisere de to separate dele af appen helt forskellige.

Inde i visningsmappen foretrækker vi en funktion-første tilgang til strukturering af filer. Dette føles meget naturligt i sammenhæng med React: sider , layout , komponenter, enhancers osv.

For ikke at blive skør med antallet af filer i en mappe har vi muligvis en funktionsbaseret opdeling i hver af disse mapper.

Derefter inde i redux-mappen ...

Indtast re-ænder

Hver funktion i applikationen skal kortlægges for at adskille handlinger og reducere, så det giver mening at gå efter en funktion-første tilgang.

Den originale ænder modulære tilgang er en god forenkling af redux og tilbyder en struktureret måde at tilføje hver nye funktion i din app på.

Alligevel ville vi undersøge lidt, hvad der sker, når appen skaleres. Vi indså, at en enkelt fil til en funktion bliver for rodet og svær at vedligeholde på lang sigt.

Sådan blev re-ænder født. Løsningen var at opdele hver funktion i en andemappe .

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

En andemappe SKAL:

  • indeholder hele logikken til kun at håndtere ET koncept i din app, fx: produkt , indkøbskurv , session osv.
  • har en index.jsfil, der eksporteres i henhold til de oprindelige ænderegler.
  • opbevar kode med samme formål i den samme fil, såsom reduceringsanordninger , vælgere og handlinger
  • indeholder testene relateret til ænder.

I dette eksempel har vi ikke brugt nogen abstraktion bygget oven på redux. Når du bygger software, er det vigtigt at starte med mindst mulig abstraktion. På denne måde sørger du for, at omkostningerne ved dine abstraktioner ikke opvejer fordelene.

Hvis du har brug for at overbevise dig selv om, at abstraktioner kan være dårlige, skal du se denne fantastiske tale af Cheng Lou.

Lad os se, hvad der går ind i hver fil.

Typer

Den slags fil indeholder navnene på de handlinger, som du der sender i din ansøgning. Som en god praksis skal du prøve at omfatte navnene baseret på den funktion, de tilhører. Dette hjælper, når du debugger mere komplekse applikationer.

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

Handlinger

Denne fil indeholder alle funktionerne til at skabe handlinger.

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.

Den funktionsbaserede split til redux-koden er meget mere fleksibel og skalerbar, efterhånden som din applikationskodebase vokser. Og den funktionsbaserede split for visninger fungerer, når du bygger små komponenter, der deles på tværs af applikationen.

Du kan tjekke en komplet react-redux-eksempel codebase her. Bare husk, at repoen stadig er under aktiv udvikling.

Hvordan strukturerer du dine redux-apps? Jeg ser frem til at høre nogle feedback på denne tilgang, jeg har præsenteret.

Hvis du fandt denne artikel nyttig, skal du klikke på det grønne hjerte nedenfor, og jeg ved, at min indsats ikke er forgæves.