首頁 > 軟體

JavaScript Generator非同步過度的實現詳解

2022-08-05 22:00:38

非同步過渡方案Generator

在使用 Generator 前,首先知道 Generator 是什麼。

如果讀者有 Python 開發經驗,就會發現,無論是概念還是形式上,ES2015 中的 Generator 幾乎就是 Python 中 Generator 的翻版。

Generator 本質上是一個函數,它最大的特點就是可以被中斷,然後恢復執行。通常來說,當開發者呼叫一個函數之後,這個函數的執行就脫離了開發者的控制,只有函數執行完畢之後,控制權才能重新回到呼叫者手中,因此程式設計師在編寫方法程式碼時,唯一

能夠影響方法執行的只有預先定義的 return 關鍵字。

Promise 也是如此,我們也無法控制 Promise 的執行,新建一個 Promise 後,其狀態自動轉換為 pending,同時開始執行,直到狀態改變後我們才能進行下一步操作。

Generator 函數不同,Generator 函數可以由使用者執行中斷或者恢復執行的操作,Generator 中斷後可以轉去執行別的操作,然後再回過頭從中斷的地方恢復執行。

1. Generator 的使用

Generator 函數和普通函數在外表上最大的區別有兩個:

  • function 關鍵字和方法名中間有個星號(*)。
  • 方法體中使用 yield 關鍵字。
function* Generator() {
  yield "Hello World";
  return "end";
}

和普通方法一樣,Generator 可以定義成多種形式:

// 普通方法形式
function* generator() {}
//函數表示式
const gen = function* generator() {}
// 物件的屬性方法
const obi = {
  * generator() {
  }
}

Generator 函數的狀態

yield 關鍵字用來定義函數執行的狀態,在前面程式碼中,如果 Generator 中定義了 xyield 關鍵字,那麼就有 x + 1 種狀態(+1是因為最後的 return 語句)。

2. Generator 函數的執行

跟普通函數相比,Generator 函數更像是一個類或者一種資料型別,以下面的程式碼為例,直接執行一個 Generator 會得到一個 Generator 物件,而不是執行方法體中的內容。

const gen = Generator();

按照通常的思路,gen 應該是 Generator() 函數的返回值,上面也提到Generator 函數可能有多種狀態,讀者可能會因此聯想到 Promise,一個 Promise 也可能有三種狀態。不同的是 Promise 只能有一個確定的狀態,而 Generator 物件會逐個經歷所有的狀態,直到 Generator 函數執行完畢。

當呼叫 Generator 函數之後,該函數並沒有立刻執行,函數的返回結果也不是字串,而是一個物件,可以將該物件理解為一個指標,指向 Generator 函數當前的狀態。(為了便於說明,我們下面採用指標的說法)。

Generator 被呼叫後,指標指向方法體的開始行,當 next 方法呼叫後,該指標向下移動,方法也跟著向下執行,最後會停在第一個遇到的 yield 關鍵字前面,當再次呼叫 next 方法時,指標會繼續移動到下一個 yield 關鍵字,直到執行到方法的最後一行,以下面程式碼為例,完整的執行程式碼如下:

function* Generator() {
  yield "Hello World";
  return "end";
}
const gen = Generator();
console.log(gen.next()); // { value: 'Hello World', done: false }
console.log(gen.next()); // { value: 'end', done: true }
console.log(gen.next()); // { value: undefined, done: true }

上面的程式碼一共呼叫了三次 next 方法,每次都返回一個包含執行資訊的物件,包含一個表示式的值和一個標記執行狀態的 flag

第一次呼叫 next 方法,遇到一個 yield 語句後停止,返回物件的 value 的值就是 yield 語句的值,done 屬性用來標誌 Generator 方法是否執行完畢。

第二次呼叫 next 方法,程式執行到 return 語句的位置,返回物件的 value 值即為 return 語句的值,如果沒有 return 語句,則會一直執行到函數結束,value 值為 undefineddone 屬性值為 true

