Коллбэки и события на компонентах

Компоненты, хоть и каждый сам по себе, обычно как-то общаются с остальной частью страницы

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

Коллбэки

Коллбэк (от англ. callback) – это функция, которую мы передаём куда-либо и ожидаем, что она будет вызвана при наступлении события.

Например, мы можем добавить в options для Menu новый параметр – функцию onselect, которая будет вызываться при выборе пункта меню:

var menu = new Menu({
  title: "Сладости",
  template: _.template(document.getElementById('menu-template').innerHTML),
  listTemplate: _.template(document.getElementById('menu-list-template').innerHTML,
  items: {
    "donut": "Пончик",
    "cake": "Пирожное",
    "chocolate": "Шоколадка"
  },
  onselect: showSelected
});

function showSelected(href) {
  alert(href);
}

В коде меню нужно будет вызывать её, например так:

...
  function select(link) {
    options.onselect(link.getAttribute('href').slice(1));
    ...
  }
...

Полный пример:

Результат
menu.js
menu.css
index.html
function Menu(options) {
  var elem;

  function getElem() {
    if (!elem) render();
    return elem;
  }

  function render() {
    var html = options.template({
      title: options.title
    });

    elem = document.createElement('div');
    elem.innerHTML = html;
    elem = elem.firstElementChild;

    elem.onmousedown = function() {
      return false;
    }

    elem.onclick = function(event) {
      if (event.target.closest('.title')) {
        toggle();
      }

      if (event.target.closest('a')) {
        event.preventDefault();
        select(event.target.closest('a'));
      }

    }
  }

  function renderItems() {
    if (elem.querySelector('ul')) return;

    var listHtml = options.listTemplate({
      items: options.items
    });
    elem.insertAdjacentHTML("beforeEnd", listHtml);
  }

  function select(link) {
    options.onselect(link.getAttribute('href').slice(1));
  }

  function open() {
    renderItems();
    elem.classList.add('open');
  };

  function close() {
    elem.classList.remove('open');
  };

  function toggle() {
    if (elem.classList.contains('open')) close();
    else open();
  };

  this.getElem = getElem;
  this.toggle = toggle;
  this.close = close;
  this.open = open;
}
.menu ul {
  display: none;
  margin: 0;
}

.menu .title {
  font-weight: bold;
  cursor: pointer;
}

.menu .title:before {
  content: '▶';
  padding-right: 6px;
  color: green;
}

.menu.open ul {
  display: block;
}

.menu.open .title:before {
  content: '▼';
}
<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="menu.css">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.3.0/lodash.js"></script>
  <script src="https://cdn.polyfill.io/v1/polyfill.js?features=Element.prototype.closest"></script>
  <script src="menu.js"></script>
</head>

<body>

  <script type="text/template" id="menu-template">
    <div class="menu">
      <span class="title"><%-title%></span>
    </div>
  </script>

  <!--
встроенная браузерная функция encodeURIComponent кодирует спец-символы для URL,
например русские буквы и пробелы
в этом примере русских букв в ключах items нет, но потенциально они возможны
-->
  <script type="text/template" id="menu-list-template">
    <ul>
      <% for(var name in items) { %>
        <li>
          <a href="#<%=encodeURIComponent(name)%>">
            <%-items[name]%>
          </a>
        </li>
        <% } %>
    </ul>
  </script>

  <script>
    var menu = new Menu({
      title: "Сладости",
      template: _.template(document.getElementById('menu-template').innerHTML.trim()),
      listTemplate: _.template(document.getElementById('menu-list-template').innerHTML.trim()),
      items: {
        cake: "Торт", // cake  <a href="#cake">Торт</a>
        donut: "Пончик", // donut
        chokolate: "Шоколадка" // chokolate
      },
      onselect: showSelected
    });

    function showSelected(itemName) {
      alert(itemName);
    }

    document.body.appendChild(menu.getElem());
  </script>

</body>

</html>

Свои события

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

Воспользуемся ими, чтобы корневой элемент меню генерировал событие, которое мы назовём select, при выборе элемента, и передавал в объект события выбранное значение.

Для этого модифицируем функцию select:

function Menu(options) {
  ...

  function select(link) {
    var widgetEvent = new CustomEvent("select", {
      bubbles: true,
      // detail - стандартное свойство CustomEvent для произвольных данных
      detail: link.getAttribute('href').slice(1)
    });
    elem.dispatchEvent(widgetEvent);
  }

  ...
}

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

var menu = new Menu(...);

var elem = menu.getElem();

elem.addEventListener('select', function(event) {
  alert( event.detail );
});

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

Полный пример:

Результат
menu.js
menu.css
index.html
function Menu(options) {
  var elem;

  function getElem() {
    if (!elem) render();
    return elem;
  }

  function render() {
    var html = options.template({
      title: options.title
    });

    elem = document.createElement('div');
    elem.innerHTML = html;
    elem = elem.firstElementChild;

    elem.onmousedown = function() {
      return false;
    }

    elem.onclick = function(event) {
      if (event.target.closest('.title')) {
        toggle();
      }

      if (event.target.closest('a')) {
        event.preventDefault();
        select(event.target.closest('a'));
      }

    }
  }

  function renderItems() {
    if (elem.querySelector('ul')) return;

    var listHtml = options.listTemplate({
      items: options.items
    });
    elem.insertAdjacentHTML("beforeEnd", listHtml);
  }

  function select(link) {
    var widgetEvent = new CustomEvent("select", {
      bubbles: true,
      detail: link.getAttribute('href').slice(1)
    });
    elem.dispatchEvent(widgetEvent);
  }

  function open() {
    renderItems();
    elem.classList.add('open');
  };

  function close() {
    elem.classList.remove('open');
  };

  function toggle() {
    if (elem.classList.contains('open')) close();
    else open();
  };

  this.getElem = getElem;
  this.toggle = toggle;
  this.close = close;
  this.open = open;
}
.menu ul {
  display: none;
  margin: 0;
}

.menu .title {
  font-weight: bold;
  cursor: pointer;
}

.menu .title:before {
  content: '▶';
  padding-right: 6px;
  color: green;
}

.menu.open ul {
  display: block;
}

.menu.open .title:before {
  content: '▼';
}
<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="menu.css">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.3.0/lodash.js"></script>
  <script src="https://cdn.polyfill.io/v1/polyfill.js?features=CustomEvent,Element.prototype.closest"></script>
  <script src="menu.js"></script>
</head>

<body>

  <script type="text/template" id="menu-template">
    <div class="menu">
      <span class="title"><%-title%></span>
    </div>
  </script>

  <script type="text/template" id="menu-list-template">
    <ul>
      <% for(var name in items) { %>
        <li>
          <a href="#<%=encodeURIComponent(name)%>">
            <%-items[name]%>
          </a>
        </li>
        <% } %>
    </ul>
  </script>

  <script>
    var menu = new Menu({
      title: "Сладости",
      template: _.template(document.getElementById('menu-template').innerHTML.trim()),
      listTemplate: _.template(document.getElementById('menu-list-template').innerHTML.trim()),
      items: {
        cake: "Торт", // cake  <a href="#cake">Торт</a>
        donut: "Пончик", // donut
        chokolate: "Шоколадка" // chokolate
      }
    });

    var elem = menu.getElem();
    document.body.appendChild(elem);
    elem.addEventListener('select', function(event) {
      alert(event.detail);
    });
  </script>

</body>

</html>
Внимание, инкапсуляция!

Очень важно, что внешний код ставит обработчик на корневой элемент, но не на внутренние элементы меню.

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

Меню для него – «чёрный ящик». Корневой элемент – точка доступа к его функционалу. Событие – не то, которое произошло на ссылке, а «переработанный вариант», интерпретация действия со стороны меню.

Такое правило позволяет нам не опасаться проблем при оптимизации, расширении и даже полной переделке DOM-структуры меню. Коль скоро события и методы сохраняются, внешний код будет работать как прежде.

Ещё раз – внешний код не имеет права залезать внутрь DOM-структуры меню, ставить там обработчики и так далее.

Итого

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

  • Коллбэки – функции, которые передаются «снаружи» при создании компонента, и которые он обязуется вызвать при наступлении событий.
  • События – компонент генерирует их на корневом элементе при помощи dispatchEvent, а внешний код ставит обработчики при помощи addEventListener. Такие события всплывают, если указан флаг bubbles, поэтому можно использовать с ними делегирование.

Задачи

важность: 5

Добавьте событие в голосовалку, созданную в задаче Голосовалка, используя механизм генерации событий на объекте.

Пусть каждое изменение голоса сопровождается событием change со свойством detail, содержащим обновлённое значение:

var voter = new Voter({
  elem: document.getElementById('voter')
});

voter.setVote(5);

document.getElementById('voter').addEventListener('change', function(e) {
  alert( e.detail ); // новое значение голоса
});

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

Результат использования кода выше (планируемый):

Исходный документ возьмите из решения задачи Голосовалка.

важность: 5

Добавьте в решение задачи Компонент: список с выделением событие select.

Оно должно срабатывать при каждом изменении выбора и в свойстве detail содержать список выбранных строк.

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

В качестве исходного кода возьмите решение задачи Компонент: список с выделением.

важность: 5

Напишите свой, самостоятельно оформленный, селект.

Требования:

  • Открытие и закрытие по клику на заголовок.
  • Закрытие селекта происходит при выборе или клике на любое другое место документа, в том числе на другой аналогичный селект.
  • Событие "select" при выборе опции возникает на элементе селекта и всплывает.
  • Значение опции хранится в атрибуте data-value (HTML-структура есть в исходном документе).

Например:

В примере выше два селекта, чтобы можно было проверить процесс открытия-закрытия.

Открыть песочницу для задачи.

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

Альтернатива – события focusin/focusout, т.е. считаем, что пока фокус в селекте – он открыт. С одной стороны, это более мощный способ, он позволяет перемещаться по элементам управления при помощи Tab и корректно обрабатывать уход при помощи клавиатуры.

С другой стороны, это решение не универсально: если выводится alert, то фокус «прыгает» в него, уходя с элемента, а потом возвращается обратно. При этом JavaScript зарегистрирует уход фокуса focusout и возвращение focusin, хотя по смыслу фокус с элемента никуда не уходил, просто был alert.

Побочный эффект – к закрытию и раскрытию (лишнему) элемента управления при таких «ненамеренных» потерях фокуса. Поэтому был выбран onclick.

Решение:

Открыть решение в песочнице.

важность: 5

На основе слайдера из задачи Слайдер-компонент создайте графический компонент, который умеет возвращать/получать значение.

Синтаксис:

var slider = new Slider({
  elem: document.getElementById('slider'),
  max: 100 // слайдер на самой правой позиции соответствует 100
});

Метод setValue устанавливает значение:

slider.setValue(50);

У слайдера должно быть два события: slide при каждом передвижении и change при отпускании мыши (установке значения).

Пример использования:

var sliderElem = document.getElementById('slider');

sliderElem.addEventListener('slide', function(event) {
  document.getElementById('slide').innerHTML = event.detail;
});

sliderElem.addEventListener('change', function(event) {
  document.getElementById('change').innerHTML = event.detail;
});

В действии:

  • Ширина/высота слайдера может быть любой, JS-код это должен учитывать.
  • Центр бегунка должен располагаться в точности над выбранным значением. Например, он должен быть в центре для 50 при max=100.

Исходный документ – возьмите решение задачи Слайдер-компонент.

Для решения этой задачи достаточно создать две функции: valueToPosition будет получать по значению положение бегунка, а positionToValue – наоборот, транслировать текущую координату бегунка в значение.

Как сопоставить позицию слайдера и значение?

Для этого посмотрим крайние значения слайдера. Допустим, размер бегунка 10px.

Раз центр соответствует значению, то крайнее левое значение будет соответствовать центру на 5px, а крайнее правой – центру на 5px от правой границы:

Соответственно, ширина области изменения будет sliderElem.clientWidth - thumbElem.clientWidth. Далее её можно уже поделить на части, количество пикселей на значение будет:

pixelsPerValue = (sliderElem.clientWidth - thumbElem.clientWidth) / max;

Может получиться так, что это значение будет дробным, меньше единицы. Например, если max = 1000, а ширина слайдера 110 (пробег 100), то будет 0.1 пикселя на значение.

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

Крайнее левое значение thumbElem.style.left равно нулю, крайнее правой – как раз ширине доступной области sliderElem.clientWidth - thumbElem.clientWidth. Поэтому можно получить значение слайдера, поделив его на pixelsPerValue:

function positionToValue(left) {
  return Math.round(left / pixelsPerValue);
}

function valueToPosition(value) {
  return pixelsPerValue * value;
}

Открыть решение в песочнице.

Карта учебника

Комментарии

перед тем как писать…
  • Приветствуются комментарии, содержащие дополнения и вопросы по статье, и ответы на них.
  • Для одной строки кода используйте тег <code>, для нескольких строк кода — тег <pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)
  • Если что-то непонятно в статье — пишите, что именно и с какого места.