JavaScript: интересные возможности AbortController +9




Привет, друзья!


Представляю вашем вниманию адаптированный и дополненный перевод этой замечательной статьи.


AbortController и AbortSignal предоставляют возможность применения некоторых интересных паттернов, рассмотрению которых и посвящена данная статья.


Однако давайте начнем с типичного примера использования AbortController.


Предположим, что у нас имеется такая разметка:


<div id="app">
  <label
    >Задержка в мс:
    <input type="number" value="5000" id="delayInput" />
  </label>
  <div>
    <p id="logBox"></p>
    <pre id="dataBox"></pre>
  </div>
  <div>
    <button id="fetchBtn">Отправить запрос</button>
    <button id="abortBtn">Прервать запрос</button>
  </div>
</div>

Скрипт:


// в чистом `JS` доступ к элементам с идентификаторами
// можно получать напрямую
// `window.fetchBtn === document.getElementById('fetchBtn')`
fetchBtn.onclick = async () => {
  // создаем экземпляр контроллера
  const controller = new AbortController()

  abortBtn.addEventListener(
    'click',
    () => {
      // прерываем запрос
      controller.abort()
    },
    { once: true }
  )

  try {
    logBox.textContent = 'Start fetching'

    const response = await fetch(
      // указываем задержку
      `https://jsonplaceholder.typicode.com/users/1?_delay=${delayInput.value}`,
      // передаем сигнал
      { signal: controller.signal }
    )

    logBox.textContent = 'End fetching'

    const data = await response.json()

    dataBox.textContent = JSON.stringify(data, null, 2)
  } catch (e) {
    // если запрос был прерван
    if (e.name === 'AbortError') {
      logBox.textContent = 'Request aborted'
    } else {
      console.error(e)
    }
  }
}

Запрос выполняется с задержкой, указанной в соответствующем поле. Если во время выполнения запроса возникает событие нажатия кнопки abortBtn, выполнение запроса прекращается — выбрасывается исключение AbortError.


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


Контроллер и сигнал


Еще раз взглянем на сигнатуру AbortController:


const controller = new AbortController()
const { signal } = controller

signal — это экземпляр класса AbortSignal. Для чего нужны 2 разных класса? Дело в том, что они выполняют разные задачи:


  • контроллер позволяет владельцу прерывать сигнал с помощью controller.abort();
  • сигнал не может прерываться напрямую: его можно либо передавать, например, в fetch, либо обрабатывать изменение его состояния вручную.

Прерванное состояние проверяется с помощью signal.aborted или через обработчик события abort (fetch реализует это самостоятельно).


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


Случаи использования


Прерывание "легаси" объектов


Некоторые старые части DOM API не поддерживают AbortSignal. Одной из таких частей является WebSocket, предоставляющий метод close для закрытия соединения. Реализовать его прерывание можно следующим образом:


function createAbortableSocket(url, signal) {
  const w = new WebSocket(url)

  if (signal.aborted) {
    w.close()  // сигнал прерван, закрываем соединение
  }

  signal.addEventListener('abort', () => w.close(), { once: true })

  return w
}

Обратите внимание: если сигнал уже прерван, событие abort не возникает — в этом случае мы сразу закрываем соединение с помощью метода close.


Удаление обработчиков событий


Следующий код работать не будет:


window.addEventListener('resize', () => doSomething())

// не надо так делать
window.removeEventListener('resize', () => doSomething())

Две функции обратного вызова — это разные объекты, поэтому удаление несуществующего обработчика просто тихо завершается неудачей.


AbortSignal позволяет передать обработчику сигнал для его отмены:


const controller = new AbortController()
const { signal } = controller

window.addEventListener('resize', () => doSomething(), { signal })

// позднее
controller.abort()

Обратите внимание: в старых браузерах приведенный пример работать не будет. Полифил.


Паттерн "Конструктор"


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


Вот как обычно выглядит такой код:


const someObject = new SomeObject()
someObject.start()

