1. 程式人生 > >學習JavaScript中的非同步Generator

學習JavaScript中的非同步Generator

非同步的generators和非同步iteration已經到來! 這是錯誤的, 它們現在還在階段 3,這表示他們很有可能在JavaScript未來的一個版本中釋出。 在他們釋出之前,你可以通過Babel來在你的專案中使用還在階段3的建議內容。

網站基本上還是一些分散執行的應用,因為任何在語言上的修改都會造成永久的影響,所以所有的未來的版本都需要向後相容。因此,被加入到ECMAScript標準的特性,它必須是十分的可靠,而且它的語法需要很優雅。

考慮到這一點,我們希望非同步generator和迭代器可以顯著地影響我們如何構建今後的程式碼,同時也解決現在的問題。讓我們開始瞭解非同步generator是如何工作的,它在我們的正式開發中又會遇到什麼樣的問題。

總結: 非同步的Generators是如何工作的呢

簡而言之,非同步的generators和普通的generator函式很像,但是它可以yield Promises。如果你想很瞭解ES2015的generator函式,那麼可以先去看一下Chris Aquino的部落格,再去看一下Jafar Husain的一篇非同步程式設計的很棒的演講

總的來說,普通的generator函式基本上就是一個迭代器觀察者 模式的集合。generator是一個可以中止的函式,你可以通過呼叫.next()來一步步執行。可以同通過.next()來多次從generator輸出內容,也可以通過.next(valueToPush)

來多次傳入引數。這種雙向的介面可以使你通過一種語法同時完成迭代器和觀察者的功能!

當然generators也有它的缺點:它在呼叫.next()的時候必須立即(同步)返回資料。換句話來說,就是程式碼在呼叫.next()的時候就需要得到資料。在generator需要時能夠生成新資料的情況下是可以的,但是沒有辦法處理迭代一個非同步的(或者臨時的)資料來源,它們需要自己控制在下一次資料準備好的時候執行下一次。

WebSocket訊息機制就是一個很好的非同步獲取資料的例子。如果我們已經接收到了所有的資料,那麼我們當然可以同步地遍歷它們。但是,我們也可能會遇到我們並不知道什麼時候會接收到資料,所以我們需要一個機制去等待資料接收完成後去遍歷。非同步generators和非同步迭代器可以讓我們做到這個。

**簡單的來說就是:**generator函式適用於資料可以被使用者控制的情況,非同步generators適用於允許資料來源本身控制的情況。

一個簡單的例子: 生成和使用AsyncGenerator

讓我們用一個例子來練習我們的非同步方案。我們需要編寫一個非同步的generator函式,它可以重複的等待一個隨機的毫秒數後生成一個新的數字。在幾秒鐘中時間裡,它可能會從0開始生成5個左右的數字。首先我們先通過建立一個Promise來建立一個定時器:

// 建立一個Promise,並在ms後resolves
var timer = function(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
};

執行timer(5000)會返回一個Promise,並且會在5秒後resolve。現在我們可以寫一個非同步generator:

// Repeatedly generate a number starting
// from 0 after a random amount of time
var source = async function\*() {
  var i = 0;
  while (true) {
    await timer(Math.random() \* 1000);
    yield i++;
  }
};

如此複雜的功能卻可以寫的如此優雅!我們的非同步generator函式等待一個隨機的時間後yield並減小i的值。如果我們沒有非同步generator,我們可以像下面一樣使用普通的generator函式,通過yieldPromises來實現:

var source = function\*() {
  var i = 0;
  while (true) {
    yield timer(Math.random() \* 1000)
      .then(() => i++);
  }
};

當然,這裡還有一些特殊情況和引用需要我們處理,所以最好有一個專門的函式型別!現在是時候編寫使用程式碼了;因為我們需要await操作符,所以我們將會建立一個非同步的run()函式。

// 把所有都集合到一起
var run = async function() {
  var stream = source();
  for await (let n of stream) {
    console.log(n);
  }
};

run();
// => 0
// => 1
// => 2
// => 3
// ...

這是多麼神奇,只有20行不到的程式碼。首先,我們先運行了非同步generator函式source,它返回了一個特殊的AsyncGenerator物件。然後,我們使用一個語法上叫“非同步迭代”的for await...of迴圈遍歷source生成的物件。

但是我們還可以再改進一下: 假設我們是想要輸出source生成的數字。我們可以在for await...of迴圈裡面直接輸出它們,但是我們最好在迴圈的外面“轉換”stream 的值,像是使用.map()一樣來轉換數組裡的值。它是如此的簡單:

// Return a new async iterator that applies a
// transform to the values from another async generator
var map = async function\*(stream, transform) {
  for await (let n of stream) {
    yield transform(n);
  }
};

接下來我們只需要再往run()函式中加一行程式碼就好了:

 // Tie everything together
 var run = async function() {
   var stream = source();
+  // Square values generated by source() as they arrive
+  stream = map(stream, n => n \* n);
   for await (let n of stream) {
     console.log(n);
   }
 };

當我們執行 run()就會輸出:

// => 0
// => 1
// => 4
// => 9
// ...

多麼感人啊!但是隻是用於計算數字有一點大材小用了。

中級例子: 在WebSockets中使用AsyncIterator(非同步迭代器)

我們一般是通過繫結事件來監聽WebSocket的資料:

var ws = new WebSocket('ws://localhost:3000/');
ws.addEventListener('message', event => {
  console.log(event.data);
});

但是如果可以把WebSocket的資訊當做stream,這樣就可以用我們上面的辦法“iterate”這些資訊。不幸的是,WebSockets還沒有非同步迭代器的功能,但是我們只需要寫短短的幾行就可以自己來實現這個功能。我們的run()函式大概的樣子如下:

// Tie everything together
var run = async () => {
  var ws = new WebSocket('ws://localhost:3000/');
  for await (let message of ws) {
    console.log(message);
  }
};

Now for that polyfill.你可能會回憶起Chris Aquino’s blog series中寫到的內容,一個物件要使用for...of迴圈,必須要有Symbol.iterator屬性。同樣的,一個物件要想使用for await...of迴圈,它必須要有Symbol.asyncIterator屬性。下面就是具體的實現:

// Add an async iterator to all WebSockets
WebSocket.prototype[Symbol.asyncIterator] = async function\*() {
  while(this.readyState !== 3) {
    yield (await oncePromise(this, 'message')).data;
  }
};

這個非同步迭代器會等待接受資訊,然後會對WebSocket的MessageEvent返回的資料的data屬性進行yieldoncePromise()函式有一點黑科技:它返回了一個Promise,當事件觸發時它會被resolves,然後立即移除事件監聽。

// Generate a Promise that listens only once for an event
var oncePromise = (emitter, event) => {
  return new Promise(resolve => {
    var handler = (...args) => {
      emitter.removeEventListener(event, handler);
      resolve(...args);
    };
    emitter.addEventListener(event, handler);
  });
};

這樣看上去有一點低效,但是證明了websocket的資訊接收確實可以用我們的非同步迭代器實現。如果你在http://localhost:3000 有一個執行的WebSocket服務,那麼你可以通過呼叫run()來監聽資訊流:

run();
// => "hello"
// => "sandwich"
// => "otters"
// ...

高階例子: 重寫 RxJS

現在是時候面對最後的挑戰了。反應型函式程式設計 (FRP)在UI程式設計和JavaScript中被大量使用, RxJS是這種程式設計方式中最流行的框架。RxJS中模型事件來源例如Observable–它們很想一個一個事件流或者lazy array,它們可以被類似陣列語法中的map()filter()處理。

自從FRP補充了JavaScript中的非阻塞式理念,類RxJS的API很有可能會加入到JavaScript未來的一個版本中。同時,我們可以使用非同步generators編寫我們自己的類似RxJS的功能,而這僅僅只需要80行程式碼。下面就是我們要實現的目標:

  1. 監聽所有的點選事件

  2. 過濾點選事件只獲取點選anchor標籤的事件

  3. 只允許不同的點選Only allow distinct clicks

  4. 將點選事件對映到點選計數器和點選事件

  5. 每500ms只可以觸發一次點選

  6. 列印點選的次數和事件

這些問題都是RxJS解決了的問題,所以我們將要嘗試重新實現。下面是我們的實現:

// Tie everything together
var run = async () => {
  var i = 0;
  var clicks = streamify('click', document.querySelector('body'));

  clicks = filter(clicks, e => e.target.matches('a'));
  clicks = distinct(clicks, e => e.target);
  clicks = map(clicks, e => [i++, e]);
  clicks = throttle(clicks, 500);

  subscribe(clicks, ([ id, click ]) => {
    console.log(id);
    console.log(click);
    click.preventDefault();
  });
};

run();

為了使上面的函式正常執行,我們還需要6個函式:streamify(), filter(), distinct(), map(), throttle()subscribe()

