Skip to content

Instantly share code, notes, and snippets.

@kovchiy
Last active May 19, 2016 22:11
Show Gist options
  • Save kovchiy/07e8f0c42848ecdd7bee4aa6263cdf07 to your computer and use it in GitHub Desktop.
Save kovchiy/07e8f0c42848ecdd7bee4aa6263cdf07 to your computer and use it in GitHub Desktop.

Взаимодействие компонент

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

Ниже пойдет речь о четырех наиболее рациональных способах организации взаимодействия компонентов интерфейса в Beast.

1. Через отношение блок-элемент

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

К примеру, крестик очищает содержимое инпута, вызывая метод родителя:

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(), которые вовзращают заранее сохраненные ссылки на компоненты, вне зависимости от их уровня вложенности.

2. Через общего родителя

Порой требуется связать несколько блоков или даже еще сложнее — элемент одного блока с другим блоком. И сразу пример: форма отправки сообщений по нажатию на кнопку «Отправить» должна показать попап с текстом «Сообщение отправляется...» и полоской прогресса, а после «Сообщение отправлено» и кнопку «ОК».

На первый взгляд всё довольно просто: надо связать между собой компоненты 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-дерева. Но сути это не меняет, так как блок в любом случае в курсе семантики своих входных данных.

3. Через общую шину событий

Если компоненты находятся далеко друг от друга или родители у них постоянно меняются, но взаимодействие все равно должно происходить, на помощь приходят события общей шины (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')
                }
            }
        }
    }
})

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

4. Предметноориентированные абстракции

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

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

Разберем ситуацию, где все вышеперечисленные способы взаимодействия дают сбой. Мобильный интерейс: в блоке с информацией об организации кнопка «Показать всё» вызывает новый экран с расширенным описанием. В этом новом экране есть кнопка «Показать на карте», которая вызывает следующий экран с картой, и так далее — классический навигационный стек.

Можно попытаться вложить приезжающие экраны в карточку организаци — тогда у последней сохранится прямой доступ к ним.

<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
        },
    }
})

За прочими деталями реализации — в исходный код. Описанные предметноориентрованные абстракции выставляют единственное требование к контексту — чтобы кто-то из родительских компонентов унаследовался от одной из них, чтобы было куда складывать стопку приезжающих экранов.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment