สัญญา - เป็นไปได้ไหมที่จะบังคับให้ยกเลิกคำสัญญา


95

ฉันใช้ ES6 Promises เพื่อจัดการการดึงข้อมูลเครือข่ายทั้งหมดของฉันและมีบางสถานการณ์ที่ฉันจำเป็นต้องบังคับให้ยกเลิก

โดยทั่วไปสถานการณ์จะเป็นเช่นนั้นฉันมีการค้นหาพิมพ์ล่วงหน้าบน UI ซึ่งคำขอถูกมอบหมายให้กับแบ็กเอนด์ต้องดำเนินการค้นหาตามอินพุตบางส่วน แม้ว่าคำขอเครือข่ายนี้ (# 1) อาจใช้เวลาสักหน่อย แต่ผู้ใช้ยังคงพิมพ์ต่อไปซึ่งจะเรียกใช้การเรียกแบ็กเอนด์อื่นในที่สุด (# 2)

ที่นี่ # 2 โดยธรรมชาติจะมีความสำคัญเหนือ # 1 ดังนั้นฉันจึงต้องการยกเลิกคำขอตัดสัญญา # 1 ฉันมีแคชของสัญญาทั้งหมดในชั้นข้อมูลอยู่แล้วดังนั้นฉันจึงสามารถดึงข้อมูลได้ในทางทฤษฎีขณะที่ฉันพยายามส่งสัญญาสำหรับ # 2

แต่ฉันจะยกเลิก Promise # 1 ได้อย่างไรเมื่อฉันดึงมันจากแคช

มีใครแนะนำแนวทางได้ไหม


2
เป็นตัวเลือกในการใช้ฟังก์ชัน debounce ที่เทียบเท่าไม่ให้เกิดขึ้นบ่อยครั้งและกลายเป็นคำขอที่ล้าสมัยหรือไม่? พูดว่าการหน่วงเวลา 300 มิลลิวินาทีจะเป็นการหลอกลวง ตัวอย่างเช่น Lodash มีการใช้งานอย่างใดอย่างหนึ่ง - lodash.com/docs#debounce
shershen

นี่คือเมื่อสิ่งต่างๆเช่น Bacon และ Rx เข้ามามีประโยชน์
elclanrs

@shershen ใช่ - เรามีสิ่งนี้ แต่ไม่มากเกี่ยวกับปัญหา UI. การสืบค้นของเซิร์ฟเวอร์อาจใช้เวลาสักหน่อยดังนั้นฉันจึงต้องการยกเลิกสัญญา ...
Moonwalker


ลองใช้ Observables จาก Rxjs
FieryCod

คำตอบ:


173

ไม่เรายังทำไม่ได้

ES6 สัญญาไม่สนับสนุนการยกเลิกเลย มันกำลังมาถึงและการออกแบบเป็นสิ่งที่หลายคนทำงานอย่างหนัก ความหมายของการยกเลิกเสียงทำได้ยากและอยู่ระหว่างดำเนินการ มีการถกเถียงที่น่าสนใจเกี่ยวกับ repo "fetch", esdiscuss และ repos อื่น ๆ อีกมากมายใน GH แต่ฉันจะอดทนถ้าฉันเป็นคุณ

แต่ แต่ แต่ .. การยกเลิกนั้นสำคัญมาก!

มันคือความเป็นจริงของเรื่องก็คือการยกเลิกเป็นจริงสถานการณ์ที่สำคัญในการเขียนโปรแกรมฝั่งไคลเอ็นต์ กรณีที่คุณอธิบายเช่นการยกเลิกคำขอเว็บมีความสำคัญและมีอยู่ทุกที่

ดังนั้น ... ภาษาทำให้ฉันเมา!

ใช่ขอโทษเกี่ยวกับเรื่องนั้น สัญญาจะต้องเข้ามาก่อนก่อนที่จะมีการระบุสิ่งต่างๆเพิ่มเติมดังนั้นพวกเขาจึงเข้าไปโดยไม่มีสิ่งที่เป็นประโยชน์เช่น.finallyและ.cancel- มันกำลังมาถึงข้อกำหนดผ่าน DOM การยกเลิกไม่ใช่การคิดในภายหลัง แต่เป็นเพียงข้อ จำกัด ด้านเวลาและแนวทางการออกแบบ API ซ้ำ ๆ

แล้วฉันจะทำอย่างไร?

คุณมีทางเลือกหลายทาง:

  • ใช้ไลบรารีของบุคคลที่สามเช่นbluebirdซึ่งสามารถเคลื่อนที่ได้เร็วกว่า spec มากจึงมีการยกเลิกรวมถึงสินค้าอื่น ๆ อีกมากมายนี่คือสิ่งที่ บริษัท ขนาดใหญ่เช่น WhatsApp ทำ
  • ผ่านการยกเลิกโทเค็น

การใช้ไลบรารีของบุคคลที่สามนั้นค่อนข้างชัดเจน สำหรับโทเค็นคุณสามารถทำให้เมธอดของคุณใช้ฟังก์ชันแล้วเรียกใช้งานดังต่อไปนี้:

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

ซึ่งจะช่วยให้คุณทำ:

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

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

กรณีการใช้งานจริงของคุณ - last

สิ่งนี้ไม่ยากเกินไปสำหรับวิธีการโทเค็น:

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

ซึ่งจะช่วยให้คุณทำ:

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

และไม่ไลบรารีอย่าง Bacon และ Rx จะไม่ "ส่องแสง" ที่นี่เพราะเป็นไลบรารีที่สังเกตได้พวกเขาก็มีไลบรารีสัญญาระดับผู้ใช้ที่ได้เปรียบเหมือนกันโดยไม่ถูก จำกัด เฉพาะ ฉันเดาว่าเราจะรอและดูใน ES2016 เมื่อสิ่งที่สังเกตได้เป็นของพื้นเมือง พวกเขาเป็นที่ดีสำหรับ typeahead แม้ว่า


28
เบนจามินชอบอ่านคำตอบของคุณมาก มีความคิดที่ดีมีโครงสร้างชัดเจนและมีตัวอย่างและทางเลือกที่ใช้ได้จริง เป็นประโยชน์จริงๆ ขอขอบคุณ.
Moonwalker

โทเค็นการยกเลิก @FranciscoPresencia อยู่ในขั้นตอนที่ 1
Benjamin Gruenbaum

เราจะอ่านข้อมูลเกี่ยวกับการยกเลิกโดยใช้โทเค็นนี้ได้ที่ไหน ข้อเสนออยู่ที่ไหน
อันตราย

@ ข้อเสนอตายในขั้นตอนที่ 1
Benjamin Gruenbaum

1
ฉันรักงานของรอน แต่ฉันคิดว่าเราควรรอสักครู่ก่อนที่จะแนะนำห้องสมุดที่คนยังไม่ได้ใช้:] ขอบคุณสำหรับลิงค์แม้ว่าฉันจะลองดู!
Benjamin Gruenbaum

