Привет! Речь пойдет про горячие клавиши в WEBAPI + JavaScript, рассмотрим их способы организации и проблемы, возникающие прежде всего в больших приложениях.


Рассмотрим способы обработки клавиш на конкретной задаче.


“Задача”


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


ParentController в котором есть два компонента со своими состояниями и стейтом. Controller1 и элемент, использующий CTRL+SHIFT+F для поиска по сайту, и Controller2 со своим DOM элементом, который является локальной областью, при наличии которой поиск осуществляется внутри нее. При этом они одновременно могут быть на экране. Ниже приведено несколько способов решения данной проблемы.


1. “KeyboardEvent и его ручная обработка”


Объекты KeyboardEvent описывают работу пользователя с клавиатурой. Каждое событие описывает клавишу; тип события (keydown, keypress или keyup) определяет произведённый тип действия.


Звучит здорово не правда ли? Давайте взглянем поближе.
Рассмотрим перехват нажатия клавиш CTRL+SHIFT+F, обычно соответствующий вызову глобального поиска.


element.addEventListener('keypress', (event) => {
  const keyName = event.key;

  // Приведение к нижнему регистру имени клавиши обязательно
  // т.к. при нажатии вместе с SHIFT оно будет в верхнем регистре
  if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 't') {
      alert('CTRL+SHIFT+T pressed');
  }
});

Теперь, применимо к нашей задаче можно поступить двумя способами (для примера)


Осуществлять перехват клавиш в контроллерах 1 и 2 по отдельности


Это приведет к тому, что в зависимости от порядка в DOM вам может потребоваться useCapture чтобы гарантировать очередность обработки Controller2 затем Controller1. Так вы получаете изолированную логику, но если приложение сложное и таких контроллеров много — это решение не годится т.к. некоторые могут быть одновременно на экране и у них может быть свой строгий порядок обработки, который не зависит от их положения в DOM дереве. (см. bubbling and capturing)


Осуществлять перехват клавиш в CommonController


Альтернативным решением может быть обработка нажатий в общем родительском контроллере, который точно знает когда показать свои дочерние элементы, управляемые первым и вторым контроллерами. Это при увеличении дочерних контроллеров не вызовет трудностей с отловом ивентов и принятия решений какому контроллеру обработать клавиши. Однако, будет другая проблема — в родительском контроллере появляется толстый if, который обрабатывает все возможные случаи. Для больших приложений это решение не годится, т.к. в определенный момент может появится еще один Controller который не является дочерним для ParentController тогда придется выносить обработчик на уровень выше, до их общего родителя и так далее… Пока рано или поздно один из контроллеров не начнет слишком много знать об элементах внутри него.



На самом деле всего 80% браузеров умеют работать с KeboardEvent.key, во всех остальных вы должны будете оперировать KeboardEvent.keyCode: Number кодами клавиш. Что сильно осложняет жизнь. Тут то и стоит перейти к описанию минусов данного подхода.


Минусы:


  • Не совсем удобная организация кода, требуется наличие карты кодов символов и их текстового эквивалента и прочие утилиты снижающие количество кода в обработчиках.
  • 80% Поддержки браузерами работы с символами без использования их кодов — все еще мало.
  • Перекрытие с помощью useCapture одних обработчиков другими.
  • При наличии перехватов с useCapture и вложенных элементов с такими же обработчиками
    дебаггинг затруднен.
  • Плохая масштабируемость.

Но зато нативно, нет лишних зависимостей и библиотек


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


2. “Использование библиотеки HotKeys


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


// обработчик клавиш
hotkeys('ctrl+shift+f', function(event, handler){
  alert('CTRL+SHIFT+T pressed');
});

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


Часто происходит именно так, что элемент который должен перехватить обработку появляется позже. В таком случае мы смело можем разнести логику хэндлинга нажатий в каждый из контроллеров. А прочие фишки типа скоупа, помогут нам отделить один поток нажатий от другого. Но в случае, когда порядок появления на экране ? приоритету обработки нажатий — возникают те же проблемы, что и у нативных eventListener's. Придется выносить все в общий родительский контроллер.


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


Итого плюсы:


  • Скоуп позволяет отделить потоки.
  • Синтаксис понятный и короткий.
  • Порядок определяет появление элемента, а не положение в DOM.
  • Размер и отсутствие зависимостей.

