August 10th, 2021

JavaScript: умная подсказка, начало решения задачи

Начало:
1. JavaScript: умная подсказка, разбор постановки задачи
2. JavaScript: умная подсказка, подключение автоматических тестов
3. JavaScript: умная подсказка, пять автоматических тестов

Продолжаю разбор задачи «"Умная" подсказка» к подразделу 3.2 «Движение мыши: mouseover/out, mouseenter/leave» второй части учебника по JavaScript.

Итак, авторы задачи дали нам набросок класса HoverIntent в файле hoverIntent.js, код которого доступен в песочнице.

У класса HoverIntent есть практически готовый конструктор и четыре заготовки для кода методов: onMouseOver, onMouseOut, onMouseMove и destroy. В эти заготовки нам и следует добавлять свой код.

В конструкторе этого класса методы onMouseOver и onMouseOut привязаны в качестве функций-обработчиков к событиям мыши mouseover и mouseout с помощью метода elem.addEventListener, где elem — целевой HTML-элемент (именно для него будет показана или не показана подсказка).

* * *

Самая простая подсказка реализуется в рамках класса HoverIntent следующим образом. Изменим код двух методов:
onMouseOver(event) {
  this.over();
}
и
onMouseOut(event) {
  this.out();
}

this.over и this.out — это код, передаваемый в объект класса HoverIntent при создании этого объекта. В параметре over должен содержаться код, который будет запускаться в тот момент, когда класс решит, что следует показать подсказку. В параметре out должен содержаться код, который будет запускаться в тот момент, когда класс решит, что следует скрыть подсказку.

После этих двух маленьких дописок подсказка, конечно, будет работать, но, естественно, не пройдет все тесты с положительным результатом (первый и пятый тесты будут пройдены с отрицательным результатом, остальные — с положительным), так как она не будет «умной».

* * *

Первый тест не пройден с положительным результатом потому, что умную подсказку нельзя показывать сразу после возникновения события мыши mouseover, так как еще не измерена скорость движения курсора мыши. Поэтому я решил перенести запуск кода отображения подсказки в метод onMouseMove, а сам этот метод привязать в качестве функции-обработчика к событию мыши mousemove.

Добавляем следующий код в конец конструктора:
elem.addEventListener("mousemove", this.onMouseMove);

Перемещаем вызов кода отображения подсказки this.over в метод onMouseMove:
onMouseOver(event) {
  /* ... */
}

onMouseOut(event) {
  this.out();
}

onMouseMove(event) {
  this.over();
}

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

* * *

Прежде, чем мы сделаем ее «умной», нужно решить несколько второстепенных задач. Рассмотрим работу объекта класса HoverIntent на данном этапе подробнее. Чтобы это сделать, я временно вставил инструкции console.log в ключевые методы класса HoverIntent:
onMouseOver(event) {
  console.log("over");
}

onMouseOut(event) {
  this.out(); console.log("out");
}

onMouseMove(event) {
  this.over(); console.log("показать подсказку");
}

Для разделения сообщений в консоль, относящихся к разным тестам, я вставил в конец кода каждого из первых четырех тестов инструкцию console.log("-----") (напомню, код тестов находится в файле test.js). При этом отмечу, что эту инструкцию следует вставлять не в самый конец кода каждого теста, а перед утверждением (объект assert), так как в случае прохождения теста с отрицательным результатом код после утверждения не будет исполнен.

Откроем тестовую HTML-страницу в браузере и проанализируем сообщения, выведенные в консоль разработчика после отработки пяти автоматических тестов. Обратим внимание для начала только на сообщения «over». После отработки первого теста вывелось одно сообщение «over», так и должно быть. После отработки второго теста вывелось два сообщения «over», после отработки третьего теста вывелось три сообщения «over» и так далее. Так быть не должно, ведь в каждом из тестов генерируется только по одному событию мыши mouseover.

