Promessa: è possibile forzare l'annullamento di una promessa


95

Uso ES6 Promises per gestire il recupero di tutti i dati di rete e ci sono alcune situazioni in cui devo forzarne l'annullamento.

Fondamentalmente lo scenario è tale che ho una ricerca type-ahead sull'interfaccia utente in cui la richiesta è delegata al backend deve eseguire la ricerca in base all'input parziale. Sebbene questa richiesta di rete (n. 1) possa richiedere un po 'di tempo, l'utente continua a digitare, il che alla fine attiva un'altra chiamata di backend (n. 2)

Qui il n. 2 ha naturalmente la precedenza sul n. 1, quindi desidero annullare la richiesta di wrapping Promise n. 1. Ho già una cache di tutte le promesse nel livello dati, quindi in teoria posso recuperarla mentre sto tentando di inviare una promessa per # 2.

Ma come faccio a cancellare la Promessa n. 1 una volta recuperata dalla cache?

Qualcuno potrebbe suggerire un approccio?


2
è un'opzione per utilizzare un qualche equivalente di una funzione antirimbalzo per non attivare spesso e diventare richieste obsolete? Supponiamo che un ritardo di 300 ms andrebbe bene. Ad esempio Lodash ha una delle implementazioni - lodash.com/docs#debounce
shershen

Questo è quando cose come Bacon e Rx diventano utili.
elclanrs

@shershen sì - abbiamo questo ma non si tratta tanto del problema dell'interfaccia utente ... la query del server potrebbe richiedere un po 'di tempo, quindi voglio essere in grado di annullare le promesse ...
Moonwalker


Prova gli osservabili da Rxjs
FieryCod

Risposte:


173

No. Non possiamo ancora farlo.

Le promesse di ES6 non supportano ancora la cancellazione . Sta arrivando e il suo design è qualcosa su cui molte persone hanno lavorato molto duramente. La semantica della cancellazione del suono è difficile da ottenere correttamente e questo è un lavoro in corso. Ci sono dibattiti interessanti sul repo "fetch", su discus e su molti altri repo su GH, ma sarei solo paziente se fossi in te.

Ma, ma, ma .. la cancellazione è davvero importante!

La realtà della questione è che la cancellazione è davvero uno scenario importante nella programmazione lato client. I casi che descrivi come richieste web interrotte sono importanti e sono ovunque.

Quindi ... la lingua mi ha fregato!

Sì, mi dispiace per quello. Le promesse dovevano arrivare prima che venissero specificate ulteriori cose - quindi sono entrate senza alcune cose utili come .finallye .cancel- è in arrivo, però, alle specifiche attraverso il DOM. L'annullamento non è un ripensamento, è solo un vincolo di tempo e un approccio più iterativo alla progettazione delle API.

Quindi cosa posso fare?

Hai diverse alternative:

  • Usa una libreria di terze parti come bluebird che può muoversi molto più velocemente delle specifiche e quindi avere la cancellazione e un sacco di altre chicche: questo è ciò che fanno le grandi aziende come WhatsApp.
  • Passa un token di annullamento .

L'uso di una libreria di terze parti è abbastanza ovvio. Per quanto riguarda un token, puoi fare in modo che il tuo metodo prenda una funzione e quindi la chiami, come tale:

function getWithCancel(url, token) { // the token is for cancellation
   var xhr = new XMLHttpRequest;
   xhr.open("GET", url);
   return new Promise(function(resolve, reject) {
      xhr.onload = function() { resolve(xhr.responseText); });
      token.cancel = function() {  // SPECIFY CANCELLATION
          xhr.abort(); // abort request
          reject(new Error("Cancelled")); // reject the promise
      };
      xhr.onerror = reject;
   });
};

Che ti permetterebbe di fare:

var token = {};
var promise = getWithCancel("/someUrl", token);

// later we want to abort the promise:
token.cancel();

Il tuo caso d'uso effettivo - last

Questo non è troppo difficile con l'approccio token:

function last(fn) {
    var lastToken = { cancel: function(){} }; // start with no op
    return function() {
        lastToken.cancel();
        var args = Array.prototype.slice.call(arguments);
        args.push(lastToken);
        return fn.apply(this, args);
    };
}

Che ti permetterebbe di fare:

