Пассажир в вагоне держит телефон одной рукой и листает ленту большим пальцем, дотягиваясь лишь до нижней половины экрана. Малейшая задержка отклика считывается телом как поломка: палец уже уехал, а карточка осталась на месте. События касания - это тот слой браузера, где интерфейс либо становится продолжением руки, либо мешает ей. У слоя есть своя история с знаменитой паузой в 300 миллисекунд, свои ловушки с блокировкой прокрутки и два параллельных API - touch events и pointer events, между которыми разработчик обязан сделать осознанный выбор. Разбор ниже отвечает на практические вопросы: что именно прилетает в обработчик, как не воевать с прокруткой, почему preventDefault стоит дорого и как написать свайп, который не дёргается.

Устройство событий касания от touchstart до touchcancel

Каждое прикосновение проходит через четыре события. touchstart зажигается в момент контакта, touchmove летит потоком, пока палец скользит, touchend приходит при отрыве, а touchcancel срабатывает, когда жест перехватывает сама система: началась прокрутка, пришёл звонок или палец уехал за пределы окна. Внутри обработчика лежит не одна точка, а списки: событие касания изначально проектировалось под несколько пальцев одновременно.

Историческая деталь, без которой не ясна половина легаси-кода: браузеры долго держали паузу около 300 миллисекунд между тапом и синтетическим событием click, ожидая повторного касания для масштабирования двойным тапом. Chrome убрал задержку ещё в 2014 году при корректном мета-теге viewport, а остатки проблемы лечит свойство touch-action. Отсюда два практических следствия: писать логику на click там, где достаточно клика, и не таскать в проект библиотеки-компенсаторы вроде FastClick, чья эпоха закончилась вместе с самой задержкой.

Списки touches, targetTouches и changedTouches на практике

У события три списка точек, и путают их чаще всего. touches перечисляет все пальцы, которые сейчас касаются экрана, targetTouches - только те, что начали движение на текущем элементе, changedTouches - точки, изменившиеся в этом конкретном событии. Для отрыва пальца правильный список один - changedTouches. Каждая точка несёт собственный identifier, уникальный в пределах касания, и координаты clientX, clientY относительно окна плюс pageX, pageY с учётом прокрутки.

Отслеживание пальцев в рисовалке строится на словаре, где ключом служит identifier:

const active = new Map();

canvas.addEventListener("touchstart", (event) => {
  for (const touch of event.changedTouches) {
    active.set(touch.identifier, {
      x: touch.clientX,
      y: touch.clientY
    });
  }
}, { passive: true });

Палец номер два приходит отдельным touchstart с собственным identifier, уходит - отдельным touchend, и словарь не путает следы. Игнорировать touchcancel опасно: если система перехватила жест, а след пальца остался в памяти, рисовалка навсегда считает его живым.

Свойство touch-action вместо борьбы с прокруткой

Прокрутка - жест владельца браузера, и спорить с ней в обработчиках - худшая из стратегий. Свойство touch-action говорит браузеру заранее, какие движения он может обрабатывать сам, не спрашивая скрипт: pan-x разрешает горизонтальное панорамирование, pan-y вертикальное, pinch-zoom масштабирование щипком, none забирает всё управление скрипту, а auto отдаёт решению браузера.

.carousel {
  touch-action: pan-y;
}

.map-canvas {
  touch-action: none;
}

.gallery {
  touch-action: pinch-zoom;
}

.button {
  touch-action: manipulation;
}

Значение manipulation - алиас комбинации pan-x, pan-y и pinch-zoom с одной важной добавкой: оно отключает масштабирование двойным тапом, а вместе с ним и потребность задерживать click. Горизонтальная карусель с pan-y отдаёт вертикальную прокрутку браузеру целиком: страница листается свободно, а карусель реагирует только на горизонтальное движение пальца. Карта со значением none обязана сама обрабатывать панорамирование и масштаб, иначе жест просто умрёт на месте. Важно и timing: touch-action читается до начала жеста, менять его в момент касания бесполезно.

