Операция отменена. Почему её результат всё ещё меняет интерфейс?

В интерфейсе отмена означает, что результат операции больше не нужен. Для выполняющего её кода это может ничего не изменить: например, браузер продолжит декодировать изображение. Я разделяю два вопроса: как остановить ненужную работу и как запретить ей менять интерфейс, если остановить её не удалось.

Учебные примеры подготовлены в октябре 2026 года. В них использованы современные возможности JavaScript и платформы; архивная дата не относится к этим реализациям.

Возьмём предпросмотр фотографии в редакторе статьи. Пока браузер готовит одно изображение, можно отменить обработку и выбрать другое. Общий флаг «идёт обработка» после выбора нового файла снова станет истинным, и запоздавший результат первой операции получит разрешение заменить фотографию в предпросмотре. Блокировка кнопки выбора убрала бы эту гонку, но заставила бы редактора ждать работу, от которой он уже отказался.

Чтобы каждый результат оставался связан со своим запуском, нужно отделить состояние операции от общего состояния интерфейса. Сначала воспроизведём ошибку без фотографий и браузерных задержек. Так будет видно, какой именно порядок событий ломает код.

Флаг снова стал истинным

Пусть результат приходит через промис. Пока active равен true, его можно показать. При отмене запишем false.

let active = false;
let visible;

async function start(result) {
  active = true;
  const image = await result;

  if (active) {
    visible = image;
  }
}

function cancel() {
  active = false;
}

Этот код действительно отбрасывает результат после отмены, если больше ничего не запускать. Ошибка появляется, когда та же переменная начинает описывать следующую операцию.

Создадим два промиса, которые можем завершать сами. Promise.withResolvers() возвращает промис вместе с функциями его завершения. Вместо попытки попасть кликом в нужную миллисекунду мы укажем порядок непосредственно в коде.

const first = Promise.withResolvers();
const second = Promise.withResolvers();

const a = start(first.promise);
cancel();
const b = start(second.promise);

second.resolve('B');
await b;

first.resolve('A');
await a;

console.log(visible);

В консоли будет A. Вторая операция успела показать результат, затем продолжилась первая и увидела active === true. Флаг снова разрешает показ, хотя теперь он относится к другой фотографии.

Если вместо active хранить строку processing, смысл проверки не изменится. Обе фотографии проходят одну и ту же стадию. Нам нужно различать сами запуски.

Что обещает отмена

У отмены здесь две задачи. Мы хотим прекратить ненужную работу, чтобы она не занимала ресурсы. Одновременно нужно запретить ей менять текущий интерфейс. Вторую задачу можно решить немедленно, даже когда первая зависит от используемого API.

AbortController передаёт сигнал операции, которая умеет его учитывать. Вызов abort() не отменяет произвольный промис и не мешает функции продолжить работу после await. Реакция зависит от конкретного API; общий механизм описан в стандарте DOM.

Например, у createImageBitmap() нет параметра signal. Пока браузер декодирует фотографию, мы можем отменить её показ, но передать этому вызову AbortSignal некуда. Готовое изображение всё равно придётся получить и освободить.

Создадим отдельный объект для каждого запуска. Переменная current будет указывать на текущий запуск, а асинхронная функция сохранит свой объект в operation. Перед обновлением интерфейса она сравнит эти ссылки. Если они различаются, обработку уже отменили или вместо неё начали новую.

if (current !== operation) {
  return;
}

Внутри одного модуля для этого достаточно сравнения ссылок на объекты. Если передавать сообщения между основным потоком и воркером, запуск можно обозначить отдельным идентификатором.

Результат можно забыть, ресурс нужно закрыть

Отказ от показа ещё не освобождает память. Допустим, мы ожидаем декодированное изображение:

const resource = await prepare(file, operation.controller.signal);

if (current !== operation) {
  resource.close();
  return;
}

operation.resource = resource;

Если отмена произошла до завершения prepare, изображения в объекте операции ещё не было. Код отмены не мог его закрыть. Теперь изображение получено, и освободить его должна функция, которая дождалась результата.

Без resource.close() интерфейс останется правильным, но момент освобождения ненужного изображения мы оставим браузеру. У ImageBitmap для явного освобождения есть метод close().

Другая ситуация: изображение уже получили, начали уменьшать и только потом отменили работу. Тогда исходное изображение находится в operation.resource, и его можно закрыть при отмене. Уменьшенная копия, если всё же появится позже, потребует своего close(). Эти два объекта освобождаются в разных местах, потому что возникают в разные моменты.

Я бы держал это различие видимым в коде. Универсальная функция «очистить всё», которая берёт изображение из общего состояния, после запуска B удалит уже другую фотографию.

Одна функция завершает свой запуск

Соберём модуль preview.js. Функция prepare подготавливает исходное изображение и возвращает ресурс с методами process() и close(). Первый возвращает промис с уменьшенной копией, второй освобождает исходник. Функция show сразу рисует переданную копию и обновляет подпись.

finish сначала проверяет, относится ли вызов к текущему запуску. Поэтому одну функцию можно использовать для успешного результата, ошибки и таймаута. Вызов от прежнего запуска ничего не изменит.

export function createPreview(prepare, show, timers = globalThis) {
  let current = null;

  function release(operation) {
    timers.clearTimeout(operation.timer);
    operation.controller.abort();

    const resource = operation.resource;
    operation.resource = null;
    resource?.close();
  }

  function finish(operation, state) {
    if (current !== operation) {
      return;
    }

    current = null;
    release(operation);
    show(state);
  }

  async function run(operation, file) {
    let resource;

    try {
      resource = await prepare(file, operation.controller.signal);
    } catch {
      finish(operation, { status: 'error' });
      return;
    }

    if (current !== operation) {
      resource.close();
      return;
    }

    operation.resource = resource;
    let image;

    try {
      image = await resource.process();
    } catch {
      finish(operation, { status: 'error' });
      return;
    }

    try {
      finish(operation, { status: 'ready', image });
    } finally {
      image.close();
    }
  }

  function start(file) {
    if (current) {
      const previous = current;
      current = null;
      release(previous);
    }

    const operation = {
      controller: new AbortController(),
      resource: null,
      timer: null
    };

    current = operation;
    show({ status: 'working' });
    operation.timer = timers.setTimeout(() => {
      finish(operation, { status: 'timeout' });
    }, 5000);

    return run(operation, file);
  }

  function cancel() {
    if (current) {
      finish(current, { status: 'cancelled' });
    }
  }

  return { start, cancel };
}

Здесь два места ожидания. После первого мы либо передаём полученный ресурс своему объекту операции, либо закрываем его. После второго результат попадает в finish, которая повторяет проверку владельца. Затем уменьшенное изображение закрывается в finally независимо от того, показали его или отбросили.

Перед освобождением ресурсов current становится null. Даже если сигнал отмены вызовет дополнительное завершение старой работы, она уже потеряла право менять интерфейс. Повторный cancel() тоже ничего не сделает.

При новом start предыдущая операция теряет это право до подготовки следующей. Мы не ждём её промис и не показываем промежуточную надпись об отмене: после выбора нового файла сразу сообщаем о начале его обработки.

Таймер в примере запланирован на пять секунд. Когда его обработчик выполнится, текущая работа получит сигнал отмены и больше ничего не покажет. Это не жёсткий предел ожидания: браузер может задержать таймер, например из-за занятого основного потока. Физически прервать декодирование таймер тоже не умеет.

Пример рассчитывает на определённое поведение переданных функций. Они и обработчики сигнала отмены не вызывают start или cancel повторно из своего синхронного кода. close не бросает исключений. После вызова process исходник можно закрыть, даже если промис ещё не завершён; успешный результат будет новым, ещё не закрытым объектом изображения. Ниже разберём, почему createImageBitmap позволяет так сделать.

show работает синхронно. Если он сохраняет изображение для дальнейшего использования вместо немедленного рисования на canvas, закрывать объект должен уже этот обработчик. Наш finally освободил бы его слишком рано.

Подключаем настоящую фотографию

В prepareImage декодируем файл, затем уменьшаем ширину до 320 пикселей, сохраняя пропорции. Маленькую фотографию не увеличиваем. Сохраним эту функцию в image.js рядом с preview.js.

export async function prepareImage(file, signal) {
  signal.throwIfAborted();
  const source = await createImageBitmap(file);

  return {
    process() {
      signal.throwIfAborted();

      const width = Math.min(320, source.width);
      const height = Math.max(1, Math.round((source.height * width) / source.width));

      return createImageBitmap(source, {
        resizeWidth: width,
        resizeHeight: height,
        resizeQuality: 'high'
      });
    },
    close() {
      source.close();
    }
  };
}

Проверки сигнала не позволяют начать этап, который уже отменён. Между проверкой и вызовом createImageBitmap в этом коде нет ожидания. Если отмена произойдёт во время декодирования или уменьшения, preview.js получит изображение, проверит текущий запуск и закроет ненужный результат.

В ветке для ImageBitmap алгоритм createImageBitmap копирует данные исходника до возврата промиса, а его завершение ставит в очередь отдельной задачей. Поэтому после вызова можно закрыть source, не испортив результат. Сам факт возврата промиса не означает, что вся работа выполняется вне основного потока. Если другой process читает исходник после собственного await, преждевременный close ему уже не подойдёт.

Намеренно не добавляю второй throwIfAborted() сразу после декодирования. К этому моменту ресурс уже существует. Если бросить исключение и забыть source.close(), вызывающий код получит отказ вместо объекта, который мог бы освободить. В нашем варианте подготовленный ресурс доходит до владельца даже после отмены, и тот его закрывает.

Для показа достаточно canvas. Рисунок остаётся на нём после drawImage, поэтому уменьшенную копию можно закрыть сразу после возвращения из show.

import { createPreview } from './preview.js';
import { prepareImage } from './image.js';

const canvas = document.querySelector('canvas');
const context = canvas.getContext('2d');
const status = document.querySelector('[role="status"]');
const messages = {
  working: 'Готовим изображение…',
  cancelled: 'Обработка отменена.',
  timeout: 'Обработка заняла слишком много времени. Попробуйте другое изображение.',
  error: 'Не удалось обработать изображение. Попробуйте другой файл.',
  ready: 'Изображение готово.'
};

const preview = createPreview(prepareImage, state => {
  status.textContent = messages[state.status];

  if (state.status === 'ready') {
    canvas.width = state.image.width;
    canvas.height = state.image.height;
    context.drawImage(state.image, 0, 0);
  } else if (state.status === 'working') {
    context.clearRect(0, 0, canvas.width, canvas.height);
  }
});

document.querySelector('input').addEventListener('change', async event => {
  const file = event.target.files[0];

  if (file) {
    await preview.start(file);
  }
});

document.querySelector('button').addEventListener('click', preview.cancel);

Этот модуль подключается через <script type="module"> к такой разметке:

<label>Изображение <input type="file" accept="image/*"></label>
<button type="button">Отменить</button>
<p role="status">Выберите изображение.</p>
<canvas width="320" height="160" aria-label="Уменьшенное изображение"></canvas>

Это пример управления операцией, а не готовая защита от любого загруженного файла. В частности, декодирование большой фотографии может занять много памяти ещё до уменьшения. Ограничение размеров изображения и общий бюджет памяти придётся решать отдельно; объект владельца сам их не вводит.

Проверяем продолжения по очереди

Нормально работающая кнопка отмены ещё мало что доказывает. Нужны проверки, в которых старая работа действительно продолжается после отмены. Для этого prepare в проверках возвращает управляемый промис, а его process возвращает другой. Владелец остаётся тем же, что в браузере.

Первый промис останавливает выполнение перед получением исходного изображения. Второй останавливает его перед получением уменьшенной копии. На этих двух границах можно воспроизвести разные случаи:

