Графические компоненты

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

Здесь мы сосредоточимся на графических компонентах, которые также называют «виджетами».

В браузерах есть встроенные виджеты, например <select>, <input> и другие элементы, о которых мы даже и не думаем, «как они работают». Они «просто работают»: показывают значение, вызывают события…

Наша задача – сделать то же самое на уровне выше. Мы будем создавать объекты, которые генерируют меню, диалог или другие компоненты интерфейса, и дают возможность удобно работать с ними.

Виджет Menu

Мы начнём работу с виджета, который предусматривает уже готовую разметку.

То есть, в нужном месте HTML находится DOM-структура для меню – заголовок и список опций:

<div class="menu" id="sweets-menu">
  <span class="title">Сладости</span>
  <ul>
    <li>Торт</li>
    <li>Пончик</li>
    <li>...</li>
  </ul>
</div>

Далее она может дополняться, изменяться, но в начале – она такая.

Обратим внимание на важные соглашения виджета:

Вся разметка заключена в корневой элемент <div class="menu" id="sweets-menu">.

Это очень удобно: вынул этот элемент из DOM – нет меню, вставил в другое место – переместил меню. Кроме того, можно удобно искать подэлементы.

Внутри корневого элемента – только классы, не id.

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

Исключение – корневой элемент. В данном случае мы предполагаем, что данное конкретное «меню сладостей» в документе только одно, поэтому даём ему id.

Класс виджета

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

function Menu(options) {
  var elem = options.elem;

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

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

}

// использование
var menu = new Menu({
  elem: document.getElementById('sweets-menu')
});

Меню:

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

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

  elem.onclick = function(event) {
    if (event.target.closest('.title')) {
      elem.classList.toggle('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="style.css">
  <script src="https://cdn.polyfill.io/v1/polyfill.js?features=Element.prototype.closest"></script>
  <script src="menu.js"></script>
</head>

<body>

  <div id="sweets-menu" class="menu">
    <span class="title">Сладости</span>
    <ul>
      <li>Торт</li>
      <li>Пончик</li>
      <li>Пирожное</li>
      <li>Шоколадка</li>
      <li>Мороженое</li>
    </ul>
  </div>

  <script>
    var menu = new Menu({
      elem: document.getElementById('sweets-menu')
    });
  </script>

</body>

</html>

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

У конструктора только один аргумент – объект options.

Это удобно, так как у графических компонентов обычно много настроек, большинство из которых имеют разумные значения «по умолчанию». Если передавать аргументы через запятую – их будет слишком много.

Обработчики назначаются через делегирование.

Вместо того, чтобы найти элемент и поставить обработчик на него:

var titleElem = elem.querySelector('.title');

titleElem.onclick = function() {
  elem.classList.toggle('open');
}

…Мы ставим обработчик на корневой elem и используем делегирование:

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

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

В этот код лучше добавить дополнительную проверку на то, что найденный .title находится внутри elem:

elem.onclick = function(event) {
  var closestTitle = event.target.closest('.title');
  if (closestTitle && elem.contains(closestTitle)) {
    elem.classList.toggle('open');
  }
};

Публичные методы

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

Рассмотрим повнимательнее этот фрагмент:

if (event.target.closest('.title')) {
  elem.classList.toggle('open');
}

Здесь в обработчике события сразу код работы с элементом. Пока одна строка – всё понятно, но если их будет много, то при чтении понадобится долго и упорно вникать: «А что же, всё-таки, такое делается при клике?»

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

function Menu(options) {
  var elem = options.elem;

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

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

  function toggle() {
    elem.classList.toggle('open');
  }

  this.toggle = toggle;
}

Теперь метод toggle можно использовать и снаружи:

var menu = new Menu(...);
menu.toggle();

Генерация DOM-элемента

До этого момента меню «оживляло» уже существующий HTML.

Но далеко не всегда в HTML уже есть готовая разметка. В сложных интерфейсах намного чаще её нет, а есть данные, на основе которых компонент генерирует разметку.

В случае меню, данные – это набор пунктов меню, которые передаются конструктору.

Для генерации DOM добавим меню три метода:

  • render() – генерирует корневой DOM-элемент и заголовок меню.
  • renderItems() – генерирует DOM для списка опций ul/li.
  • getElem() – возвращает DOM-элемент меню, при необходимости запуская генерацию, публичный метод.

Функция генерации корневого элемента с заголовком render отделена от генерации списка renderItems. Почему – будет видно чуть далее.

Новый способ использования меню:

// создать объект меню с данным заголовком и опциями
var menu = new Menu({
  title: "Сладости",
  items: [
    "Торт",
    "Пончик",
    "Пирожное",
    "Шоколадка",
    "Мороженое"
  ]
});

// получить сгенерированный DOM-элемент меню
var elem = menu.getElem();

// вставить меню в нужное место страницы
document.body.appendChild(elem);

Код Menu с новыми методами:

function Menu(options) {
  var elem;

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

  function render() {
    elem = document.createElement('div');
    elem.className = "menu";

    var titleElem = document.createElement('span');
    elem.appendChild(titleElem);
    titleElem.className = "title";
    titleElem.textContent = options.title;

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

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

  }

  function renderItems() {
    var items = options.items || [];
    var list = document.createElement('ul');
    items.forEach(function(item) {
      var li = document.createElement('li');
      li.textContent = item;
      list.appendChild(li);
    });
    elem.appendChild(list);
  }

  function open() {
    if (!elem.querySelector('ul')) {
      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;
}

Отметим некоторые особенности этого кода.

Обработчики отделяются от реальных действий.

В обработчике onclick мы «ловим» событие и выясняем, что именно произошло. Возможно, нужно проверить event.target, координаты, клавиши-модификаторы, и т.п. Это всё можно делать здесь же.

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

Здесь есть ряд важных плюсов:

  • Обработчик onclick не «распухает» чрезмерно.
  • Код гораздо лучше читается.
  • Метод можно повторно использовать, в том числе и сделать публичным, как в коде выше.
Генерация DOM, по возможности, должна быть «ленивой».

Мы стараемся откладывать работу до момента, когда она реально нужна. Например, когда new Menu создаётся, то переменная elem лишь объявляется. DOM-дерево будет сгенерировано только при вызове getElem() функцией render().

Более того! Пока меню закрыто – достаточно заголовка. Кроме того, возможно, посетитель вообще никогда не раскроет это меню, так зачем генерировать список раньше времени? А при первом открытиии open() вызовет функцию renderItems(), которая специально для этого выделена отдельно от render().

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

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

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

В действии:

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

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

  function render() {
    elem = document.createElement('div');
    elem.className = "menu";

    var titleElem = document.createElement('span');
    elem.appendChild(titleElem);
    titleElem.className = "title";
    titleElem.textContent = options.title;

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

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

  }

  function renderItems() {
    var items = options.items || [];
    var list = document.createElement('ul');
    items.forEach(function(item) {
      var li = document.createElement('li');
      li.textContent = item;
      list.appendChild(li);
    });
    elem.appendChild(list);
  }

  function open() {
    if (!elem.querySelector('ul')) {
      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="style.css">
  <script src="https://cdn.polyfill.io/v1/polyfill.js?features=Element.prototype.closest"></script>
  <script src="menu.js"></script>
</head>

<body>

  <button onclick="menu.toggle()">menu.toggle()</button>
  <button onclick="menu.open()">menu.open()</button>
  <button onclick="menu.close()">menu.close()</button>

  <script>
    var menu = new Menu({
      title: "Сладости",
      items: [
        "Торт",
        "Пончик",
        "Пирожное",
        "Шоколадка",
        "Мороженое"
      ]
    });

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

</body>

</html>

Итого

Мы начали создавать компонент «с чистого листа», пока без дополнительных библиотек.

Основные принципы:

  • Виджет – это объект, который либо контролирует готовое дерево DOM, либо создаёт своё.
  • В конструктор виджета передаётся объект аргументов options.
  • Виджет при необходимости создаёт элемент или «оживляет» готовый. Внутри в разметке не используются id.
  • Обработчики назначаются через делегирование – для производительности и упрощения виджета.
  • Обработчики событий вызывают соответствующий метод, не пытаются делать всё сами.
  • При инициализации, если существенный участок работы можно отложить до реального задействования виджета – откладываем его.

Задачи

важность: 5

Создайте компонент «Часы» (Clock).

Интерфейс:

var clock = new Clock({
  elem: элемент
});

clock.start(); // старт
clock.stop(); // стоп

Остальные методы, если нужны, должны быть приватными.

При нажатии на alert часы должны приостанавливаться, а затем продолжать идти с правильным временем.

Пример результата:

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

важность: 5

Перепишите слайдер в виде компонента:

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

важность: 5

Перепишите решение задачи Список с выделением в виде компонента.

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

Использование:

var listSelect = new ListSelect({
  elem: document.querySelector('ul')
});
// listSelect.getSelected()

Демо:

важность: 5

Напишите функцию-конструктор new Voter(options) для голосовалки. Она должна получать элемент в options.elem, в следующей разметке:

<div id="voter" class="voter">
  <span class="down">—</span>
  <span class="vote">0</span>
  <span class="up">+</span>
</div>

По клику на + и число должно увеличиваться или уменьшаться.

Публичный метод voter.setVote(vote) должен устанавливать текущее число – значение голоса.

Все остальные методы и свойства пусть будут приватными.

Результат:

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

важность: 5

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

Внешний код, использующий класс Voter, не должен измениться.

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

важность: 5

Создайте функцию-конструктор StepVoter, которая наследует от голосовалки, созданной в задаче Голосовалка в прототипном стиле ООП и добавляет голосовалке опцию options.step, которая задаёт «шаг» голоса.

Пример:

var voter = new StepVoter({
  elem: document.getElementById('voter'),
  step: 2 // увеличивать/уменьшать сразу на 2 пункта
});

Результат:

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

В качестве исходного кода используйте решение задачи Голосовалка в прототипном стиле ООП.

P.S. Код voter.js изменять нельзя, нужно не переписать Voter, а отнаследовать от него.

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

Комментарии

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