第三次呼叫 next 方法時,Generator 已經執行完畢,因此 value 的值為undefined

2.1 yield 關鍵字

yield 本意為 生產 ,在 Python、Java 以及 C# 中都有 yield 關鍵字,但只有Python 中 yield 的語意相似(理由前面也說了)。

next 方法被呼叫時,Generator 函數開始向下執行,遇到 yield 關鍵字時,會暫停當前操作,並且對 yield 後的表示式進行求值,無論 yield 後面表示式返回的是何種型別的值,yield 操作最後返回的都是一個物件,該物件有 valuedone 兩個屬性。

value 很好理解,如果後面是一個基本型別,那麼 value 的值就是對應的值,更為常見的是 yield 後面跟的是 Promise 物件。

done 屬性表示當前 Generator 物件的狀態,剛開始執行時 done 屬性的值為false,當 Generator 執行到最後一個 yield 或者 return 語句時,done 的值會變成 true,表示 Generator 執行結束。

注意:yield關鍵字本身不產生返回值。例如下面的程式碼:

function* foo(x) {
  const y = yield(x + 1);
  return y;
}
const gen = foo(5);
console.log(gen.next()); // { value: 6, done: false }
console.log(gen.next()); // { value: undefined, done: true }

為什麼第二個 next 方法執行後,y 的值卻是 undefined

實際上,我們可以做如下理解:next 方法的返回值是 yield 關鍵字後面表示式的值,而 yield 關鍵字本身可以視為一個不產生返回值的函數,因此 y 並沒有被賦值。上面的例子中如果要計算 y 的值,可以將程式碼改成:

function* foo(x) {
  let y;
  yield y = x + 1;
  return 'end';
}

next 方法還可以接受一個數值作為引數,代表上一個 yield 求值的結果。

function* foo(x) {
  const y = yield(x + 1);
  return y;
}
const gen = foo(5);
console.log(gen.next()); // { value: 6, done: false }
console.log(gen.next(10)); // { value: 10, done: true }

上面的程式碼等價於:

function* foo(x) {
  let y = yield(x + 1);
  y = 10;
  return y;
}
const gen = foo(5);
console.log(gen.next()); // { value: 6, done: false }
console.log(gen.next()); // { value: 10, done: true }

next 可以接收引數代表可以從外部傳一個值到 Generator 函數內部,乍一看沒有什麼用處,實際上正是這個特性使得 Generator 可以用來組織非同步方法,我們會在後面介紹。

2.2 next 方法與 Iterator 介面

一個 Iterator 同樣使用 next 方法來遍歷元素。由於 Generator 函數會返回一個物件,而該物件實現了一個 Iterator 介面,因此所有能夠遍歷 Iterator 介面的方法都可以用來執行 Generator,例如 for/ofaray.from()等。

可以使用 for/of 迴圈的方式來執行 Generator 函數內的步驟,由於 for/of 本身就會呼叫 next 方法,因此不需要手動呼叫。

注意:迴圈會在 done 屬性為 true 時停止,以下面的程式碼為例,最後的 'end' 並不會被列印出來,如果希望被列印,需要將最後的 return 改為 yield

function* Generator() {
  yield "Hello Node";
  yield "From Lear"
  return "end"
}
const gen = Generator();
for (let i of gen) {
  console.log(i);
}
// 和 for/of 迴圈等價
console.log(Array.from(Generator()));;

前面提到過,直接列印 Generator 函數的範例沒有結果,但既然 Generator 函數返回了一個遍歷器,那麼就應該具有 Symbol.iterator 屬性。

console.log(gen[Symbol.iterator]);

// 輸出:[Function: [Symbol.iterator]]

3. Generator 中的錯誤處理

Generator 函數的原型中定義了 throw 方法,用於丟擲異常。

