Самый низкий общий предок

Одна из самых больших проблем при написании приложения JavaScript - синхронизация нескольких частей интерфейса. Взаимодействие с пользователем в одной части интерфейса часто влияет на данные в другой. При неправильном управлении эти данные могут оказаться в нескольких местах, но с несовместимыми значениями.

За последние несколько лет сообщество JavaScript многое узнало о том, как решать эту проблему. Решение включает принцип наименьшего общего предка. Чтобы объяснить этот принцип, давайте рассмотрим некоторые элементы интерфейса, которые вы можете создать, работая над реальным приложением.

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

Складная панель (единое представление состояния приложения)

Допустим, мы создаем приложение со складной панелью:

Вот шаблон компонента:

<a {{action 'toggleIsOpen'}}>Panel Title</a>
{{#if isOpen}}
  <div>Anim pariatur cliche...</div>
{{/if}}

Если панель isOpen, мы покажем тело. Щелчок по заголовку вызывает действие toggleIsOpen.

Вот код JavaScript компонента:

export default Ember.Component.extend({
  isOpen: true,
  actions: {
    toggleIsOpen() {
      this.toggleProperty('isOpen');
    }
  }
});

Значение по умолчанию isOpen - истина, поэтому панель сначала открывается. Каждый раз, когда пользователь щелкает заголовок, isOpen переключается между истинным и ложным.

В настоящее время наше приложение выглядит так:

isOpen - единственная часть состояния в нашем приложении, которая изменяется. Кроме того, складная панель - единственная часть интерфейса, которая должна о ней знать. Из-за этого для самой панели имеет смысл «владеть» этой частью состояния приложения. Итак, оставим isOpen там, где он есть - как простое свойство компонента.

Добавление кнопки (несколько представлений состояния приложения)

У нас есть новый запрос функции: добавление отдельной кнопки, которая также может переключать панель. На кнопке также должно быть написано «Развернуть» или «Свернуть», в зависимости от состояния панели.

Теперь у нас есть два элемента в нашем приложении, представление которых зависит от одного и того же состояния. Что нам делать?

Один из вариантов - добавить свойство isOpen в наш компонент ‹button› и попытаться синхронизировать оба свойства:

Но такое дублирование состояния не является хорошим решением, потому что оно может легко привести к несовместимому интерфейсу.

Лучшее решение - переместить состояние вверх на уровень ‹app› и передать ‹app› isOpen как ‹collapsible-panel›, так и ‹button›:

Думайте о свойствах isOpen двух дочерних элементов как о доступных только для чтения указателях на свойство isOpen в ‹app›. Теперь и ‹collapsible-panel›, и ‹button› гарантированно будут синхронизироваться, поскольку они отображаются в одном и том же состоянии.

Вот шаблон для ‹app›:

{{collapsible-panel isOpen=isOpen onClick=(action 'toggleIsOpen')}}
{{button isOpen=isOpen onClick=(action 'toggleIsOpen')}}

А вот JS, который выглядит точно так же, как ‹collapsible-panel› в первом примере:

export default Ember.Component.extend({
  isOpen: true,
  actions: {
    toggleIsOpen() {
      this.toggleProperty('isOpen');
    }
  }
});

Когда пользователь щелкает ‹collapsible-panel› или ‹button›, мы отправляем действие до ‹app›, которое затем изменяет его свойство isOpen. Новое значение isOpen распространяется на всю остальную часть приложения, и проблема несовместимых элементов интерфейса исчезает.

Важно отметить, что мы позаботились о том, чтобы только владелец состояния - в данном случае ‹app› - мог изменить это состояние. Дети делегируют свои действия хозяину.

Переместив состояние на ‹app› и предоставив isOpen единый источник правды, мы смогли гарантировать, что ‹collapsible-panel› и ‹button› никогда не выйдут из строя.

В приведенном выше примере ‹app› был единственным предком обоих компонентов в нашем дереве компонентов. Допустим, у нас есть приложение побольше:

В этом случае было бы достаточно переместить isOpen в ‹main›, чтобы гарантировать, что isOpen имеет единственный источник истины.

А теперь определение. Для данного набора компонентов Самый низкий общий предок этого набора - это компонент, который находится на самой глубине в дереве пользовательского интерфейса, но все еще находится над каждым компонентом в этом наборе.

Это определение вместе с приведенным выше обсуждением приводит нас к следующему общему принципу:

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

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

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

Добавление кнопки выхода (вывод состояния приложения из пользовательского интерфейса)

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

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

Пример того, как это могло произойти, - если многим компонентам в вашем приложении необходимо знать о текущем пользователе:

Предполагая, что такому количеству узлов необходимо знать о текущем пользователе, LCA будет ‹app›, и каждый компонент должен будет передать currentUser каждому другому компоненту. Это определенно пахнет Средним человеком.

Решение этой проблемы - вытащить состояние из иерархии пользовательского интерфейса. Идея здесь в том, что, в то время как isOpen в нашем предыдущем примере соответствовал непосредственно конкретному экранному элементу пользовательского интерфейса (независимо от того, была ли панель открыта), currentUser - нет. Таким образом, для ‹app› (или любого другого элемента пользовательского интерфейса) бессмысленно «владеть» currentUser.

В Ember мы можем использовать Сервис для решения этой проблемы (в React вы можете использовать хранилище Flux). Служба - это долговечный контейнер данных, который существует независимо от дерева пользовательского интерфейса. После того, как мы его настроим, компоненты могут использовать внедрение зависимостей, чтобы запрашивать данные в Сервисе. Важно отметить, что их родительские компоненты ничем не мудрее.

// app/services/current-user.js
export default Ember.Service.extend({
  name: 'Bob',
  email: '[email protected]'
});
// app/components/sidebar.js
export default Ember.Component.extend({
  currentUser: Ember.inject.service('current-user')
});

Теперь ‹sidebar› может получить доступ к currentUser в своем шаблоне:

<h2>{{currentUser.name}}</h2>

‹Sidebar› стал более самодостаточным, что упростило перемещение по пользовательскому интерфейсу при последующих рефакторингах.

Заключение

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

Состояние, которое хранится непосредственно в иерархии пользовательского интерфейса, часто легче понять и требует меньше шаблонов; но чем больше компонентов нуждаются в определенном состоянии, тем более хрупкой становится иерархия пользовательского интерфейса. В конце концов, имеет смысл переместить «популярное состояние» в контейнер данных, не зависящий от пользовательского интерфейса, карту идентичности, из которой другие компоненты могут читать.

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

В заключение,

  1. Начните с сохранения состояния на экземплярах локальных компонентов
  2. Если часть состояния приложения требуется более чем одному компоненту, переместите это состояние в LCA.
  3. Если существует множество компонентов, действующих как посредники, или если какой-либо компонент не может владеть каким-либо состоянием, вытащите это состояние из иерархии пользовательского интерфейса в контейнер данных (службу Ember, хранилище Flux и т. Д.).

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

Первоначально опубликовано на www.samselikoff.com.