Abortable-валідация — подход, при котором выполняемая проверка может
быть принудительно остановлена до завершения. В контексте
Yup и yupResolver это особенно важно при
асинхронных сценариях:
При работе с React Hook Form и yupResolver
проблема становится особенно заметной: пользователь быстро изменяет
поле, а предыдущая асинхронная валидация всё ещё выполняется. В
результате старый ответ может перезаписать более новый.
Типичный пример:
const schema = yup.object({
username: yup
.string()
.required()
.test(
'username-check',
'Логин уже занят',
async (value) => {
const response = await fetch(`/api/check/${value}`)
const data = await response.json()
return data.available
}
)
})
Если пользователь быстро вводит:
a
ab
abc
abcd
то отправятся четыре запроса.
Сервер может ответить в произвольном порядке:
abcd -> 200ms
ab -> 800ms
abc -> 500ms
a -> 1000ms
Последний пришедший ответ способен испортить актуальное состояние формы.
Race condition возникает, когда старый асинхронный запрос завершился позже нового.
Пример:
1. Проверяем "alex"
2. Пользователь вводит "alexander"
3. Проверка "alexander" успешно завершилась
4. Старый запрос "alex" завершился позже
5. UI показывает ошибку для старого значения
В результате:
Современный JavaScript предоставляет встроенный механизм отмены
запросов — AbortController.
Базовый пример:
const controller = new AbortController()
fetch('/api/users', {
signal: controller.signal
})
controller.abort()
После вызова:
controller.abort()
запрос прерывается.
Каждый AbortController создаёт объект:
controller.signal
Этот объект:
fetch;Пример:
signal.aborted
import * as yup from 'yup'
let controller = null
const schema = yup.object({
username: yup.string().test(
'username-check',
'Логин занят',
async (value) => {
if (controller) {
controller.abort()
}
controller = new AbortController()
try {
const response = await fetch(
`/api/check/${value}`,
{
signal: controller.signal
}
)
const data = await response.json()
return data.available
} catch (error) {
if (error.name === 'AbortError') {
return true
}
throw error
}
}
)
})
При отмене старой проверки:
if (error.name === 'AbortError') {
return true
}
необходимо игнорировать её результат.
Причины:
Если вернуть false, форма покажет несуществующую
ошибку.
Типичная интеграция:
import { yupResolver } from '@hookform/resolvers/yup'
const form = useForm({
resolver: yupResolver(schema)
})
yupResolver полностью поддерживает асинхронные
схемы:
await schema.validate()
Поэтому любые async test() работают автоматически.
Глобальная переменная подходит только для простых сценариев.
Более надёжный вариант — хранить контроллеры по полям.
const controllers = new Map()
function createAbortableValidator(name, callback) {
return async function(value) {
if (controllers.has(name)) {
controllers.get(name).abort()
}
const controller = new AbortController()
controllers.set(name, controller)
try {
return await callback(value, controller.signal)
} catch (error) {
if (error.name === 'AbortError') {
return true
}
throw error
}
}
}
const schema = yup.object({
username: yup.string().test(
'username',
'Логин занят',
createAbortableValidator(
'username',
async (value, signal) => {
const response = await fetch(
`/api/check/${value}`,
{ signal }
)
const data = await response.json()
return data.available
}
)
)
})
Такой подход позволяет:
Например:
username -> свой AbortController
email -> свой AbortController
phone -> свой AbortController
Даже abortable-подход не отменяет факт отправки большого количества запросов.
Дополнительно используется debounce.
function debounce(fn, delay) {
let timeout
return (...args) => {
clearTimeout(timeout)
return new Promise((resolve) => {
timeout = setTimeout(async () => {
resolve(await fn(...args))
}, delay)
})
}
}
const validateUsername = debounce(
createAbortableValidator(
'username',
async (value, signal) => {
const response = await fetch(
`/api/check/${value}`,
{ signal }
)
const data = await response.json()
return data.available
}
),
400
)
const schema = yup.object({
username: yup
.string()
.required()
.min(3)
.test(
'username',
'Логин занят',
validateUsername
)
})
Последовательность:
1. Пользователь вводит символ
2. debounce откладывает запрос
3. Пользователь вводит новый символ
4. Старый timeout удаляется
5. Новый запрос становится актуальным
6. Старый fetch abort()
7. Выполняется только последняя проверка
Внутри test() доступен контекст.
test(
name,
message,
function(value) {
console.log(this)
}
)
Текущие данные формы:
this.parent.email
Имя поля:
console.log(this.path)
Создание кастомной ошибки:
return this.createError({
message: 'Ошибка сервера'
})
.test(
'username',
'Ошибка',
async function(value) {
try {
const response = await fetch(...)
return true
} catch (error) {
if (error.name === 'AbortError') {
return true
}
return this.createError({
message: 'Сервер недоступен'
})
}
}
)
Крайне важно различать:
| Тип | Значение |
|---|---|
| Validation error | Ошибка данных |
| AbortError | Отмена запроса |
| Network error | Ошибка сети |
| Server error | Ошибка API |
catch (error) {
if (error.name === 'AbortError') {
return true
}
if (error instanceof TypeError) {
return this.createError({
message: 'Нет соединения'
})
}
throw error
}
AbortController позволяет реализовывать timeout.
async function fetchWithTimeout(url, options = {}) {
const controller = new AbortController()
const timeout = setTimeout(() => {
controller.abort()
}, 5000)
try {
const response = await fetch(url, {
...options,
signal: controller.signal
})
return response
} finally {
clearTimeout(timeout)
}
}
.test(
'api',
'Ошибка проверки',
async function(value) {
try {
const response = await fetchWithTimeout(
`/api/check/${value}`
)
const data = await response.json()
return data.available
} catch (error) {
if (error.name === 'AbortError') {
return this.createError({
message: 'Таймаут проверки'
})
}
throw error
}
}
)
Если контроллеры не очищать:
const controllers = new Map()
то возможна утечка памяти.
finally {
controllers.delete(name)
}
Полный пример:
function createAbortableValidator(name, callback) {
return async function(value) {
if (controllers.has(name)) {
controllers.get(name).abort()
}
const controller = new AbortController()
controllers.set(name, controller)
try {
return await callback(value, controller.signal)
} catch (error) {
if (error.name === 'AbortError') {
return true
}
throw error
} finally {
controllers.delete(name)
}
}
}
В сложных сценариях предыдущий finally может удалить
новый controller.
Пример:
1. request A started
2. request B started
3. request A finally -> delete()
4. request B controller исчез
finally {
if (controllers.get(name) === controller) {
controllers.delete(name)
}
}
const controllers = new Map()
function createAbortableValidator(name, callback) {
return async function(value) {
if (controllers.has(name)) {
controllers.get(name).abort()
}
const controller = new AbortController()
controllers.set(name, controller)
try {
return await callback(
value,
controller.signal
)
} catch (error) {
if (error.name === 'AbortError') {
return true
}
throw error
} finally {
if (controllers.get(name) === controller) {
controllers.delete(name)
}
}
}
}
В React StrictMode некоторые операции могут вызываться
дважды в development-режиме.
Это приводит к:
Частая ошибка:
const schema = yup.object({
username: yup.string().test(...)
})
если схема создаётся при каждом рендере.
Правильный подход:
const schema = useMemo(() => {
return yup.object({
username: yup.string().test(...)
})
}, [])
Повторные проверки одинаковых значений создают лишнюю нагрузку.
const cache = new Map()
async function validateUsername(value, signal) {
if (cache.has(value)) {
return cache.get(value)
}
const response = await fetch(
`/api/check/${value}`,
{ signal }
)
const data = await response.json()
cache.set(value, data.available)
return data.available
}
Бесконечный cache опасен.
const cache = new Map()
function setCache(key, value) {
cache.set(key, {
value,
expires: Date.now() + 60000
})
}
function getCache(key) {
const item = cache.get(key)
if (!item) {
return null
}
if (Date.now() > item.expires) {
cache.delete(key)
return null
}
return item.value
}
Неправильная реализация приводит к плохому пользовательскому опыту:
Плохо:
.min(1)
Лучше:
.min(3)
Оптимальные значения:
300–600ms
Отмена — не ошибка.
formState.isValidating
React Hook Form предоставляет состояние:
const {
formState: {
isValidating
}
} = useForm()
{
isValidating && (
<span>Проверка...</span>
)
}
Иногда проверка требует нескольких запросов.
const [
username,
email
] = await Promise.all([
fetch('/api/username'),
fetch('/api/email')
])
const controller = new AbortController()
Promise.all([
fetch('/a', {
signal: controller.signal
}),
fetch('/b', {
signal: controller.signal
})
])
abort() остановит оба запроса.
На сервере:
fetch может отличаться;Полная встроенная поддержка:
Node.js 18+
Для старых версий требуются polyfill.
npm install abort-controller
import AbortController from 'abort-controller'
Для больших приложений abortable-валидацию обычно выносят:
class ValidationService {
controllers = new Map()
async validateUsername(value) {
if (this.controllers.has('username')) {
this.controllers
.get('username')
.abort()
}
const controller =
new AbortController()
this.controllers.set(
'username',
controller
)
const response = await fetch(
`/api/check/${value}`,
{
signal: controller.signal
}
)
const data = await response.json()
return data.available
}
}
const service =
new ValidationService()
const schema = yup.object({
username: yup.string().test(
'username',
'Логин занят',
(value) =>
service.validateUsername(value)
)
})