Механика работает по цепочке предков: браузер собирает ограничения со всех элементов от точки касания до корня и применяет самое строгое из них. Поэтому запрет на body глушит прокрутку во всех внутренних блоках, а карусель с pan-y внутри контейнера со значением none жестов не дождётся.

Пассивные слушатели и цена вызова preventDefault

Вызов preventDefault внутри обработчика касания останавливает прокрутку. Проблема в том, что браузер не знает заранее, вызовет ли слушатель этот метод, и вынужден ждать отработки скрипта, прежде чем двинуть страницу. Лишние миллисекунды на каждый жест - плата за неопределённость.

Решение пришло в Chrome 56 в 2017 году: слушатели touchstart и touchmove на уровне документа по умолчанию стали пассивными. Браузер получает обещание, что preventDefault вызван не будет, и прокручивает сразу. Замеры самой команды звучали внушительно: один процент самых медленных прокруток укладывался примерно в 400 миллисекунд до интервенции и в 250 после неё, выигрыш около 38 процентов. Побочный эффект знаком каждому по консоли: попытка вызвать preventDefault в пассивном слушателе оставляет предупреждение "Unable to preventDefault inside passive event listener invocation" и не работает.

Флаг passive ставится явно в обоих направлениях:

window.addEventListener("touchmove", onMove, { passive: false });

list.addEventListener("touchstart", () => {
  console.log("тап без ожидания");
}, { passive: true });

Значение false - честное признание, что жест будет перехвачен: карусель, которую тянут за палец, действительно нуждается в отмене прокрутки. Значение true - по умолчанию для всего остального.

Отдельная забота - анимация отклика. Движение элемента за пальцем рисуют через transform и requestAnimationFrame: transform обрабатывается композитором без пересчёта раскладки, тогда как смена left или top заставляет страницу заново считать геометрию. При бюджете кадра в 16.7 миллисекунды на 60 кадров в секунду каждый лишний пересчёт стоит доли плавности. Подсказка will-change: transform заранее выделяет элементу отдельный слой, но слоями не разбрасываются: каждый съедает память.

Pointer events объединяют мышь, стилус и палец

Параллельная модель pointer events закрывает главный дефект событийного зоопарка: отдельные ветки кода для мыши и касаний. События pointerdown, pointermove, pointerup и pointercancel приходят для любого устройства ввода, а свойство pointerType сообщает его тип: mouse, touch или pen. Указатель несёт уникальный pointerId, размеры контактной площадки width и height, давление pressure и наклон стилуса tiltX и tiltY.

Для рисования важны два механизма. Захват указателя setPointerCapture привязывает поток событий к элементу: палец может уехать за пределы канвы, но события продолжат прилетать ей. Метод getCoalescedEvents выгружает пакет промежуточных точек, которые браузер сжал ради экономии: на высокой частоте обновления между двумя кадрами проходит десяток микродвижений, и без них кривая получается ломаной.

canvas.addEventListener("pointerdown", (event) => {
  canvas.setPointerCapture(event.pointerId);
  strokes.set(event.pointerId, []);
});

canvas.addEventListener("pointermove", (event) => {
  const stroke = strokes.get(event.pointerId);
  if (!stroke) return;
  for (const point of event.getCoalescedEvents()) {
    stroke.push({ x: point.clientX, y: point.clientY });
  }
});

В графических редакторах pointerType решает задачу отсечения ладони: касание стилусом рисует, касание ладонью отсеивается, и пользователю не нужно переключать режимы вручную. Плюс модели в универсальности: одно дерево обработчиков вместо трёх. Touch events остаются каноничным инструментом для мульти-тача с несколькими пальцами, но базовая совместимость pointer events давно закрыта во всех движках, и новый код пишут именно на них.

Код распознавания свайпа и щипка на реальных примерах

Свайп узнаётся по трём условиям: горизонтальное смещение больше порога, оно больше вертикального, а жест уложился во временной коридор. Порог берут в 40-50 пикселей, чтобы отличить намеренное движение от дрожи пальца, время - до полусекунды:

