Модуль 10.6
AbortController и таймауты
Управление отменой асинхронных операций и ограничение времени выполнения
1. Введение: Зачем нужен AbortController?
Добро пожаловать в модуль, посвящённый одному из самых недооценённых инструментов в современном TypeScript/JavaScript — AbortController. Этот интерфейс позволяет отменять асинхронные операции, такие как fetch-запросы, чтение файлов, таймеры и другие долгие операции.
Почему это важно? Представьте: пользователь вводит текст в поле поиска, и каждый символ отправляет запрос к серверу. Пользователь быстро печатает "TypeScript", и вместо одного запроса отправляются 10 промежуточных. Без отмены все 10 запросов завершатся, и результат последнего может прийти раньше предпоследнего — получим гонку данных (race condition).
Проблема без отмены: Пользователь ищет "React", потом меняет на "Vue". Запрос "React" всё ещё летит и может вернуть результат позже, перезаписав результат для "Vue".
AbortController решает эту проблему, предоставляя механизм для программной отмены операций. Он является стандартом Web API и доступен как в браузерах, так и в Node.js (начиная с версии 15).
2. Основы AbortController и его типы
AbortController — это простой интерфейс с двумя основными свойствами:
signal
( AbortSignal) и методом
abort().
const controller = new AbortController(); // Получаем сигнал для отмены const signal = controller.signal; // Тип signal — AbortSignal const typedSignal: AbortSignal = controller.signal; // Отменяем операцию controller.abort();
Типизация AbortController:
// TypeScript определяет тип AbortController автоматически interface AbortController { readonly signal: AbortSignal; abort(reason?: any): void; } // AbortSignal имеет следующие ключевые свойства: interface AbortSignal { readonly aborted: boolean; readonly reason: any; throwIfAborted(): void; onabort: ((ev: Event) => any) | null; addEventListener(type: "abort", listener: ...): void; removeEventListener(type: "abort", listener: ...): void; }
Важно: AbortSignal не имеет публичного конструктора. Его можно получить только через AbortController.signal или через статический метод AbortSignal.abort() (начиная с ES2022).
3. Типизация AbortSignal в TypeScript
TypeScript предоставляет полную поддержку типизации для AbortSignal. Давайте рассмотрим основные паттерны использования AbortSignal в типизированных функциях.
// Функция, принимающая AbortSignal async function fetchData( url: string, signal: AbortSignal ): Promise<Response> { const response = await fetch(url, { signal }); return response; } // Опциональный AbortSignal async function fetchDataOptional( url: string, options?: { signal?: AbortSignal; timeout?: number } ): Promise<Response> { const response = await fetch(url, { signal: options?.signal, }); return response; }
Совет преподавателя: Всегда передавайте AbortSignal как отдельный параметр, а не через options-объект. Это делает API более явным и позволяет TypeScript лучше проверять типы на этапе компиляции.
Когда вы передаёте signal через options-объект, TypeScript не может автоматически определить, что операция должна поддерживать отмену. Явная передача signal делает намерения кода более чёткими.
// Проверка отмены с throwIfAborted() async function longOperation( signal: AbortSignal ): Promise<string> { // Проверяем, не отменена ли операция signal.throwIfAborted(); await delay(1000); // Проверяем снова после долгой операции signal.throwIfAborted(); return "Результат"; } // Получение причины отмены async function checkAbortReason( signal: AbortSignal ): Promise<void> { if (signal.aborted) { console.log("Причина отмены:", signal.reason); } }
4. Отмена fetch-запросов
Самый распространённый случай использования AbortController — отмена HTTP-запросов. Функция fetch() принимает AbortSignal через объект опций.
// Пример 1: Простая отмена fetch const controller = new AbortController(); try { const response = await fetch("https://api.example.com/data", { signal: controller.signal, }); const data = await response.json(); console.log(data); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { console.log("Запрос был отменён"); } } // Отмена через 5 секунд setTimeout(() => controller.abort(), 5000);
Предупреждение: Когда fetch отменяется через AbortController, он выбрасывает
DOMException
с именем "AbortError", а не обычную ошибку сети. Это важно учитывать при обработке ошибок.
Не путайте AbortError с другими ошибками fetch. AbortError означает, что операция была намеренно отменена вызовом abort(), а не произошла из-за сетевой проблемы или таймаута.
// Пример 2: Типизированная функция fetch с отменой interface FetchOptions { signal?: AbortSignal; timeout?: number; headers?: Record<string, string>; } async function typedFetch<T>( url: string, options: FetchOptions = {} ): Promise<T> { const { signal, timeout = 30000, headers = {} } = options; // Создаём собственный AbortController для таймаута const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); // Если передан внешний signal, слушаем его if (signal) { signal.addEventListener("abort", () => { controller.abort(signal.reason); }); } try { const response = await fetch(url, { signal: controller.signal, headers, }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return response.json() as Promise<T>; } finally { clearTimeout(timeoutId); } }
5. Пользовательская отмена операций
AbortController можно использовать не только для fetch, но и для отмены любых асинхронных операций. Давайте создадим типизированную обёртку для отменяемых операций.
// Базовый паттерн отменяемой операции class CancellableOperation<T> { private controller: AbortController; private promise: Promise<T>; constructor( executor: (signal: AbortSignal) => Promise<T> ) { this.controller = new AbortController(); this.promise = this.run(executor); } private async run( executor: (signal: AbortSignal) => Promise<T> ): Promise<T> { return executor(this.controller.signal); } cancel(reason?: string): void { this.controller.abort(reason ?? "Операция отменена пользователем"); } get result(): Promise<T> { return this.promise; } get signal(): AbortSignal { return this.controller.signal; } get isCancelled(): boolean { return this.controller.signal.aborted; } }
// Использование CancellableOperation const operation = new CancellableOperation<User>( async (signal) => { const response = await fetch("/api/user", { signal }); return response.json(); } ); // Можно отменить через 2 секунды setTimeout(() => operation.cancel(), 2000); try { const user = await operation.result; console.log(user); } catch (err) { if (operation.isCancelled) { console.log("Операция была отменена"); } }
Паттерн "Отмена по таймауту": Если вы хотите автоматически отменять операцию через определённое время, оберните AbortController в функцию с setTimeout. Это создаёт паттерн "operational timeout".
6. Типизированная функция таймаута
Создадим типизированную функцию для установки таймаута, которая поддерживает отмену через AbortController. Это основа для более сложных паттернов управления временем.
// Типизированная функция таймаута с поддержкой AbortSignal function typedTimeout( ms: number, signal?: AbortSignal ): Promise<void> { return new Promise((resolve, reject) => { // Если уже отменено, сразу отклоняем if (signal?.aborted) { reject(signal.reason); return; } const timer = setTimeout(() => { resolve(); }, ms); // Если передан signal, слушаем отмену if (signal) { signal.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }, { once: true }); } }); } // Использование: const controller = new AbortController(); try { await typedTimeout(5000, controller.signal); console.log("Прошло 5 секунд"); } catch (err) { if (err === controller.signal.reason) { console.log("Таймаут был отменён"); } } // Отменяем через 1 секунду setTimeout(() => controller.abort(), 1000);
Совет: Используйте { once: true }
при добавлении слушателя abort. Это автоматически удалит слушатель после первого срабатывания, предотвращая утечки памяти.
Также не забывайте вызывать clearTimeout
при отмене, чтобы предотвратить утечку таймера.
7. Promise.race для реализации таймаутов
Promise.race — это мощный инструмент для реализации паттерна "операция с таймаутом".
Он разрешается первым из переданных промисов.
// Базовый паттерн: операция с таймаутом function withTimeout<T>( promise: Promise<T>, ms: number, errorMessage: string = "Таймаут операции" ): Promise<T> { const timeout = new Promise<never>((_, reject) => { setTimeout(() => { reject(new TimeoutError(errorMessage)); }, ms); }); return Promise.race([promise, timeout]); } // Использование: const result = await withTimeout( fetchData("/api/data"), 5000, "Загрузка данных заняла слишком много времени" );
// Улучшенный withTimeout с AbortController class TimeoutError extends Error { constructor(message: string, public timeoutMs: number) { super(message); this.name = "TimeoutError"; } } function withAbortableTimeout<T>( operation: (signal: AbortSignal) => Promise<T>, timeoutMs: number, externalSignal?: AbortSignal ): Promise<T> { const controller = new AbortController(); // Таймер для автоматической отмены const timer = setTimeout(() => { controller.abort(new TimeoutError( "Операция превысила лимит времени", timeoutMs )); }, timeoutMs); // Пробрасываем внешний signal if (externalSignal) { externalSignal.addEventListener("abort", () => { controller.abort(externalSignal.reason); }, { once: true }); } return operation(controller.signal) .finally(() => clearTimeout(timer)); }
8. Типизация AbortError
Правильная типизация и обработка ошибок отмены — ключ к надёжному коду. Давайте создадим типизированную систему для работы с AbortError.
// Типизированная ошибка отмены class AbortErrorTyped extends Error { readonly isAbortError = true as const; readonly reason: unknown; constructor(reason?: unknown) { super("Операция была отменена"); this.name = "AbortError"; this.reason = reason; } } // Хелпер для проверки AbortError function isAbortError(error: unknown): error is AbortErrorTyped { return ( error instanceof AbortErrorTyped || (error instanceof DOMException && error.name === "AbortError") || (error instanceof Error && error.name === "AbortError") ); } // Использование: async function safeFetch(url: string, signal: AbortSignal) { try { const response = await fetch(url, { signal }); return await response.json(); } catch (err) { if (isAbortError(err)) { console.log("Запрос был отменён"); throw err; } throw err; } }
9. Комбинация AbortController и таймаута
Одна из самых мощных техник — комбинация AbortController с таймером для автоматической отмены операций, которые выполняются слишком долго.
// Утилита для создания AbortController с таймаутом function createAbortControllerWithTimeout( timeoutMs: number, parentSignal?: AbortSignal ): { controller: AbortController; cleanup: () => void } { const controller = new AbortController(); // Таймер автоматической отмены const timeoutId = setTimeout(() => { controller.abort(new Error(`Таймаут ${timeoutMs}мс`)); }, timeoutMs); // Проброс родительского signal if (parentSignal) { if (parentSignal.aborted) { controller.abort(parentSignal.reason); } else { parentSignal.addEventListener("abort", () => { controller.abort(parentSignal.reason); }, { once: true }); } } // Функция очистки const cleanup = () => clearTimeout(timeoutId); return { controller, cleanup }; } // Использование: async function fetchWithTimeout( url: string, timeoutMs: number ): Promise<Response> { const { controller, cleanup } = createAbortControllerWithTimeout(timeoutMs); try { const response = await fetch(url, { signal: controller.signal, }); return response; } finally { cleanup(); } }
Важно: Всегда вызывайте cleanup() в блоке finally, чтобы гарантированно удалить таймер. Это предотвращает утечки памяти и нежелательные вызовы abort после завершения операции.
10. Таймауты для асинхронных операций
Давайте создадим комплексную систему таймаутов для различных асинхронных операций — от простых задержек до сложных цепочек промисов.
// Типизированный таймаут для Promise interface TimeoutOptions { timeoutMs: number; message?: string; signal?: AbortSignal; } async function withTimeoutAsync<T>( promise: Promise<T>, options: TimeoutOptions ): Promise<T> { const { timeoutMs, message = "Таймаут операции", signal } = options; const timeoutPromise = new Promise<never>((_, reject) => { const timer = setTimeout(() => { reject(new TimeoutError(message, timeoutMs)); }, timeoutMs); signal?.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }, { once: true }); }); return Promise.race([promise, timeoutPromise]); } // Использование: const data = await withTimeoutAsync( fetchDataFromAPI(), { timeoutMs: 10000, message: "Загрузка данных превысила 10 секунд", } );
11. AbortController в React useEffect
В React AbortController критически важен для предотвращения утечек памяти и состояний гонки (race conditions) при размонтировании компонентов.
// Базовый пример: fetch в useEffect с отменой function UserProfile({ userId }: { userId: string }) { const [user, setUser] = useState<User | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { const controller = new AbortController(); async function loadUser() { try { setLoading(true); setError(null); const response = await fetch( `/api/users/${userId}`, { signal: controller.signal } ); if (!response.ok) { throw new Error("Ошибка загрузки"); } const data = await response.json(); setUser(data); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { // Игнорируем ошибку отмены return; } setError(err instanceof Error ? err.message : "Неизвестная ошибка"); } finally { setLoading(false); } } loadUser(); // Cleanup: отменяем запрос при размонтировании return () => controller.abort(); }, [userId]); if (loading) return <Spinner />; if (error) return <ErrorMessage message={error} />; return <UserInfo user={user!} />; }
Критическая ошибка: Без abort() в cleanup функции useEffect, если компонент размонтируется во время запроса, setState будет вызван на размонтированном компоненте, что приведёт к утечке памяти.
Также это предотвращает проблему "stale state" — когда пользователь переходит на другой профиль, старый запрос всё ещё может обновить состояние компонента.
12. AbortController в Node.js
Начиная с Node.js 15, AbortController доступен как глобальный объект. Он используется для отмены операций с файлами, потоками, процессами и другими ресурсами Node.js.
// Отмена чтения файла в Node.js import { readFile } from "fs/promises"; import { AbortController } from "node:events"; async function readFileWithTimeout( path: string, timeoutMs: number ): Promise<Buffer> { const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(), timeoutMs ); try { const data = await readFile(path, { signal: controller.signal, }); return data; } finally { clearTimeout(timeoutId); } } // Отмена HTTP-запроса в Node.js (fetch) async function fetchWithTimeout( url: string, timeoutMs: number ): Promise<Response> { const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(), timeoutMs ); try { const response = await fetch(url, { signal: controller.signal, }); return response; } finally { clearTimeout(timeoutId); } }
Совет для Node.js: В Node.js 18+ глобальный fetch также поддерживает AbortSignal.
Используйте тот же паттерн, что и в браузерах. Для более ранних версий используйте пакет
node-fetch или
axios.
13. Паттерн CancelableOperation<T>
Давайте создадим полноценный типизированный паттерн CancelableOperation, который инкапсулирует логику отмены и предоставляет чистый API для работы с отменяемыми операциями.
// Полноценный типизированный CancelableOperation class CancelableOperation<T> { private controller: AbortController; private _promise: Promise<T>; private _resolved = false; private _result?: T; constructor( executor: (signal: AbortSignal) => Promise<T> ) { this.controller = new AbortController(); this._promise = executor(this.controller.signal) .then((result) => { this._resolved = true; this._result = result; return result; }); } cancel(reason?: string): void { if (!this._resolved) { this.controller.abort(reason ?? "Операция отменена"); } } get promise(): Promise<T> { return this._promise; } get signal(): AbortSignal { return this.controller.signal; } get isCancelled(): boolean { return this.controller.signal.aborted; } get isResolved(): boolean { return this._resolved; } get result(): T | undefined { return this._result; } get reason(): any { return this.controller.signal.reason; } }
// Фабрика для создания отменяемых операций function createCancelable<T>( fn: (signal: AbortSignal) => Promise<T> ): CancelableOperation<T> { return new CancelableOperation(fn); } // Использование: const searchOp = createCancelable<SearchResult[]>( async (signal) => { const response = await fetch( `/api/search?q=${query}`, { signal } ); return response.json(); } ); // Автоматическая отмена через 3 секунды setTimeout(() => searchOp.cancel(), 3000); try { const results = await searchOp.promise; console.log(results); } catch (err) { if (searchOp.isCancelled) { console.log("Поиск был отменён"); } }
14. Автоматическая отмена при размонтировании
В React важно автоматически отменять все асинхронные операции при размонтировании компонента. Давайте создадим хук для этой задачи.
// Хук для автоматической отмены при размонтировании function useAbortController(): AbortController { const controllerRef = useRef<AbortController>( new AbortController() ); useEffect(() => { return () => { controllerRef.current.abort(); }; }, []); return controllerRef.current; } // Хук для запросов с автоматической отменой function useAbortableFetch<T>() { const controller = useAbortController(); const [data, setData] = useState<T | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState<Error | null>(null); const fetch = useCallback( async (url: string) => { try { setLoading(true); setError(null); const response = await fetch(url, { signal: controller.signal, }); const result = await response.json(); setData(result); return result; } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { return; } setError(err as Error); throw err; } finally { setLoading(false); } }, [controller] ); return { data, loading, error, fetch }; } // Использование: function UserList() { const { data, loading, error, fetch } = useAbortableFetch<User[]>(); useEffect(() => { fetch("/api/users"); }, []); if (loading) return <div>Загрузка...</div>; if (error) return <div>Ошибка: {error.message}</div>; return <UserListItems users={data ?? []} />; }
15. Таймауты для WebSocket
WebSocket соединения также нуждаются в таймаутах и механизмах отмены. Давайте создадим типизированную обёртку для WebSocket с поддержкой AbortController.
// Типизированный WebSocket с поддержкой отмены interface TypedWebSocketOptions<T> { url: string; timeoutMs?: number; signal?: AbortSignal; onMessage: (data: T) => void; onError?: (error: Event) => void; } function createTypedWebSocket<T>( options: TypedWebSocketOptions<T> ): { ws: WebSocket; close: () => void } { const ws = new WebSocket(options.url); const controller = new AbortController(); // Таймаут подключения const timeoutId = setTimeout(() => { if (ws.readyState !== WebSocket.OPEN) { ws.close(); controller.abort(); } }, options.timeoutMs ?? 10000); // Обработка сообщений ws.addEventListener("message", (event) => { if (controller.signal.aborted) return; try { const data = JSON.parse(event.data as string) as T; options.onMessage(data); } catch (err) { options.onError?.(err as Event); } }); // Обработка ошибок ws.addEventListener("error", (error) => { clearTimeout(timeoutId); options.onError?.(error); }); // Обработка открытия ws.addEventListener("open", () => { clearTimeout(timeoutId); }); // Проброс внешнего signal options.signal?.addEventListener("abort", () => { ws.close(); controller.abort(); }, { once: true }); const close = () => { clearTimeout(timeoutId); ws.close(); controller.abort(); }; return { ws, close }; }
16. Повторные попытки с отменой
Часто необходимо повторять неудачные операции с экспоненциальной задержкой, но при этом поддерживать отмену. Давайте создадим типизированную функцию для повторных попыток.
// Типизированная функция повторных попыток с отменой interface RetryOptions { maxRetries: number; baseDelay: number; maxDelay?: number; signal?: AbortSignal; shouldRetry?: (error: Error) => boolean; } async function retryWithAbort<T>( fn: (signal: AbortSignal) => Promise<T>, options: RetryOptions ): Promise<T> { const { maxRetries, baseDelay, maxDelay = 30000, signal, shouldRetry = () => true, } = options; let lastError: Error; for (let attempt = 0; attempt <= maxRetries; attempt++) { // Проверяем отмену перед каждой попыткой if (signal?.aborted) { throw new DOMException("Операция отменена", "AbortError"); } try { return await fn(signal ?? new AbortController().signal); } catch (error) { lastError = error as Error; // Если отменено, не повторяем if (signal?.aborted) throw error; // Проверяем, стоит ли повторять if (!shouldRetry(error as Error)) throw error; // Если это последняя попытка, выбрасываем ошибку if (attempt === maxRetries) throw error; // Экспоненциальная задержка const delay = Math.min( baseDelay * Math.pow(2, attempt), maxDelay ); // Ждём с возможностью отмены await delayWithAbort(delay, signal); } } throw lastError!; } function delayWithAbort( ms: number, signal?: AbortSignal ): Promise<void> { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(signal.reason); return; } const timer = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }, { once: true }); }); }
17. Составной AbortController
Иногда необходимо отменить несколько операций одновременно. Составной AbortController объединяет несколько AbortSignal в один.
// Составной AbortController для отмены нескольких операций class CompositeAbortController { private controllers: AbortController[] = []; private compositeController: AbortController; constructor() { this.compositeController = new AbortController(); } createChild(): AbortController { const child = new AbortController(); this.controllers.push(child); // Если родитель отменён, отменяем ребёнка this.compositeController.signal.addEventListener("abort", () => { child.abort(this.compositeController.signal.reason); }, { once: true }); return child; } abortAll(reason?: any): void { this.compositeController.abort(reason); this.controllers.forEach(c => c.abort(reason)); } get signal(): AbortSignal { return this.compositeController.signal; } get isAborted(): boolean { return this.compositeController.signal.aborted; } } // Использование: const composite = new CompositeAbortController(); // Создаём несколько дочерних операций const signal1 = composite.createChild().signal; const signal2 = composite.createChild().signal; const signal3 = composite.createChild().signal; // Запускаем три параллельные операции Promise.all([ fetchData("/api/1", signal1), fetchData("/api/2", signal2), fetchData("/api/3", signal3), ]); // Отменяем все три одновременно composite.abortAll("Пользователь покинул страницу");
18. Практический типизированный API клиент
Давайте соберём все знания в полноценный типизированный API клиент с поддержкой таймаутов и отмены.
// Типы для API клиента interface ApiClientConfig { baseUrl: string; defaultTimeout?: number; defaultHeaders?: Record<string, string>; } interface RequestOptions<T> { timeout?: number; signal?: AbortSignal; headers?: Record<string, string>; body?: T; retries?: number; } class ApiClient { private config: ApiClientConfig; constructor(config: ApiClientConfig) { this.config = config; } private createTimeoutSignal( timeoutMs: number, parentSignal?: AbortSignal ): { signal: AbortSignal; cleanup: () => void } { const controller = new AbortController(); const timer = setTimeout(() => { controller.abort(new TimeoutError( `Таймаут ${timeoutMs}мс`, timeoutMs )); }, timeoutMs); if (parentSignal) { if (parentSignal.aborted) { controller.abort(parentSignal.reason); } else { parentSignal.addEventListener("abort", () => { controller.abort(parentSignal.reason); }, { once: true }); } } return { signal: controller.signal, cleanup: () => clearTimeout(timer), }; } async request<TResponse, TBody = unknown>( method: string, path: string, options: RequestOptions<TBody> = {} ): Promise<TResponse> { const { timeout = this.config.defaultTimeout ?? 30000, signal, headers = {}, body, retries = 0, } = options; const { signal: timeoutSignal, cleanup } = this.createTimeoutSignal( timeout, signal ); let lastError: Error; for (let attempt = 0; attempt <= retries; attempt++) { try { const response = await fetch( `${this.config.baseUrl}${path}`, { method, signal: timeoutSignal, headers: { ...this.config.defaultHeaders, ...headers, "Content-Type": "application/json", }, body: body ? JSON.stringify(body) : undefined, } ); if (!response.ok) { throw new ApiError( response.status, await response.text() ); } return await response.json() as TResponse; } catch (err) { lastError = err as Error; if (timeoutSignal.aborted) break; if (attempt === retries) break; await delay(Math.pow(2, attempt) * 1000); } finally { cleanup(); } } throw lastError!; } async get<T>( path: string, options?: Omit<RequestOptions<never>, "body"> ): Promise<T> { return this.request<T>("GET", path, options); } async post<TResponse, TBody>( path: string, body: TBody, options?: Omit<RequestOptions<TBody>, "body"> ): Promise<TResponse> { return this.request<TResponse, TBody>("POST", path, { ...options, body, }); } }
19. Продвинутые паттерны и техники
Рассмотрим несколько продвинутых паттернов использования AbortController в реальных проектах.
// Паттерн 1: Debounced Search с отменой function createDebouncedSearch<T>( searchFn: (query: string, signal: AbortSignal) => Promise<T>, delayMs: number = 300 ) { let lastController: AbortController | null = null; let timeoutId: ReturnType<typeof setTimeout> | null = null; return (query: string): Promise<T | null> => { // Отменяем предыдущий запрос lastController?.abort(); if (timeoutId) clearTimeout(timeoutId); return new Promise((resolve) => { timeoutId = setTimeout(async () => { const controller = new AbortController(); lastController = controller; try { const result = await searchFn(query, controller.signal); resolve(result); } catch (err) { if (controller.signal.aborted) { resolve(null); } else { throw err; } } }, delayMs); }); }; } // Паттерн 2: Timeout с кастомным сообщением function timeout<T>( promise: Promise<T>, ms: number, message = "Operation timed out" ): Promise<T> { const controller = new AbortController(); const timeoutPromise = new Promise<never>((_, reject) => { const timer = setTimeout(() => { controller.abort(); reject(new TimeoutError(message, ms)); }, ms); }); return Promise.race([promise, timeoutPromise]); }
20. Лучшие практики и рекомендации
DO:
- Всегда очищайте AbortController в useEffect cleanup
- Используйте { once: true } при добавлении слушателей abort
- Передавайте AbortSignal как первый параметр функции
- Создавайте AbortController в начале операции
- Проверяйте signal.aborted перед длительными операциями
DON'T:
- Не создавайте AbortController один раз и переиспользуйте его
- Не забывайте вызывать clearTimeout при отмене
- Не игнорируйте AbortError при обработке ошибок
- Не передавайте один и тот же AbortSignal в несколько параллельных операций
- Не используйте AbortController для синхронных операций
21. Частые ошибки и как их избежать
Ошибка 1: Утечка таймера
Забыли вызвать clearTimeout при отмене — таймер продолжает работать в фоне.
Ошибка 2: Повторное использование AbortController
AbortController можно отменить только один раз. После abort() его нельзя сбросить.
Ошибка 3: Неправильная обработка AbortError
Все ошибки отмены — это DOMException с именем "AbortError". Не пытайтесь ловить обычные Error.
Ошибка 4: Отсутствие cleanup в React
Без controller.abort() в useEffect cleanup возникнут утечки памяти и гонки данных.
22. Реальные примеры из проектов
// Пример: Поиск с автоматической отменой предыдущих запросов class SearchEngine<T> { private currentController: AbortController | null = null; private debounceTimer: ReturnType<typeof setTimeout> | null = null; private cache = new Map<string, T>(); constructor( private fetchFn: (q: string, s: AbortSignal) => Promise<T>, private debounceMs = 300 ) {} search(query: string): Promise<T | null> { // Проверяем кэш if (this.cache.has(query)) { return Promise.resolve(this.cache.get(query)!); } // Отменяем предыдущий запрос this.currentController?.abort(); if (this.debounceTimer) { clearTimeout(this.debounceTimer); } return new Promise((resolve) => { this.debounceTimer = setTimeout(async () => { const controller = new AbortController(); this.currentController = controller; try { const result = await this.fetchFn(query, controller.signal); this.cache.set(query, result); resolve(result); } catch (err) { if (controller.signal.aborted) { resolve(null); } else { throw err; } } }, this.debounceMs); }); } cancel(): void { this.currentController?.abort(); if (this.debounceTimer) { clearTimeout(this.debounceTimer); } } clearCache(): void { this.cache.clear(); } }
23. Итоги модуля
Ключевые концепции:
- AbortController — механизм отмены асинхронных операций
- AbortSignal — передаётся в операции для проверки отмены
- AbortError — DOMException с именем "AbortError"
- Promise.race — реализация паттерна "операция с таймаутом"
- React cleanup — обязательная отмена в useEffect cleanup
- Node.js — доступен с версии 15+
- Composite AbortController — отмена нескольких операций
Следующий шаг: Практика! Попробуйте реализовать API клиент с таймаутами, дебаунсированный поиск с отменой, и React компонент с правильной обработкой отмены.
Модуль 10.6 — AbortController и таймауты | TypeScript Learning Course
Тест: AbortController и таймауты
10 вопросов