Сложные компоненты в Ember.js. Часть 1 — Анализ путей взаимодействия пользователя (user flow)


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

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

Selecting an artist

Мы пройдемся по основным путям пользователя в интерфейсе и посмотрим, как они реализуются через взаимодействие разных уровней компонента.

Познакомимся с компонентом

Шаблон, который мы будем использовать (и который применяется в анимации выше), чтобы понять, как функционирует компонент, выглядит так:

tests/dummy/app/templates/index.hbs

<div class="form-group">
  <label>Choose an artist</label>
  {{#auto-complete
        on-select=(action "selectArtist")
        on-input=(action "filterArtists")
        class="autocomplete-container" as |autocomplete isDropdownOpen inputValue
                                           toggleDropdown onSelect onInput|}}
    <div class="input-group">
      {{auto-complete-input
          autocomplete=autocomplete
          value=inputValue
          on-change=onInput
          type="text"
          class="combobox input-large form-control"
          placeholder="Select an artist"}}
      {{#auto-complete-list autocomplete=autocomplete isVisible=isDropdownOpen
              class="typeahead typeahead-long dropdown-menu" as |list|}}
        {{#each matchingArtists as |artist|}}
          {{#auto-complete-option
              id=artist.id
              label=artist.name
              item=artist
              list=list
              on-click=onSelect
              activeId=selectedArtist.id}}
            <a href="#">{{artist.name}}</a>
          {{/auto-complete-option}}
        {{else}}
          <li><a href="#">No results.</a></li>
        {{/each}}
      {{/auto-complete-list}}
      {{#auto-complete-dropdown-toggle on-click=toggleDropdown
              class="input-group-addon dropdown-toggle"}}
        <span class="caret"></span>
      {{/auto-complete-dropdown-toggle}}
    </div>
  {{/auto-complete}}
</div>

Сперва, это кажется чем-то пугающим. Но по мере знакомства с его деталями, ваши страхи пропадут.

Компонент верхнего уровня — auto-complete. Это "командный центр", та часть, которая управляет "глобальным" состоянием всего виджета: виден ли ниспадающий список, какое текущее значение у поля ввода и т. д.

Скорее всего, вы совершенно обоснованно поинтересуетесь, почему эти состояния не обрабатывает субкомпонент, что кажется более подходящим вариантом: текущее значение поля ввода обрабатывает auto-complete-input, а состояние открытия/закрытия ниспадающего списка — auto-complete-dropdown-toggle.

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

Данные вниз, действия вверх — все время вниз (и вверх)

Это незначительное нарушение в разделении функций (или вообще не нарушение?) отлично согласуется с самой важной парадигмой взаимодействия в компоненте: данные вниз, действия вверх.

Поле ввода, когда его значение меняется, посылает действие вверх к родительскому элементу, указывая на изменение. Предок может отреагировать на это и передавать любые изменения данных (состояния) через привязки атрибута, которые у него есть в поле ввода. Поэтому auto-complete необходимо обработать или как минимум получить доступ к состоянию, которое используется субкомпонентами в нижнем потоке.

Традиционный способ передачи данных вниз (и установления привязки) от предка к потомку осуществляется через блочные параметры родительского компонента. Компонент auto-complete имеет такие параметры:

tests/dummy/app/templates/index.hbs

{{#auto-complete
      on-select=(action "selectArtist")
      on-input=(action "filterArtists")
      class="autocomplete-container" as |autocomplete isDropdownOpen inputValue
                                         toggleDropdown onSelect onInput|}}
  (...)
{{/auto-complete}}

Блочные параметры находятся между вертикальными линиями после ключевого слова as. Вам нужно взглянуть на собственный шаблон компонента, чтобы увидеть, откуда они исходят:

addon/templates/components/auto-complete.hbs

{{yield this isDropdownOpen inputValue
        (action "toggleDropdown") (action "selectItem") (action "inputDidChange")}}

Параметры подбираются согласно позиции. Поэтому то, что выдается в первой позиции, становится первым блочным параметром. В этом случае мы выдаем сам компонент в качестве первого параметра, состояния вышеупомянутого компонента как второй и третий параметр, и затем идут (замкнутые) действия, которые запустят функции в компоненте auto-complete при вызове в одном из дочерних компонентов. Они служат "дистанционным управлением" (такой термин использовал Мигель Камба в его потрясающей презентации на EmberCamp) для дочерних компонентов, которое позволяет управлять предком.

Взаимосвязь снизу вверх от дочерних компонентов при необходимости вызывает эти действия.

Теперь нам хватает знаний, чтобы проследить за базовой реализацией путей пользователя. Поэтому приступим.

Пути взаимодействия пользователя

Ручной выбор из ниспадающего списка

Самое базовое действие, которое можно произвести с виджетом, это открыть список вариантов.

Я отбросил части, которые не нужны для понимания этого процесса, поэтому у нас осталось следующее:

tests/dummy/app/templates/index.hbs

<div class="form-group">
  <label>Choose an artist</label>
  {{#auto-complete
        on-select=(action "selectArtist")
        on-input=(action "filterArtists")
        class="autocomplete-container" as |autocomplete isDropdownOpen inputValue
                                           toggleDropdown onSelect onInput|}}
    <div class="input-group">
      {{#auto-complete-list autocomplete=autocomplete isVisible=isDropdownOpen
              class="typeahead typeahead-long dropdown-menu" as |list|}}
        (...)
      {{/auto-complete-list}}
      {{#auto-complete-dropdown-toggle on-click=toggleDropdown
              class="input-group-addon dropdown-toggle"}}
        <span class="caret"></span>
      {{/auto-complete-dropdown-toggle}}
    </div>
  {{/auto-complete}}
</div>

auto-complete-dropdown-toggle — компонент, на который можно нажать, чтобы открыть или закрыть список элементов. На первый взгляд, кажется, что его атрибут on-click — это действие, которое будет запущено при нажатии. Но давайте убедимся:

addon/components/auto-complete-dropdown-toggle.js

import Ember from 'ember';

export default Ember.Component.extend({
  tagName: 'span',
  classNames: 'ember-autocomplete-toggle',
  'data-dropdown': 'dropdown',
  'on-click': null,

  toggleDropdown: Ember.on('click', function() {
    this.get('on-click')();
  })
});

Действительно, он вызывает переданное ему действие, а именно toggleDropdown самого верхнего компонента auto-complete:

addon/components/auto-complete.js

import Ember from 'ember';

export default Ember.Component.extend({
  (...)
  actions: {
    toggleDropdown() {
      this.toggleProperty('isDropdownOpen');
    },
  }
});

Метод toggleProperty переключает значение его параметра. Поэтому, если оно было false, то теперь становится true. isDropdownOpen выдается как блочный параметр, поэтому, когда он становится true, auto-complete-list отобразится заново, так как один из его атрибутов, isVisible, изменится. И затем откроется ниспадающий список:

tests/dummy/app/templates/index.hbs

<div class="form-group">
  <label>Choose an artist</label>
  {{#auto-complete
      (...)
      class="autocomplete-container" as |autocomplete isDropdownOpen inputValue
                                           toggleDropdown onSelect onInput|}}
    <div class="input-group">
      {{#auto-complete-list autocomplete=autocomplete isVisible=isDropdownOpen
              class="typeahead typeahead-long dropdown-menu" as |list|}}
        (...)
      {{/auto-complete-list}}
    </div>
  {{/auto-complete}}
</div>

Тот же процесс запускается, когда на переключение нажимают снова, только в этот раз isDropdownOpen возвращается к false, и ниспадающее меню закрывается.

Выбор элемента

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

Я снова сузил шаблон, чтобы оставить только относящиеся к делу части, отбросив поле ввода и переключение:

tests/dummy/app/templates/index.hbs

<div class="form-group">
  <label>Choose an artist</label>
  {{#auto-complete
        on-select=(action "selectArtist")
        on-input=(action "filterArtists")
        class="autocomplete-container" as |autocomplete isDropdownOpen inputValue
                                           toggleDropdown onSelect onInput|}}
    <div class="input-group">
      (...)
      {{#auto-complete-list autocomplete=autocomplete isVisible=isDropdownOpen
              class="typeahead typeahead-long dropdown-menu" as |list|}}
        {{#each matchingArtists as |artist|}}
          {{#auto-complete-option
              id=artist.id
              label=artist.name
              item=artist
              list=list
              on-click=onSelect
              activeId=selectedArtist.id}}
            <a href="#">{{artist.name}}</a>
          {{/auto-complete-option}}
        {{else}}
          <li><a href="#">No results.</a></li>
        {{/each}}
      {{/auto-complete-list}}
      (...)
    </div>
  {{/auto-complete}}
</div>

Когда на один из элементов нажимают, атрибут on-click (а именно замкнутое действие onSelect, которое предоставляется auto-complete) вызывается в компоненте auto-complete-option:

addon/components/auto-complete-option.js

import Ember from 'ember';

export default Ember.Component.extend({
  (...)
  selectOption: Ember.on('click', function() {
    this.get('on-click')(this.get('item'), this.get('label'));
  }),
});

Так где определяется onSelect? Это один из блочных параметров, который выдается компонентом auto-complete, а если точнее, то действием (action "selectItem"):

addon/templates/components/auto-complete.hbs

{{yield this isDropdownOpen inputValue
        (action "toggleDropdown") (action "selectItem") (action "inputDidChange")}}

selectItem вполне простое:

addon/components/auto-complete.js

import Ember from 'ember';

export default Ember.Component.extend({
  (...)
  actions: {
    selectItem(item, value) {
      this.get('on-select')(item);
      this.set('isDropdownOpen', false);
      this.set('inputValue', value);
    },
    (...)
  }
});

Сначала оно вызывает действие on-select, которое было ему передано "извне" (от контроллера). Оно просто устанавливает selectedArtist объекту с артистом, который заключен в элементе списка. Затем он устанавливает флажок isDropdownOpen на false (что, как мы видели в предыдущем разделе, закрывает список) и устанавливает текст в поле ввода согласно метке (label) элемента (имени артиста).

Автозаполнение элемента

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

Неудивительно, что здесь будет использоваться тот же принцип дизайна, что и раньше. То есть передается действие, которое следует вызвать из потомка. Затем изменяется некоторое свойство в родительском компоненте, которое просачивается вниз к потомку. А он потом отображается заново, так как изменился атрибут.

Давайте взглянем на отвечающие за это части шаблона:

tests/dummy/app/templates/index.hbs

<div class="form-group">
  <label>Choose an artist</label>
  {{#auto-complete
        on-select=(action "selectArtist")
        on-input=(action "filterArtists")
        class="autocomplete-container" as |autocomplete isDropdownOpen inputValue
                                           toggleDropdown onSelect onInput|}}
    <div class="input-group">
      {{auto-complete-input
          autocomplete=autocomplete
          value=inputValue
          on-change=onInput
          type="text"
          class="combobox input-large form-control"
          placeholder="Select an artist"}}
      {{#auto-complete-list autocomplete=autocomplete isVisible=isDropdownOpen
              class="typeahead typeahead-long dropdown-menu" as |list|}}
        {{#each matchingArtists as |artist|}}
          {{#auto-complete-option
              (...)
          {{/auto-complete-option}}
        {{else}}
          <li><a href="#">No results.</a></li>
        {{/each}}
      {{/auto-complete-list}}
      (...)
    </div>
  {{/auto-complete}}
</div>

В этот раз мы начнем с auto-complete-input, где обрабатывается событие input. Оно запускается, когда пользователь набирает текст:

addon/components/auto-complete-input.js

import Ember from 'ember';

export default Ember.TextField.extend({
  (...)
  valueDidChange: Ember.on('input', function() {
    const value = this.$().val();
    this.get('on-change')(value);
  })
});

Это почти что копия вызова действия on-select, которое мы видели ранее в auto-complete-option. Здесь вызывается функция on-change, которая была передана из блочного параметра auto-complete.

Если мы взглянем на шаблон auto-complete, то увидим, что он создает замкнутое действие (action 'inputDidChange') и выдает его. И это следующая вещь, на которую стоит взглянуть. Вот, где происходят основные действия:

addon/components/auto-complete.js

import Ember from 'ember';

export default Ember.Component.extend({
  (...)
  actions: {
    inputDidChange(value) {
      this.get('on-input')(value);
      this.set('isDropdownOpen', true);
      const firstOption = this.get('list.firstOption');
      if (firstOption) {
        const autocompletedLabel = firstOption.get('label');
        this.get('on-select')(firstOption.get('item'));
        this.set('inputValue', autocompletedLabel);
        this.get('input.element').setSelectionRange(value.length, autocompletedLabel.length);
      }
    }
  }
});

Сначала мы вызываем действие on-input, которое фильтрует артистов, неподходящих под набранное сочетание букв. В результате matchingArtists будет содержать только подходящих артистов. Тогда открывается ниспадающий список, чтобы отобразить эти элементы (или пояснительное сообщение, если совпадений нет). Если есть хотя бы один подходящий элемент, он выбирается (и становится selectedArtist).

Чтобы улучшить пользовательское восприятие, в поле ввода появляется "предполагаемый" элемент согласно первым буквам, поэтому пользователь может продолжить набирать текст и выбрать другого артиста, если первый оказался не тем, кого имели в виду. (Взгляните на анимацию, когда я набираю букву "J").

Концепции дизайна

Я не совсем доволен текущим состоянием компонента по следующим причинам:

  • Компонент auto-complete проникает в auto-complete-input (устанавливает его свойство input), чтобы вызвать на нем setSelectionRange (смотрите последний отрезок кода).
  • Тот же компонент извлекает варианты из списка и получает их item для выбора. И снова это довольно навязчивый метод, и код не сработает, если внутренние элементы auto-complete-option изменятся.
  • Компонент auto-complete по-прежнему выдает свой экземпляр как блочный параметр. Это позволяет "потребителям нижнего потока" получить доступ к любым свойствам и методам, ломая его инкапсуляцию.

Во время представления этих концепций на Global Ember Meetup и Ember.js Belgium я сказал, что мне нравится воспринимать компоненты как объекты пользовательского интерфейса. Такое восприятие помогает донести мысль, что некоторые (большинство?) объектно-ориентированные практики следует применять к компонентам. Если такое предположение верное, мы можем использовать концепции дизайна ООП и руководства, которые разрабатывались десятилетиями. Они дают начальную информацию о том, как проектировать (и на что обратить особое внимание) сложные иерархии компонента.

Например, я рассматриваю набор блочных параметров, выданных компонентом, как его публичный API. Это значит, что выдача this из шаблона компонента считается плохим вариантом, так как нарушает инкапсуляцию. В некоторых случаях относительно легко найти обходной путь, но иногда все гораздо сложнее. Посмотрим, смогу ли я преодолеть сложности, что описаны выше.

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

Ориентиры

Кстати, недавно Мигель Камба, кажется, тоже задумался о компонентах. Я уже упоминал его отличную презентацию на EmberCamp в этом году под названием "Composable components" (Компоненты для компоновки). Но, кроме этого, он выпустил ember-power-select, который преследует те же цели, что и компонент auto-complete в моей серии статей.

Но он более проработанный и гибкий, поэтому если вам в приложении нужен ниспадающий список, используйте ember-power-select. Мой компонент предназначен лишь для обучения и демонстрации. И все-таки я разместил его на Github в balinterdi/ember-cli-autocomplete на случай, если вы захотите взглянуть на его код по мере чтения статей. Я поместил тег ccd-part-one, чтобы отметить содержание этой статьи.

В Следующей статье...

...этой серии, я хотел бы посвятить (некоторым) своим проблемам, которые я упоминал выше, и рассмотреть их решение. Следите за новостями!

Перевод статьи - Complex Components in Ember.js - Part 1 - Analyzing User Flows


Комментарии (0)

    Выделите опечатку и нажмите Ctrl + Enter, чтобы отправить сообщение об ошибке.