[譯] 如何在 JavaScript 中使用 Generator?

jonjia發表於2017-12-28

如何在 JavaScript 中使用 Generator

Generator 是一種非常強力的語法,但它的使用並不廣泛(參見下圖 twitter 上的調查!)。為什麼這樣呢?相比於 async/await,它的使用更復雜,除錯起來也不太容易(大多數情況又回到了從前),即使我們可以通過非常簡單的方式獲得類似體驗,但是人們一般會更喜歡 async/await。

1513838054(1).jpg

然而,Generator 允許我們通過 yield 關鍵字遍歷我們自己的程式碼!這是一種超級強大的語法,實際上,我們可以操縱執行過程!從不太明顯的取消操作開始,讓我們先從同步操作開始吧。

我為文中提到的功能建立了一個程式碼倉庫 —— github.com/Bloomca/obs…

批處理 (或計劃)

執行 Generator 函式會返回一個遍歷器物件,那意味著通過它我們可以同步地遍歷。為什麼我們想這麼做?原因有可能是為了實現批處理。想象一下,我們需要下載 1000 個專案,並在表格中逐行的顯示它們(不要問我為什麼,假設我們不使用框架)。雖然立刻展示它們沒有什麼不好的,但有時這可能不是最好的解決方案 —— 也許你的 MacBook Pro 可以輕鬆處理它,但普通人的電腦不能(更別說手機了)。所以,這意味著我們需要用某種方式延遲執行。

請注意,這個例子是關於效能優化,在你遇到這個問題之前,沒必要這樣做 —— 過早優化是萬惡之源!

// 最初的同步實現版本
function renderItems(items) {
  for (item of items) {
    renderItem(item);
  }
}

// 函式將由我們的執行器遍歷執行
// 實際上,我們可以用相同的同步方式來執行它!
function* renderItems(items) {
  // 我使用 for..of 遍歷方法來避免新函式的產生
  for (item of items) {
    yield renderItem(item);
  }
}
複製程式碼

沒有什麼區別是吧?那麼,這裡的區別在於,現在我們可以在不改變原始碼的情況下以不同方式執行這個函式。實際上,正如我之前提到的,沒有必要等待,我們可以同步執行它。所以,來調整下我們的程式碼。在每個 yield 後邊加一個 4 ms(JavaScript VM 中的一個心跳) 的延遲怎麼樣?我們有 1000 個專案,渲染將需要 4 秒 —— 還不錯,假設我想在 2 秒之內渲染完畢,很容易想到的方法是每次渲染 2 個。突然使用 Promise 的解決方案將變得更加複雜 —— 我們必須要傳遞另一個引數:每次渲染的專案個數。通過我們的執行器,我們仍然需要傳遞這個引數,但好處是對我們的 renderItems 方法完全沒有影響。

function runWithBatch(chunk, fn, ...args) {
  const gen = fn(...args);
  let num = 0;
  return new Promise((resolve, promiseReject) => {
    callNextStep();

    function callNextStep(res) {
      let result;
      try {
        result = gen.next(res);
      } catch (e) {
        return reject(e);
      }
      next(result);
    }

    function next({ done, value }) {
      if (done) {
        return resolve(value);
      }

      // every chunk we sleep for a tick
      if (num++ % chunk === 0) {
        return sleep(4).then(proceed);
      } else {
        return proceed();
      }

      function proceed() {
        return callNextStep(value);
      }
    }
  });
}

// 第一個引數 —— 每批處理多少個專案
const items = [...];
batchRunner(2, function*() {
  for (item of items) {
    yield renderItem(item);
  }
});
複製程式碼

正如你所看到的,我們可以輕鬆改變每批處理專案的個數,不去考慮執行器,回到正常的同步執行方式 —— 所有這些都不會影響我們的 renderItems 方法。

取消

我們來考慮下傳統的功能 —— 取消。在我 promises cancellation in general (譯文:如何取消你的 Promise?) 這篇文章中已經詳細談到了。所以我會使用其中一些程式碼:

function runWithCancel(fn, ...args) {
  const gen = fn(...args);
  let cancelled, cancel;
  const promise = new Promise((resolve, promiseReject) => {
    // define cancel function to return it from our fn
    // 定義 cancel 方法,並返回它
    cancel = () => {
      cancelled = true;
      reject({ reason: 'cancelled' });
    };

    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 };
}
複製程式碼

這裡最好的部分是我們可以取消所有還沒來得及執行的請求(也可以給我們的執行器傳遞類似 AbortController 的物件引數,所以它甚至可以取消當前的請求!),而且我們沒有修改過自己業務邏輯中的一行的程式碼。

暫停/恢復

另一個特殊的需求可能是暫停/恢復功能。你為什麼想要這個功能?想象一下,我們渲染了 1000 行資料,而且速度非常慢,我們希望給使用者提供暫停/恢復渲染的功能,這樣他們就可以停止所有的後臺工作讀取已經下載的內容了。讓我們開始吧!

// 實現渲染的方法還是一樣的
function* renderItems() {
  for (item of items) {
    yield renderItem(item);
  }
}

function runWithPause(genFn, ...args) {
  let pausePromiseResolve = null;
  let pausePromise;

  const gen = genFn(...args);

  const promise = new Promise((resolve, reject) => {
    onFulfilledWithPromise();

    function onFulfilledWithPromise(res) {
      if (pausePromise) {
        pausePromise.then(() => onFulfilled(res));
      } else {
        onFulfilled(res);
      }
    }

    function onFulfilled(res) {
      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(onFulfilledWithPromise, onRejected);
    }
  });

  return {
    pause: () => {
      pausePromise = new Promise(resolve => {
        pausePromiseResolve = resolve;
      });
    },
    resume: () => {
      pausePromiseResolve();
      pausePromise = null;
    },
    promise
  };
}
複製程式碼

呼叫這個執行器,可以給我們返回一個具有暫停/恢復功能的物件,所有這些都可以輕鬆得到,還是使用我們之前的業務程式碼!所以,如果你有很多"沉重"的請求鏈,需要耗費很長時間,而你想給你的使用者提供暫停/恢復功能的話,你可以隨意在你的程式碼中實現這個執行器。

錯誤處理

我們有個神祕的 onRejected 呼叫,這是我們這部分談論的主題。如果我們使用正常的 async/await 或 Promise 鏈式寫法,我們將通過 try/catch 語句來進行錯誤處理,如果不新增大量的邏輯程式碼就很難進行錯誤處理。通常情況下,如果我們需要以某種方式處理錯誤(比如重試),我們只是在 Promise 內部進行處理,這將會回撥自己,可能再次回到同樣的點。而且,這還不是一個通用的解決方案 —— 可悲的是,在這裡甚至 Generator 也不能幫助我們。我們發現了 Generator 的侷限 —— 雖然我們可以控制執行流程,但不能移動 Generator 函式的主體;所以我們不能後退一步,重新執行我們的命令。一個可行的解決方案是使用 command pattern, 它告訴了我們 yield 結果的資料結構 —— 應該是我們需要執行此命令需要的所有資訊,這樣我們就可以再次執行它了。所以,我們的方法需要改為:

function* renderItems() {
  for (item of items) {
    // 我們需要將所有東西傳遞出去:
    // 方法, 內容, 引數
    yield [renderItem, null, item];
  }
}

複製程式碼

正如你所看到的,這使得我們不清楚發生了什麼 —— 所以,也許最好是寫一些 wrapWithRetry 方法,它會檢查 catch 程式碼塊中的錯誤型別並再次嘗試。但是我們仍然可以做一些不影響我們功能的事情。例如,我們可以增加一個關於忽略錯誤的策略 —— 在 async/await 中我們不得不使用 try/catch 包裝每個呼叫,或者新增空的 .catch(() => {}) 部分。有了 Generator,我們可以寫一個執行器,忽略所有的錯誤。

function runWithIgnore(fn, ...args) {
  const gen = fn(...args);
  return new Promise((resolve, promiseReject) => {
    onFulfilled();

    function onFulfilled(res) {
      proceed({ data: res });
    }

    // 這些是 yield 返回的錯誤
    // 我們想忽略它們
    // 所以我們像往常一樣做,但不去傳遞出錯誤
    function onRejected(error) {
      proceed({ error });
    }

    function proceed(data) {
      let result;
      try {
        result = gen.next(data);
      } catch (e) {
        // 這些錯誤是同步錯誤(比如 TypeError 等)
        return reject(e);
      }
      // 為了區分錯誤和正常的結果
      // 我們用它來執行
      next(result);
    }

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

關於 async/await

Async/await 是現在的首選語法(甚至 co 也談到了它 ),這也是未來。但是,Generator 也在 ECMAScript 標準內,這意味著為了使用它們,除了寫幾個工具函式,你不需要任何東西。我試圖向你們展示一些不那麼簡單的例子,這些例項的價值取決於你的看法。請記住,沒有那麼多人熟悉 Generator,並且如果在整個程式碼庫中只有一個地方使用它們,那麼使用 Promise 可能會更容易一些 —— 但是另一方面,通過 Generator 某些問題可以被優雅和簡潔的處理。

明智地選擇 —— 能力越大,責任越重(蜘蛛俠 2,2004)!

相關文章


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

相關文章