// позднее
someObject.stop()

AbortSignal позволяет сделать его более эргономичным:


const controller = new AbortController()
const { signal } = controller

const someObject = new SomeObject(signal)

// позднее
controller.abort()

Когда это может пригодиться?


  1. Это делает использование SomeObject однократным, его состояние переходит от запуска до остановки только один раз. Когда требуется другой SomeObject, просто создается новый экземпляр.
  2. Один AbortSignal может распределяться между несколькими SomeObject для их одновременной остановки после выполнения связанной группы задач.
  3. SomeObject может передавать полученный сигнал дальше, например, в fetch.

export class SomeObject {
  constructor(signal) {
    this.signal = signal

    // начальный запрос
    const r = fetch('https://example.com/some-data', { signal })
  }

  doSomeComplexOperation() {
    if (this.signal.aborted) {
      // объект не может использоваться после остановки
      throw new Error(`объект остановлен`)
    }
    for (let i = 0; i < 1_000_000; i += 1) {
      // выполняем сложные вычисления
    }
  }
}

Выполнение асинхронных операций в хуках (P)react


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


function SomeComponent({ someProp }) {
  useEffect(() => {
    fetch(url + someProp).then().catch().finally()
  }, [someProp])

return <></>
}

Вместо этого, предыдущий эффект можно прерывать с помощью AbortController:


function FooComponent({ someProp }) {
  useEffect(() => {
    const controller = new AbortController()
    const { signal } = controller

    ;(async () => {
      const r = await fetch(url + someProp, { signal })
      // ...
    })()

    return () => controller.abort()
  }, [someProp])

  return <></>
}

Эту логику можно инкапсулировать в кастомном хуке, например, useEffectAsync.


Обратите внимание: несмотря на то, что ключевое слово await приостанавливает выполнение асинхронного кода, состояние хука является стабильным. Другими словами, значения, используемые в хуке, остаются неизменяемыми на протяжении всего жизненного цикла хука:


function SomeComponent() {
  const [v, setV] = useState(0)

  useEffectAsync(async (signal) => {
    await new Promise((r) => setTimeout(r, 1000))

    // Какое значение будет иметь `v`?
    // Значение этой переменной всегда будет равняться `0`,
    // даже если в период задержки будет нажата кнопка
  }, [])

  return <button onClick={() => setV((v) => v + 1)}>Увеличить</button>
}

Вспомогательные функции


AbortSignal предоставляет несколько вспомогательных функций.


Обратите внимание: не все эти функции могут быть доступны к моменту чтения данной статьи.


AbortSignal.timeout(ms)


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


function abortTimeout(ms) {
  const controller = new AbortController()

  const timerId = setTimeout(() => {
    controller.abort()
    clearTimeout(timerId)
  }, ms)

  return controller.signal
}

AbortSignal.any(signals)


Данная функция создает сигнал, который прерывается в случае прекращения любого из переданных ему сигналов. Обратите внимание: если не передать ни одного сигнала, производный сигнал никогда не будет прерван. Полифил:


function abortAny(signals) {
  const controller = new AbortController()

  signals.forEach((signal) => {
    if (signal.aborted) {
      controller.abort()
    } else {
      signal.addEventListener('abort', () => controller.abort(), { once: true })
    }
  })

  return controller.signal
}

AbortSignal.throwIfAborted()


Данная функция просто выбрасывает исключение в случае прерывания сигнала:


if (signal.aborted) {
  throw new Error(errMsg)
}
// альтернатива
signal.throwIfAborted()

Полифил может выглядеть так:


function throwIfSignalAborted(signal) {
  if (signal.aborted) {
    throw new Error(errMsg)
  }
}

Пожалуй, это все, чем я хотел поделиться с вами в данной статье. Надеюсь, вы, как и я, узнали для себя что-то новое и не зря потратили время.


Благодарю за внимание и happy coding!







Комментарии (1):

  1. serjJS
    /#24467216 / +2

    Отличная статья, спасибо