Доброго времени суток.


Занимаюсь разработкой проекта на React и Redux. Хочу в этой статье описать архитектуру своего проекта.

Итак, начнем. Файловая структура:



Для подключения редьюсеров создаем класс singleton reducerRegister:

./reducerRegister.js
class ReducerRegistry {
  constructor () {
    if (!ReducerRegistry.instance) {
      this._emitChange = null
      this._reducers = {}
      ReducerRegistry.instance = this
    }
    return ReducerRegistry.instance
  }

  getReducers () {
    return {...this._reducers}
  }

  register (name, reducer) {
    this._reducers = {...this._reducers, [name]: reducer}
    if (this._emitChange) {
      this._emitChange(this.getReducers())
    }
  }

  setChangeListener (listner) {
    this._emitChange = listner
  }
}

const reducerRegistry = new ReducerRegistry()

export default reducerRegistry

С помощью этого класса редьюсеры могут сами себя регистрировать в store.

Создаем store:


./configureStore
export default function configureStore (initialState) {
  const combine = (reducers) => {
    const reducerNames = Object.keys(reducers)
    Object.keys(initialState).forEach(item => {
      if (reducerNames.indexOf(item) === -1) {
        reducers[item] = (state = null) => state
      }
    })
    reducers['router'] = connectRouter(history)
    return combineReducers(reducers)
  }

  const reducer = combine(reducerRegistry.getReducers())
  const store = createStore(reducer, initialState, compose(composeWithDevTools(applyMiddleware(thunk)), applyMiddleware(routerMiddleware(history))))

  reducerRegistry.setChangeListener(reducers => {
    store.replaceReducer(combine(reducers))
  })

  return store
}

С помощью функции store.replaceReducer загружаем редьюсеры в store.

Основной файл


Добавляем маршруты и подключаем redux

./index.js
const Cabinet = React.lazy(() => import('./moduleCabinet/Index'))

let store = configureStore({
  profile: {loading: null}
})

class App extends Component {
  render () {
    const history = createBrowserHistory()
    return (
      <Router basename="/">
        <ConnectedRouter history={history}>
          <Suspense fallback={<Loader/>}>
            <Switch>
              <Route exact path="/" component={Main} />
              <Route path="/admin" render={(props) => <RouteAdmin {...props} />}/>
              <Route path="/cabinet" component={props =>  <Cabinet {...props} />}}/>
              <Route
                path="/"
                component={() => <div>page not found</div>}
              />
            </Switch>
          </Suspense>
        </ConnectedRouter>
      </Router>
    )
  }
}

if (document.getElementById('app')) {
  ReactDOM.render(
    <Provider store={store}>
      <App/>
    </Provider>,
    document.getElementById('app')
  )
}

С помощью React.lazy делаем ленивую загрузку компонентов.React.lazy доступен, начиная с версии 16.6: React. Lazy loading. В элементе Suspense обрабатывается загрузка компонента.

AdminModule может загрузить только авторизованный пользователь, для этого используем компонент RouteAdmin:

./RouteAdmin.js
const NotAccess = (props) => {
  return (
    <div>
      <h1 className="text-danger">Доступ закрыт</h1>
    </div>
  )
}

export default class RouteAdmin extends Component {
  constructor (props) {
    super(props)
    this.state = {
      component: null
    }
  }

  componentDidMount () {
    axios.post('/admin').then(data => data.data).then(data => {
      if (data.auth === true) {
        const Admin = React.lazy(() => import('./moduleAdmin/Index'))
        this.setState({component: Admin})
      } else {
        this.setState({component: NotAccess})
      }

    })
  }

  render () {
    const Component = this.state.component
    return (
      <Route path="/admin" {...this.props} render={this.state.component}/>
    )
  }
}

Реализация модуля


Основной файл — добавляем маршруты модуля

./moduleAdmin/Index.js
export default class IndexComponent extends Component {
  constructor (props) {
    super(props)
  }

  render () {
    return (
      <>
        <Route to="/admin/Profiles" component={Profiles} />
        ...
      </>
    )
  }
}

./moduleAdmin/pages/Profiles.js
class Profiles extends Component {
    componentDidMount() {
        this.props.getInfo()
    }

    render() {
        if (this.props.loading === Process.Start) {
            return <Loader />
        }
        if (this.props.loading === Process.Success) {
            return (
                <div>
                    <h1>Profiles</h1>
                </div>
            )
        }
        return null
    }
}

const mapStateToProps = (state) => {
    return {
        loading: state.profiles.loading
    }
}

const mapDispatchToProps = (dispatch) => {
    return {
        getInfo: () => dispatch(getInfo())
    }
}

export default connect(
    mapStateToProps,
    mapDispatchToProps
)(Profiles)

Создаем редьюсер


Тут же регистрируем его в store:

./moduleAdmin/redux/profile.js
const Process = {
    Start: 0, Success: 1, Error: 2
}

