Пассажир в вагоне держит телефон одной рукой и листает ленту большим пальцем, дотягиваясь лишь до нижней половины экрана. Малейшая задержка отклика считывается телом как поломка: палец уже уехал, а карточка осталась на месте. События касания - это тот слой браузера, где интерфейс либо становится продолжением руки, либо мешает ей. У слоя есть своя история с знаменитой паузой в 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: палец закрывает кнопку, и отклик обязан быть мгновенным визуально, а не только функционально.
Финальная проверка перед релизом собирается в короткий список:
- объявить touch-action для каждого интерактивного блока вместо перехвата прокрутки в скрипте;
- регистрировать слушатели касаний с явным флагом passive и отключать его только там, где нужен preventDefault;
- писать новую логику на pointer events и держать touch events только под мульти-тач;
- держать интерактивные зоны не меньше 24 на 24 пикселя из WCAG 2.2;
- обрабатывать pointercancel и touchcancel как штатные сценарии, а не как ошибку;
- проверять тап, свайп и длинное нажатие на живом железе, а не только в эмуляторе с мышью.
События касания - редкий слой веба, где идеальный код почти невидим: пользователь чувствует интерфейс рукой, а не глазами. Отсюда и главный критерий качества: жесты работают так, что о них нечего рассказать. А рассказать обычно есть что про те приложения, где палец скользит по стеклу вхолостую, а страница слушает мышиные события, которые на телефоне приходят только после задержки.