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

от автора

В первой части я уделил внимание только общей концепции: редюсеры, компоненты и экшны чаще меняются одновременно, а не по отдельности, поэтому и группировать и их целесообразнее по модулям, а не по отдельным папкам actions, components, reducers. Также к модулям были предъявлены требования:

  1. быть независимыми друг от друга
  2. взаимодействовать с приложением через API ядра

В этой части я расскажу о структуре ядра, подходящей для разработки data-driven систем.
Начнем с определения модуля. Работать с простым объектом не совсем удобно. Добавим немного ООП:

class ModuleBase{   constructor(base){     this[_base] = base     this[_ref] = getRef(this)   }    /**    * unique module id    * @returns {string}    */   get id(){     return this.constructor.name   }    /**    * full module ref including all parents    * @returns {string}    */   get ref(){     return this[_ref]   }    /**    * module title in navigation    * @returns {string}    */   get title(){     return this.id   }    /**    * module group in navigation    * @returns {string}    */   get group(){     return null   }    /**    * react component    * @returns {function}    */   get component() {     return null   }    /**    * router route    * @return {object}    */   get route(){     return getRoute(this)   }    /**    * router path    * @return {string}    */   get path(){     return this.id   }    /**    * children modules    * @return {Array}    */   get children(){     return []   }    /**    * @type {function}    */   reduce   //.... }

В коде выше для реализации инкапсуляции используются символы.

Теперь объявление модуля более привычно – необходимо унаследовать класс ModuleBase, переопределить необходимые геттеры и по желанию добавить функцию reduce, которая будет выполняться функцию редюсера.
В прошлый раз мы ограничили вложенность модулей вторым уровнем. В реальных приложениях этого бывает недостаточно. Кроме этого в прошлый раз нам нужно было выбирать между редюсером родительского модуля и комбинацией редюсеров дочерних. Это «ломает» композицию.
Например, если мы хотим создать стандартный CRUD над сущностью в БД логично организовать модули так:

/SomeEntity   /components     /Master.js   /children     /index.js     /create.js     /update.js   /index.js 

Считаем, что для create и update используются стандартный компонент формы, а для вывода данных стандартный компонент Grid из ядра системы, поэтому достаточно определить только модули для этих операций.
Родительский модуль отвечает за вывод лейаута, ссылок «создать», «назад к списку» и сообщений об успешности или не успешности запросов к серверу. Index – за фильтрацию, пагинацию и ссылки. Create и Update выводят формы на создание и редактирование.
Таким образом, редюсер родительского модуля должен иметь доступ ко всему подграфу состояния модуля, а дочерние – каждый к своей части. Реализуем две функции компоновки

Для роутов

const getRoute = module => {   const route = {     path: module.path,     title: module.title,     component: module.component   }    const children = module.children   if(children) {     ModuleBase.check(children)     const index = children.filter(x => x.id.endsWith(INDEX))     if(index.length > 0){       // share title with parent module       route.indexRoute = {         component: index[0].component       }     }      route.childRoutes = module.children       .filter(x => !x.id.endsWith(INDEX))       .map(getRoute)   }    return route }

И для реюсеров

class ModuleBase{   //....   combineReducers(){     const childrenMap = {}      let children = Array.isArray(this.children) ? this.children : []     ModuleBase.check(children)      const withReducers = children.filter(x => typeof(x.reduce) === 'function' || x.children.length > 0)     for (let i = 0; i < withReducers.length; i++) {       childrenMap[children[i].id] = children[i]     }      if(withReducers.length == 0){       return reducerOrDefault(this.reduce)     }      const reducers = {}     for(let i in childrenMap){       reducers[i] = childrenMap[i].combineReducers()     }      const parent = this     const reducer = typeof(this.reduce) === 'function'       ? (state, action) => {         if(!state){           state = parent.initialState         }          const nextState = parent.reduce(state, action)          if(typeof(nextState) !== 'object'){           throw Error(parent.id + '.reduce returned wrong value. Reducers must return plain objects')         }          for(let i in childrenMap){           if(!nextState[i]){             nextState[i] = childrenMap[i].initialState           }            nextState[i] = {...reducers[i](nextState[i], action)}           if(typeof(nextState[i]) !== 'object'){             throw Error(childrenMap[i].id + '.reduce returned wrong value. Reducers must return plain objects')           }         }          return {...nextState}       }       : combineReducers(reducers)       return reducer   }

Это не самая эффективная реализация подобного редюсера. К сожалению, даже она заняла у меня достаточно много времени. Буду благодарен, если кто-то в комментариях подскажет, как можно сделать лучше.

Соответствие роутов и стейта

Данная реализация модульной системы полагается на соответствие стейта и роутов один к одному, с небольшими исключениями:

  1. /Update заменяется на /:id
  2. /Index опускается (используется indexRoute)
  3. Для Delete нет своего роута. Удаление производится из модуля Index

Метод path можно переопределить и тогда роут будет отличать от названия модуля
Можно конструировать цепочки модулей любой вложенности. Более того, если в вашем приложении только один корневой роут /, то целесообразно сделать модуль App и вложить в него все остальные, чтобы использовать один подход повсеместно.

Это позволит в редюсере App (если такой нужен) обрабатывать любые события приложения и модифицировать состояние любого дочернего модуля. Пожалуй, это слишком круто для любого, даже самого крутого редюсера. Я не рекомендую вообще переопределять reduce для родительского модуля приложения. Однако, такой редюсер может быть полезен для каких-то системных операций.

С роутингом покончено, осталось «законектить» компоненты к стейту. Так как редюсеры скомпонованы рекурсивно в соответствие со вложенностью дочерних модулей коннектить будем также. Здесь все просто. Реализацию mapDispatchToProps рассмотрим чуть ниже.

Компоненты ядра

Итак, ModuleBase– первая и неотъемлемая часть ядра. Без него свой код к приложению вообще не подцепить. ModuleBase предоставляет следующее API:

  1. Регистрация компонента в роутере
  2. Регистрация редюсера модуля
  3. Connect компонентов к стейту redux

Не плохо, но недостаточно. CRUD должно быть делать просто. Добавим DataGridModuleBase и FormModuleBase. До текущего момента мы не уточняли какие компоненты используются в модулях.

Компоненты и контейнеры

Контейнеры – один из широко распространённых паттернов в React. Если коротко, то разница между компонентами и контейнерами в следующем:

  1. Компоненты (или презентационные компоненты) не содержат внешних зависимостей и логики
  2. Контейнеры (как понятно из названия) оборачивают компоненты, реализуя байндинг между внешним миром и компонентами

Контейнеры (как понятно из названия) оборачивают компоненты, реализуя байндинг между внешним миром и слоем представления.
Такая организация улучшает повторное использование кода, упрощает разделение работы между разными специалистами и тестирование. Функция connect по сути является фабрикой контейнеров.
Для разработки DataGridModule нам потребуются:

  1. компонент DataGrid
  2. его контейнер DataGridContainer
  3. редюсер для связи между контейнером и состоянием приложения в redux

Реализацию презентационного компонента я опускаю. Для подключения к стейту у нас есть функция ModuleBase.connect. Осталось получать данные с сервера. Можно на каждый грид создавать новый класс и переопределять componentDidMount или другие методы жизненного цикла компонента. Подход, в целом, рабочий, но имеющий два значительных недостатка:

  1. гигантское количество boilerplate и копипасты. А копи-пейст, как известно, всегда приводит к ошибкам
  2. низкая скорость разработки модулей (ядро пока не предоставляет никакого API для ускорения разработки, это неправильно)

Примеси (mixin)

Расширим возможности компоновки компонентов и контейнеров с помощью mixin’ов. class и extends – это объекты первого класса в ES6. Иными словами, запись const Enhanced = superclass => class extends superclass корректна. Это возможно, благодаря системе наследования JavaScript, основанной на прототипах.
Добавим в ядро функцию mix и примеси Preloader и ServerData:

const Preloader = Component => class extends Component {   render() {     const propsToCheck = subset(this.props, this.constructor.initialState)     let isInitialized = true     let isFetching = false      for(let i in propsToCheck){         if(typeof(propsToCheck[i][IS_FETCHING]) === 'boolean'){           if(!isFetching && propsToCheck[i][IS_FETCHING]){             isFetching = true           }            // if something except "isFetching" presents it's initialized           if(isInitialized && Object.keys(propsToCheck[i]).length === 1){             isInitialized = false           }         }     }      return isInitialized       ? (<Dimmer.Dimmable dimmed={isFetching}>         <Dimmer active={isFetching} inverted>           <Loader />         </Dimmer>         {super.render()}       </Dimmer.Dimmable>)       : (<Dimmer.Dimmable dimmed={true}>         <Dimmer active={true} inverted>           <Loader />         </Dimmer>         <div style={divStyle}></div>       </Dimmer.Dimmable>)   } }  const ServerData = superclass => class extends mix(superclass).with(Preloader) {   componentDidMount() {     this.props.queryFor(       this.props.params,       subset(this.props, this.constructor.initialState))   }

Первый проверяет все ключи в стейте и если находит хотя-бы один с определенным свойством isFetching: true выводит поверх компонента диммер. Если кроме isFetching в объекте свойств нет, считаем, что они должны прийти с сервера и вообще не отображаем компонент (считаем не инициализированным).
Миксин ServerData автоматически подмешивает прелоадер и переопределяет componentDidMount.

queryFor

Рассмотрим более подробно реализацию queryFor. Ее передал Module.connect через mapDispatchToProps.

export const queryFactory = dispatch => {   if(typeof (dispatch) != 'function'){     throw new Error('dispatch is not a function')   }    return (moduleId, url, params = undefined) => {     dispatch({       type: combinePath(moduleId, GET),       params     })      return new Promise(resolve => {       dispatch(function () {         get(url, params).then(response => {           const error = 'ok' in response && !response.ok           const data = error             ? {ok: response.ok, status: response.status}             : response            dispatch({             type: combinePath(moduleId, GET + (error ? FAILED : SUCCEEDED)),             ...data           })            resolve(data)         })       })     })   } }  export const queryAll = (dispatch, moduleRef, params, ...keys) => {   const query = queryFactory(dispatch)   if(!keys.length){     throw new Error('keys array must be not empty')   }    const action = combinePath(moduleRef, keys[0])   let promise = query(action, fixPath(action), params)   for(let i = 1; i < keys.length; i++){     promise.then(() => {       let act = combinePath(moduleRef, keys[i])       query(act, fixPath(act), params)     })   } }  export const queryFor = (dispatch, moduleRef, params, state) => {   const keys = []    for (let i in state) {     if (state[i].isFetching !== undefined) {       keys.push(toUpperCamelCase(i))     }   }    return queryAll(dispatch, moduleRef, params, ...keys) 

С помощью queryFactory создаем функцию query, которая делает запрос на сервер, диспатчит в store соответствующие события и возвращает promise, чтобы можно было выстроить цепочку запросов функции queryAll, список запросов в которую передаст та самая функция queryFor, которая ориентируется на наличие isFetching в объекте в доме, который построил Джек.
Допишем «обогощалку» для стейта, требующего серверных данных:

ServerData.fromServer = (initialState, ...keys) => {   for(let i = 0; i < keys.length; i++){     initialState[keys[i]].isFetching = false   }    return initialState }

Теперь достаточно знать правила использования миксина, чтобы сделать из любого компонента, работающего с клиентскими данными на серверный. Достаточно правильно настроить initialState и подключить mixin.
Осталось обработать события старта получения данных, успешного получения и ошибок и изменять соответствующим образом состояние контейнера. Для этого допишем редюсер в модуле.

ServerData.reducerFor

ServerData.reducerFor = (moduleRef, initialState, next = null, method = GET) => {   if(!moduleRef){     throw Error('You must provide valid module name')   }    if(!initialState){     throw Error('You must provide valid initialState')   }    const reducer = {}    for (let i in initialState) {     reducer[i] = hasFetching(initialState, i)       ? ServerData.serverRequestReducerFactory(combinePath(moduleRef, i), initialState[i], next, method)       : passThrough(initialState[i])   }    if(Object.keys(reducer) < 1){     throw Error('No "isFetching" found. Cannot build reducer')   }    const combined = combineReducers(reducer)   return combined }  export default class DataGridModuleBase extends ModuleBase {   constructor(base){     super(base)     // Create is required due to children module     this.reduce = ServerData.reducerFor(this.ref, DataGridContainer.initialState)   }    get component () {     return this.connect(DataGridContainer)   } } 

Добавляем модуль с гридом в приложение

export default class SomeEntityGrid extends DataGridModuleBase { }  export default class App extends ModuleBase{   constructor(base){     super(base)     this[_children] = [new SomeEntityGrid(this)]   }    get path (){     return '/'   }    get component () {     return AppComponent   }    get children(){     return this[_children]   }

Если вы дочитали до конца, то FromModuleBase сможете реализовать по аналогии.

Финальная структура ядра

/core   /ModuleBase.js   /api.js   /components   /containers   /modules   /mixins

  1. Базовые модули содержат повторно-используемую логику и наборы стандартных компонентов, часто используемых вместе (например, CRUD).
  2. Папки components и containers содержат часто-используемые компоненты и контейнеры, соответственно.
  3. С помощью примесей можно компоновать компоненты и контейнеры: грид с серверными данными, грид с инлайн-вводом, грид с серверными данными и инлайн-вводом и т.д.
  4. api.js содержит функции для работы с сервером: fetch, get, post, put, del,…

ссылка на оригинал статьи https://habrahabr.ru/post/327196/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *