Trung bình JavaScript

Hủy tác vụ với AbortController: fetch, AbortSignal.timeout, AbortSignal.any và search-as-you-type

Kết quả search bị cũ và việc server làm dư thừa có chung một cách sửa. Tìm hiểu AbortController, AbortSignal.timeout, AbortSignal.any và cách dọn dẹp không rò rỉ.

09 Th11, 2026 13 phút Cập nhật 23 Th10, 2026 7 Khối code
Sơ đồ
sequenceDiagram participant U as Người dùng participant S as Ô tìm kiếm participant A as API U->>S: gõ lap S->>A: tìm lap kèm signal 1 U->>S: gõ laptop S->>S: abort signal 1 S->>A: tìm laptop kèm signal 2 A-->>S: kết quả cho laptop S->>U: chỉ hiện kết quả laptop

Ô tìm kiếm sản phẩm bắn một request sau mỗi lần gõ phím. User gõ "lap", rồi "lapt", rồi "laptop". Ba request rời khỏi trình duyệt; cái cho "lap" chậm nhất vì nó khớp nhiều sản phẩm nhất, nên nó về sau cùng và đè lên kết quả của "laptop". User tìm laptop mà thấy toàn ốp điện thoại với cáp HDMI, rồi bug report ghi "search lâu lâu bị random". Trong khi đó backend đã chạy ba câu full-text query, mà chỉ có một câu trả lời là đáng giá.

Phía server cũng có chuyện y vậy, chỉ là ngược chiều. User mở một báo cáo nặng, chờ hai giây thấy chán rồi đóng tab. Node vẫn tiếp tục chạy năm lời gọi API phía sau và câu query database thêm hai chục giây nữa, cho một response không ai đọc. Nhân lên cho một buổi chiều đông khách là mình có ngay vấn đề capacity, mà toàn bộ được làm từ mấy việc bị bỏ dở.

Cách sửa cho cả hai trường hợp là cùng một công cụ: AbortController. Nó có sẵn trong mọi trình duyệt hiện đại và trong Node.js, fetch hiểu nó luôn, và cộng thêm AbortSignal.timeout với AbortSignal.any là xử lý được cả timeout lẫn chuyện gộp nhiều lý do hủy.

AbortController hoạt động ra sao

API được chia thành hai object với quyền hạn khác nhau có chủ đích:

  • AbortController là cái remote. Ai cầm nó thì gọi được controller.abort(reason).
  • AbortSignal (controller.signal) là đầu thu chỉ-đọc. Mình truyền nó vô công việc muốn hủy được. Nó có signal.aborted, signal.reason, signal.throwIfAborted() và event abort.

Việc tách đôi này quan trọng: mình đưa signal cho một hàm, hàm đó biết được khi nào bị hủy nhưng không hủy được ai khác. Người gọi giữ controller.

Vài luật nên nhớ:

  • abort() là idempotent. Lần gọi đầu tiên thắng; mấy lần sau không làm gì, và reason giữ nguyên giá trị đầu.
  • abort() không truyền gì thì reason là một DOMException tên AbortError. abort(reason) thì xài đúng cái mình truyền, tiện để phân biệt "user chuyển trang" với "bị query mới hơn đè lên".
  • Signal không reset được. Mỗi tác vụ cần hủy độc lập thì phải có controller mới.
  • Listener của abort chạy đồng bộ ngay bên trong abort(). Giữ tụi nó thật gọn.

Search-as-you-type, từng bước

sequenceDiagram participant U as Người dùng participant S as Ô tìm kiếm participant A as API U->>S: gõ lap S->>A: tìm lap kèm signal 1 U->>S: gõ laptop S->>S: abort signal 1 S->>A: tìm laptop kèm signal 2 A-->>S: kết quả cho laptop S->>U: chỉ hiện kết quả laptop

Luật rất đơn giản: query mới bắt đầu thì abort query trước đó. Response cũ sẽ không bao giờ tới được đoạn code render kết quả, vì promise của nó reject chứ không resolve.

Hủy fetch

fetch nhận option signal. Khi signal abort, request bị dẹp (trình duyệt đóng connection hoặc hủy stream HTTP/2) và promise reject với reason của signal. Nếu header response đã về rồi, đọc body bằng res.json() cũng reject luôn.

export function isAbortError(error: unknown): boolean {
  return error instanceof DOMException && error.name === 'AbortError';
}

export async function loadUser(id: string, signal?: AbortSignal) {
  const res = await fetch(`/api/users/${id}`, { signal });
  if (!res.ok) {
    throw new Error(`Request failed with ${res.status}`);
  }

  return res.json();
}

Bị abort là kết quả nằm trong dự tính, không phải lỗi đáng để báo cáo. Lọc nó ra trước khi lỗi bay lên Sentry hay hiện toast, không thì người gõ nhanh nào cũng tạo ra một đống noise. Để ý luôn chuyện abort không làm: nếu server đã nhận request rồi, nó vẫn có thể xử lý cho xong. Cancel giúp client khỏi chờ và giải phóng connection; nó không phải rollback transaction. Đừng bao giờ trông vô nó để chặn một thao tác ghi.

Timeout với AbortSignal.timeout

fetch không có option timeout. Trước khi có AbortSignal.timeout, người ta hay bọc fetch trong Promise.race với một timer, cách đó dừng được việc chờ nhưng request vẫn chạy. Giờ có sẵn một helper tĩnh trả về signal tự abort:

try {
  const res = await fetch('/api/reports/monthly', { signal: AbortSignal.timeout(8000) });
  const report = await res.json();
  render(report);
} catch (error) {
  if (error instanceof DOMException && error.name === 'TimeoutError') {
    showMessage('The report is taking too long. Try a smaller date range.');
  } else {
    throw error;
  }
}

Reason ở đây là DOMException tên TimeoutError, không phải AbortError, nhờ vậy mình phân biệt được "mình bỏ cuộc" với "user tự hủy". Trên trình duyệt, timer chỉ đếm thời gian active, nên nó coi như tạm dừng khi trang nằm trong back/forward cache. AbortSignal.timeout có từ Node.js 17.3+ và trên mọi trình duyệt evergreen.

Gộp signal với AbortSignal.any

Code thực tế hay có nhiều lý do để dừng: user gõ tiếp, component unmount, request chạy quá lâu, cả server đang shutdown. AbortSignal.any([...]) trả về một signal abort ngay khi bất kỳ input nào abort, và reason của nó là reason của cái abort đầu tiên. Nó có từ Node.js 20.3 và trên mọi trình duyệt lớn trong khoảng 2023 tới 2024.

type Result = { id: string; title: string };

export function createSearch(
  fetchResults: (query: string, signal: AbortSignal) => Promise<Result[]>,
  timeoutMs = 5000,
) {
  let current: AbortController | null = null;

  return async function search(query: string): Promise<Result[] | null> {
    current?.abort();
    const controller = new AbortController();
    current = controller;

    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);

    try {
      return await fetchResults(query, signal);
    } catch (error) {
      if (controller.signal.aborted) {
        return null;
      }
      throw error;
    }
  };
}

Coi kỹ chỗ catch: nó check controller của chính mình, không check signal đã gộp. Nếu lời gọi này bị query mới hơn chen ngang, trả null để UI biết "kệ tui". Nếu timeout nổ, TimeoutError được ném tiếp lên để UI hiện thông báo. Lý do khác nhau, xử lý khác nhau, mà chỉ truyền xuống đúng một signal.

React: abort trong cleanup của effect

Trong React, chỗ tự nhiên nhất để cancel là hàm cleanup của useEffect. Nó chạy trước effect kế tiếp và lúc unmount, đúng y hai tình huống "có query mới hơn" và "không còn ai nghe nữa". Kết hợp thêm chút debounce để bắn ít request hơn.

import { useEffect, useState } from 'react';