var synced = last(getWithCancel);
synced("/url1?q=a"); // this will get canceled 
synced("/url1?q=ab"); // this will get canceled too
synced("/url1?q=abc");  // this will get canceled too
synced("/url1?q=abcd").then(function() {
    // only this will run
});

E no, biblioteche come Bacon e Rx non "brillano" qui perché sono librerie osservabili, hanno solo lo stesso vantaggio che le biblioteche promesse a livello utente hanno non essendo vincolate alle specifiche. Immagino che aspetteremo di avere e vedere in ES2016 quando gli osservabili diventeranno nativi. Tuttavia sono eleganti per i tipografi.


28
Benjamin, mi è piaciuto molto leggere la tua risposta. Molto ben pensato, strutturato, articolato e con buoni esempi pratici e alternative. Davvero utile. Grazie.
Moonwalker

I token di cancellazione di @FranciscoPresencia sono in arrivo come proposta della fase 1.
Benjamin Gruenbaum,

Dove possiamo leggere su questa cancellazione basata su token? Dov'è la proposta?
danno il

@harm la proposta è morta allo stadio 1.
Benjamin Gruenbaum

1
Adoro il lavoro di Ron, ma penso che dovremmo aspettare un po 'prima di dare consigli alle biblioteche che le persone non stanno ancora usando:] Grazie per il link anche se lo controllerò!
Benjamin Gruenbaum

24

Le proposte standard di promesse cancellabili sono fallite.

Una promessa non è una superficie di controllo per l'azione asincrona che la realizza; confonde il proprietario con il consumatore. Creare invece funzioni asincrone che possono essere annullate tramite alcuni token passati.

Un'altra promessa rende un bel token, rendendo l'annullamento facile da implementare con Promise.race:

Esempio: utilizzare Promise.raceper annullare l'effetto di una catena precedente:

let cancel = () => {};

input.oninput = function(ev) {
  let term = ev.target.value;
  console.log(`searching for "${term}"`);
  cancel();
  let p = new Promise(resolve => cancel = resolve);
  Promise.race([p, getSearchResults(term)]).then(results => {
    if (results) {
      console.log(`results for "${term}"`,results);
    }
  });
}

function getSearchResults(term) {
  return new Promise(resolve => {
    let timeout = 100 + Math.floor(Math.random() * 1900);
    setTimeout(() => resolve([term.toLowerCase(), term.toUpperCase()]), timeout);
  });
}
Search: <input id="input">

Qui stiamo "cancellando" le ricerche precedenti inserendo un undefinedrisultato e provandolo, ma potremmo facilmente immaginare di rifiutarlo con "CancelledError".

Ovviamente questo non annulla effettivamente la ricerca in rete, ma questa è una limitazione di fetch. Se fetchdovesse prendere come argomento una promessa di annullamento, potrebbe annullare l'attività di rete.

Ho proposto questo "Annulla schema di promessa" su es-discuss, esattamente per suggerire di fetchfarlo.


@jib perché rifiutare la mia modifica? Lo chiarisco solo.
allenyllee

8

Ho controllato il riferimento a Mozilla JS e ho trovato questo:

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race

Controlliamolo:

var p1 = new Promise(function(resolve, reject) { 
    setTimeout(resolve, 500, "one"); 
});
var p2 = new Promise(function(resolve, reject) { 
    setTimeout(resolve, 100, "two"); 
});

Promise.race([p1, p2]).then(function(value) {
  console.log(value); // "two"
  // Both resolve, but p2 is faster
});

Abbiamo qui p1 e p2 inseriti Promise.race(...)come argomenti, questo in realtà sta creando una nuova promessa di risoluzione, che è ciò di cui hai bisogno.


PIACEVOLE - questo forse è esattamente ciò di cui ho bisogno. Lo proverò.
Moonwalker

Se hai problemi con esso puoi incollare il codice qui così posso aiutarti :)
nikola-miljkovic

6
Provato. Non proprio lì. Questo risolve la promessa più veloce ... devo sempre risolvere l'ultima promessa inviata, ovvero annullare incondizionatamente le promesse precedenti ..
Moonwalker

1
In questo modo tutte le altre promesse non vengono più gestite, non puoi effettivamente annullare una promessa.
nikola-miljkovic

