В первой части я уделил внимание только общей концепции: редюсеры, компоненты и экшны чаще меняются одновременно, а не по отдельности, поэтому и группировать и их целесообразнее по модулям, а не по отдельным папкам actions, components, reducers. Также к модулям были предъявлены требования:
- быть независимыми друг от друга
- взаимодействовать с приложением через 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 }
Это не самая эффективная реализация подобного редюсера. К сожалению, даже она заняла у меня достаточно много времени. Буду благодарен, если кто-то в комментариях подскажет, как можно сделать лучше.
Соответствие роутов и стейта
Данная реализация модульной системы полагается на соответствие стейта и роутов один к одному, с небольшими исключениями:
/Updateзаменяется на/:id/Indexопускается (используетсяindexRoute)- Для
Deleteнет своего роута. Удаление производится из модуляIndex
Метод path можно переопределить и тогда роут будет отличать от названия модуля
Можно конструировать цепочки модулей любой вложенности. Более того, если в вашем приложении только один корневой роут /, то целесообразно сделать модуль App и вложить в него все остальные, чтобы использовать один подход повсеместно.
Это позволит в редюсере App (если такой нужен) обрабатывать любые события приложения и модифицировать состояние любого дочернего модуля. Пожалуй, это слишком круто для любого, даже самого крутого редюсера. Я не рекомендую вообще переопределять reduce для родительского модуля приложения. Однако, такой редюсер может быть полезен для каких-то системных операций.
С роутингом покончено, осталось «законектить» компоненты к стейту. Так как редюсеры скомпонованы рекурсивно в соответствие со вложенностью дочерних модулей коннектить будем также. Здесь все просто. Реализацию mapDispatchToProps рассмотрим чуть ниже.
Компоненты ядра
Итак, ModuleBase– первая и неотъемлемая часть ядра. Без него свой код к приложению вообще не подцепить. ModuleBase предоставляет следующее API:
- Регистрация компонента в роутере
- Регистрация редюсера модуля
- Connect компонентов к стейту redux
Не плохо, но недостаточно. CRUD должно быть делать просто. Добавим DataGridModuleBase и FormModuleBase. До текущего момента мы не уточняли какие компоненты используются в модулях.
Компоненты и контейнеры
Контейнеры – один из широко распространённых паттернов в React. Если коротко, то разница между компонентами и контейнерами в следующем:
- Компоненты (или презентационные компоненты) не содержат внешних зависимостей и логики
- Контейнеры (как понятно из названия) оборачивают компоненты, реализуя байндинг между внешним миром и компонентами
Контейнеры (как понятно из названия) оборачивают компоненты, реализуя байндинг между внешним миром и слоем представления.
Такая организация улучшает повторное использование кода, упрощает разделение работы между разными специалистами и тестирование. Функция connect по сути является фабрикой контейнеров.
Для разработки DataGridModule нам потребуются:
- компонент
DataGrid - его контейнер
DataGridContainer - редюсер для связи между контейнером и состоянием приложения в redux
Реализацию презентационного компонента я опускаю. Для подключения к стейту у нас есть функция ModuleBase.connect. Осталось получать данные с сервера. Можно на каждый грид создавать новый класс и переопределять componentDidMount или другие методы жизненного цикла компонента. Подход, в целом, рабочий, но имеющий два значительных недостатка:
- гигантское количество boilerplate и копипасты. А копи-пейст, как известно, всегда приводит к ошибкам
- низкая скорость разработки модулей (ядро пока не предоставляет никакого 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
- Базовые модули содержат повторно-используемую логику и наборы стандартных компонентов, часто используемых вместе (например,
CRUD). - Папки
componentsиcontainersсодержат часто-используемые компоненты и контейнеры, соответственно. - С помощью примесей можно компоновать компоненты и контейнеры: грид с серверными данными, грид с инлайн-вводом, грид с серверными данными и инлайн-вводом и т.д.
- api.js содержит функции для работы с сервером: fetch, get, post, put, del,…
ссылка на оригинал статьи https://habrahabr.ru/post/327196/
Добавить комментарий