В первой части я уделил внимание только общей концепции: редюсеры, компоненты и экшны чаще меняются одновременно, а не по отдельности, поэтому и группировать и их целесообразнее по модулям, а не по отдельным папкам 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 {
}
//..
const _children= Symbol('children')
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,…
Автор: marshinov