Минусы:


  • Можно одновременно обрабатывать только один скоуп
  • Дебаггинг все так же трудный из-за вызовов функций в цикле может быть неизвестно на каком обработчике потерялся обработался ивент
  • Утверждение что ивент обработан если он имеет флаг defaultPrevented и его распространение прервано — не верно.
  • Глобальные функции вызова регистрации и отписки от событий

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


3. “Использование библиотеки stack-shortcuts


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


Какие задачи решались при создании?


  • Реактивный принцип работы
  • Простой дебаггинг обработчиков
  • Однозначное состояние обработки ивента
  • Кросплатформенность
  • Удобство импорта и отсутствие глобальных функций
  • Отсутствие прямого обращения к window при подключении
  • Отсутствие необходимости вызывать preventDefault или stopPropagation

// подписка
this.shortcuts = shortcuts({
    'CMD+SHIFT+F': function (event, next) {
      alert('CMD+SHIFT+F pressed');
    }
});

// утилизация
this.shortcuts.destroy();

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


  • Привязка к DOM все также отсутствует (за исключением одного листенера) и стэк обработчиков наполняется в зависимости от порядка их регистрации.
  • От использование scope для изоляции сразу отказались т.к. не ясно какие задачи он решает и кажется, что лишь усложняет архитектуру.
  • Дебаггинг и функция next об этом пожалуй стоит подробнее...
  • Мутации в ивентах данных которые он несет в event.detail

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


next() — Вызов функции означает, что ивент не обработался и будет передан в следующий обработчик. Довольно знакомый контракт, который применяется в промежуточных обработчиках или мидлварах в express. Так вы всегда будете знать обработан ли ивент или просто мутирован или "учтен".



Так выглядит стэк вызовов если поставить брейкпоинт в одном из них.


Ну и про минусы:


  • Пока нет тайпингов для TypeScript
  • Нет скоупов — сплитскрин сайт не сделать)
  • Одно сочетание при регистрации (такого нет CMD+F,CMD+V,T запятую не поймет)

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


  1. vintage
    04.02.2019 12:27

    Поправил ваш исходный код, сделав всю остальную статью бессмысленной:


    element.addEventListener( 'keydown', event => {
    
      if( event.defaultPrevented ) return
    
      const KEY = event.key.toUpperCase()
      const CTRL = event.ctrlKey
      const SHIFT = event.shiftKey
    
      switch( KEY ) {
        case CTRL && SHIFT && 'F' : this.onSearch() ; break
        case CTRL && SHIFT && 'Z' : this.onRedo() ; break
        case CTRL && 'Z' : this.onUndo() ; break
        default : return
      }
    
      event.preventDefault()
    
    } )


    1. BusinessDuck Автор
      04.02.2019 23:26

      Не уверен, что вы поняли мой посыл. Но может это конечно моя вина (первая статья все таки)… Обратите внимание на строки

      Утверждение что event обработан если он имеет флаг defaultPrevented и его распространение прервано — не верно.

      В качестве пояснения, приведу пример. Перехватить CTRL+F без preventDefault не получится. Таким образом if( event.defaultPrevented ) return не гарантирует состояния обработанности. — Это первое.

      До картинки вы похоже не дочитали про кросбраузерность использоваться символьного представления клавиши… — это второе…
      Ну и пожалуй в коде какие-то проблемы…

      switch( KEY ) {
          case CTRL && SHIFT && 'F' : this.onSearch() ; break
          case CTRL && SHIFT && 'Z' : this.onRedo() ; break
          case CTRL && 'Z' : this.onUndo() ; break
          default : return
        }


      И даже если ваш приведенный пример кода правильно написать — простым в дебаге и понятным, от этого он не становится. Не говоря уже про рефакторинг такого и масштабируемость


      1. vintage
        05.02.2019 22:58

        Перехватить CTRL+F без preventDefault не получится. Таким образом if( event.defaultPrevented ) return не гарантирует состояния обработанности.

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


        про кросбраузерность использоваться символьного представления клавиши

        Для этого есть полифилы.


        Ну и пожалуй в коде какие-то проблемы…

        Нет там никаких проблем, не выдумывайте.


        простым в дебаге и понятным, от этого он не становится. Не говоря уже про рефакторинг такого и масштабируемость

        Что вам не понятно в этом тривиальном коде и что вызывает у вас сложности с дебагом, рефакторингом и масштабируемостью?