[譯] 如何取消你的 Promise?

jonjia發表於2017-12-14

如何取消你的 Promise?

在 JavaScript 語言的國際標準 ECMAScript 的 ES6 版本中,引入了新的非同步原生物件 Promise。這是一個非常強大的概念,它使我們可以避免臭名昭著的 回撥陷阱。例如,幾個非同步操作很容易寫成下面這樣的程式碼:

function updateUser(cb) {
  fetchData(function(error, data) => {
    if (error) {
      throw error;
    }
    updateUserData(data, function(error, data) => {
      if (error) {
        throw error;
      }
      updateUserAddress(data, function(error, data) => {
        if (error) {
          throw error;
        }
        updateMarketingData(data, function(error, data) => {
          if (error) {
            throw error;
          }

          // finally!
          cb();
        });
      });
    });
  });
}

複製程式碼

正如你所看到的,我們巢狀了幾個回撥函式,如果想要改變一些回撥函式的順序,或者想同時執行一些回撥函式,我們將很難管理這些程式碼。但是,通過 Promise,我們可以將其重構為可讀性更好的版本:

// 我們不再需要回撥函式了 – 只需要使用 then 方法
// 處理函式的返回結果
function updateUser() {
  return fetchData()
    .then(updateUserData)
    .then(updateUserAddress)
    .then(updateMarketingData);
}

複製程式碼

這樣的程式碼不僅更簡潔,可讀性更強,而且可以輕鬆切換回撥的順序,同時執行回撥或刪除不必要的回撥(或者在回撥鏈中間新增一個回撥)。

使用 Promise 鏈式寫法的一個缺點是我們無法訪問每個回撥函式的作用域(或者其中未返回的的變數),你可以閱讀 Alex Rauschmayer 博士這篇 a great article 來解決這個問題。

但是,我發現了 這個問題,你不能取消 Promise,這是一個很關鍵的問題。有時你需要取消 Promise,你要構建變通的方法 — 工作量取決於你多長時間使用一次這個功能。

使用 Bluebird

Bluebird 是一個 Promise 實現庫, 完全相容原生的 Promise 物件, 並且在原型物件 Promise.prototype 上新增了一些有用的方法(譯者注:擴充套件了原生 Promise 物件的方法)。在這裡我們只介紹下 cancel 方法, 它部分實現了我們的想要的 — 當我們使用 promise.cancel 取消 Promise 時,它允許我們有自定義的邏輯(為什麼是部分實現? 因為程式碼冗長還不通用).

在我們的例子中,我們來看看如何使用 Bluebird 實現取消 Promise:

import Promise from 'Bluebird';

function updateUser() {
  return new Promise((resolve, reject, onCancel) => {
    let cancelled = false;

    // 你需要更改 Bluebird 的配置,才能使用 cancellation 特性
    // http://bluebirdjs.com/docs/api/promise.config.html
    onCancel(() => {
      cancelled = true;
      reject({ reason: 'cancelled' });
    });

    return fetchData()
      .then(wrapWithCancel(updateUserData))
      .then(wrapWithCancel(updateUserAddress))
      .then(wrapWithCancel(updateMarketingData))
      .then(resolve)
      .catch(reject);

    function wrapWithCancel(fn) {
      // promise resolved 的狀態只需要傳遞一個引數
      return (data) => {
        if (!cancelled) {
          return fn(data);
        }
      };
    }
  });
}

const promise = updateUser();
// 等一會...
promise.cancel(); // 使用者還是會被更新
複製程式碼

正如你所看到的,我們在之前乾淨的例子中增加了很多程式碼。不幸的是,沒有其他辦法,因為我們不能停止執行一個隨機的 Promise 鏈(如果我們想,我們需要把它包裝到另一個函式中),所以我們需要用處理取消狀態的函式包裝每個回撥函式。

純 Promises

上面的技術並不是 Bluebird 的特別之處,更多的是關於介面 - 你可以實現你自己的取消版本,但需要額外的屬性/變數。通常這種方法被稱為cancellationToken,在本質上,它幾乎和前一個一樣,但不是在Promise.prototype.cancel上有這個方法,我們將它例項化在一個不同的物件 - 我們可以用cancel屬性返回一個物件,或者我們可以接受額外的引數,一個物件,我們將在那裡新增一個屬性。

