Abort signal: how to cancel asynchronous tasks in JavaScript

Posted by kustomjs on Mon, 06 Apr 2020 04:14:50 +0200

By Tomasz Jakut

Crazy technology house

Original text: ckeditor.com/blog/Aborti...

No reprint without permission

Sometimes it can be difficult to perform asynchronous tasks, especially when a specific programming language does not allow you to cancel operations that are not started by mistake or are no longer needed. Fortunately, JavaScript provides a very convenient function to suspend asynchronous activities. In this article, you can learn how to create a function that can be aborted.

Abort signal

Shortly after the introduction of Promise into ES2015 and the emergence of Web API s that support new asynchronous solutions, The need to cancel asynchronous tasks arises . The first attempts focused on Create a common solution And expect to be part of the ECMAScript standard in the future. However, the discussion soon fell into a deadlock and could not solve the problem. As a result, WHATWG prepared its own solution, and Introduce it directly into DOM in the form of AbortController . The obvious disadvantage of this solution is that the AbortController is not provided in Node.js, so there is no elegant or official way to cancel asynchronous tasks in this environment.

As you can see in the DOM specification, AbortController is described in a very general way. So you can use it in any type of asynchronous API - even one that doesn't exist yet. Currently, only the Fetch API is officially supported, but you can also use it in your own code!

Before we start, let's take a moment to analyze how AbortController works:

const abortController = new AbortController(); // 1
const abortSignal = abortController.signal; // 2

fetch( 'http://example.com', {
    signal: abortSignal // 3
} ).catch( ( { message } ) => { // 5
    console.log( message );
} );

abortController.abort(); // 4
Copy code

Looking at the above code, you will find that a new instance (1) of the AbortController DOM interface is created at the beginning and its signal property is bound to the variable (2). Then call fetch() and pass signal as one of its options (3). To abort getting resources, you simply call abortController.abort() (4). It automatically rejects promise for fetch(), and the control is passed to the catch() block (5).

The signal attribute itself is very interesting, it's the main star of the show. The property is AbortSignal DOM interface , which has the aborted attribute and contains information about whether the user has called the abortController.abort() method. You can also bind the abort event listener to the event listener that will be called when you call abortController.abort(). In other words: the AbortController is just a public interface to AbortSignal.

Terminate function

Suppose we use an asynchronous function to perform some very complex calculations (for example, Asynchronous processing of data from large arrays ) For simplicity, the example function simulates this by waiting for five seconds before returning the result:

function calculate() {
  return new Promise( ( resolve, reject ) => {
    setTimeout( ()=> {
      resolve( 1 );
    }, 5000 );
  } );
}

calculate().then( ( result ) => {
  console.log( result );
} );
Copy code

But sometimes users want to be able to abort this costly operation. Yes, they should have the ability. Add a button to start and stop the calculation:

<button id="calculate">Calculate</button>

<script type="module">
  document.querySelector( '#calculate' ).addEventListener( 'click', async ( { target } ) => { // 1
    target.innerText = 'Stop calculation';

    const result = await calculate(); // 2

    alert( result ); // 3

    target.innerText = 'Calculate';
  } );

  function calculate() {
    return new Promise( ( resolve, reject ) => {
      setTimeout( ()=> {
        resolve( 1 );
      }, 5000 );
    } );
  }
</script>
Copy code

In the above code, add an asynchronous click event listener to the button (1) and invoke the calculate() function (2) in it. After five seconds, the alarm dialog box (3) with the results is displayed. In addition, script [type = module] is used to force JavaScript code into strict mode -- because it is more elegant than the 'use strict' compilation directive.

Now add the ability to abort asynchronous tasks:

{ // 1
  let abortController = null; // 2

  document.querySelector( '#calculate' ).addEventListener( 'click', async ( { target } ) => {
    if ( abortController ) {
      abortController.abort(); // 5

      abortController = null;
      target.innerText = 'Calculate';

      return;
    }

    abortController = new AbortController(); // 3
    target.innerText = 'Stop calculation';

    try {
      const result = await calculate( abortController.signal ); // 4

      alert( result );
    } catch {
      alert( 'WHY DID YOU DO THAT?!' ); // 9
    } finally { // 10
      abortController = null;
      target.innerText = 'Calculate';
    }
  } );

  function calculate( abortSignal ) {
    return new Promise( ( resolve, reject ) => {
      const timeout = setTimeout( ()=> {
        resolve( 1 );
      }, 5000 );

      abortSignal.addEventListener( 'abort', () => { // 6
        const error = new DOMException( 'Calculation aborted by the user', 'AbortError' );

        clearTimeout( timeout ); // 7
        reject( error ); // 8
      } );
    } );
  }
}
Copy code

As you can see, the code gets longer. But there is no reason to panic, it has not become more difficult to understand!

Everything is contained in block (1), which Equivalent to IIFE . Therefore, the abortController variable (2) does not leak into the global scope.

First, set its value to null. This value changes when the mouse clicks the button. Then set its value to the new instance of AbortController (3). After that, pass the signal attribute of the instance directly to your calculate() function (4).

If the user clicks the button again within five seconds, it causes the abortController.abort() function to be called (5). In turn, this triggers the abort event (6) on the AbortSignal instance that you previously passed to calculate().

Inside the abort event listener, the tick timer (7) is removed and promise (8) with an appropriate error is rejected; According to specifications , which must be DOMException of type 'AbortError'). The error ultimately passes control to catch (9) and finally block (10).

You should also be prepared to deal with code that:

const abortController = new AbortController();

abortController.abort();
calculate( abortController.signal );
Copy code

In this case, the abort event will not be triggered because it occurs before the signal is passed to the calculate() function. So you should do some refactoring:

function calculate( abortSignal ) {
  return new Promise( ( resolve, reject ) => {
    const error = new DOMException( 'Calculation aborted by the user', 'AbortError' ); // 1

    if ( abortSignal.aborted ) { // 2
      return reject( error );
    }

    const timeout = setTimeout( ()=> {
      resolve( 1 );
    }, 5000 );

    abortSignal.addEventListener( 'abort', () => {
      clearTimeout( timeout );
      reject( error );
    } );
  } );
}
Copy code

The error was moved to the top (1). So you can reuse it in different parts of the code (but creating an error factory is more elegant, even if it sounds silly). In addition, a protection clause appears to check the value of abortSignal.aborted (2). If it is equal to true, the calculate() function rejects promise with the appropriate error without any other action.

This is how to create a fully aborted asynchronous function. The demo is available here( blog.comandeer.pl/assets/i-ci...

Welcome to the front end official account: front-end pioneer, free access to the front end engineering utility kit.

Topics: Attribute Javascript Programming ECMAScript