Грамотная архитектура веб-приложения важна не только для продакшен-версий, но и для долгоживущих прототипов. Итеративные изменения возможны лишь в условиях, котогда сложность проведения последующей итерации не растет экспоненциально.
Ниже пойдет речь о четырех наиболее рациональных способах организации взаимодействия компонентов интерфейса в Beast.
Методолгия БЭМ предлагает самый удобный и простой способ провязывания компонентов — когда одни (элементы) подчиняются другим (блокам). Принято считать, что все связи в ирерахических структурах должны быть направлены от родителя к ребенку, а ребенок не должен ничего знать о контексте своего использования. Однако, основываясь на том, что элементы не могут существовать без своего родительского блока, это правило можно и нужно нарушать, но только при связывании блока и элемента.
К примеру, крестик очищает содержимое инпута, вызывая метод родителя:
Beast.decl({
TextInput: {
clear: function () {
this.elem('input').domNode().value = ''
}
},
TextInput__clear: {
on: {
click: function () {
this.parentBlock().clear()
}
}
}
})
Безусловно, это можно было бы сделать, следуя правилу «от родителя к ребенку», но так описание поведение элементов смешается с поведением самого блока, и наглядная декларативная картина пропадет:
Beast.decl({
TextInput: {
domInit: function () {
this.elem('clear')[0].on('click', function () {
this.clear()
}.bind(this))
},
clear: function () {
this.elem('input').domNode().value = ''
}
}
})
Более того, для безопасного и быстрого обращения и к родителю, и к элементу существуют методы parentBlock()
и elem()
, которые вовзращают заранее сохраненные ссылки на компоненты, вне зависимости от их уровня вложенности.
Порой требуется связать несколько блоков или даже еще сложнее — элемент одного блока с другим блоком. И сразу пример: форма отправки сообщений по нажатию на кнопку «Отправить» должна показать попап с текстом «Сообщение отправляется...» и полоской прогресса, а после «Сообщение отправлено» и кнопку «ОК».
На первый взгляд всё довольно просто: надо связать между собой компоненты MessageForm
, Popup
, Progressbar
и Button
. Сложность заключается в том, чтобы организовать по-настоящему слабое связывание: когда попап, являясь автономным блоком, не знает о своем содержимом, но это содержимое должно каким-то образом управлять попапом. Для этого, как минимум, компонент Popup
должен отречься от содержимого элемента Popup__content
.
Beast.decl({
Popup__content: {
noElems:true
}
})
В общем случае правило следующее: взаимодействие компонент должно происходить через ближайшего общего родителя.
Если вдуматься, то любая грамотная архитектура сводится к этому правилу — поведение определяется на территории родителькой сущности, которая по определению имеет прямой доступ ко всем дочерним элементам, несет ответственность за их наличие и порядок. И желательно, чтобы эта сущность была ближайшей общей, чтобы разгрузить родителей высшего порядка (делегирование, другими словами).
Возвращаясь к примеру, определять содержимое попапа должен родительский блок, в нашем случае MessageForm
. Внутрь он волен положить как собственные элементы (благодаря флагу noElems
), так и другие блоки.
Beast.decl({
MessageForm: {
expand: {
this.append(
<textarea/>,
<submit>Отправить</submit>,
<Popup>
<content>
<scene>
<windowText>Сообщение отправляется...</windowText>
<Progressbar/>
</scene>
<scene>
<windowText>Сообщение отправлено</windowText>
<Button>ОК</Button>
</scene>
</content>
</Popup>
)
},
domInit: function () {
this.elem('scene')[1].get('Button')[0].on('click', function () {
this.get('Popup')[0].mod('State', 'release')
}.bind(this))
},
submit: function () {
this.get('Popup')[0].mod('State', 'active')
this.elem('scene')[0].mod('State', 'active')
// Отправка сообщения, которое завершится событием 'DidSubmit'
},
on: {
DidSubmit: function () {
this.elem('scene')[1].mod('State', 'active')
}
}
},
MessageForm__submit: {
on: {
click: function () {
this.parentBlock().submit()
}
}
},
})
Разберем код подробнее. Итак, по нажатию на кнопку «Отправить» форма должна показать окно с текстом «Сообщение отправляется...» и полоской прогресса:
MessageForm__submit: {
on: {
click: function () {
this.parentBlock().submit()
}
}
}
MessageForm: {
submit: function () {
this.get('Popup')[0].mod('State', 'active')
this.elem('scene')[0].mod('State', 'active')
}
}
Общий родитель MessageForm
в методе submit
меняет модификатор дочернего блока Popup
; следом все тот же родитель делает активной первую сцену. MessageForm__scene
приходится элементом блоку MessageForm
, потому что выше Popup__content
выставил в своей декларации флаг noElems:true
.
И еще одна интересная связь — клик по блоку Button
закрывает Popup
. Благодаря правилу общего родителя удается организовать то самое слабое связывание, когда и кнопка, и окно выступают лишь объектами взаимодействия, но понятия не имеют, как и для чего их используют в данный момент. Опять же, родитель MessageForm
не может не знать, как получить ссылку на кнопку и окно, так как сам их создавал.
MessageForm: {
domInit: function () {
this.elem('scene')[1].get('Button')[0].on('click', function () {
this.get('Popup')[0].mod('State', 'release')
}.bind(this))
}
}
Блок не всегда создает дочерние компоненты — многое переносится из входного BML-дерева. Но сути это не меняет, так как блок в любом случае в курсе семантики своих входных данных.
Если компоненты находятся далеко друг от друга или родители у них постоянно меняются, но взаимодействие все равно должно происходить, на помощь приходят события общей шины (DOM-события окна).
Предположим, требуется запретить появление более чем одного модального окна. Все модальные окна при активации посылают общей шине событие, которое сами же и слушают: если такое событие пришло от другого модального окна, текущее закрывается.
Beast.decl({
ModalWindow: {
onMod: {
State: {
active: function () {
this.triggerWin('Activate', this)
}
}
},
onWin: {
'modalWindow:Activate': function (e, target) {
if (target !== this) {
this.mod('state', 'release')
}
}
}
}
})
Как правило, события общей шины используются для организации связи один ко многим, где многие не приходятся дочерними элементами первому, к ним нет прямого и безопасного доступа, а число их неизвестно заранее. Но для связи, к примеру, двух компонент, тоже находящихся на неопределенном расстоянии друг от друга, существует более прозрачный подход.
Эта часть наиболее сложная для восприятия, но ровно настолько, насколько могут быть сложными возникающие на практике задачи.
Итак, общая шина тоже не является серебряной пулей — порой она рождает неочевидные и непрозрачные связи, в которых тяжело разбираться спустя время. Поскольку компонент, порождающий событие, не несет ответственности за последствия и понятия не имеет, как на это отреагируют соседи, легко провалиться в непредсказуемую цепную реакцию: когда одно событие порождает другое, а другое третье, а третье снова первое. А хороший жизнеспособный код должен состоять из явного и безопасного определения связей между компонентами.
Разберем ситуацию, где все вышеперечисленные способы взаимодействия дают сбой. Мобильный интерейс: в блоке с информацией об организации кнопка «Показать всё» вызывает новый экран с расширенным описанием. В этом новом экране есть кнопка «Показать на карте», которая вызывает следующий экран с картой, и так далее — классический навигационный стек.
Можно попытаться вложить приезжающие экраны в карточку организаци — тогда у последней сохранится прямой доступ к ним.
<App>
...
<OrganizationCard>
...
<more>Показать всё</more>
<OverlayScreen>
...
<showMap>Показать на карте</showMap>
<OverlayScreen>
...
<Map/>
</OverlayScreen>
</OverlayScreen>
</OrganizationCard>
</App>
Приезжающим экранам OverlayScreen
придется научиться выпрыгивать из контекста OrganizationCard
, что с горем по полам решается css-свойством position:fixed
. Но у любого устройства небесконечные ресурсы: контекст OverlayScreen
отъедает ресурсы на отрисовку, приезжающие экраны отдъедают ресурсы тоже — невидимые экраны нужно либо удалять, либо прятать через display:none
, что невозможно, так как предыдущий контекст будет являться родителем для нового.
Кроме того, вложенность экранов может зависеть от порядка действий пользователя: он мог сначала не карту вызвать, а картинку покрупнее открыть, и уже внутри нажать «Показать на карте».
В таком случае приезжающие экраны логично сделать плоским списком, вынесенным за пределы контекста карточки:
<App>
<SomeWrapper>
<OrganizationCard>
...
<more>Показать всё</more>
</OrganizationCard>
</SomeWrapper>
<OverlayScreen>
...
<showMap>Показать на карте</showMap>
<showPhoto>Показать фото</showPhoto>
</OverlayScreen>
<OverlayScreen>...</OverlayScreen>
<OverlayScreen>...</OverlayScreen>
</App>
Но как теперь связать OrganizationCard__more
и первый OverlayScreen
? Эти два компонента находятся на неопределенном расстоянии друг от друга. Если кидать событие общей шины OrganizationCard:ShowOverlayScreen
, то его услышат все три OverlayScreen
. Как обратиться в конкретному? Возможно, стоит идентифицировать его?
<App>
<SomeWrapper>
<OrganizationCard>
...
<more>Показать всё</more>
</OrganizationCard>
</SomeWrapper>
<OverlayScreen id="fullOrganizationDescription">
...
<showMap>Показать на карте</showMap>
<showPhoto>Показать фото</showPhoto>
</OverlayScreen>
<OverlayScreen>...</OverlayScreen>
<OverlayScreen>...</OverlayScreen>
</App>
И тут срабатывает ловушка сильного связывания: для корректной работы блока OrganizationCard
требуется носить знание о том, что в корневой компонент нужно не забыть положить OverlayScreen id="fullOrganizationDescription"
.
Хуже того, с точки зрения данных OverlayScreen
действительно подчиняется OrganizationCard
, так как именно в последней удобно хранить как сокращенную, так и полную версии описания организации — и тут интересы интерфейса конфликтуют с интересами данных. С точки зрения данных удобно и логично поступить именно так:
<App>
<OrganizationCard>
<shortDescription>...</shortDescription>
<fullDescription>...</fullDescription>
<more>Показать всё</more>
</OrganizationCard>
</App>
и уже потом раскрыть элемент fullDescription
до OverlayScreen
со всей начинкой. И тут срабатывает проблема вложнености компонент из первой попытки. Круг замкнулся.
Но и это еще не всё. Пользователь может вообще ничего не нажимать и не вызывать и ограничиться лишь коротким описанием. В добавок интерфейс будет состоять из десятка карточек с разными организациями, и каждая содержит в себе неопределенное число вложенных доуточняющих экранов, которые открываются в неопределенном порядке; большая часть этих экранов не пригодится вовсе. Как бы грозно это ни звучало — с точки зреня пользователя это совершенно обычный интерфейс. А перечисленные выше способы помимо того, что обладают жирными архитектурными минусами, так еще и требуют предварительной генерации полного дерева интерфейса, которое в каждом случае будет избыточным.
И чтобы окончательно всё запутать, представим, что помимо экрана с карточками организаций есть еще несколько экранов с другими наборами карточек, у которых тоже могут быть свои доуточняющие экраны. И в пользовательские сценарии конечно же входит быстрое переключение между этими стопками экранов. Это не выдуманный и переусложненный пример, а модель интерфейса AppStore для iOS и сотни подобных мобильных приложений.
Итого, имеем следующие задачи:
- Разрешить конфликт вложенных данных и разнесенных компонент.
- Сохранить слабое связывание, не требующее дополнительной работы с внешним контекстом.
- Достраивать дерево интерфейса лишь по необходимости и удалять ненужные более ветки.
- Допустить одновременную работу первых трех пунктов в неограниченном количестве автономных контекстов, для возможности переключения между ними.
Предлагается ввести несколько дополнительных абстракций, реализующих описанный шаблон взаимодействия: StackNavigation
, SwitchNavigation
и NavigationItem
. В чистом виде эти три абстракции не используются — от них наследуются интерфейсные компоненты с определенным внешним видом и своей начинкой. Но для наглядности рассмотрим их структуру именно в чистом виде:
<SwitchNavigation>
<item State="visible">
<StackNavigation>
<item State="hidden">
<OrganizationCard>
<more>Показать всё</more>
</OrganizationCard>
<item>
<item State="hidden">...</item>
<item State="visible">...</item>
</StackNavigation>
<item>
<item State="hidden">
<StackNavigation>...</StackNavigation>
</item>
...
</SwitchNavigation>
Абстракция SwitchNavigation
содержит неопределенное количество переключаемых контекстов item
. Внутри лежит по компоненту StackNavigation
, который накапливает в себе стек открытых контекстов и удаляет последний закрытый. SwitchNavigation
можно вложить в другой SwitchNavigation
или StackNavigation
в зависимости от задачи.
Как в новые экраны попадают в очередь StackNavigation
: третья абстракция NavigationItem
имеет методы pushToStackNavigation
и popFromStackNavigation
, которая добавляет саму себя в ближайший родительский StackNavigation
. На практике это выглядит так:
Beast.decl({
OverlayScreen: {
inherits: 'NavigationItem'
},
...
OrganizationCard__more: {
on: {
tap: function () {
<OverlayScreen/>
.append(this.param('OverlayScreenContent'))
.pushToStackNavigation(this)
}
}
}
})
Как NavigationItem
находит ближайший родительский StackNavigation
:
Beast.decl({
NavigationItem: {
pushToStackNavigation: function (context) {
this.getParentStackNavigation(context).push(this)
},
getParentStackNavigation: function (context) {
var node = context.parentNode()
while (!node.isKindOf('StackNavigation')) node = node.parentNode()
return node
},
}
})
За прочими деталями реализации — в исходный код. Описанные предметноориентрованные абстракции выставляют единственное требование к контексту — чтобы кто-то из родительских компонентов унаследовался от одной из них, чтобы было куда складывать стопку приезжающих экранов.
Если теперь размотать клубок, то получится описание взаимодействия через общего родителя, расширенное и дополненное. То есть, любое сколь угодно сложное взаимодействие элементов иреархических структур можно выразить подобным образом.