Архитектура модульных React + Redux приложений
Большинство разработчиков начинает знакомство с Redux с Todo List Project. Это приложение имеет следующую структуру:
actions/
todos.js
components/
todos/
TodoItem.js
...
constants/
actionTypes.js
reducers/
todos.js
index.js
rootReducer.js
На первый взгляд такая организация кода кажется логичной, ведь она напоминает стандартные соглашения многих backend MVC-фреймворков:
app/
controllers/
models/
views/
На самом деле, это неудачный выбор как для MVC, так и для React+Redux приложений по следующим причинам:
- С ростом приложения следить за взаимосвязью между компонентами, экшнами и редюсерами становится крайне сложно
- При изменении экшна или компонента с большой вероятностью потребуется внести изменения и в редюсер. Если количество файлов велико, скролить IDE вверх/вниз не удобно
- Такая структура потворствует копипасте в редюсерах
Не удивительно, что многие авторы (раз, два, три) советуют структурировать приложение по «функциональности» (by feature).
Мы достаточно давно пришли к такому-же выводу в бекэнд-разработке., поэтому во фронтэнде поступаем также. В русском языке нет подходящего перевода для слова feature как единицы функциональности. Вместо него мы употребляем слово «модуль». В ES6 термин «модуль» имеет другое значение. Чтобы не путать их между собой в случае неоднозначности можно использовать словосочетание «модуль приложения». В повседневной работе сложностей не возникало, кроме этого термин «модуль» хорошо понятен и подходит для коммуникации с бизнес-пользователями.
Модульная структура
Мо́дуль — функционально законченный фрагмент программы.
Мо́дульное программи́рование — это организация программы как совокупности небольших независимых блоков, называемых модулями, структура и поведение которых подчиняются определённым правилам.
Модульное приложение в моем понимании должно отвечать следующим требованиям:
- Весь код модуля располагается в одной папке. Чтобы полностью удалить модуль из программы достаточно удалить соответствующую папку. Удаление модуля не нарушает работоспособности других модулей, но лишает приложение части функциональности.
- Модули не зависимы друг от друга. Модификация любого модуля не влияет на работу других модулей. Допускается зависимость модулей от «ядра» системы.
- Ядро системы содержит публичное API, предоставляющее модулям средства ввода/вывода и набор компонентов для создания UI.
Получаем такую структуру приложения
app/
modules/
Module1/
…
index.js
Module2/
…
index.js
…
index.js
core/
…
index.js
routes.js
store.js
В точку входа помещаем
AppContainer
, необходимый для react-hot-reload
, со вложенным компонентом Root
. Root
содержит только Provider, обеспечивающий связь с redux
и react-router
, определяющий точку входа в приложение с помощью indexRoute
. Компонент можно вынести в npm-пакет и подключать в любом приложении, т.к. он только инициализирует инфраструктуру и не содержит логики предметной модели.index.js
import 'isomorphic-fetch'
import './styles/app.sass'
import React from 'react'
import ReactDOM from 'react-dom'
import { AppContainer } from 'react-hot-loader'
import browserHistory from './core/history'
import Root from './core/containers/Root'
import store from './store';
import routes from './routes';
ReactDOM.render(
,
document.getElementById('root'));
Root.js
import React from 'react'
import PropTypes from 'prop-types'
import {Provider} from "react-redux"
import {Router} from "react-router"
const Root = props => (
)
Root.propTypes = {
history: PropTypes.object.isRequired,
routes: PropTypes.array.isRequired,
store: PropTypes.object.isRequired
}
export default Root
Пока все достаточно просто. Нам осталось подключить модульную систему к состоянию (store) и настроить роутинг.
defineModule
Напишем небольшую функцию:export const defineModule = (
title,
path,
component,
reducer = (state = {}) => state,
onEnter = null) => {
return {title, path, component, reducer, onEnter}
}
Создадим в папке
modules
модуль личного кабинета пользователя.modules/
Profile/
Profile.js
index.js
Profile/index.js
import React from 'react'
import PropTypes from 'prop-types'
const Profile = default (props) => (Привет, {props.name}
)
Profile.propTypes = {
name: PropTypes.string.isRequired,
const SET_NAME = 'Profile/SetName'
const reducer (state = {name: ‘Василий’}, action) => {
switch(action.type){
case SET_NAME: {…state, name: action.name}
}
}
export default defineModule('Личный кабинет', '/profile, Profile)
И зарегистрируем модуль в файле modules/index.js
import Profile from './Profile'
export default {
Profile
}
Этого шага можно избежать, но для наглядности, оставим ручную инициализацию модульной структуры. Две строчки импорта/экспорта написать не так сложно.
Я использую
CamelCase
и /
для лучшей читаемости в названиях экшнов. Для того, чтобы было проще собирать, можно воспользоваться такой функцией: export const combineName = (...parts) => parts
.filter(x => x && toLowerCamelCase(x) != DATA)
.map(x => toUpperCamelCase(x))
.reduce((c,n) => c ? c + '/' + n : n)
const Module = 'Profile'
const SET_NAME = combineName(Module, 'SetName')
Осталось подключить личный кабинет к роутеру и вставить модуль в лейаут. С лейаутом все просто. Создаем
core/components/App.js
. Обратите внимание, что в компонент Navigation
передается тот же массив, что и в роутер, чтобы избежать дублирования.import React from 'react'
import PropTypes from 'prop-types'
import Navigation from './Navigation'
const App = props => (
{props.title}
{props.children}
)
App.propTypes = {
title: PropTypes.string.isRequired,
routes: PropTypes.array.isRequired
}
export default App
Роутер
А с роутером будет немного сложнее. В общем случае должна быть возможность ассоциировать с модулем более одного URL. Например
/profile
содержит основную информацию о профиле, а /profile/transactions
— список транзакций пользователя. Допустим Мы хотим всегда выводить имя пользователя в личном кабинете, а ниже вывести компонент с двумя табами: «общая информация» и «транзакции».Тогда, логичная структура роутов будет такой:
Route >
Компонент
Profile
будет выводить имя пользователя и табы, а Info
и Transactions
— детали профиля и список транзакций соответственно. Но необходимо также поддерживать вариант, когда компоненты модуля не нуждаются в дополнительном группирующем модуле (например, список заказ и окно просмотра заказа являются независимыми страницами).Введем соглашение
Из модуля можно экспортировать объект структурой как возвращаемый из функции
defineModule
или массив таких объектов. Все компоненты будут добавлены в список роутов без дополнительной вложенности.Модуль может содержать ключ children
, содержащий структуру, аналогичную файлу modules/index.js
. В этом случае один из них должен называться Index
. Он будет использован в качестве IndexRoute
. Тогда мы получим структуру, соответствующую «личному кабинету».
Воспользуемся моноидальной природой списка и получим плоский массив модулей с учетом возможности экспортировать массив или объект.
export const flatModules = modules => Object.keys(modules)
.map(x => {
const res = Array.isArray(modules[x]) ? modules[x] : [modules[x]]
res.forEach(y => y[MODULE] = x)
return res
})
.reduce((c,n) => c.concat(n))
В Router можно передавать не только компоненты
Route
, но и просто массив с обычными объектами, чем мы и воспользуемся.export const getRoutes = (modules, store, App, Home, title = 'Главная') =>
[
{
path: '/',
title: title,
component: App,
indexRoute: {
component: Home
},
childRoutes: flatModules(modules)
.map(x => {
if (!x.component) {
throw new Error('Component for module ' + x + ' is not defined')
}
const route = {
path: x.path,
title: x.title,
component: x.component,
onEnter: x.onEnter
? routeParams => {
x.onEnter(routeParams, store.dispatch)
}
: null
}
if(x.children){
if(!x.children.Index || !typeof(x.children.Index.component)){
throw new Error('Component for index route of "' + x.title + '" is not defined')
}
route.indexRoute = {
component: x.children.Index.component
}
route.childRoutes = Object.keys(x.children).map(y => {
const cm = x.children[y]
if (!cm.component) {
throw new Error('Component for module ' + x + '/' + y + ' is not defined')
}
return {
path: x.path + cm.path,
title: cm.title,
component: cm.component,
onEnter: cm.onEnter
? routeParams => {
cm.onEnter(routeParams, store.dispatch)
}
: null
}
})
}
return route
})
}
]
Таким образом добавление модуля в файл
modules/index.js
будет автоматически инициализировать новые роуты. Если разработчик забудет объявить роут или запутается в соглашениях, то увидит в консоли недвусмысленное сообщение об ошибке.onEnter
Обратите внимание на то, что модуль также может экспортировать функцию onEnter. В которую при переходе на соответствующий роут, будут переданы параметры пути и функция store.dispatch. Это позволяет избежать использования componentDidMount для инициализации компонентов. Вместо этого можно выкинуть в store событие (или Promise, если вы, как я, решили выкинуть redux-saga и оставить redux-thunk), обработать его в редюсере, модифицировать state, вызвав тем самым перерисовку компонента.
Подключаем редюсеры к стору
Нам понадобятся DevTools и thunk. Объявим небольшую функцию для инициализации стора.
const composeEnhancers = typeof window === 'object'
&& window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({})
: compose;
const createAppStore = (reducer, ...middleware) => {
middleware.push(thunk)
const store = createStore(
reducer,
composeEnhancers(applyMiddleware(...middleware)))
return store
}
export default createAppStore
И еще одну для получения и компоновки всех редюсеров для всех модулей:
export const combineModuleReducers = modules => {
const reducers = {}
const flat = flatModules(modules)
for (let i = 0; i < flat.length; i++) {
const red = flat[i].reducer
if (typeof(red) !== 'function') {
throw new Error('Module ' + i + ' does not define reducer!')
}
reducers[flat[i][MODULE]] = red
if(typeof(flat[i].children) === 'object'){
for(let j in flat[i].children){
if(typeof(flat[i].children[j].reducer) !== 'function'){
throw new Error('Module ' + j + ' does not define reducer!')
}
reducers[j] = flat[i].children[j].reducer
}
}
}
return reducers
}
Можно сделать менее строго и просто пропускать модули, не содержащие редюсеров, а не падать с исключением, но мне по душе более строгий подход. Если модуль не содержит вообще никакой логики, проще оформить его просто компонентом и добавить в роутер вручную.
Совмещаем все в файле store.js
export default createAppStore(combineReducers(combineModuleReducers(modules)))
Теперь каждому модулю соответствует часть стейта, совпадающая с ключем в файле modules/index.js
. Для личного кабинета это будет Profile
На этом про структуру модульных приложений у меня все. Организация «ядра» и предоставление публичного API модулям — тема отдельной статьи.