Promessa - é possível forçar o cancelamento de uma promessa

95

Eu uso o ES6 Promises para gerenciar toda a minha recuperação de dados de rede e há algumas situações em que preciso forçar o cancelamento.

Basicamente, o cenário é tal que tenho uma pesquisa de digitação antecipada na IU em que a solicitação é delegada ao back-end para realizar a pesquisa com base na entrada parcial. Embora esta solicitação de rede (# 1) possa demorar um pouco, o usuário continua a digitar, o que eventualmente aciona outra chamada de back-end (# 2)

Aqui, o nº 2 naturalmente tem precedência sobre o nº 1, então eu gostaria de cancelar a solicitação de envolvimento da promessa nº 1. Já tenho um cache de todas as Promessas na camada de dados, então posso, teoricamente, recuperá-lo, pois estou tentando enviar uma Promessa para o # 2.

Mas como cancelo a promessa nº 1 depois de recuperá-la do cache?

Alguém poderia sugerir uma abordagem?

Moonwalker
fonte
2
Essa é uma opção de usar algum equivalente de uma função de debounce para não disparar para solicitações frequentes e tornando-se obsoletas? Digamos que um atraso de 300 ms resolve o problema. Por exemplo, Lodash tem uma das implementações - lodash.com/docs#debounce
shershen
É quando coisas como Bacon e Rx se tornam úteis.
elclanrs de
@shershen sim - temos isso, mas não se trata tanto do problema da interface do usuário ... a consulta do servidor pode levar um pouco de tempo, então eu quero cancelar as promessas ...
Moonwalker
Experimente os observáveis ​​de Rxjs
FieryCod

Respostas:

173

Não. Não podemos fazer isso ainda.

ES6 promessas não suportam o cancelamento ainda . Está a caminho, e seu design é algo em que muitas pessoas trabalharam muito. A semântica de cancelamento de som é difícil de acertar e isso é um trabalho em andamento. Existem debates interessantes sobre o repositório "fetch", no esdiscuss e em vários outros repositórios no GH, mas eu seria paciente se fosse você.

Mas, mas, mas .. o cancelamento é muito importante!

É, a realidade da questão é que o cancelamento é realmente um cenário importante na programação do lado do cliente. Os casos que você descreve, como abortar solicitações da web, são importantes e estão em toda parte.

Então ... a linguagem me ferrou!

Sim, desculpe por isso. As promessas tinham que ser feitas antes que outras coisas fossem especificadas - então elas entraram sem algumas coisas úteis como .finallye .cancel- está a caminho, porém, para a especificação por meio do DOM. O cancelamento não é uma reflexão tardia, é apenas uma restrição de tempo e uma abordagem mais iterativa para o design da API.

Então o que eu posso fazer?

Você tem várias alternativas:

  • Use uma biblioteca de terceiros como o bluebird, que pode se mover muito mais rápido do que a especificação e, portanto, tem cancelamento, bem como um monte de outras vantagens - é isso que grandes empresas como o WhatsApp fazem.
  • Passe um token de cancelamento .

Usar uma biblioteca de terceiros é bastante óbvio. Quanto a um token, você pode fazer seu método assumir uma função e chamá-la, 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;
   });
};

O que o deixaria fazer:

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

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

Seu caso de uso real - last

Isso não é muito difícil com a abordagem 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);
    };
}

O que o deixaria fazer:

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 não, bibliotecas como Bacon e Rx não "brilham" aqui porque são bibliotecas observáveis, elas apenas têm a mesma vantagem que bibliotecas de promessa em nível de usuário têm por não serem vinculadas às especificações. Acho que vamos esperar para ver no ES2016 quando os observáveis ​​se tornarem nativos. Eles são bacanas para digitação antecipada.

Benjamin Gruenbaum
fonte
28
Benjamin, gostei muito de ler sua resposta. Muito bem pensado, estruturado, articulado e com bons exemplos práticos e alternativas. Realmente util. Obrigado.
Moonwalker
Os tokens de cancelamento da @FranciscoPresencia estão a caminho como uma proposta de estágio 1.
Benjamin Gruenbaum
Onde podemos ler sobre esse cancelamento baseado em token? Onde está a proposta?
dano em
@harm a proposta está morta no estágio 1.
Benjamin Gruenbaum
1
Adoro o trabalho do Ron, mas acho que devemos esperar um pouco antes de fazer recomendações para bibliotecas que as pessoas ainda não estão usando:] Obrigado pelo link, mas vou dar uma olhada!
Benjamin Gruenbaum
24

As propostas padrão para promessas canceláveis ​​falharam.