Почему это происходит? Дело в том, что хоть объект класса HoverIntent для каждого теста создается новый, а предыдущий должен быть уничтожен сборщиком мусора, уничтожение старых объектов класса HoverIntent, как я понимаю, не происходит, потому что в браузере сохраняются ссылки на методы каждого из старых объектов, помещенные туда в конструкторах объектов с помощью метода elem.addEventListener. Таким образом, до начала работы второго теста в браузере уже есть метод из первого теста, ждущий появление события мыши mouseover. Второй тест вешает второй обработчик на это событие. Третий тест вешает третий обработчик на это событие. В результате и получается множественная обработка одного события.

Чтобы убрать такое поведение объекта класса HoverIntent, используем метод этого класса destroy (напомню, этот метод этого класса запускается для объекта этого класса после каждого автоматического теста). Внесем изменения в код:
destroy() {
  this.elem.removeEventListener("mouseover", this.onMouseOver);
  this.elem.removeEventListener("mouseout", this.onMouseOut);
  this.elem.removeEventListener("mousemove", this.onMouseMove);
}

Теперь снова откроем тестовую HTML-страницу в браузере и проанализируем сообщения, выведенные в консоль разработчика. В этот раз всё выглядит так, как планировалось.

* * *

Для решения следующей второстепенной задачи включим (раскомментируем) код демонстрационного примера на тестовой HTML-странице (напомню, он запускается после автоматических тестов, для этого этот код передан в функцию setTimeout, которая переносит запуск демонстрационного примера на временную отметку 2000 миллисекунд после момента запуска скриптов для тестовой HTML-страницы).

Откроем тестовую HTML-страницу в браузере, дождемся запуска демонстрационного примера (то есть нужно подождать 2 секунды), заведем курсор мыши на «часы», представленные на тестовой HTML-странице HTML-элементом div с идентификатором elem. После этого будем двигать курсор мыши, не позволяя ему покинуть пределы «часов» (у них желтый фон). Проанализируем сообщения в консоли разработчика.

Если мы двигаем курсором мыши на цифры «часов», переходя с цифр часов на цифры минут и на цифры секунд (не выходя за пределы «часов»), то в консоли разработчика появляются сообщения «over» и «out». Почему это происходит, ведь мы уже завели курсор мыши на целевой HTML-элемент «часы» (самое первое сообщение «over») и не выводим курсор мыши за пределы «часов»? Откуда появляются эти сообщения «over» и «out»?

В текущем подразделе обсуждаемого учебника это было объяснено. Сообщения «over» и «out» в вышеописанном случае появляются при переходе с родительского HTML-элемента на его потомков и обратно (цифры часов, минут и секунд помещены в отдельные HTML-элементы span, являющиеся потомками HTML-элемента div, представляющего часы на тестовой HTML-странице). Тут еще замешано всплытие событий, про которое рассказывалось в подразделе 2.2 «Всплытие и погружение» второй части обсуждаемого учебника.

По идее, нам неинтересны переходы на потомков целевого HTML-элемента и обратно. Значит, мы должны их отбросить. Как это сделать? Для этого я вставил в начало методов onMouseOver и onMouseOut такой код:
if (this.elem.contains(event.relatedTarget)) return;
Этот код работает для обоих вышеуказанных методов.

Для метода onMouseOver вообще, по идее, могут быть такие переходы (с event.relatedTarget на event.target):
1. с null на elem;
2. с родителя (или прародителя и так далее) или соседа на elem;
3. с elem на потомка (или одного из детей потомка и так далее);
4. с потомка (или одного из детей потомка и так далее) на elem;
5. с потомка (или одного из детей потомка и так далее) на потомка (или одного из детей потомка и так далее).

Нам интересны переходы в пунктах 1 и 2 этого списка. Для этих пунктов выражение this.elem.contains(event.relatedTarget) примет значение false, а, следовательно, выполнение метода onMouseOver продолжится.

Для пунктов 3, 4 и 5 выражение this.elem.contains(event.relatedTarget) примет значение true, а, следовательно, выполнение метода onMouseOver завершится, толком не начавшись, что нам и нужно.