Что делаем после отмены A и запуска B Что должно произойти
Возвращаем уменьшенную копию A Копия A закрывается, B продолжает работать
Завершаем обработку A с ошибкой Ошибка A не заменяет подпись или результат B
Возвращаем только что подготовленный исходник A Исходник A закрывается, уменьшение не начинается
Вызываем сохранённую функцию таймаута A B сохраняет право на результат и не получает сигнал отмены

В последней проверке мы вызываем сохранённую функцию напрямую, даже после снятия таймера. Так отдельно проверяется защита внутри finish: сможет ли функция от A завершить работу B? Нативный таймер после clearTimeout вызывать её не должен.

Поздний результат проверим отдельным файлом stale-result.js рядом с preview.js. Его можно запустить командой node stale-result.js. Ресурс в этом тесте считает закрытия вместо работы с пикселями, а его process ждёт, пока мы сами завершим промис. Сигнал started позволяет дождаться входа в обработку, прежде чем отменять её.

import assert from 'node:assert/strict';
import { createPreview } from './preview.js';

function createResource(name) {
  const result = Promise.withResolvers();
  const started = Promise.withResolvers();

  return {
    result,
    started,
    closed: 0,
    image: {
      name,
      closed: 0,
      close() {
        this.closed += 1;
      }
    },
    process() {
      started.resolve();
      return result.promise;
    },
    close() {
      this.closed += 1;
    }
  };
}

const first = createResource('A');
const second = createResource('B');
const resources = { A: first, B: second };
const states = [];
const prepare = async name => resources[name];
const show = state => states.push(state);
const preview = createPreview(prepare, show);

const a = preview.start('A');
await first.started.promise;

preview.cancel();
const b = preview.start('B');
await second.started.promise;

first.result.resolve(first.image);
await a;

assert.equal(states.at(-1).status, 'working');
assert.equal(first.closed, 1);
assert.equal(first.image.closed, 1);
assert.equal(second.closed, 0);

second.result.resolve(second.image);
await b;

assert.equal(states.at(-1).image.name, 'B');
assert.equal(second.closed, 1);
assert.equal(second.image.closed, 1);

Здесь проверяется и интерфейс, и освобождение ресурсов. Один только статус working не заметил бы утечку исходника A. Один счётчик закрытий не заметил бы, что вместе с ним закрыли ресурс B.

Отдельно проверим успешное завершение B и ошибку во время её обработки. Иначе код, который отбрасывает вообще все результаты, тоже пройдёт проверки устаревания. Повторный вызов отмены и повторное завершение по таймауту не должны порождать новые сообщения.

Если результат приходит из воркера

Граница владения сохраняется и при обработке в отдельном потоке. Если каждому запуску принадлежит отдельный Worker, обработчик сообщения может замыкать его экземпляр и сравнивать с текущим. Если один воркер обрабатывает несколько запусков, сообщениям нужен идентификатор операции. Проверки одной фазы «обрабатываем» недостаточно по той же причине, что и общего active.

У воркера есть terminate(), который прекращает его работу. Это полезно для освобождения вычислительных ресурсов, но уже начатое асинхронное продолжение в основном потоке принадлежит своему запуску. Если обработчик сообщения успел вызвать другой API и остановился на await, после ожидания ему всё равно нужно проверить владельца. Полученное через сообщение изображение также нельзя забыть только потому, что оно больше не нужно: если владение ImageBitmap передано основному потоку, здесь его и закрывают.

У этого решения есть и предел. Если вызванный API продолжает удерживать незавершённую операцию, её ресурсы могут оставаться в памяти и после отмены показа. Сам по себе ожидающий промис ещё не доказывает утечку. Мы защитили текущий интерфейс от старого результата, но не заставили произвольный API прекратить работу. Для освобождения такой операции нужен отдельный договор с тем, кто её выполняет.

Теперь можно выбрать другую фотографию сразу. Прежний запуск ещё может закончиться, но уже не подменит изображение, не покажет свою ошибку и не закроет ресурс нового запуска. При чтении такого кода я смотрю на каждое продолжение после await: как оно узнаёт, можно ли ещё менять интерфейс, и кто освободит полученный ресурс, если запуск уже отменён.