export function useSearch(query: string) {
  const [results, setResults] = useState<{ id: string; title: string }[]>([]);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    if (query.trim().length < 2) {
      setResults([]);
      return;
    }

    const controller = new AbortController();
    const timer = setTimeout(() => {
      fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
        .then((res) => res.json())
        .then((data) => {
          setResults(data);
          setError(null);
        })
        .catch((err) => {
          if (err.name !== 'AbortError') setError(err);
        });
    }, 250);

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [query]);

  return { results, error };
}

Cách này cũng giúp hook chạy đúng dưới React StrictMode lúc dev, vốn cố tình mount, unmount rồi mount lại effect: request đầu bị cleanup abort, chỉ request thứ hai cập nhật state. Mấy thư viện data như TanStack Query truyền signal vô query function cũng vì lý do đó; chuyển tiếp nó xuống fetch là có cancel miễn phí.

Làm cho code của mình abort được

Signal chỉ có ích khi code nhận nó chịu lắng nghe. Với hàm async tự viết, theo hai pattern: gọi signal.throwIfAborted() giữa các bước, và biến timer hay việc chờ event thành promise reject khi bị abort, đồng thời tự dọn dẹp sau khi xong.

export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(signal.reason);
      return;
    }

    const onAbort = () => {
      clearTimeout(timer);
      reject(signal!.reason);
    };

    const timer = setTimeout(() => {
      signal?.removeEventListener('abort', onAbort);
      resolve();
    }, ms);

    signal?.addEventListener('abort', onAbort, { once: true });
  });
}

export async function syncAll(ids: string[], signal: AbortSignal) {
  for (const id of ids) {
    signal.throwIfAborted();
    await syncOne(id, { signal });
    await sleep(100, signal);
  }
}

Gỡ listener khi timer chạy xong không phải chuyện làm cho đẹp. Một signal sống lâu (ví dụ signal báo server shutdown) mà không gỡ thì cứ mỗi lần gọi lại dồn thêm một listener, và Node sẽ in ra MaxListenersExceededWarning. Cùng một signal còn dọn được listener của DOM: element.addEventListener('scroll', handler, { signal }) sẽ tự gỡ handler khi signal abort.

Phía server, nối signal với connection của client để request bị bỏ rơi dừng luôn mấy việc phía sau:

app.get('/reports/:id', async (req, res, next) => {
  const controller = new AbortController();
  res.on('close', () => {
    if (!res.writableEnded) controller.abort(new Error('client disconnected'));
  });

  try {
    const report = await buildReport(req.params.id, { signal: controller.signal });
    res.json(report);
  } catch (error) {
    if (controller.signal.aborted) return;
    next(error);
  }
});

Kiểm thử cancel với Vitest

Bug về cancel thực chất là race condition, nên test thẳng vô cái race: chạy hai lượt search, cho lượt đầu chậm hơn, rồi assert chỉ lượt thứ hai ra kết quả.

import { describe, expect, it } from 'vitest';
import { createSearch, sleep } from './search';

describe('createSearch', () => {
  it('aborts the previous request when a new query starts', async () => {
    const signals: AbortSignal[] = [];

    const search = createSearch((query, signal) => {
      signals.push(signal);
      return new Promise((resolve, reject) => {
        signal.addEventListener('abort', () => reject(signal.reason), { once: true });
        setTimeout(() => resolve([{ id: query, title: query }]), query === 'lap' ? 50 : 10);
      });
    });

    const first = search('lap');
    const second = search('laptop');

    await expect(first).resolves.toBeNull();
    await expect(second).resolves.toEqual([{ id: 'laptop', title: 'laptop' }]);
    expect(signals[0].aborted).toBe(true);
    expect(signals[1].aborted).toBe(false);
  });
});

describe('sleep', () => {
  it('rejects with the abort reason', async () => {
    const controller = new AbortController();
    const pending = sleep(10_000, controller.signal);

    controller.abort(new Error('stop'));

    await expect(pending).rejects.toThrow('stop');
  });
});