Для метода onMouseOut вообще, по идее, могут быть такие переходы (с event.target на event.relatedTarget):
1. с elem на null;
2. с elem на родителя (или прародителя и так далее) или соседа;
3. с потомка (или одного из детей потомка и так далее) на elem;
4. с elem на потомка (или одного из детей потомка и так далее);
5. с потомка (или одного из детей потомка и так далее) на потомка (или одного из детей потомка и так далее).

В этом списке нам, как и в предыдущем списке, интересны пункты 1 и 2, а пункты 3, 4 и 5 необходимо отбросить. Для отфильтровывания нужных пунктов работает тот же код, что и для метода onMouseOver, пояснения идентичны пояснениям для предыдущего списка.

Теперь мы будем иметь дело только с нужными для решения обсуждаемой задачи событиями мыши mouseover и mouseout. События мыши mousemove отфильтровывать не требуется, так как они нам нужны все (движения мыши над потомками целевого HTML-элемента считаем движениями мыши над целевым HTML-элементом).

Демонстрационный пример пока временно опять закомментируем и вернемся к автоматическим тестам.

* * *

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

Чтобы отследить эту ситуацию, я решил создать дополнительное свойство-переменную isShow в объекте класса HoverIntent, которая равна значению true, если подсказка уже показана. Внесем изменения в код (изменения показаны красным цветом):
onMouseOver(event) {
  // не обрабатываем переходы для потомков
  if (this.elem.contains(event.relatedTarget)) return;

  this.isShow = false;
  console.log("over");
}

onMouseOut(event) {
  // не обрабатываем переходы для потомков
  if (this.elem.contains(event.relatedTarget)) return;

  this.out(); console.log("out");
}

onMouseMove(event) {
  // не обрабатываем движения мыши, если подсказка уже показана
  if (this.isShow) return;

  this.over(); console.log("показать подсказку");
  this.isShow = true;
}

* * *

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

Чтобы это учесть, внесем изменения в код метода onMouseOut (изменения показаны красным цветом):
onMouseOut(event) {
  // не обрабатываем переходы для потомков
  if (this.elem.contains(event.relatedTarget)) return;

  // скроем подсказку, если она была показана
  if (this.isShow) this.out(); console.log("out");
}

В песочнице к обсуждаемой задаче в коде тестовой HTML-страницы подсказка уже существует, она представлена скрытым HTML-элементом div с идентификатором tooltip. Для отображения и сокрытия такой подсказки достаточно менять свойство hidden HTML-элемента, представляющего подсказку.

Но можно, к примеру, убрать подсказку (представляющий ее HTML-элемент) из кода тестовой HTML-страницы, а отображение и сокрытие подсказки сделать так (возможные изменения в коде демонстрационного примера):
let tooltip;

setTimeout(function() {
  new HoverIntent({
    elem,
    over() {
      // tooltip.hidden = false;
      tooltip = document.createElement('div');
      tooltip.className = "tooltip";
      tooltip.innerHTML = "Подсказка";

      tooltip.style.left = elem.getBoundingClientRect().left + pageXOffset + 'px';
      tooltip.style.top = elem.getBoundingClientRect().bottom + pageYOffset + 5 + 'px';
      document.body.append(tooltip);
    },
    out() {
      // tooltip.hidden = true;
      tooltip.remove();
    }
  });
}, 2000);

Вот тут видна четкая зависимость между кодом функции over и кодом функции out. Если код функции over не будет запущен, а при этом код функции out будет запущен, получим ошибку. Почему? Инструкция tooltip.remove() предполагает, что tooltip является объектом, представляющим HTML-элемент. А если код функции over не будет запущен, то переменная tooltip будет содержать значение undefined, а не нужный объект. У значения undefined отсутствуют методы.

Конечно, в этом конкретном случае связь между кодом функции over и кодом функции out можно убрать, вынеся создание HTML-элемента за пределы функции over, как показано в тексте постановки задачи.

Однако, проверку if (this.isShow), думаю, всё-таки правильнее будет оставить. Это сделает наш класс HoverIntent более надежным.

Продолжение следует...