// 把所有的event emitter放入一個stream
var streamify = async function\*(event, element) {
  while (true) {
    yield await oncePromise(element, event);
  }
};

streamify() 像是一個WebSocket非同步迭代器: oncePromise() 使用 .addEventListener() 去監聽事件一次, 然後resolves Promise. 通過while (true)迴圈 , 我們可以一直監聽事件。

// Only pass along events that meet a condition
var filter = async function\*(stream, test) {
  for await (var event of stream) {
    if (test(event)) {
      yield event;
    }
  }
};

filter() 會只允許通過test的事件被 yield. map()幾乎是相同的:

// Transform every event of the stream
var map = async function\*(stream, transform) {
  for await (var event of stream) {
    yield transform(event);
  }
};

map()可以簡單地在yield之前變換事件。distinct()展示了非同步generator的其中一個強大的功能:它可以儲存區域性變數!

var identity = e => e;

// 只允許與最後一個不相同的事件通過
var distinct = async function\*(stream, extract = identity) {
  var lastVal;
  var thisVal;
  for await (var event of stream) {
    thisVal = extract(event);
    if (thisVal !== lastVal) {
      lastVal = thisVal;
      yield event;
    }
  }
};

最後,強大的throttle()函式和distinct()很像:它記錄最後一個事件的時間,且只允許超過最後一次yield事件一個確定的時間的事件通過。

// 只允許超過最後一次事件確定時間的事件通過。
var throttle = async function\*(stream, delay) {
  var lastTime;
  var thisTime;
  for await (var event of stream) {
    thisTime = (new Date()).getTime();
    if (!lastTime || thisTime - lastTime > delay) {
      lastTime = thisTime;
      yield event;
    }
  }
};

我們做了這麼多,最後,我們還需要打印出每次的點選事件和當前的次數。subscribe()做了一些零碎的事情:它在每一次事件迴圈的時候執行,並執行callback,所以沒有必要使用yield

// 每次事件到達都呼叫一次回撥函式
var subscribe = async (stream, callback) => {
  for await (var event of stream) {
    callback(event);
  }
};

到這裡,我們已經寫了一個我們自己的反應型函式式管道!

你可以在這裡獲取到所有的例子的程式碼和要點。

挑戰

非同步generators是如此的優雅。而generator函式允許我們從迭代器中回去資料,非同步generators可以讓我們迭代“推送”過來的資料。這是多麼好的非同步資料結構的抽象。當然,也有一些注意事項。

首先,對一個objects增加支援for await...of的功能有一些粗糙,除非你可以避免使用yieldawait。尤其是,使用.addEventListener()轉換任何東西都很棘手,因為你不可以在一個回撥中使用yield操作:

var streamify = async function\*(event, element) {
  element.addEventListener(event, e => {
    // 這裡將無法執行,因為yield
    // 不可以在一個普通函式中被使用
    yield e;
  });
};

同樣的,你也不可以在.forEach()和其他函式型的方法中使用yield。這是一個固有的限制因為我們不能保證在generator已經完成後不使用yield

為了繞過這個問題,我們寫了一個oncePromise()函式來幫組我們。撇開一些潛在的效能問題,需要注意的是Promise的回撥總是在當前的呼叫堆疊結束之後執行。在瀏覽器端,類似microtasks一樣執行Promise的回撥是不會出現問題的,但是一些Promise的polyfill在下一次事件迴圈執行之前是不會執行callback。因此,呼叫.preventDefault()函式有時候會沒有有效果,因為可能DOM時間已經冒泡到瀏覽器了。

JavaScript現在已經有了多個非同步流資料型別:Stream, AsyncGenerator和最後的Observable。雖然三個都是屬於“推送”資料來源,但是在處理回撥和控制底層資源上還是有一些微妙的語義上的不同。如果你想了解更多關於反應函式式語法的細節,可以瀏覽General Theory of Reactivity.

更多

在程式語言的競賽中,JavaScript不是一個懶鬼。ES2015中的變數的解構賦值,ES2016中的非同步函式,而現在的非同步迭代器可以使JavaScript使用優雅的解決複雜UI和I/O程式設計的問題而不是使用充滿不可控的多執行緒方案。

除此之外,還有很多的新內容和新特性!所以請關注部落格和TC39 proposals repo來獲取最新的好東西。同時,你也可以通過在Babel中開啟Stage 3 提案的方式在你的程式碼中使用非同步generator函式。

你是否有興趣學習網頁平臺的下一代的JavaScript? 歡迎來我們的前端訓練營, 或者 我們可以提供企業培訓g!