Những lỗi hay gặp

  • Xài lại controller cũ. Đã abort một lần là signal abort luôn. Mỗi request cần hủy riêng thì phải có AbortController mới.
  • Báo abort như một lỗi. Lọc AbortError ra trước khi log hay hiện toast lỗi.
  • Chỉ check error.name với AbortError. AbortSignal.timeout sinh ra TimeoutError, còn abort(reason) tự đặt thì ra đúng cái mình truyền. Khi sự khác biệt quan trọng, quyết định dựa vô signal nào đã abort.
  • Tưởng abort sẽ hoàn tác việc phía server. Request có thể đã được xử lý xong. Với thao tác ghi, xài idempotency key.
  • Rò listener trên signal sống lâu. Xài { once: true } và gỡ listener ở nhánh thành công.
  • Nhận signal rồi bỏ đó. Hàm nhận signal mà không chuyển xuống fetch hay không check nó thì chỉ tạo cảm giác an toàn giả.

Khi nào nên xài, khi nào không

Nên xài AbortController khi:

  • Response có thể về sai thứ tự và chỉ cái mới nhất có giá trị: search, autocomplete, filter, chuyển tab.
  • Component hay trang có thể biến mất trong lúc công việc còn đang chạy.
  • Cần timeout thật sự cho fetch hoặc cho API nào nhận signal (Node stream, timers/promises, events.once, nhiều SDK).
  • Việc phía server có thể bỏ ngang khi client ngắt kết nối hoặc process đang shutdown.

Đừng xài khi:

  • Thao tác là ghi dữ liệu và đã bắt đầu thì phải chạy xong. Làm cho nó idempotent rồi để nó chạy hết.
  • Công việc đồng bộ và nặng CPU. Signal không ngắt được vòng lặp đang bận trừ khi vòng lặp tự check signal.aborted; việc nặng mà cần dừng được thì đưa qua worker.
  • Bộ dedupe request hoặc thư viện data đã lo chuyện cancel giùm rồi. Chuyển tiếp signal của nó thay vì thêm một lớp nữa.

fetch nào có khả năng trở nên vô nghĩa thì nên mang theo signal. Chỉ thêm một tham số, mà dẹp được nguyên một họ bug "lâu lâu bị random".

Thử thách tương tác

Thử thách

Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.

Đề bài

Tự viết phiên bản AbortSignal.any của riêng bạn. JavaScript: function anySignal(signals) nhận một mảng AbortSignal và trả về một AbortSignal mới (tạo bằng AbortController của bạn). Python: file đã có sẵn AbortError, AbortSignal (add_listener, remove_listener, listener_count, throw_if_aborted) và AbortController (abort(reason=None) gọi từng listener với reason). Viết hàm def any_signal(signals) -> AbortSignal. Luật chung cho cả hai: - Nếu có input đã abort sẵn, signal trả về phải abort ngay với reason của input đó. - Không thì signal trả về abort ở lần đầu tiên có input abort, với đúng reason của input đó. Mấy input khác abort sau không làm đổi reason. - List rỗng thì trả về signal chưa abort. - Khi signal gộp abort, gỡ hết listener mà bạn đã gắn vô mọi input (bên Python test kiểm tra bằng listener_count()).

Code khởi tạo

javascript
/**
 * Return an AbortSignal that aborts as soon as any input signal aborts,
 * using that input's reason. Works like AbortSignal.any.
 */
function anySignal(signals) {
  // TODO: create an AbortController for the combined signal
  // TODO: if any input is already aborted, abort right away with its reason
  // TODO: otherwise listen for 'abort' on every input; on the first one,
  //       remove all your listeners and abort with that input's reason
  // TODO: return the controller's signal
  throw new Error('TODO');
}
python
from collections.abc import Callable, Iterable


class AbortError(Exception):
    pass


class AbortSignal:
    def __init__(self) -> None:
        self.aborted = False
        self.reason: BaseException | None = None
        self._listeners: list[Callable[[BaseException], None]] = []

    def add_listener(self, fn: Callable[[BaseException], None]) -> None:
        self._listeners.append(fn)

    def remove_listener(self, fn: Callable[[BaseException], None]) -> None:
        if fn in self._listeners:
            self._listeners.remove(fn)

    def listener_count(self) -> int:
        return len(self._listeners)

    def throw_if_aborted(self) -> None:
        if self.aborted:
            raise self.reason