function updateUser() {
  let resolve, reject, cancelled;
  const promise = new Promise((resolveFromPromise, rejectFromPromise) => {
    resolve = resolveFromPromise;
    reject = rejectFromPromise;
  });

  fetchData()
    .then(wrapWithCancel(updateUserData))
    .then(wrapWithCancel(updateUserAddress))
    .then(wrapWithCancel(updateMarketingData))
    .then(resolve)
    .then(reject);

  return {
    promise,
    cancel: () => {
      cancelled = true;
      reject({ reason: 'cancelled' });
    }
  };

  function wrapWithCancel(fn) {
    return (data) => {
      if (!cancelled) {
        return fn(data);
      }
    };
  }
}

const { promise, cancel } = updateUser();
// 等一會...
cancel(); // 使用者還是會被更新
複製程式碼

這比以前的解決方案稍微冗長一點,但是它解決了同樣的問題,如果你沒有使用 Bluebird(或者不想在 Promise 中使用非標準的方法),這是一個可行的解決方案。正如你所看到的,我們改變了簽名 - 現在我們返回物件而不是一個 Promise,但實際上我們可以傳遞一個物件引數給函式,並附上cancel方法(或者 Promise 的 monkey-patch 例項,但它也會在以後給你造成問題)。如果你只在幾個地方有這個要求,這是一個很好的解決方案。

切換到 generators

Generators 是 ES6 另一個新特性,但由於某些原因,它們並沒有被廣泛使用。使用前請想清楚 - 你團隊中的新手會看不懂呢,還是全部成員都遊刃有餘呢?而且,它還存在於其他一些語言中,如 Python,所以作為團隊使用這個解決方案應該會很容易。

Generators 有它自己的文件, 所以我不會介紹基礎知識,只是實現一個 Generator 執行器,這將允許我們以通用方式取消我們的 Promise,而不會影響我們的程式碼。

// 這是執行我們非同步程式碼的核心方法
// 並且提供 cancellation 方法
function runWithCancel(fn, ...args) {
  const gen = fn(...args);
  let cancelled, cancel;
  const promise = new Promise((resolve, promiseReject) => {
    // 定義 cancel 方法,並返回它
    cancel = () => {
      cancelled = true;
      reject({ reason: 'cancelled' });
    };

    let value;

    onFulfilled();

    function onFulfilled(res) {
      if (!cancelled) {
        let result;
        try {
          result = gen.next(res);
        } catch (e) {
          return reject(e);
        }
        next(result);
        return null;
      }
    }

    function onRejected(err) {
      var result;
      try {
        result = gen.throw(err);
      } catch (e) {
        return reject(e);
      }
      next(result);
    }

    function next({ done, value }) {
      if (done) {
        return resolve(value);
      }
      // 假設我們總是接收 Promise,所以不需要檢查型別
      return value.then(onFulfilled, onRejected);
    }
  });

  return { promise, cancel };
}
複製程式碼

這是一個相當長的函式,但基本上它(除了檢查,當然這是一個非常初級的實現) - 程式碼本身將保持完全相同,我們將從字面上獲取cancel方法!讓我們看看如何在我們的例子中使用它:

// * 表示這是一個 Generator 函式
// 你可以把 * 放到幾乎任何地方 :)
// 這種寫法語法上和 async/await 很相似
function* updateUser() {
  // 假設我們所有的函式都返回 Promise
  // 否則需要調整我們的執行器函式
  // 去接受 Generator
  const data = yield fetchData();
  const userData = yield updateUserData(data);
  const userAddress = yield updateUserAddress(userData);
  const marketingData = yield updateMarketingData(userAddress);
  return marketingData;
}

const { promise, cancel } = runWithCancel(updateUser);

// 見證奇蹟的時刻
cancel();
複製程式碼

正如你所看到的,介面保持不變,但是現在我們可以選擇在執行過程中取消任何基於 Generator 的函式,只需將其包裝到合適的執行器中即可。缺點是一致性 - 如果它只是在你的程式碼中的幾個地方,那麼別人看你程式碼時會很困惑,因為你在程式碼中使用了所有可能的非同步方法,這又是一個折中方案。

