Promesa: ¿es posible forzar la cancelación de una promesa?

91

Utilizo ES6 Promises para administrar toda la recuperación de datos de mi red y hay algunas situaciones en las que necesito forzar su cancelación.

Básicamente, el escenario es tal que tengo una búsqueda de escritura anticipada en la interfaz de usuario donde la solicitud se delega al backend y tiene que realizar la búsqueda en función de la entrada parcial. Si bien esta solicitud de red (n. ° 1) puede llevar un poco de tiempo, el usuario continúa escribiendo, lo que eventualmente activa otra llamada de backend (n. ° 2)

Aquí el n. ° 2 tiene prioridad sobre el n. ° 1, por lo que me gustaría cancelar la solicitud n. ° 1 de cierre de Promise. Ya tengo un caché de todas las Promesas en la capa de datos, por lo que teóricamente puedo recuperarlo mientras intento enviar una Promesa para el n. ° 2.

Pero, ¿cómo cancelo la Promesa # 1 una vez que la recupero del caché?

¿Alguien podría sugerir un enfoque?

Caminante lunar
fuente
2
¿Es esa una opción para usar un equivalente de una función de rebote para que no se active con frecuencia y se convierta en solicitudes obsoletas? Digamos que un retraso de 300 ms funcionaría. Por ejemplo, Lodash tiene una de las implementaciones - lodash.com/docs#debounce
shershen
Aquí es cuando cosas como Bacon y Rx resultan útiles.
elclanrs
@shershen sí, tenemos esto, pero esto no se trata tanto del problema de la interfaz de usuario ... la consulta del servidor puede llevar un poco de tiempo, así que quiero poder cancelar las promesas ...
Moonwalker
Pruebe Observables de Rxjs
FieryCod

Respuestas:

163

No. No podemos hacer eso todavía.

ES6 promesas no son compatibles con la cancelación todavía . Está en camino y su diseño es algo en lo que mucha gente trabajó muy duro. Sonido semántica de cancelación de es difícil de hacer bien y esto es un trabajo en progreso. Hay debates interesantes sobre el repositorio "fetch", sobre esdiscuss y sobre varios otros repositorios en GH, pero sería paciente si fuera usted.

Pero, pero, pero ... ¡la cancelación es realmente importante!

Es decir, la realidad del asunto es que la cancelación es realmente un escenario importante en la programación del lado del cliente. Los casos que describe como abortar solicitudes web son importantes y están en todas partes.

Entonces ... ¡el idioma me jodió!

Sí, lo siento por eso. Las promesas tenían que entrar primero antes de que se especificaran más cosas, por lo que entraron sin algunas cosas útiles como .finallyy .cancel, sin embargo, está en camino, según las especificaciones a través del DOM. La cancelación no es una ocurrencia tardía, es solo una restricción de tiempo y un enfoque más iterativo para el diseño de API.

¿Entonces Que puedo hacer?

Tienes varias alternativas:

  • Use una biblioteca de terceros como bluebird, que puede moverse mucho más rápido que la especificación y, por lo tanto, tener cancelación, así como un montón de otras ventajas, esto es lo que hacen las grandes empresas como WhatsApp.
  • Pase una ficha de cancelación .

Usar una biblioteca de terceros es bastante obvio. En cuanto a un token, puede hacer que su método tome una función y luego la llame, como tal:

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;
   });
};

Lo que te permitiría hacer:

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

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

Su caso de uso real: last

Esto no es demasiado difícil con el enfoque de 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);
    };
}

Lo que te permitiría hacer:

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
});

Y no, las bibliotecas como Bacon y Rx no "brillan" aquí porque son bibliotecas observables, simplemente tienen la misma ventaja que tienen las bibliotecas de promesa de nivel de usuario al no estar vinculadas a especificaciones. Supongo que esperaremos y veremos en ES2016 cuando los observables se vuelvan nativos. Ellos son ingenioso para typeahead sin embargo.

Benjamin Gruenbaum
fuente
25
Benjamin, disfruté mucho leyendo tu respuesta. Muy bien pensado, estructurado, articulado y con buenos ejemplos prácticos y alternativas. De mucha ayuda. Gracias.
Moonwalker
Los tokens de cancelación de @FranciscoPresencia están en camino como una propuesta de etapa 1.
Benjamin Gruenbaum
¿Dónde podemos leer sobre esta cancelación basada en tokens? ¿Dónde está la propuesta?
daño
@harm la propuesta está muerta en la etapa 1.
Benjamin Gruenbaum
1
Me encanta el trabajo de Ron, pero creo que deberíamos esperar un poco antes de hacer recomendaciones para las bibliotecas que la gente aún no usa:] ¡Gracias por el enlace, aunque lo revisaré!
Benjamin Gruenbaum
24

