Недавно, работая с компонентом ввода номера телефона в форме регистрации, я столкнулся с весьма неочевидной особенностью работы различных обработчиков событий. Связано это непосредственно с onChange
, onPaste
и onInput
. Мне пришлось провести достаточно глубокий ресерч, чтобы разобраться в особенностях, которые я встретил. Начнем по порядку.
Кейсы пользовательского взаимодействия
Для начала разберемся, что же это был за инпут (потому что для обычного использования инпута в базовых ситуациях и видах эта статья вряд ли будет полезна). Инпут был приблизительно следующего формата:
Особенности инпута:
Вводить можно только номер телефона
Используется маскирование
Код страны выбирается с помощью селекта слева и недоступен для пользовательского редактирования
При попытке ввода символов необходимо попытаться определить, к какой стране относится номер
При попытке вставки из буфера обмена устройства также необходимо попытаться определить страну и обрезать ненужные символы (+, код страны и все остальное кроме цифр)
Необходимо поддержать возможность вставки из автозаполнения (браузерных садджестов). Самое важное, почему и пишется теперь эта статья.
Проблема
Небольшой гуглеж позволил мне понять, что правильно обработать данные из автозаполнения дает возможность обработчик onInput
. Как ни странно, onPaste
нам не подойдет, потому что это лишь вставка из буфера обмена (да, они отличаются), а onChange
взаимодействует лишь с вводом с клавиатуры.
Добавив событие onInput
, прокинув туда логику по определению страны и обрезке ненужных символов и протестировав в одном из браузеров, я довольный пошел создавать ПР c решенной задачей в одну строчку. Как бы ни так, в Google Chrome, как оказалось, это не срабатывает. Вставка происходит, однако ненужные символы не обрезаются. Я начал копать причину.
Решение
Различие между onChange и onInput в React
Как оказалось, даже в документации к React и в нескольких issues на гитхабе репозитория самого React, говорится, что onInput
:
более предпочтителен
вызывает после себя
onChange
Вот в чем проблема. Моя логика внутри onInput
срабатывала. Только после этого вызывался onChange
и делал обычную вставку текста из садджеста автозаполнения. Накинем немного теории чисто для контекста:
onChange
в React:
React обрабатывает событие
onChange
как обертку над нативным событиемinput
. Оно срабатывает каждый раз, когда значение поля ввода изменяется, включая ввод с клавиатуры, вставку текста (из буфера обмена или автозаполнения), а также изменения через скрипты.Отличие от нативного события
change
в браузере заключается в том, что нативное событиеchange
срабатывает только при потере фокуса (blur
), а React-обработчикonChange
срабатывает сразу после изменения значения.
onInput
в React:
onInput
в React напрямую связано с нативным событиемinput
. Оно срабатывает при любом изменении значения, включая ввод текста, вставку, удаление, а также автозаполнение.В отличие от
onChange
,onInput
фокусируется только на самом событии ввода и не добавляет дополнительных абстракций.
Почему onInput вызывает onChange?
Это происходит из-за особенностей работы React. onChange
в React фактически оборачивает событие onInput
для обеспечения кроссбраузерной совместимости. Таким образом, любое срабатывание onInput
автоматически вызывает onChange
. Это не баг, а намеренное поведение React.
Проблема с UX при автозаполнении номера телефона
Когда пользователь вставляет номер телефона через автозаполнение, onInput
действительно срабатывает, а затем триггерит onChange
. Если попытаться обработать обрезку в onInput
, то onChange
может переписать результат.
Я попытался избавиться от этого самым очевидным способом:
queueMicrotask(() => trimPastedValue(e.target.value));
Это само собой сработало, так как теперь вставка обрабатывалась как микротаска, а значит сначала сработает onInput
, а потом моя логика вставки из садджеста. Однако, я получил неожиданный эффект (хотя это весьма логично). Поскольку onInput
- аналог onChange
с некоторыми доработками, то эта же логика вызывалась для обычного ввода с клавиатуры. А так как это теперь микротаска, я не мог нормально ввести некоторые комбинации цифр, которые расчитывались логикой как то, что надо отрезать.
Разделение событий обычного ввода и автозаполнения
Стоит принять тот факт, что придется отказаться от обработчика onChange
. Да, совсем. Оставив только onInput
, мы сможем определить, каким ивентом является событие, которое нам приходит. Как ни странно, в моей конфигурации я добился этого с помощью следующей структуры:
const handlePhoneChange = (e: InputOnInputEvent) => {
if (e.nativeEvent.inputType) { // проверяем только наличие поля
setPhoneNumber(e.target.value); // обычный ввод с клавиатуры
return;
}
pasteValue(e.target.value); // вставка с обрезкой и определением страны
};
Хотя ресерч предлагал мне проверять тот же inputType
на определенные значения. Что-то вроде этого:
if (inputType === "insertFromPaste" || inputType === "insertFromAutoComplete") {
pasteValue(e.target.value);
}
Однако, я не увидел, чтобы при попытке вставки из буфера обмена или автозаполнении в ивент передавался какой-либо inputType
. Этого поля вообще не было в event
.
Выводы
Если вы предполагаете, что в вашем проекте потенциально могут быть использованы инпуты со сложными конфигурациями (маски, обрезка каких-то символов руками и т.д.), то при создании переиспользуемого компонента инпута я настоятельно рекомендую завязываться только на onInput
и не использовать onChange
. Оставляя только onChange
, вы рискуете столкнуться с тем же, что и я и вам придется перелопачивать часть логики попутно разбираясь, почему же эта ерунда не работает так, как нужно. Если вам нужно полностью исключить влияние onChange
, используйте только onInput
с продуманной логикой обработки. Важно помнить, что onChange
всегда сработает после onInput
.