function* generator() {
  try {
    yield console.log("Hello");
  } catch (e) {
    console.log(e);
  }
  yield console.log("Node");
  return "end";
}
const gen = generator();
gen.next();
gen.throw("throw error");

// 輸出
// Hello
// throw error
// Node

上面程式碼中,執行完第一個 yield 操作後,Generator 物件丟擲了異常,然後被函數體中 try/catch 捕獲。當異常被捕獲後,Generator 函數會繼續向下執行,直到遇到下一個 yield 操作並輸出 yield 表示式的值。

function* generator() {
  try {
    yield console.log("Hello World");
  } catch (e) {
    console.log(e);
  }
  console.log('test');
  yield console.log("Node");
  return "end";
}
const gen = generator();
gen.next();
gen.throw("throw error");

// 輸出
// Hello World
// throw error
// test
// Node 

如果 Generator 函數在執行的過程中出錯,也可以在外部進行捕獲。

function* generator() {
  yield console.log(undefined, undefined);
  return "end";
}
const gen = generator();
try {
  gen.next();
} catch (e) {
}

Generator 的原型物件還定義了 return() 方法,用來結束一個 Generator 函數的執行,這和函數內部的 return 關鍵字不是一個概念。

function* generator() {
  yield console.log('Hello World');
  yield console.log('Hello 夏安');
  return "end";
}
const gen = generator();
gen.next(); // Hello World
gen.return();
// return() 方法後面的 next 不會被執行
gen.next();

4. 用 Generator 組織非同步方法

我們之所以可以使用 Generator 函數來處理非同步任務,原因有二:

  • Generator 函數可以中斷和恢復執行,這個特性由 yield 關鍵字來實現。
  • Generator 函數內外可以交換資料,這個特性由 next 函數來實現。

概括一下 Generator 函數處理非同步操作的核心思想:先將函數暫停在某處,然後拿到非同步操作的結果,然後再把這個結果傳到方法體內。

yield 關鍵字後面除了通常的函數表示式外,比較常見的是後面跟的是一個 Promise,由於 yield 關鍵字會對其後的表示式進行求值並返回,那麼呼叫 next 方法時就會返回一個 Promise 物件,我們可以呼叫其 then 方法,並在回撥中使用 next 方法將結果傳回 Generator

function* gen() {
  const result = yield readFile_promise("foo.txt");
  console.log(result);
}
const g = gen();
const result = g.next();
result.value.then(function (data) {
  g.next(data);
});

上面的程式碼中,Generator 函數封裝了 readFile_promise 方法,該方法返回一個 PromiseGenerator 函數對 readFile_promise 的呼叫方式和同步操作基本相同,除了 yield 關鍵字之外。

上面的 Generator 函數中只有一個非同步操作,當有多個非同步操作時,就會變成下面的形式。

function* gen() {
  const result = yield readFile_promise("foo.txt");
  console.log(result);
  const result2 = yield readFile_promise("bar.txt");
  console.log(result2);
}
const g = gen();
const result = g.next();
result.value.then(function (data) {
  g.next(data).value.then(function (data) {
    g.next(data);
  })
});

然而看起來還是巢狀的回撥?難道使用 Generator 的初衷不是優化巢狀寫法嗎?說的沒錯,雖然在呼叫時保持了同步形式,但我們需要手動執行 Generator 函數,於是在執行時又回到了巢狀呼叫。這是 Generator 的缺點。

5. Generator 的自動執行

Generator 函數來說,我們也看到了要順序地讀取多個檔案,就要像上面程式碼那樣寫很多用來執行的程式碼。無論是 Promise 還是 Generator,就算在編寫非同步程式碼時能獲得便利,但執行階段卻要寫更多的程式碼,Promise 需要手動呼叫 then 方法,Generator 中則是手動呼叫 next 方法。

