Большинство разработчиков начинает знакомство с 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( <AppContainer> <Root store={store} history={browserHistory} routes={routes}/> </AppContainer>, document.getElementById('root')); import React from 'react' import PropTypes from 'prop-types' import {Provider} from "react-redux" import {Router} from "react-router" core/containers/Root const Root = props => ( <Provider store={props.store}> <Router history={props.history} routes={props.routes} /> </Provider>) Root.propTypes = { history: PropTypes.object.isRequired, routes: PropTypes.array.isRequired, store: PropTypes.object.isRequired } export default Root
Пока все достаточно просто. Нам осталось подключить модульную систему к состоянию (store) и настроить роутинг. Определим небольшую функцию:
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) => (<h2>Привет, {props.name}</h2>) 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 => ( <div> <h1>{props.title}</h1> <Navigation routes={props.routes}/> {props.children} </div>) App.propTypes = { title: PropTypes.string.isRequired, routes: PropTypes.array.isRequired } export default App
Роутер
А с роутером будет немного сложнее. В общем случае должна быть возможность ассоциировать с модулем более одного URL. Например /profile
содержит основную информацию о профиле, а /profile/transactions
– список транзакций пользователя. Допустим Мы хотим всегда выводить имя пользователя в личном кабинете, а ниже вывести компонент с двумя табами: «общая информация» и «транзакции».
Тогда, логичная структура роутов будет такой:
<Router> <Route path="/profile" component={Profile}> <Route path="/info" component={Info}/> <Route path="/transactions" component={Transaction}/> </ Route > </Router>
Компонент 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 модулям – тема отдельной статьи.
ссылка на оригинал статьи https://habrahabr.ru/post/326484/
Добавить комментарий