wleonardo

学习JavaScript中的异步Generator

wleonardo · 2017-02-02翻译 · 395阅读 原文链接

你想看精简版本么 这里是所有三个例子的要点

异步的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!

译者wleonardo尚未开通打赏功能

相关文章