24

ข้อเสนอมาตรฐานสำหรับสัญญาที่ยกเลิกได้ล้มเหลว

คำสัญญาไม่ใช่พื้นผิวการควบคุมสำหรับการดำเนินการ async ที่ตอบสนองมัน ทำให้เจ้าของสับสนกับผู้บริโภค ให้สร้างฟังก์ชันอะซิงโครนัสที่สามารถยกเลิกได้โดยใช้โทเค็นที่ส่งผ่านบางรายการแทน

คำสัญญาอื่นทำให้โทเค็นที่ดีทำให้การยกเลิกใช้งานได้ง่ายด้วยPromise.race:

ตัวอย่าง:ใช้Promise.raceเพื่อยกเลิกเอฟเฟกต์ของเชนก่อนหน้า:

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

ที่นี่เรากำลัง "ยกเลิก" การค้นหาก่อนหน้านี้โดยการฉีดundefinedผลลัพธ์และทดสอบ แต่เราสามารถจินตนาการได้โดยง่ายว่าจะปฏิเสธ"CancelledError"แทน

หลักสูตรนี้ไม่จริงยกเลิกการค้นหาเครือข่าย แต่ที่ข้อ จำกัด fetchของ หากfetchจะใช้สัญญายกเลิกเป็นอาร์กิวเมนต์ก็สามารถยกเลิกกิจกรรมเครือข่ายได้

ฉันได้เสนอ "ยกเลิกรูปแบบสัญญา" ใน es-Discuss เพื่อแนะนำให้fetchทำเช่นนี้


@jib ทำไมปฏิเสธการปรับเปลี่ยนของฉัน? ฉันแค่ชี้แจงมัน
อัลลีนีลลี

8

ฉันได้ตรวจสอบข้อมูลอ้างอิงของ Mozilla JS และพบสิ่งนี้:

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

มาดูกันเลย:

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

เรามี p1 ที่นี่และ p2 Promise.race(...)เป็นอาร์กิวเมนต์นี่คือการสร้างสัญญาการแก้ไขใหม่ซึ่งเป็นสิ่งที่คุณต้องการ


ดี - นี่อาจเป็นสิ่งที่ฉันต้องการ ฉันจะลองดู
Moonwalker

หากคุณมีปัญหาคุณสามารถวางรหัสที่นี่เพื่อให้ฉันสามารถช่วยเหลือคุณได้ :)
nikola-miljkovic

