ES6生成器,看似同步的非同步流程控制表達風格

华为云开发者联盟發表於2024-04-10

本文分享自華為雲社群《3月閱讀周·你不知道的JavaScript | ES6生成器,看似同步的非同步流程控制表達風格》,作者: 葉一一。

生成器

打破完整執行

JavaScript開發者在程式碼中幾乎普遍依賴的一個假定:一個函式一旦開始執行,就會執行到結束,期間不會有其他程式碼能夠打斷它並插入其間。

ES6引入了一個新的函式型別,它並不符合這種執行到結束的特性。這類新的函式被稱為生成器。

var x = 1;

function foo() {
  x++;
  bar(); // <-- 這一行在x++和console.log(x)語句之間執行
  console.log('x:', x);
}

function bar() {
  x++;
}

foo(); // x: 3

如果bar()並不在那裡會怎樣呢?顯然結果就會是2,而不是3。最終的結果是3,所以bar()會在x++和console.log(x)之間執行。

但JavaScript並不是搶佔式的,(目前)也不是多執行緒的。然而,如果foo()自身可以透過某種形式在程式碼的這個位置指示暫停的話,那就仍然可以以一種合作式的方式實現這樣的中斷(併發)。

下面是實現合作式併發的ES6程式碼:

var x = 1;

function* foo() {
  x++;
  yield; // 暫停!
  console.log('x:', x);
}

function bar() {
  x++;
}
// 構造一個迭代器it來控制這個生成器
var it = foo();

// 這裡啟動foo()!
it.next();
console.log('x:', x); // 2
bar();
console.log('x:', x); // 3
it.next(); // x: 3
  • it = foo()運算並沒有執行生成器*foo(),而只是構造了一個迭代器(iterator),這個迭代器會控制它的執行。
  • *foo()在yield語句處暫停,在這一點上第一個it.next()呼叫結束。此時*foo()仍在執行並且是活躍的,但處於暫停狀態。
  • 最後的it.next()呼叫從暫停處恢復了生成器*foo()的執行,並執行console.log(..)語句,這條語句使用當前x的值3。

生成器就是一類特殊的函式,可以一次或多次啟動和停止,並不一定非得要完成。

輸入和輸出

生成器函式是一個特殊的函式,它仍然有一些函式的基本特性。比如,它仍然可以接受引數(即輸入),也能夠返回值(即輸出)。

function* foo(x, y) {
  return x * y;
}

var it = foo(6, 7);
var res = it.next();

res.value; // 42

向*foo(..)傳入實參6和7分別作為引數x和y。*foo(..)向呼叫程式碼返回42。

多個迭代器

每次構建一個迭代器,實際上就隱式構建了生成器的一個例項,透過這個迭代器來控制的是這個生成器例項。

同一個生成器的多個例項可以同時執行,它們甚至可以彼此互動:

function* foo() {
  var x = yield 2;
  z++;
  var y = yield x * z;
  console.log(x, y, z);
}

var z = 1;

var it1 = foo();
var it2 = foo();

var val1 = it1.next().value; // 2 <-- yield 2
var val2 = it2.next().value; // 2 <-- yield 2

val1 = it1.next(val2 * 10).value; // 40   <-- x:20,  z:2
val2 = it2.next(val1 * 5).value; // 600  <-- x:200, z:3

it1.next(val2 / 2); // y:300
// 20300 3
it2.next(val1 / 4); // y:10
// 200 10 3

簡單梳理一下執行流程:

(1) *foo()的兩個例項同時啟動,兩個next()分別從yield 2語句得到值2。

(2) val2 * 10也就是2 * 10,傳送到第一個生成器例項it1,因此x得到值20。z從1增加到2,然後20 * 2透過yield發出,將val1設定為40。

(3) val1 * 5也就是40 * 5,傳送到第二個生成器例項it2,因此x得到值200。z再次從2遞增到3,然後200 * 3透過yield發出,將val2設定為600。

(4) val2 / 2也就是600 / 2,傳送到第一個生成器例項it1,因此y得到值300,然後列印出x y z的值分別是20300 3。

(5) val1 / 4也就是40 / 4,傳送到第二個生成器例項it2,因此y得到值10,然後列印出x y z的值分別為200 10 3。

生成器產生值

生產者與迭代器

假定你要產生一系列值,其中每個值都與前面一個有特定的關係。要實現這一點,需要一個有狀態的生產者能夠記住其生成的最後一個值。

迭代器是一個定義良好的介面,用於從一個生產者一步步得到一系列值。JavaScript迭代器的介面,就是每次想要從生產者得到下一個值的時候呼叫next()。

