Redux alkalmazásának méretezése kacsákkal

Hogyan skálázódik a kezelőfelület? Hogyan győződhet meg arról, hogy az általad írt kód 6 hónap múlva karbantartható?

Redux 2015-ben viharral vette fel a front-end fejlesztés világát, és mint a React kereteit is meghaladta a szabványt.

A cégnél, ahol dolgozom, nemrégiben befejeztük egy meglehetősen nagy React kódbázis felújítását, a reflux helyett a reduxot adtuk hozzá.

Megtettük, mert a jól felépített alkalmazás és a megfelelő szabályrendszer nélkül lehetetlen lett volna továbblépni.

A kódbázis több mint két éves, és a reflux kezdettől fogva ott volt. Olyan kódot kellett megváltoztatnunk, amelyhez több mint egy év alatt nem nyúltak hozzá, és elég kusza volt a React komponenseivel.

A projekten végzett munka alapján összeállítottam ezt a repót, elmagyarázva a redux kódunk szervezésében alkalmazott megközelítésünket.

Amikor megismeri a reduxot, valamint a műveletek és a reduktorok szerepét, nagyon egyszerű példákkal kezdi. A legtöbb ma elérhető oktatóanyag nem lép tovább a következő szintre. De ha a Redux-szal valami olyan dolgot építesz, ami bonyolultabb, mint a toto lista, akkor okosabb módszerre lesz szükséged a kódbázis méretezéséhez az idő múlásával.

Valaki egyszer azt mondta, hogy a dolgok elnevezése az egyik legnehezebb munka a számítástechnikában. Nem tudtam többet megegyezni. De a mappák strukturálása és a fájlok rendezése szoros második.

Fedezzük fel, hogyan viszonyultunk korábban a kódszervezéshez.

Funkció vs szolgáltatás

Az alkalmazások strukturálásának két bevált megközelítése van: a funkció első és a funkció az első .

Az egyik balra lent láthatja a függvény első mappa struktúráját. A jobb oldalon látható egy funkció-első megközelítés.

A Function-first azt jelenti, hogy a legfelső szintű könyvtárakat a belső fájlok rendeltetése alapján nevezik el. Tehát van: tartályok , alkatrészek , műveletek , reduktorok stb.

Ez egyáltalán nem méretarányos. Az alkalmazás növekedésével és további funkciók hozzáadásával fájlokat ad hozzá ugyanazokba a mappákba. Így végül a fájl megkereséséhez egyetlen mappában kell lapoznia.

A probléma a mappák összekapcsolásával is kapcsolatos. Az egyetlen átfolyás az alkalmazáson valószínűleg fájlokat igényel az összes mappából.

Ennek a megközelítésnek az egyik előnye, hogy izolálja - esetünkben - a Redux reakcióját. Tehát, ha meg akarja változtatni az állapotkezelő könyvtárat, akkor tudja, melyik mappához kell hozzáérnie. Ha megváltoztatja a nézet könyvtárat, akkor a redux mappák sértetlenek maradhatnak.

A Feature-first azt jelenti, hogy a legfelső szintű könyvtárak az alkalmazás fő jellemzőiről kapják a nevüket: termék , kosár , munkamenet .

Ez a megközelítés sokkal jobban skálázható, mert minden új szolgáltatáshoz új mappa tartozik. De nincs különbség a React komponensek és a redux között. Egyikük hosszú távon történő megváltoztatása nagyon trükkös munka.

Ezenkívül vannak olyan fájljai, amelyek nem tartoznak egyetlen szolgáltatáshoz sem. Végül egy közös vagy megosztott mappa lesz , mert újra szeretné használni a kódot az alkalmazás számos funkciójában.

Két világ legjobbjai

Bár ez a cikk nem terjed ki a cikkre, szeretném megérinteni ezt az egyetlen gondolatot: mindig válasszuk el az State Management fájlokat az UI fájloktól.

Gondoljon az alkalmazására hosszú távon. Képzelje el, mi történik a kódbázissal, amikor a React- ről egy másik könyvtárra vált. Vagy gondolja át, hogy a kódbázis hogyan használná a ReactNative -ot a webes verzióval párhuzamosan.

Megközelítésünk abból indul ki, hogy el kell különíteni a React kódot egyetlen mappába - úgynevezett nézetbe - és a redux kódot külön mappába - redux néven.

Ez az első szintű felosztás rugalmasságot biztosít számunkra az alkalmazás két különálló részének teljesen különböző szervezésében.

A nézetek mappában a fájlok strukturálásában inkább a funkció-első megközelítést részesítjük előnyben. Ez nagyon természetesnek tűnik a React kontextusában: oldalak , elrendezések , alkatrészek, javítók stb.

Annak érdekében, hogy ne őrüljünk meg egy mappában lévő fájlok számával, előfordulhat, hogy ezeken a mappákon belül egy funkciókon alapuló felosztás történik.

Ezután a redux mappában ...

Adja meg az újrakacsákat

Az alkalmazás minden jellemzőjének külön kell jelölnie a műveleteket és a reduktorokat, ezért van értelme a funkció-első megközelítést választani.

Az eredeti kacsás moduláris megközelítés szép egyszerűsítést jelent a redux számára, és strukturált módon kínálja az alkalmazás minden új funkcióját.

Mégis, szerettük volna egy kicsit felfedezni, mi történik, ha az alkalmazás méretarányos. Rájöttünk, hogy egy funkció egyetlen fájlja túlságosan összezavarodott és nehéz fenntartani hosszú távon.

Így születtek az újrakacsák . A megoldás az volt, hogy minden funkciót kacsa mappára osztottunk .

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

A kacsa mappa KELL:

  • tartalmazza a teljes logikát, amellyel csak EGY fogalmat kezelhet az alkalmazásában, például: termék , kosár , munkamenet stb.
  • rendelkezzen egy index.jsfájllal, amely az eredeti kacsa szabályok szerint exportál.
  • tartsa kód hasonló célból ugyanazon fájlt, például szűkítő , szelektor és akciók
  • tartalmazzák a kacsával kapcsolatos vizsgálatokat .

Ennél a példánál nem használtunk a redux tetejére épített absztrakciót. A szoftver készítésekor fontos, hogy a legkevesebb absztrakcióval kezdjük. Így megbizonyosodhat arról, hogy az absztrakciók költsége nem haladja meg az előnyöket.

Ha meg kell győznie magát arról, hogy az absztrakciók rosszak lehetnek, nézze meg Cheng Lou fantasztikus előadását.

Lássuk, mi kerül az egyes fájlokba.

Típusok

A típusú fájl tartalmazza az alkalmazásban elküldött műveletek nevét. Jó gyakorlatként meg kell próbálnia a neveket annak a tulajdonságnak a alapján lefedni, amelyhez tartoznak. Ez segít a bonyolultabb alkalmazások hibakeresésében.

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

Műveletek

Ez a fájl tartalmazza az összes műveletalkotó funkciót.

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.