L'ho provato, la seconda promessa (una in questo ex) non lasciare che il processo
finisca

3

Per Node.js ed Electron, consiglio vivamente di utilizzare Promise Extensions per JavaScript (Prex) . Il suo autore Ron Buckton è uno degli ingegneri chiave di TypeScript ed è anche il ragazzo dietro l'attuale proposta di cancellazione ECMAScript di TC39 . La libreria è ben documentata e ci sono buone probabilità che alcuni di Prex raggiungano lo standard.

Su una nota personale e provenendo dal background di C #, mi piace molto il fatto che Prex sia modellato sull'esistente nel framework Managed Threads , ovvero basato sull'approccio adottato con le API CancellationTokenSource/ CancellationToken.NET. Nella mia esperienza, quelli sono stati molto utili per implementare una solida logica di cancellazione nelle app gestite.

Ho anche verificato che funzionasse all'interno di un browser raggruppando Prex utilizzando Browserify .

Ecco un esempio di ritardo con cancellazione ( Gist e RunKit , utilizzando Prex per i suoi CancellationTokeneDeferred ):

// by @noseratio
// https://gist.github.com/noseratio/141a2df292b108ec4c147db4530379d2
// https://runkit.com/noseratio/cancellablepromise

const prex = require('prex');

/**
 * A cancellable promise.
 * @extends Promise
 */
class CancellablePromise extends Promise {
  static get [Symbol.species]() { 
    // tinyurl.com/promise-constructor
    return Promise; 
  }

  constructor(executor, token) {
    const withCancellation = async () => {
      // create a new linked token source 
      const linkedSource = new prex.CancellationTokenSource(token? [token]: []);
      try {
        const linkedToken = linkedSource.token;
        const deferred = new prex.Deferred();
  
        linkedToken.register(() => deferred.reject(new prex.CancelError()));
  
        executor({ 
          resolve: value => deferred.resolve(value),
          reject: error => deferred.reject(error),
          token: linkedToken
        });

        await deferred.promise;
      } 
      finally {
        // this will also free all linkedToken registrations,
        // so the executor doesn't have to worry about it
        linkedSource.close();
      }
    };

    super((resolve, reject) => withCancellation().then(resolve, reject));
  }
}

/**
 * A cancellable delay.
 * @extends Promise
 */
class Delay extends CancellablePromise {
  static get [Symbol.species]() { return Promise; }

  constructor(delayMs, token) {
    super(r => {
      const id = setTimeout(r.resolve, delayMs);
      r.token.register(() => clearTimeout(id));
    }, token);
  }
}

// main
async function main() {
  const tokenSource = new prex.CancellationTokenSource();
  const token = tokenSource.token;
  setTimeout(() => tokenSource.cancel(), 2000); // cancel after 2000ms

  let delay = 1000;
  console.log(`delaying by ${delay}ms`); 
  await new Delay(delay, token);
  console.log("successfully delayed."); // we should reach here

  delay = 2000;
  console.log(`delaying by ${delay}ms`); 
  await new Delay(delay, token);
  console.log("successfully delayed."); // we should not reach here
}

main().catch(error => console.error(`Error caught, ${error}`));

Nota che la cancellazione è una gara. Ad esempio, una promessa potrebbe essere stata risolta con successo, ma nel momento in cui la osservi (con awaito then), anche la cancellazione potrebbe essere stata attivata. Dipende da te come gestisci questa gara, ma non fa male chiamare token.throwIfCancellationRequested()un tempo supplementare, come ho fatto sopra.


1

Ho affrontato un problema simile di recente.

Avevo un client basato su promesse (non di rete) e volevo fornire sempre gli ultimi dati richiesti all'utente per mantenere fluida l'interfaccia utente.

Dopo aver lottato con l'idea di cancellazione, Promise.race(...)e Promise.all(..)ho appena iniziato ricordando la mia ultima richiesta id e quando la promessa è stata soddisfatta ero il rendering solo i miei dati quando abbinato l'id di una ultima richiesta.

Spero che aiuti qualcuno.


La domanda di Slomski non riguarda cosa mostrare sull'interfaccia utente. Si tratta di annullare la promessa
CyberAbhay


0