const carousel = document.querySelector(".carousel");
let startX = 0;
let startY = 0;
let startT = 0;

carousel.addEventListener("touchstart", (event) => {
  startX = event.changedTouches[0].clientX;
  startY = event.changedTouches[0].clientY;
  startT = event.timeStamp;
}, { passive: true });

carousel.addEventListener("touchend", (event) => {
  const dx = event.changedTouches[0].clientX - startX;
  const dy = event.changedTouches[0].clientY - startY;
  const dt = event.timeStamp - startT;
  if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) && dt < 500) {
    carousel.scrollBy({
      left: dx > 0 ? -320 : 320,
      behavior: "smooth"
    });
  }
}, { passive: true });

Длинное нажатие собирают из таймера и порога дрожи: таймер на 450 миллисекунд стартует при касании и отменяется, как только палец сместился сильнее чем на 10 пикселей.

let pressTimer = 0;
let origin = null;

card.addEventListener("touchstart", (event) => {
  const t = event.changedTouches[0];
  origin = { x: t.clientX, y: t.clientY };
  pressTimer = setTimeout(() => showMenu(card), 450);
}, { passive: true });

card.addEventListener("touchmove", (event) => {
  const t = event.touches[0];
  const drift = Math.hypot(t.clientX - origin.x, t.clientY - origin.y);
  if (drift > 10) {
    clearTimeout(pressTimer);
  }
}, { passive: true });

card.addEventListener("touchend", () => {
  clearTimeout(pressTimer);
}, { passive: true });

Щипок считают по расстоянию между двумя точками: отношение текущего расстояния к стартовому даёт коэффициент масштаба.

function span(a, b) {
  return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
}

map.addEventListener("touchmove", (event) => {
  if (event.touches.length === 2) {
    const current = span(event.touches[0], event.touches[1]);
    const scale = current / startSpan;
    map.style.transform = "scale(" + scale + ")";
  }
}, { passive: false });

Движение карточки за пальцем рисуют через transform и requestAnimationFrame, а не через смену left: композиция слоёв не будет перерасчёт раскладки, и 60 кадров в секунду остаются достижимыми на среднем устройстве.

Мелочи интерфейса от overscroll-behavior до отклика кнопок

Пограничная прокрутка заслуживает собственного свойства. overscroll-behavior со значениями auto, contain и none решает, что происходит на краю прокручиваемого блока: поведение по умолчанию, остановка эффекта резинки или полный запрет передачи жеста родителю. Значение contain на выдвижной панели спасает от ситуации, когда список доскроллили до конца и потянули панель наверх. Значение none на корневом элемента гасит обновление страницы потягиванием, которое так любят случайно вызывать в мобильных браузерах. Свойство появилось в Chrome 63, а поддержка в Safari пришла только в 2022 году.

.drawer {
  overscroll-behavior-y: contain;
}

html {
  overscroll-behavior: none;
}

Кнопки на сенсорном экране живут по другим правилам, чем на десктопе. Наведение недоступно, поэтому селектор :hover не должен быть единственным индикатором состояния, а подсветку системного тапа убирают или красят в нейтральный цвет через -webkit-tap-highlight-color. Правило цели касания из WCAG 2.2, принятое в октябре 2023 года, требует минимум 24 на 24 CSS-пикселя для интерактивных зон. Отдельная дисциплина - состояние :active: палец закрывает кнопку, и отклик обязан быть мгновенным визуально, а не только функционально.

Финальная проверка перед релизом собирается в короткий список:

  1. объявить touch-action для каждого интерактивного блока вместо перехвата прокрутки в скрипте;
  2. регистрировать слушатели касаний с явным флагом passive и отключать его только там, где нужен preventDefault;
  3. писать новую логику на pointer events и держать touch events только под мульти-тач;
  4. держать интерактивные зоны не меньше 24 на 24 пикселя из WCAG 2.2;
  5. обрабатывать pointercancel и touchcancel как штатные сценарии, а не как ошибку;
  6. проверять тап, свайп и длинное нажатие на живом железе, а не только в эмуляторе с мышью.

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