В преддверии старта курса «React.js разработчик» подготовили перевод полезного материала.
Как масштабируется front-end вашего приложения? Как сделать так, чтобы ваш код можно было поддерживать полгода спустя?
В 2015 году Redux штурмом взял мир front-end разработки и зарекомендовал себя как стандарт выйдя за рамки React.
В компании, в которой я работаю, недавно закончился рефакторинг большой кодовой базы на React, где мы внедрили redux вместо reflux.
Нам пришлось пойти на этот шаг, потому что движение вперед оказалось невозможным без хорошо структурированного приложения и четкого набора правил.
Кодовой базе уже больше двух лет и reflux был в ней с самого начала. Нам пришлось менять код, сильно завязанный на компонентах React, который никто не трогал больше года.
Опираясь опыт от проделанной работы, я создал этот репозиторий, который поможет объяснить наш подход к организации кода на redux.
Когда вы узнаете больше о redux, actions и reducers, вы начинаете с простых примеров. Множество туториалов, доступных на сегодняшний день, дальше них не заходят. Однако если вы создаете на Redux что-то сложнее, чем список задач, вам понадобится разумный способ масштабирования вашей кодовой базы с течением времени.
Кто-то однажды сказал, что в computer science нет задачи сложнее, чем давать разным вещам названия. Я не мог не согласиться. В таком случае структурирование папок и организация файлов будут стоять на втором месте.
Давайте посмотрим на то, как мы раньше подходили к организации кода.
Function vs Feature
Есть два общепринятых подхода к организации приложений: function-first и feature-first.
На скриншоте слева структура папок организована по принципу function-first, а справа — feature-first.
Function-first означает, что ваши каталоги верхнего уровня называются в соответствии с файлами внутри. Итак, у вас есть: containers, components, actions, reducers и т.д.
Такое вообще не масштабируется. По мере роста вашего приложения и появления нового функционала, вы будете добавлять файлы в те же папки. В итоге, вам нужно будет долго скролить содержимое одной из папок, чтобы найти нужный файл.
Еще одна проблема заключается в объединении папок. Один из потоков вашего приложения, вероятно, потребует доступ к файлам из всех папок.
Одним из преимуществ этого подхода является то, что он умеет изолировать, в нашем случае React от Redux. Поэтому если вы захотите изменить библиотеку управления состоянием, вы будете знать, какие папки вам понадобятся. Если вам понадобится менять библиотеку view, вы сможете оставить нетронутыми папки с redux.
Feature-first значит, что каталоги верхнего уровня будут называться в соответствии с основным функционалом приложения: product, cart, session.
Такой подход гораздо лучше масштабируется, поскольку каждая новая фича лежит в новой папке. Однако у вас нет разделения между компонентами Redux и React. Изменение в одном из них в долгосрочной перспективе – задача непростая.
Помимо этого, у вас будут файлы, которые не будут относиться ни к одной функции. В итоге все сведется к папке common или shared, поскольку вам же захочется использовать свой код в разных фичах вашего приложения.
Объединяя лучшее двух миров
Хоть это и не относится к теме статьи, хочу сказать, что файлы управления состоянием от файлов UI нужно хранить отдельно.
Думайте о своем приложении в долгосрочной перспективе. Представьте себе, что произойдет с вашим кодом, если вы перейдете с React на что-то иное. Или подумайте о том, как ваша кодовая база будет использовать ReactNative параллельно с веб-версией.
В основе нашего подхода лежит принцип изоляции кода React в одной папке, которая называется views, а кода redux в другой папке, которая называется redux.
Такое разделение на начальном уровне дает нам гибкость организовывать отдельные части приложения совершенно разными способами.
Внутри папки views мы поддерживаем подход к организации файлов function-first. В контексте React это выглядит естественно: pages, layouts, components, enhancers и т.д.
Чтобы не сходить с ума от количества файлов в папке, внутри этих папок можно использовать подход feature-first.
Тем временем в папке redux…
Вводим re-ducks
Каждая функция приложения должна соответствовать отдельным actions и reducers, чтобы был смысл применять подход feature-first.
Оригинальный модульный подход ducks хорошо упрощает работу с redux и предлагает структурированный способ добавления нового функционала в ваше приложение.
Тем не менее, вы хотели понять, что происходит при масштабировании приложения. Мы осознали, что способ организации по одному файлу на фичу загромождает приложение и делает его поддержку проблемной.
Так появился re-ducks. Решение состояло в разделении функционала на duck-папки.
duck/
├── actions.js
├── index.js
├── operations.js
├── reducers.js
├── selectors.js
├── tests.js
├── types.js
├── utils.js
Duck-папка должна:
- Содержать всю логику обработки только ОДНОГО концепта вашего приложения, например: product, cart, session и т.д.
- Содержать файл index.js, который экспортируется в соответствии с правилами duck.
- Хранить в одном файле код, который выполняет аналогичную работу, например reducers, selectors и actions.
- Содержать тесты, относящиеся к duck.
Например, в этом примере мы не использовали абстракции, построенные поверх redux. При создании программного обеспечения важно начинать с наименьшего количества абстракций. Таким образом, вы убедитесь, что стоимость ваших абстракций не превышает пользы от них.
Если вы хотите убедиться, что абстракции – это плохо, посмотрите этой видео с Cheng Lou.
Давайте рассмотрим содержание каждого файла.
Types
Файл types содержит имена actions, которые вы выполняете в своем приложении. В качестве хорошей практики, вы должны попытаться охватить область имен, соответствующую функции, которой они принадлежат. Такой подход поможет при дебаге сложных приложений.
const QUACK = "app/duck/QUACK";
const SWIM = "app/duck/SWIM";
export default {
QUACK,
SWIM
};
Actions
В этом файле содержатся все функции action creator.
import types from "./types";
const quack = ( ) => ( {
type: types.QUACK
} );
const swim = ( distance ) => ( {
type: types.SWIM,
payload: {
distance
}
} );
export default {
swim,
quack
};
Обратите внимание, что все actions представлены функциями, даже если они не параметризованы. Последовательный подход является наиболее приоритетным для большой кодовой базы.
Operations
Для представления цепных операций (operations) вам понадобится redux middleware, чтобы улучшить функцию dispatch. Популярные примеры: redux-thunk, redux-saga или redux-observable.
В нашем случае используется redux-thunk. Нам нужно отделить thunks от action creators даже ценой написания лишнего кода. Поэтому мы будем определять операцию как обертку над actions.
Если операция отправляет только один action, то есть фактически не использует redux-thunk, мы пересылаем функцию action creator. Если операция использует thunk, она может отправить много actions и связать их с помощью 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
};
Зовите их операциями, thunks, сагами, эпиками, как захотите. Просто обозначьте для себя принципы наименования и придерживайтесь их.
В самом конце мы поговорим про index и увидим, что операции – это часть публичного интерфейса duck. Actions инкапсулируются, операции становятся доступными извне.
Reducers
Если у вас более многогранная функция, вам определенно стоит использовать несколько reducer’ов для обработки сложных структур состояний. Помимо этого, не бойтесь использовать столько combineReducer’ов, сколько нужно. Это позволит свободнее работать со структурами объектов состояний.
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;
В большом приложении дерево состояний будет состоять минимум из трех уровней. Функции reducer должны быть как можно меньше и обрабатывать только простые конструкции данных. Функция combineReducers – это все, что вам нужно для создания гибкой и поддерживаемой структуры состояния.
Ознакомьтесь с полноценным примером проекта и посмотрите, как правильно использовать combineReducers, особенно в файлах reducers.js
и store.js
, где мы собираем дерево состояний.
Selectors
Наряду с операциями, селекторы (selector) являются частью публичного интерфейса duck. Разница между операциями и селекторами схожа с паттерном CQRS.
Селекторные функции берут срез состояния приложения и возвращают на его основе некоторые данные. Они никогда не вносят изменения в состояние приложения.
function checkIfDuckIsInRange( duck ) {
return duck.distance > 1000;
}
export default {
checkIfDuckIsInRange
};
Index
Этот файл указывает на то, что будет экспортироваться из duck-папки.
Он:
- Экспортирует функцию reducer из duck по умолчанию.
- Экспортирует в виде именных экспортов селекторы и операции.
- Экспортирует типы, если они требуются в других duck’ах.
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
Преимущество использования Redux вместе со структурой ducks состоит в том, что вы можете писать тесты прямо после кода, который нужно протестировать.
Тестирование вашего кода на Redux довольно прямолинейно:
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 ) ;
} );
} );
} );
Внутри этого файла вы можете писать тесты для reducer’ов, операций, селекторов и т.д.
Я мог бы написать целую отдельную статью о преимуществах тестирования кода, но их уже и так достаточно, поэтому просто тестируйте свой код!
Вот и все
Приятная новость о re-ducks состоит в том, что вы можете использовать один и тот же шаблон для всего своего кода redux.
Подход к разделению на основе feature для вашего кода redux помогает вашему приложению оставаться гибким и масштабируемым по мере роста. А подход к разделению на основе function будет хорошо работать, при построении маленьких компонентов, которые являются общими для разных частей приложения.
Вы можете взглянуть на полноценную кодовую базу react-redux-example здесь. Имейте ввиду, что в репозитории идет активная работа.
Как вы организуете свои redux-приложения? Я с нетерпением жду отзывов об описанном подходе.
До встречи на курсе.
Автор: Дмитрий