Puoi rifiutare la promessa prima di terminare:

// Our function to cancel promises receives a promise and return the same one and a cancel function
const cancellablePromise = (promiseToCancel) => {
  let cancel
  const promise = new Promise((resolve, reject) => {
    cancel = reject
    promiseToCancel
      .then(resolve)
      .catch(reject)
  })
  return {promise, cancel}
}

// A simple promise to exeute a function with a delay
const waitAndExecute = (time, functionToExecute) => new Promise((resolve, reject) => {
  timeInMs = time * 1000
  setTimeout(()=>{
    console.log(`Waited ${time} secs`)
    resolve(functionToExecute())
  }, timeInMs)
})

// The promise that we will cancel
const fetchURL = () => fetch('https://pokeapi.co/api/v2/pokemon/ditto/')

// Create a function that resolve in 1 seconds. (We will cancel it in 0.5 secs)
const {promise, cancel} = cancellablePromise(waitAndExecute(1, fetchURL))

promise
  .then((res) => {
    console.log('then', res) // This will executed in 1 second
  })
  .catch(() => {
    console.log('catch') // We will force the promise reject in 0.5 seconds
  })

waitAndExecute(0.5, cancel) // Cancel previous promise in 0.5 seconds, so it will be rejected before finishing. Commenting this line will make the promise resolve

Sfortunatamente la chiamata di recupero è già stata eseguita, quindi vedrai la chiamata risolversi nella scheda Rete. Il tuo codice lo ignorerà.


0

Utilizzando la sottoclasse Promise fornita dal pacchetto esterno, questo può essere fatto come segue: Demo live

import CPromise from "c-promise2";

function fetchWithTimeout(url, {timeout, ...fetchOptions}= {}) {
    return new CPromise((resolve, reject, {signal}) => {
        fetch(url, {...fetchOptions, signal}).then(resolve, reject)
    }, timeout)
}

const chain= fetchWithTimeout('http://localhost/')
    .then(response => response.json())
    .then(console.log, console.warn);

//chain.cancel(); call this to abort the promise and releated request

-1

Perché @jib rifiuta la mia modifica, quindi inserisco la mia risposta qui. È solo la modifica della risposta di @ jib con alcuni commenti e l'utilizzo di nomi di variabili più comprensibili.

Di seguito mostro solo esempi di due metodi diversi: uno è resolve () l'altro è rifiuto ()

let cancelCallback = () => {};

input.oninput = function(ev) {
  let term = ev.target.value;
  console.log(`searching for "${term}"`);
  cancelCallback(); //cancel previous promise by calling cancelCallback()

  let setCancelCallbackPromise = () => {
    return new Promise((resolve, reject) => {
      // set cancelCallback when running this promise
      cancelCallback = () => {
        // pass cancel messages by resolve()
        return resolve('Canceled');
      };
    })
  }

  Promise.race([setCancelCallbackPromise(), getSearchResults(term)]).then(results => {
    // check if the calling of resolve() is from cancelCallback() or getSearchResults()
    if (results == 'Canceled') {
      console.log("error(by resolve): ", results);
    } else {
      console.log(`results for "${term}"`, results);
    }
  });
}


input2.oninput = function(ev) {
  let term = ev.target.value;
  console.log(`searching for "${term}"`);
  cancelCallback(); //cancel previous promise by calling cancelCallback()

  let setCancelCallbackPromise = () => {
    return new Promise((resolve, reject) => {
      // set cancelCallback when running this promise
      cancelCallback = () => {
        // pass cancel messages by reject()
        return reject('Canceled');
      };
    })
  }

  Promise.race([setCancelCallbackPromise(), getSearchResults(term)]).then(results => {
    // check if the calling of resolve() is from cancelCallback() or getSearchResults()
    if (results !== 'Canceled') {
      console.log(`results for "${term}"`, results);
    }
  }).catch(error => {
    console.log("error(by reject): ", error);
  })
}

function getSearchResults(term) {
  return new Promise(resolve => {
    let timeout = 100 + Math.floor(Math.random() * 1900);
    setTimeout(() => resolve([term.toLowerCase(), term.toUpperCase()]), timeout);
  });
}
Search(use resolve): <input id="input">
<br> Search2(use reject and catch error): <input id="input2">

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.