javascript

Архитектура модульных React + Redux приложений

  • суббота, 15 апреля 2017 г. в 03:13:37
https://habrahabr.ru/post/326484/
  • Разработка веб-сайтов
  • Проектирование и рефакторинг
  • Анализ и проектирование систем
  • JavaScript




Большинство разработчиков начинает знакомство с 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 приложений по следующим причинам:

  1. С ростом приложения следить за взаимосвязью между компонентами, экшнами и редюсерами становится крайне сложно
  2. При изменении экшна или компонента с большой вероятностью потребуется внести изменения и в редюсер. Если количество файлов велико, скролить IDE вверх/вниз не удобно
  3. Такая структура потворствует копипасте в редюсерах

Не удивительно, что многие авторы(раз, два, три) советуют структурировать приложение по «функциональности» (by feature).

Мы достаточно давно пришли к такому-же выводу в бекэнд-разработке., поэтому во фронтэнде поступаем также. В русском языке нет подходящего перевода для слова feature как единицы функциональности. Вместо него мы употребляем слово «модуль». В ES6 термин «модуль» имеет другое значение. Чтобы не путать их между собой в случае неоднозначности можно использовать словосочетание «модуль приложения». В повседневной работе сложностей не возникало, кроме этого термин «модуль» хорошо понятен и подходит для коммуникации с бизнес-пользователями.

Модульная структура

Мо́дуль — функционально законченный фрагмент программы.

Мо́дульное программи́рование — это организация программы как совокупности небольших независимых блоков, называемых модулями, структура и поведение которых подчиняются определённым правилам.

Модульное приложение в моем понимании должно отвечать следующим требованиям:

  1. Весь код модуля располагается в одной папке. Чтобы полностью удалить модуль из программы достаточно удалить соответствующую папку. Удаление модуля не нарушает работоспособности других модулей, но лишает приложение части функциональности.
  2. Модули не зависимы друг от друга. Модификация любого модуля не влияет на работу других модулей. Допускается зависимость модулей от «ядра» системы.
  3. Ядро системы содержит публичное 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'));

Root.js



import React from 'react'
import PropTypes from 'prop-types'
import {Provider} from "react-redux"
import {Router} from "react-router"

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) и настроить роутинг.

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/Profile.js


import React from 'react'
import PropTypes from 'prop-types'
const Profile = props => (<h2>Привет, {props.name}</h2>)
Profile.propTypes = {
  name: PropTypes.string.isRequired
}

export default Profile

Profile/index.js



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 модулям – тема отдельной статьи.