我想,Generator 是最具擴充套件性的選擇,因為你可以從字面上完成所有你想要的事情 - 如果出現某種情況,你可以暫停,等待,重試,或者執行另一個 Generator。但是,我並沒有經常在 JavaScript 程式碼中看到他們,所以你應該考慮採用和認知負載 - 你真的有很多的它的使用場景嗎?如果是,那麼這是一個非常好的解決方案,你將來可能會感謝你自己。

注意 async/await

ES2017 版本提供了 async/await,你可以在 Node.js(版本7.6之後)中沒有任何標誌的情況下使用它們。不幸的是,沒有任何東西可以支援取消 Promise,而且由於 async 函式隱含地返回 Promise,所以我們不能真正感覺到它(附加一個屬性或返回其他東西),只有 resolved/rejected 狀態的值。這意味著為了使我們的函式可以被取消,我們需要傳遞一個物件,並將每個呼叫包裝在我們著名的包裝器方法中:

async function updateUser(token) {
  let cancelled = false;

  // 我們不呼叫 reject,因為我們無法訪問
  // 返回的 Promise
  // 我們不呼叫其它函式
  // 在結束時呼叫 reject
  token.cancel = () => {
    cancelled = true;
  };

  const data = await wrapWithCancel(fetchData)();
  const userData = await wrapWithCancel(updateUserData)(data);
  const userAddress = await wrapWithCancel(updateUserAddress)(userData);
  const marketingData = await wrapWithCancel(updateMarketingData)(userAddress);

  // 因為我們已經包裝了所有的函式,以防取消
  // 不需要呼叫任何實際函式來達到這一點
  // 我們也不能呼叫 reject 方法
  // 因為我們無法控制返回的 Promise
  if (cancelled) {
    throw { reason: 'cancelled' };
  }

  return marketingData;

  function wrapWithCancel(fn) {
    return data => {
      if (!cancelled) {
        return fn(data);
      }
    }
  }
}

const token = {};
const promise = updateUser(token);
// 等一會...
token.cancel(); // 使用者還是會被更新
複製程式碼

這是非常相似的解決方案,但是因為我們沒有直接在cancel方法中呼叫 reject,所以可能會使讀者感到困惑。另一方面,它是現在語言的一個標準功能,具有非常方便的語法,允許你在後面使用前面呼叫的結果(所以在這裡解決了 Promise 鏈式呼叫的問題),並且具有非常簡明和直觀的通過try / catch的錯誤處理。所以,如果取消不再困擾你(或者你可以用這種方式來取消某些東西),那麼這個特性絕對是在現代 JavaScript 中編寫非同步程式碼的最好方式。

使用 streams (就像 RxJS)

Streams 是完全不同的概念,但實際上它的應用更廣泛 不僅在 JavaScript ,所以你可以將其視為獨立於平臺的模式。和 Promie/Generator 相比,Streams 可能更好也可能更糟糕。如果你已經接觸過它,並且使用它來處理過一些(或者所有的)非同步邏輯,你會發現 Streams 更好,如果你沒接觸過,你會發現 Streams 更糟糕,因為它是完全不同的方法。

我不是一個使用 Streams 的專家,只是使用過一些,我認為你應該使用它們來處理所有的非同步事件,或者完全不使用它們。所以,如果你已經在使用它們,這個問題對你來說應該不是一件難事,因為這是 Streams 庫的一個長期以來眾所周知的特性。

正如我所提到的,我沒有足夠的使用 Streams 的經驗來提供使用它們的解決方案,所以我只是放幾個關於 Streams 實現取消的連結:

接受

事情朝著好的方向發展 - fetch 將會新增 abort 方法,如何取消 Promise 在將來還會熱議很長一段時間。取消 Promise 能夠實現嗎?可能會可能不會。而且,取消 Promise 對於許多應用程式來說不是至關重要的 - 是的,你可以提出一些額外的請求,但有一個以上的請求結果是非常罕見的。另外,如果發生一次或兩次,則可以從一開始就使用擴充套件示例來解決這些特定函式。但是,如果你的應用程式中有很多這樣的情況,請考慮一下上面列出的內容。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章