Мащабиране на вашето приложение Redux с патици

Как се мащабира приложението ви отпред? Как да се уверите, че кодът, който пишете, може да се поддържа 6 месеца след това?

Redux превзе света на предния край чрез буря през 2015 г. и се утвърди като стандарт - дори извън обсега на React.

В компанията, в която работя, наскоро завършихме рефакторинг на доста голяма кодова база на React, добавяйки редукс вместо рефлукс.

Направихме го, защото придвижването напред би било невъзможно без добре структурирано приложение и добър набор от правила.

Кодовата база е на повече от две години и рефлуксът е бил там от самото начало. Трябваше да сменим кода, който не беше пипан повече от година и беше доста заплетен с компонентите на React.

Въз основа на работата, която свършихме по проекта, съставих това репо, обяснявайки нашия подход при организирането на нашия редукционен код.

Когато научите за редукцията и ролите на действията и редукторите, започвате с много прости примери. Повечето уроци, налични днес, не преминават на следващото ниво. Но ако изграждате нещо с Redux, което е по-сложно от списъка със задачи, ще ви е необходим по-интелигентен начин за мащабиране на вашата кодова база с течение на времето.

Някой веднъж каза, че именуването на нещата е една от най-трудните задачи в компютърните науки. Не можех да се съглася повече. Но структурирането на папки и организирането на файлове е близо.

Нека да проучим как подходихме към кодовата организация в миналото.

Функция срещу функция

Има два утвърдени подхода за структуриране на приложенията: функция-първа и функция-първа .

Едно отляво отдолу можете да видите структурата на първата папка на функция. Вдясно можете да видите подход с първи функции.

Функция първо означава, че директориите ви от най-високо ниво са именувани според целта на файловете вътре. Така че имате: контейнери , компоненти , действия , редуктори и т.н.

Това изобщо не се мащабира. Тъй като приложението ви расте и добавяте повече функции, добавяте файлове в същите папки. Така че в крайна сметка трябва да превъртите вътре в една папка, за да намерите файла си.

Проблемът е и в свързването на папките заедно. Един поток през вашето приложение вероятно ще изисква файлове от всички папки.

Едно от предимствата на този подход е, че той изолира - в нашия случай - реагира от редукс. Така че, ако искате да промените библиотеката за управление на състоянието, знаете кои папки трябва да докоснете. Ако промените библиотеката на изгледа, можете да запазите вашите папки redux непокътнати.

Feature-first означава, че директориите от най-високо ниво са кръстени на основните характеристики на приложението: продукт , кошница , сесия .

Този подход се мащабира много по-добре, тъй като всяка нова функция идва с нова папка. Но нямате разделение между компонентите на React и редукса. Смяната на един от тях в дългосрочен план е много трудна работа.

Освен това имате файлове, които не принадлежат към нито една функция. В крайна сметка получавате обща или споделена папка , защото искате да използвате повторно кода в много функции на приложението си.

Най-доброто от два свята

Въпреки че не е в обхвата на тази статия, искам да се докосна до тази единствена идея: винаги отделяйте файловете за управление на държавата от потребителските интерфейси.

Помислете за вашето приложение в дългосрочен план. Представете си какво се случва с кодовата база, когато превключите от React към друга библиотека. Или помислете как вашата кодова база би използвала ReactNative паралелно с уеб версията.

Нашият подход започва от необходимостта да изолираме кода на React в една папка - наречена изгледи - и редукционния код в отделна папка - наречена редукс.

Това разделяне на първо ниво ни дава гъвкавостта да организираме двете отделни части на приложението напълно различни.

Вътре в папката с изгледи предпочитаме първоначален подход при структуриране на файлове. Това се чувства много естествено в контекста на React: страници , оформления , компоненти, подобрители и т.н.

За да не полудеем от броя на файловете в дадена папка, може да имаме базиран на функции разделяне във всяка от тези папки.

След това, в папката redux ...

Въведете повторни патици

Всяка характеристика на приложението трябва да се съпостави с отделни действия и редуктори, така че има смисъл да се подходи за първоначален подход.

Оригиналният модулен подход на патици е приятно опростяване за редукс и предлага структуриран начин за добавяне на всяка нова функция във вашето приложение.

И все пак искахме да проучим малко какво се случва, когато приложението се мащабира. Разбрахме, че един файл за функция става прекалено претрупан и труден за поддържане в дългосрочен план.

Така се раждат повторните патици . Решението беше да се раздели всяка функция в патешка патица .

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

Патешка патица ТРЯБВА:

  • съдържат цялата логика за работа само с ЕДНА концепция във вашето приложение, напр .: продукт , количка , сесия и т.н.
  • има index.jsфайл, който експортира в съответствие с оригиналните правила за патици.
  • съхранявайте код с подобна цел в същия файл, като редуктори , селектори и действия
  • съдържат тестовете, свързани с патицата.

За този пример не сме използвали абстракция, изградена върху редукса. Когато изграждате софтуер, е важно да започнете с най-малкото количество абстракции. По този начин се уверете, че цената на вашите абстракции не надвишава ползите.

Ако трябва да се убедите, че абстракциите могат да бъдат лоши, гледайте тази страхотна беседа на Ченг Лу.

Нека да видим какво влиза във всеки файл.

Видове

Файлът с типове съдържа имената на действията, които изпращате във вашето приложение. Като добра практика, трябва да се опитате да обхванете имената въз основа на характеристиката, на която принадлежат. Това помага при отстраняване на грешки при по-сложни приложения.

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

Действия

Този файл съдържа всички функции на създателя на действия.

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.