Las propuestas estándar de promesas cancelables han fracasado.

Una promesa no es una superficie de control para la acción asincrónica que la cumple; confunde propietario con consumidor. En su lugar, cree funciones asincrónicas que se pueden cancelar mediante algún token pasado.

Otra promesa es un buen token, lo que hace que cancelar sea fácil de implementar con Promise.race:

Ejemplo: Úselo Promise.racepara cancelar el efecto de una cadena anterior:

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">

Aquí estamos "cancelando" búsquedas anteriores inyectando un undefinedresultado y probando, pero podríamos imaginar fácilmente rechazar con "CancelledError".

Por supuesto, esto en realidad no cancela la búsqueda de red, pero esa es una limitación de fetch. Si fetchtomara una promesa de cancelación como argumento, podría cancelar la actividad de la red.

He propuesto este "patrón de cancelación de promesa" en es-discus, exactamente para sugerir que fetchhaga esto.

foque
fuente
@jib ¿por qué rechazar mi modificación? Solo lo aclaro.
allenyllee
8

Revisé la referencia de Mozilla JS y encontré esto:

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

Vamos a ver:

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
});

Aquí tenemos p1 y p2 Promise.race(...)como argumentos, esto en realidad está creando una nueva promesa de resolución, que es lo que necesita.

nikola-miljkovic
fuente
AGRADABLE - esto quizás sea exactamente lo que necesito. Voy a darle una oportunidad.
Moonwalker
Si tiene problemas con él, puede pegar el código aquí para que pueda ayudarlo :)
nikola-miljkovic
5
Lo intenté. No del todo ahí. Esto resuelve la Promesa más rápida ... Necesito resolver siempre la última enviada, es decir, cancelar incondicionalmente cualquier Promesa anterior ...
Moonwalker
1
De esta manera, todas las demás promesas ya no se manejan, en realidad no puede cancelar una promesa.
nikola-miljkovic
Lo intenté, la segunda promesa (una en este ex) no deje que el proceso salga :(
morteza ataiy
3

Para Node.js y Electron, recomiendo encarecidamente utilizar Promise Extensions para JavaScript (Prex) . Su autor, Ron Buckton, es uno de los ingenieros clave de TypeScript y también es el responsable de la propuesta de cancelación ECMAScript actual del TC39 . La biblioteca está bien documentada y es probable que algunos de Prex cumplan con el estándar.

En una nota personal y viniendo de los antecedentes de C #, me gusta mucho el hecho de que Prex se basa en el marco de Cancelación existente en Managed Threads , es decir, basado en el enfoque adoptado con CancellationTokenSource/ CancellationToken.NET API. En mi experiencia, han sido muy útiles para implementar una lógica de cancelación sólida en aplicaciones administradas.

También verifiqué que funciona dentro de un navegador al empaquetar Prex usando Browserify .

Aquí hay un ejemplo de un retraso con cancelación ( Gist y RunKit , usando Prex para su CancellationTokeny Deferred):

// 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}`));

Tenga en cuenta que la cancelación es una carrera. Es decir, es posible que una promesa se haya resuelto con éxito, pero en el momento en que la cumpla (con awaito then), es posible que la cancelación también se haya activado. Depende de usted cómo maneje esta carrera, pero no está de más pedir token.throwIfCancellationRequested()un tiempo extra, como lo hice anteriormente.

ratio nasal
fuente
1

Recientemente me enfrenté a un problema similar.

Tenía un cliente basado en promesas (no uno de red) y quería proporcionar siempre los últimos datos solicitados al usuario para mantener la interfaz de usuario sin problemas.

Después de luchar con la idea cancelación, Promise.race(...)y Promise.all(..)acabo de empezar recordando mi última petición Identificación y cuando se cumplió la promesa que sólo estaba haciendo mis datos cuando coincidían con el ID de una última petición.

Espero que ayude a alguien.

Igor Słomski
fuente
Slomski, la pregunta no es qué mostrar en la interfaz de usuario. Se trata de cancelar la promesa
CyberAbhay
0

Puedes hacer que la promesa se rechace antes de terminar:

// 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

Lamentablemente, la llamada de recuperación ya se realizó, por lo que verá la llamada resolviéndose en la pestaña Red. Tu código simplemente lo ignorará.

Rashomon
fuente
0

Usando la subclase Promise proporcionada por el paquete externo, esto se puede hacer de la siguiente manera: Demostración en vivo

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
Dmitriy Mozgovoy
fuente
-1

Como @jib rechaza mi modificación, publico mi respuesta aquí. Es solo la modificación de la respuesta de @ jib con algunos comentarios y usando nombres de variable más comprensibles.

A continuación, solo muestro ejemplos de dos métodos diferentes: uno es resolver () el otro es rechazar ()

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">

allenyllee
fuente