class AbortController:
    def __init__(self) -> None:
        self.signal = AbortSignal()

    def abort(self, reason: BaseException | None = None) -> None:
        signal = self.signal
        if signal.aborted:
            return
        signal.aborted = True
        signal.reason = reason if reason is not None else AbortError("This operation was aborted")
        listeners, signal._listeners = signal._listeners, []
        for fn in listeners:
            fn(signal.reason)


def any_signal(signals: Iterable[AbortSignal]) -> AbortSignal:
    # TODO: create an AbortController for the combined signal
    # TODO: if any input is already aborted, abort right away with its reason
    # TODO: otherwise add a listener to every input; when the first one fires,
    #       remove ALL your listeners from every input, then abort with that input's reason
    # TODO: return the controller's signal
    raise NotImplementedError

Lời giải của bạn

Gợi ý

Đã mở hết gợi ý
Gợi ý 1

Check từng input coi đã aborted chưa trước khi đăng ký listener; có thì return sớm với controller.abort(signal.reason).

Gợi ý 2

Giữ một list các cặp (signal, handler) để một hàm cleanup gỡ hết được.

Gợi ý 3

Trong Python, gán biến vòng lặp vô tham số mặc định (source=signal) để mỗi handler nhớ đúng input của nó.

Lời giải · javascript
/**
 * Return an AbortSignal that aborts as soon as any input signal aborts,
 * using that input's reason. Works like AbortSignal.any.
 */
function anySignal(signals) {
  const controller = new AbortController();

  for (const signal of signals) {
    if (signal.aborted) {
      controller.abort(signal.reason);
      return controller.signal;
    }
  }

  const subscriptions = [];
  const unsubscribeAll = () => {
    for (const [signal, handler] of subscriptions) {
      signal.removeEventListener('abort', handler);
    }
  };

  for (const signal of signals) {
    const handler = () => {
      unsubscribeAll();
      controller.abort(signal.reason);
    };
    subscriptions.push([signal, handler]);
    signal.addEventListener('abort', handler, { once: true });
  }

  return controller.signal;
}
Lời giải · python
from collections.abc import Callable, Iterable


class AbortError(Exception):
    pass


class AbortSignal:
    def __init__(self) -> None:
        self.aborted = False
        self.reason: BaseException | None = None
        self._listeners: list[Callable[[BaseException], None]] = []

    def add_listener(self, fn: Callable[[BaseException], None]) -> None:
        self._listeners.append(fn)

    def remove_listener(self, fn: Callable[[BaseException], None]) -> None:
        if fn in self._listeners:
            self._listeners.remove(fn)

    def listener_count(self) -> int:
        return len(self._listeners)

    def throw_if_aborted(self) -> None:
        if self.aborted:
            raise self.reason


class AbortController:
    def __init__(self) -> None:
        self.signal = AbortSignal()

    def abort(self, reason: BaseException | None = None) -> None:
        signal = self.signal
        if signal.aborted:
            return
        signal.aborted = True
        signal.reason = reason if reason is not None else AbortError("This operation was aborted")
        listeners, signal._listeners = signal._listeners, []
        for fn in listeners:
            fn(signal.reason)


def any_signal(signals: Iterable[AbortSignal]) -> AbortSignal:
    signals = list(signals)
    controller = AbortController()

    for signal in signals:
        if signal.aborted:
            controller.abort(signal.reason)
            return controller.signal

    subscriptions: list[tuple[AbortSignal, Callable[[BaseException], None]]] = []

    def unsubscribe_all() -> None:
        for sig, handler in subscriptions:
            sig.remove_listener(handler)

    for signal in signals:
        def handler(_reason: BaseException, source: AbortSignal = signal) -> None:
            unsubscribe_all()
            controller.abort(source.reason)

        subscriptions.append((signal, handler))
        signal.add_listener(handler)

    return controller.signal

Cùng chủ đề JavaScript