當需要順序執行非同步操作的個數比較少的情況下,開發者還可以接受手動執行,但如果面對多個非同步操作就有些難辦了,我們避免了回撥地獄,卻又陷到了執行地獄裡面。我們不會是第一個遇到自動執行問題的人,社群已經有了很多解決方案,但為了更深入地瞭解 PromiseGenerator,我們不妨先試著獨立地解決這個問題,如何能夠讓一個 Generator 函數自動執行?

5.1 自動執行器的實現

既然 Generator 函數是依靠 next 方法來執行的,那麼我們只要實現一個函數自動執行 next 方法不就可以了嗎,針對這種思路,我們先試著寫出這樣的程式碼:

function auto(generator) {
  const gen = generator();
  while (gen.next().value !== undefined) {
    gen.next();
  }
}

思路雖然沒錯,但這種寫法並不正確,首先這種方法只能用在最簡單的 Generator 函數上,例如下面這種:

function* generator() {
  yield 'Hello World';
  return 'end';
}

另一方面,由於 Generator 沒有 hasNext 方法,在 while 迴圈中作為條件的:gen.next().value !== undefined 在第一次條件判斷時就開始執行了,這表示我們拿不到第一次執行的結果。因此這種寫法行不通。

那麼換一種思路,我們前面介紹了 for/of 迴圈,那麼也可以用它來執行 Generator

function* Generator() {
  yield "Hello World";
  yield "Hello 夏安";
  yield "end";
}
const gen = Generator();
for (let i of gen) {
  console.log(i);
}

// 輸出結果
// Hello World
// Hello 夏安
// end

看起來沒什麼問題了,但同樣地也只能拿來執行最簡單的 Generator 函數,然而我們的主要目的還是管理非同步操作。

5.2 基於Promise的執行器

前面實現的執行器都是針對普通的 Generator 函數,即裡面沒有包含非同步操作,在實際應用中,yield 後面跟的大都是 Promise,這時候 for/of 實現的執行器就不起作用了。

通過觀察,我們發現 Generator 的巢狀執行是一種遞迴呼叫,每一次的巢狀的返回結果都是一個 Promise 物件。

const g = gen();
const result = g.next();
result.value.then(function (data) {
  g.next(data).value.then(function (data) {
    g.next(data);
  })
});

那麼,我們可以根據這個寫出新的執行函數。

function autoExec(gen) {
  function next(data) {
    const result = gen.next(data);
    // 判斷執行是否結束
    if (result.done) return result.value;
    result.value.then(function (data) {
      next(data);
    });
  }
  next();
}

這個執行器因為呼叫了 then 方法,因此只適用於 yield 後面跟一個 Promise 的方法。

5.3 使用 co 模組來自動執行

為了解決 generator 執行的問題,TJ 於2013年6月釋出了著名 co 模組,這是一個用來自動執行 Generator 函數的小工具,和 Generator 配合可以實現接近同步的呼叫方式,co 方法仍然會返回一個 Promise

const co = require("co");
function* gen() {
  const result = yield readFilePromise("foo.txt");
  console.log(result);
  const result2 = yield readFilePromise("bar.txt");
  console.log(result2);
}
co(gen);

只要將 Generator 函數作為引數傳給 co 方法就能將內部的非同步任務順序執行,要使用 co 模組,yield 後面的語句只能是 promsie 物件。

到此為止,我們對非同步的處理有了一個比較妥當的方式,利用 generator+co,我們基本可以用同步的方式來書寫非同步操作了。但 co 模組仍有不足之處,由於它仍然返回一個 Promise,這代表如果想要獲得非同步方法的返回值,還要寫成下面這種形式:

co(gen).then(function (value) {
  console.log(value);
});

另外,當面對多個非同步操作時,除非將所有的非同步操作都放在一個 Generator 函數中,否則如果需要對 co 的返回值進行進一步操作,仍然要將程式碼寫到 Promise 的回撥中去。

到此這篇關於JavaScript Generator非同步過度的實現詳解的文章就介紹到這了,更多相關JavaScript Generator 內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com