可以為數字序列生成器實現標準的迭代器介面:

var something = (function () {
  var nextVal;

  return {
    // for..of迴圈需要
    [Symbol.iterator]: function () {
      return this;
    },

    // 標準迭代器介面方法
    next: function () {
      if (nextVal === undefined) {
        nextVal = 1;
      } else {
        nextVal = 3 * nextVal + 6;
      }

      return { done: false, value: nextVal };
    },
  };
})();

something.next().value; // 1
something.next().value; // 9
something.next().value; // 33
something.next().value; // 105

next()呼叫返回一個物件。這個物件有兩個屬性:done是一個boolean值,標識迭代器的完成狀態;value中放置迭代值。

iterable

iterable(可迭代),即指一個包含可以在其值上迭代的迭代器的物件。

從ES6開始,從一個iterable中提取迭代器的方法是:iterable必須支援一個函式,其名稱是專門的ES6符號值Symbol.iterator。呼叫這個函式時,它會返回一個迭代器。通常每次呼叫會返回一個全新的迭代器,雖然這一點並不是必須的。

var a = [1, 3, 5, 7, 9];

for (var v of a) {
  console.log(v);
}
// 1 3 5 7 9

上面的程式碼片段中的a就是一個iterable。for..of迴圈自動呼叫它的Symbol.iterator函式來構建一個迭代器。

for (var v of something) {
  ..
}

for..of迴圈期望something是iterable,於是它尋找並呼叫它的Symbol.iterator函式。

生成器迭代器

可以把生成器看作一個值的生產者,我們透過迭代器介面的next()呼叫一次提取出一個值。

生成器本身並不是iterable,當你執行一個生成器,就得到了一個迭代器:

function *foo(){ .. }

var it = foo();

可以透過生成器實現前面的這個something無限數字序列生產者,類似這樣:

function* something() {
  var nextVal;

  while (true) {
    if (nextVal === undefined) {
      nextVal = 1;
    } else {
      nextVal = 3 * nextVal + 6;
    }

    yield nextVal;
  }
}

因為生成器會在每個yield處暫停,函式*something()的狀態(作用域)會被保持,即意味著不需要閉包在呼叫之間保持變數狀態。

非同步迭代生成器

function foo(x, y) {
  ajax('http://some.url.1/? x=' + x + '&y=' + y, function (err, data) {
    if (err) {
      // 向*main()丟擲一個錯誤
      it.throw(err);
    } else {
      // 用收到的data恢復*main()
      it.next(data);
    }
  });
}

function* main() {
  try {
    var text = yield foo(11, 31);
    console.log(text);
  } catch (err) {
    console.error(err);
  }
}

var it = main();

// 這裡啟動!
it.next();

在yield foo(11,31)中,首先呼叫foo(11,31),它沒有返回值(即返回undefined),所以發出了一個呼叫來請求資料,但實際上之後做的是yield undefined。

這裡並不是在訊息傳遞的意義上使用yield,而只是將其用於流程控制實現暫停/阻塞。實際上,它還是會有訊息傳遞,但只是生成器恢復執行之後的單向訊息傳遞。

看一下foo(..)。如果這個Ajax請求成功,我們呼叫:

it.next(data);

這會用響應資料恢復生成器,意味著暫停的yield表示式直接接收到了這個值。然後隨著生成器程式碼繼續執行,這個值被賦給區域性變數text。

總結

我們來總結一下本篇的主要內容:

  • 生成器是ES6的一個新的函式型別,它並不像普通函式那樣總是執行到結束。取而代之的是,生成器可以在執行當中(完全保持其狀態)暫停,並且將來再從暫停的地方恢復執行。
  • yield/next(..)這一對不只是一種控制機制,實際上也是一種雙向訊息傳遞機制。yield ..表示式本質上是暫停下來等待某個值,接下來的next(..)呼叫會向被暫停的yield表示式傳回一個值(或者是隱式的undefined)。
  • 在非同步控制流程方面,生成器的關鍵優點是:生成器內部的程式碼是以自然的同步/順序方式表達任務的一系列步驟。其技巧在於,把可能的非同步隱藏在了關鍵字yield的後面,把非同步移動到控制生成器的迭代器的程式碼部分。
  • 生成器為非同步程式碼保持了順序、同步、阻塞的程式碼模式,這使得大腦可以更自然地追蹤程式碼,解決了基於回撥的非同步的兩個關鍵缺陷之一。

點選關注,第一時間瞭解華為雲新鮮技術~

相關文章