Масштабування програми Redux за допомогою качок

Як масштабується ваш інтерфейс? Як ви переконаєтесь, що код, який ви пишете, можна підтримувати через 6 місяців?

Redux бурхливо взяв світ інтерфейсної розробки в 2015 році і затвердив себе як стандарт - навіть поза рамками React.

У компанії, в якій я працюю, нещодавно ми закінчили рефакторинг досить великої кодової бази React, додавши замість reflux зменшення.

Ми зробили це, тому що рухатися вперед було б неможливо без чітко структурованого додатка та чіткого набору правил.

Кодовій базі більше двох років, і рефлюкс був там із самого початку. Нам довелося змінити код, який не торкався більше року і був досить заплутаним з компонентами React.

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

Дізнавшись про редукцію та ролі дій та редукторів, ви починаєте з дуже простих прикладів. Більшість підручників, доступних сьогодні, не переходять на наступний рівень. Але якщо ви будуєте щось із Redux, яке є більш складним, ніж список завдань, вам знадобиться розумніший спосіб масштабування вашої кодової бази з часом.

Хтось одного разу сказав, що називати речі - одна з найскладніших робіт в галузі інформатики. Я не міг більше погодитися. Але структурування папок та впорядкування файлів - це друге місце.

Давайте дослідимо, як ми підходили до організації коду в минулому.

Функція проти функції

Існує два усталених підходи до структурування додатків: функція-перша та функція-перша .

Зліва внизу ви можете побачити структуру папок, яка функціонує першою. Праворуч ви бачите підхід, призначений для першої функції.

Функція-перша означає, що ваші каталоги верхнього рівня названі відповідно до призначення файлів усередині. Отже, у вас є: контейнери , компоненти , дії , редуктори тощо.

Це взагалі не масштабується. У міру зростання вашого додатка та додавання нових функцій ви додаєте файли в ті самі папки. Отже, вам доведеться прокрутити всередині однієї папки, щоб знайти свій файл.

Проблема також полягає у зв’язці папок між собою. Для одного потоку через вашу програму, ймовірно, знадобляться файли з усіх папок.

Однією з переваг цього підходу є те, що він ізолює - в нашому випадку - реагує від відновлення. Отже, якщо ви хочете змінити бібліотеку управління станом, ви знаєте, до яких папок потрібно торкатися. Якщо ви зміните бібліотеку подання, ви зможете зберегти свої папки redux цілими.

Feature-first означає, що каталоги верхнього рівня названі на честь основних функцій програми: продукт , кошик , сесія .

Цей підхід масштабується набагато краще, оскільки кожна нова функція постачається з новою папкою. Але у вас немає розділення між компонентами React та редукцією. Змінити одного з них у довгостроковій перспективі - дуже складна робота.

Крім того, у вас є файли, які не належать до жодної функції. У вас з’являється спільна або спільна папка , оскільки ви хочете повторно використовувати код для багатьох функцій у вашому додатку.

Найкраще з двох світів

Хоча це не стосується цієї статті, я хочу торкнутися цієї єдиної ідеї: завжди відокремлювати файли управління державою від файлів інтерфейсу.

Подумайте про свою заявку в довгостроковій перспективі. Уявіть, що відбувається з кодовою базою при переході з React на іншу бібліотеку. Або подумайте, як ваша кодова база буде використовувати ReactNative паралельно з веб-версією.

Наш підхід починається з необхідності ізолювати код React в одну папку, яка називається поданнями, а код redux в окрему папку, яка називається redux.

Цей розподіл першого рівня дає нам можливість гнучко організувати дві різні частини програми абсолютно різними.

Усередині папки виглядів ми віддаємо перевагу підходу, що функціонує на основі функціонування файлів. Це дуже природно в контексті React: сторінки , макети , компоненти, покращувачі тощо.

Щоб не збожеволіти від кількості файлів у папці, у нас може бути розділений на основі функцій всередині кожної з цих папок.

Потім всередині папки redux ...

Введіть повторно качок

Кожна функція програми повинна відображати окремі дії та редуктори, тому має сенс вибрати підхід, що спрямовується на функцію.

Оригінальний модульний підхід «качок» - це приємне спрощення для redux і пропонує структурований спосіб додавання кожної нової функції у ваш додаток.

Проте ми хотіли трохи вивчити, що відбувається, коли програма масштабується. Ми зрозуміли, що один файл для функції стає занадто захаращеним і важким для підтримки в довгостроковій перспективі.

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

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

Качина папка ПОВИННА:

  • містити всю логіку обробки лише однієї концепції у вашому додатку, наприклад: продукт , кошик , сеанс тощо.
  • мати index.jsфайл, який експортується відповідно до оригінальних качиних правил.
  • зберігати код із подібною метою у тому самому файлі, як-от редуктори , селектори та дії
  • містять тести, пов’язані з качкою.

У цьому прикладі ми не використовували жодної абстракції, побудованої поверх redux. Створюючи програмне забезпечення, важливо починати з найменшої кількості абстракцій. Таким чином, ви переконуєтесь, що вартість ваших абстракцій не перевищує переваг.

Якщо вам потрібно переконати себе, що абстракції можуть бути поганими, перегляньте цю дивовижну промову Ченга Лу.

Давайте подивимося, що входить до кожного файлу.

Типи

Файл типів містить назви дій, які ви надсилаєте у своїй програмі. Як хорошу практику, ви повинні спробувати застосувати імена на основі функції, якій вони належать. Це допомагає при налагодженні більш складних програм.

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.