export const getInfo = () => {
    return (dispatch) => {
        dispatch({ type: PROFILES_GET_START })
        axios.post('/news').then((data) => {
            dispatch({ type: PROFILES_GET_SUCCESS, payload: data.data })
        }).catch(e => {
            dispatch({ type: PROFILES_GET_ERROR, payload: e })
        })
    }
}

const initialState = {
    error: null, loading: null, data: null
}

const reducer = (state = initialState, action) => {
    switch (action.type) {
        case PROFILES_GET_START: {
            return { ...state, loading: Process.Start }
        }
        case PROFILES_GET_SUCCESS: {
            return { ...state, loading: Process.Success, data: action.payload}
        }
        case PROFILES_GET_ERROR: {
            return { ...state, loading: Process.Error, error: action.payload }
        }
        default: {
            return state
        }
    }
}
reducerRegistry.register('profiles', reducer)

Надеюсь моя статья поможет в реализации вашего проекта, а ваши комментарии помогут улучшить мой.

Комментарии (15)


  1. JustDont
    24.05.2019 12:52

    Сколько не смотрю на весь этот набитый бойлерплейтом редакс, а никогда не мог понять глубинную логику разделения на action и reducer. Идеология выглядит красиво — мол, экшены фигачат сообщения вида «что случилось», а редьюсеры уже стейт правят. На практике же у нас одна ответственность вида «обновить стейт определенным образом» (на этом всё остальное приложение держится, собственно, ожидая, что стейт будет обновляться определенными способами) поделилась на два несвязанных куска — экшн, который сам по себе это ничего не значащая тыква, и редьюсер, который сам по себе тоже ничего не значащая тыква, но если она возвращает что-то интересное в ответ на определенные строки в action.type, то всё это наконец-то цепляется друг за друга и начинает вертеться.

    Вот только почему это всегда выглядит очень лишним усложнением на ровном месте?


    1. mayorovp
      24.05.2019 14:38

      Это всё имеет смысл, если один action обрабатывается несколькими редьюсерами, прямо или косвенно (через мидлвари).


      1. JustDont
        24.05.2019 14:48

        Конечно. Но смотрите: совсем посторонние сайд-эффекты (мидлварь) всегда можно как-то организованно дёрнуть из приложения, место для такого дерганья как правило легко находится или организовывается, редакс у вас или не редакс.
        Внутренняя же логика приложения из нескольких редьюсеров — это вы просто берете и очевидную ветвящуюся логику (если А, делаем B+C, иначе делаем B+D) пихаете куда-то в гораздо менее очевидное место (коллекция редьюсеров в сторе на данный момент). Проще говоря, меняете очевидный код на неочевидный — было ветвление по действиям, стало ветвление для разнообразного подписывания редьюсеров в стор. Что само по себе всегда очень плохо.


        1. VolCh
          25.05.2019 07:39
          +2

          Если я правильно понял ваш комментарий (несколько раз перечитал), то ваш очевидный код является сильно связанным. Какой-то простой компонент типа кнопки должен знать про A, B, C и D.


          1. JustDont
            25.05.2019 10:24

            Конечно нет. Зачем про это знать компонентам?
            Если у нас есть стор и его обновления (неважно, редакс это или не редакс), и архитектура более-менее MVC (вернее, обычно таки MVP или MVVM, но это в общем-то одни и те же яйца с разных ракурсов), то сфигали логика будет прибита к компонентам? Вы не представляете других мест, куда её можно положить, что ли?

            Всё, что я описал — это вариант, в котором вместо двух раздельных «делаем действие» и «редьюсеры обновляют стор» происходит только одно «делаем действие» (с необходимыми ветвлениями внутри).


            1. VolCh
              26.05.2019 15:30

              Ну вот простой компонент был с ответственностью вызвать изменение стора B при нажатии мышью на кнопку. пришло новое требование "если нажата левая кнопка, то дополнительно вызывать C, если правая, то дополнительно вызывать D". Где вы будете это делать?


              В сторах подобных редаксовым, с разделениями на экшены и редюсеры (события и их обработчики, по сути) мы просто добавим новый обработчик для существующего экшена, а может даже два новых обработчика, которые будут игнорировать не свою кнопку. Без разделения, как я понимаю, в сторе был метод B (причём B скорее всего что-то из предметной области, ане onMyButtonClick, который вызывался в onClick. Теперь нам нужно будет создавать именно onMyButtonClick скорее всего, в котором анализировать какая кнопка нажата. Ну или создавать метод типа doBwithCorD(bool withC, bool withD) и передавать соотвествующие флаги. Так?


              1. JustDont
                26.05.2019 16:12

                Без разделения, как я понимаю, в сторе был метод B (причём B скорее всего что-то из предметной области, ане onMyButtonClick, который вызывался в onClick. Теперь нам нужно будет создавать именно onMyButtonClick скорее всего, в котором анализировать какая кнопка нажата. Ну или создавать метод типа doBwithCorD(bool withC, bool withD) и передавать соотвествующие флаги. Так?

                Если «новое требование» относится к изменению предметной области (то есть, действия остались теми же самыми, только их суть теперь другая) — то код компонента вообще не меняется, в нем так и остаётся «метод В», только «метод В» теперь будет работать иначе. Это, конечно, всё зависит от уровня охвата — для цельного SPA скорее всего будет не так (а для какого-то компонентного куска про кнопки и нажатия на них — возможно).
                Если же «новое требование» к предметной области отношения не имеет — в компонент (в обработчик onClick, если точнее) вносится логика по вызову C или D в зависимости от условий. Если компонент один — то прямо в него, почему бы и нет. Если компонентов много — куда-то в общее для этих компонентов место. Это ровно то же самое, что вы сделаете, если «просто добавите новый обработчик или два», только явно изложенное в коде, а не зарытое во внутренней логике редьюсеров и в строках, подписывающих их в стор.

                В парадигме action-reducer, если вам по прежнему надо где-то «просто» нажимать на кнопки, а где-то с вывертом — вы скорее всего будете заводить новый тип action «с вывертами», и семантически это будет то же самое, что и onMyButtonClick. Ну и опять же вам придётся написать код, который позаботится о том, что в action будет положена нажатая кнопка, чтоб потом редьюсеру было, что проверять. При этом у вас нет никакой особой свободы творчества, и вы даже так просто не дернете подряд два экшна из onClick (в духе doSimpleClick(); doComplexClick(ev)) — для этого вам сначала придётся пойти и разобраться, нет ли в них какой-нибудь асинхронности, которая может породить нежелательные обновления UI, если отработает в произвольном порядке.


                1. VolCh
                  26.05.2019 18:50

                  Я скорее про ситуацию, когда кнопка вызывала какие-то изменения в модели предметной области, а новые требования говорят, что дополнительно к этим изменениям в модели нужно делать одно из двух других в зависимости от условия какого-то. Чисто UI задача для конкретного компонента — уменьшение количество кликов пользователем в какой-то ситуации. И я не буду заводить отдельный экшен, просто добавлю поле в существующий. Аналогично вызову метода с параметром.


                  1. JustDont
                    26.05.2019 19:15

                    И я не буду заводить отдельный экшен, просто добавлю поле в существующий. Аналогично вызову метода с параметром.

                    … тем самым еще больше скрывая и запутывая логику. I rest my case. Во имя архитектуры нам для выяснения логики работы конкретного действия теперь надо быть в курсе подписок редьюсеров в данный момент рантайма (хорошо, если оно там особо не пляшет в рантайме, а более-менее единожды задаётся) и в курсе полного содержимого конкретного action. Ура?

                    PS: И не поймите меня неправильно, я не считаю такой подход чем-то плохим самим по себе. Это работает, это пишется, и приемлемо читается, если вы прониклись парадигмой action-reducer. Ну или дебажится в рантайме, в крайнем случае. Но это очень далеко не идеал.


    1. staticlab
      25.05.2019 20:21

      Вы всегда можете хранить всё состояние приложения в корневом компоненте, откуда пробрасывать стейт и изменяющие методы через контекст в требуемые компоненты. Это нетрудно сделать на чистом Реакте.


    1. staticlab
      26.05.2019 13:07

      Набросал вот такой небольшой концепт, но в нём есть проблемы с масштабируемостью: https://github.com/artptr/todoapp-recon/tree/master/src/app. Нужно ещё думать, как их лучше решить.


      1. JustDont
        26.05.2019 13:27

        Ну да, это примерно то, что вменяемый человек будет колхозить для реализации MVVM в реакте, если вдруг по каким-то причинам оно ему надо будет.
        Другое дело, что я не уверен, что оно кому-то особо надо. Я пока не разубеждён в том, что для реализации хорошей M и управления ей надо использовать что-то отличное от реактивного программирования. С реактивным программированием обычно выходит всё очень чисто и просто для понимания (когда есть представление, как вообще реактивное программирование устроено). В идеале всю модель вообще можно свести к псевдо-POJO, с которым вся работа проходит через банальные геттеры и сеттеры, но у которого под капотом этого завёрнута вся логика.
        Тяжелый момент, как обычно, будет в том, как бы эту модель с логикой прицепить к рендеру без костылей. У реакта есть определенные проблемы в этом плане (например, MobX их решил, но костылями).


  1. Frozik
    24.05.2019 13:50

    По мне все круто, только для динамической загрузки редьюсеров и саг я бы заюзал microsoft/redux-dynamic-modules


  1. Asrover
    24.05.2019 14:42

    В своих проектах стараюсь использовать компоненты-контейнеры, которые отвечают за логику получения данных, а также презентационные компоненты, которые получают необходимые данные из контейнера и отображают их. Такой подход позволяет разделить ответственность и код становится более читабельным. Также одни и те же данные могут понадобиться в нескольких местах, например получение списка новостей для страницы «Новости» и для компонента «Последние новости», который скажем показывается на «Главной странице». Компонент-контейнер в таком случае позволит избежать дублирования кода.


  1. dizel3d
    25.05.2019 13:47

    Имхо, если проект еще не ушел в тяжелое легаси, лучше разрюхать react new context api и react hooks, а не городить очередной костыль для redux.
    А еще рекомендую использовать typescript.