6
พยายามแล้ว ไม่ค่อยมี นี่เป็นการแก้ไขคำสัญญาที่เร็วที่สุด ... ฉันจำเป็นต้องแก้ไขสัญญาที่ส่งล่าสุดเสมอเช่นยกเลิกสัญญาที่เก่ากว่าโดยไม่มีเงื่อนไข ..
Moonwalker

1
ด้วยวิธีนี้คำสัญญาอื่น ๆ ทั้งหมดจะไม่ได้รับการจัดการอีกต่อไปคุณไม่สามารถยกเลิกสัญญาได้
nikola-miljkovic

ฉันลองแล้วสัญญาที่สอง (หนึ่งในอดีตนี้) อย่าปล่อยให้กระบวนการออก :(
morteza ataiy

3

สำหรับ Node.js และอิเลคตรอนผมขอขอแนะนำให้ใช้สัญญาส่วนขยายสำหรับ JavaScript (Prex) Ron Bucktonผู้เขียนเป็นหนึ่งในวิศวกรคนสำคัญของ TypeScript และยังเป็นคนที่อยู่เบื้องหลังข้อเสนอการยกเลิก ECMAScriptของ TC39 ในปัจจุบัน ไลบรารีได้รับการบันทึกไว้เป็นอย่างดีและมีโอกาสที่ Prex บางส่วนจะทำตามมาตรฐาน

ในบันทึกส่วนตัวและมาจากพื้นหลัง C # ฉันชอบมากที่ความจริงที่ว่า Prex ถูกจำลองมาจากการยกเลิกที่มีอยู่ในกรอบงานManaged Threadsนั่นคือตามแนวทางที่ใช้กับCancellationTokenSource/ CancellationToken. จากประสบการณ์ของฉันสิ่งเหล่านี้มีประโยชน์มากในการใช้ตรรกะการยกเลิกที่มีประสิทธิภาพในแอปที่มีการจัดการ

ฉันยังมีการยืนยันว่ามันทำงานภายในเบราว์เซอร์โดย bundling Prex ใช้Browserify

นี่คือตัวอย่างของความล่าช้าในการยกเลิก ( GistและRunKitโดยใช้PrexสำหรับCancellationTokenและ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}`));

โปรดทราบว่าการยกเลิกเป็นการแข่งขัน กล่าวคือคำสัญญาอาจได้รับการแก้ไขสำเร็จ แต่เมื่อคุณสังเกตเห็น (มีawaitหรือthen) การยกเลิกอาจถูกกระตุ้นด้วยเช่นกัน มันขึ้นอยู่กับคุณว่าคุณจะรับมือกับการแข่งขันครั้งนี้อย่างไร แต่การเรียกtoken.throwIfCancellationRequested()ช่วงต่อเวลาพิเศษอย่างที่ฉันทำข้างต้นไม่เจ็บ


1

ฉันประสบปัญหาที่คล้ายกันเมื่อเร็ว ๆ นี้

ฉันมีไคลเอนต์ตามสัญญา (ไม่ใช่เครือข่าย) และฉันต้องการให้ข้อมูลที่ร้องขอล่าสุดแก่ผู้ใช้เสมอเพื่อให้ UI ราบรื่น

หลังจากต่อสู้กับแนวคิดการยกเลิกPromise.race(...)และPromise.all(..)ฉันเพิ่งเริ่มจำรหัสคำขอล่าสุดของฉันและเมื่อสัญญาเป็นจริงฉันแสดงผลข้อมูลของฉันเมื่อตรงกับรหัสของคำขอล่าสุด

หวังว่ามันจะช่วยใครบางคน


Slomski คำถามไม่เกี่ยวกับสิ่งที่จะแสดงบน UI เกี่ยวกับการยกเลิกสัญญา
CyberAbhay


0

คุณสามารถปฏิเสธคำสัญญาก่อนจบได้:

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

น่าเสียดายที่การโทรได้ดำเนินการไปแล้วดังนั้นคุณจะเห็นการแก้ไขการโทรในแท็บเครือข่าย รหัสของคุณจะไม่สนใจมัน


0

การใช้คลาสย่อย Promise ที่มาจากแพ็คเกจภายนอกสามารถทำได้ดังนี้: Live Demo

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

เนื่องจาก @jib ปฏิเสธการแก้ไขของฉันฉันจึงโพสต์คำตอบที่นี่ เป็นเพียงการปรับเปลี่ยน anwser ของ@ jibพร้อมความคิดเห็นและการใช้ชื่อตัวแปรที่เข้าใจได้ง่ายขึ้น

ด้านล่างฉันจะแสดงตัวอย่างของสองวิธีที่แตกต่างกัน: วิธีหนึ่งคือการแก้ไข () อีกวิธีหนึ่งคือปฏิเสธ ()

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

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.