Uma promessa não é uma superfície de controle para a ação assíncrona que a cumpre; confunde proprietário com consumidor. Em vez disso, crie funções assíncronas que podem ser canceladas por meio de algum token passado.

Outra promessa torna um token perfeito, tornando o cancelamento fácil de implementar com Promise.race:

Exemplo: use Promise.racepara cancelar o efeito de uma cadeia 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">

Aqui, estamos "cancelando" pesquisas anteriores injetando um undefinedresultado e testando-o, mas poderíamos facilmente imaginar a rejeição com "CancelledError".

Claro que isso não cancela a pesquisa de rede, mas isso é uma limitação de fetch. Se fetchtomar uma promessa de cancelamento como argumento, poderá cancelar a atividade de rede.

Eu já propôs este "Cancelar padrão promessa" na es-discuss, exatamente para sugerir que fetchfazer isso.

bujarrona
fonte
@jib por que rejeitar minha modificação? Eu apenas esclareço isso.
allenyllee
8

Eu verifiquei a referência do Mozilla JS e encontrei isto:

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

Vamos dar uma olhada:

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

Temos aqui p1 e p2 colocados Promise.race(...)como argumentos, isso na verdade está criando uma nova promessa de resolução, que é o que você precisa.

Nikola-Miljkovic
fonte
BOM - isso talvez seja exatamente o que eu preciso. Vou tentar.
Moonwalker
Se você tiver problemas com isso, pode colar o código aqui para que eu possa ajudá-lo :)
nikola-miljkovic
6
Tentei. Não exatamente lá. Isso resolve a Promessa mais rápida ... Eu preciso sempre resolver o último submetido, ou seja, cancelar incondicionalmente quaisquer Promessas antigas ..
Moonwalker
1
Desta forma, todas as outras promessas não são mais atendidas, você não pode realmente cancelar uma promessa.
nikola-miljkovic
Eu tentei, a segunda promessa (uma neste ex) não deixa o processo sair :(
morteza ataiy
3

Para Node.js e Electron, eu recomendo fortemente o uso de Promise Extensions for JavaScript (Prex) . Seu autor, Ron Buckton, é um dos principais engenheiros do TypeScript e também o responsável pela atual proposta de cancelamento do ECMAScript do TC39 . A biblioteca está bem documentada e é provável que alguns dos Prex cumpram o padrão.

Em uma nota pessoal e vindo do background do C #, eu gosto muito do fato de que o Prex é modelado sobre a estrutura existente de Cancelamento em Threads Gerenciados , ou seja, com base na abordagem adotada com APIs CancellationTokenSource/ CancellationToken.NET. Na minha experiência, eles têm sido muito úteis para implementar uma lógica de cancelamento robusta em aplicativos gerenciados.

Eu também verifiquei que ele funciona dentro de um navegador agrupando o Prex usando o Browserify .

Aqui está um exemplo de atraso com cancelamento ( Gist e RunKit , usando Prex para seu CancellationTokene 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}`));

Observe que o cancelamento é uma corrida. Ou seja, uma promessa pode ter sido resolvida com sucesso, mas no momento em que você a cumprir (com awaitou then), o cancelamento também pode ter sido acionado. Depende de você como vai enfrentar essa corrida, mas não custa pedir token.throwIfCancellationRequested()uma prorrogação, como fiz acima.

noseratio
fonte
1

Eu enfrentei problema semelhante recentemente.

Eu tinha um cliente baseado em promessa (não em rede) e queria sempre fornecer os dados mais recentes solicitados ao usuário para manter a interface do usuário tranquila.

Depois de lutar com ideia de cancelamento, Promise.race(...)e Promise.all(..)eu só comecei a lembrar o meu último ID de pedido e quando promessa foi cumprida i só foi tornando meus dados quando se combinava com o id de um último pedido.

Espero que ajude alguém.

Igor Słomski
fonte
Slomski, a questão não é sobre o que mostrar na interface do usuário. É sobre o cancelamento da promessa
CyberAbhay
0

Você pode rejeitar a promessa 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

Infelizmente, a chamada de busca já foi realizada, então você verá a resolução da chamada na guia Rede. Seu código simplesmente irá ignorá-lo.

Rashomon
fonte
0

Usando a subclasse Promise fornecida pelo pacote externo, isso pode ser feito da seguinte maneira: Demonstração ao 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
fonte
-1

Porque @jib rejeita minha modificação, então eu posto minha resposta aqui. É apenas o modfify de anwser @ de lança com alguns comentários e usando nomes de variáveis mais compreensíveis.

Abaixo, apenas mostro exemplos de dois métodos diferentes: um é resolve () e o outro é rejeitar ()

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
fonte