2016 - 属于网络流(web streams)的一年

705 阅读13分钟
原文链接: www.w3ctech.com
本文英文原文在《2016 - the year of web streams》

好吧好吧,在一月份宣布这是什么什么是年度最佳这种话题的确有点大胆,但是web stream API展现出来的潜力真是让我兴奋不已。

TL;DR:流可以用来做许多有趣的事情,诸如把“云”变成“屁股”把MPEG格式编码为GIF格式,但是最重要的是,你可以结合service workers来成为最快速的提供内容的方式.

流?他们擅长什么

当然是某些东西啦

Promises是一个很好的方式去表示一个异步取回来的值,但是如果有多个值呢?或者那些从一个极大值中拆分出来,逐步到达的部分值呢?

假设我们想获取并展现一张图片,这包括了以下部分:

  1. 从网络上获得一些数据
  2. 运行之,并把从压缩的数据转化为原始的像素数据。
  3. 渲染它

我们可以一步步的完成,又或者可以使用流处理的方式。

流处理方式与非流处理方式

如果我们一比特一比特地处理响应,我们可以更快的渲染部分图像。我们甚至可以更快地渲染整个图像,因为处理和获取数据是并行的。这就是流处理!我们一边读取从网络过来的流,一边将其从压缩的数据转换像素数据,而同时我们把他绘制到屏幕上。

你可以用事件做到类似的效果,但是流有以下的优点:

  • 开始/结束感知 - 尽管流可以是无限大的
  • 缓冲尚未阅读的值 - 而在监听器连接成功前的数据,事件会将其丢失掉
  • 通过管道进行连接 - 你可以让流都流过管道从而形成一个异步序列
  • 内置错误处理 - 错误随着流往下传播
  • 支持取消 - 取消消息会传回到管道里
  • 流控制 - 你可以对读取的速度作出反应

最后一个优点十分重要。想象一下我们在下载并且展示一个视频。如果我们一秒内可以下载并且解码200帧,但是一秒内只能展示24帧,我们最终可能会因为解码帧的大量积压而耗尽了内存。

这就是我们需要流控制的地方。负责渲染的流每秒从解码流中拉取24次解码帧。解码器因此注意到他解码的速率远大于帧被读取的速率,因此降低了解码速率。接着,网络流注意到他获取数据的速率远大于数据被解码的速率,从而降低下载速率。

因为流和读取器之间紧密的关系,一个流只可以用一个读取器。然而,一个未被读取的流是可以被“teed”的,这意味着他可以分裂成两个拥有一样数据的流。在这种情况下三通处理器为两个读取器保存缓冲数据。

好的,这些只是理论,我知道你还不打算交出你心里的2016最佳的奖杯,但是请跟着我阅读。

浏览器默认会使用流来处理。当你看到浏览器一边下载,一边展示一个页面/图像/视频的时候,这要感谢流处理。然而,这只是最近,得益于标准化工作,流已经暴露给脚本了。

流和fetch API

Response对象,就像fetch规范中定义的,你可以用多种格式来读取响应,但是response.body让你直接访问底层流。response.body已经被目前的chrome稳定浏览器版本所支持。

假设我想获取一个响应的内容长度,而不希望依赖于头部,也不想在内存中保存整个响应。我可以通过流这样做:

// fetch() 返回一个promise一旦头部被获取就resolve
fetch(url).then(response => {
  // response.body是一个可读流
  // 调用getReadeer()函数让我们可以独占到流内容的访问
  var reader = response.body.getReader();
  var bytesReceived = 0;

  // read() 返回一个promise当值被读取到时会进行resolve
  reader.read().then(function processResult(result) {
    // 结果对象含有一下两个属性
    // done  - 当流给予了你所有数据的时候会设为true
    // value - 一些数据。当done是true的时候,为undefined
    if (result.done) {
      console.log("Fetch complete");
      return;
    }

    // fetch流的result.value是一个Uint8Array
    bytesReceived += result.value.length;
    console.log('Received', bytesReceived, 'bytes of data so far');

    // 阅读更多,再次调用这个函数
    return reader.read().then(processResult);
  });
});

观看示例 (1.3mb)

这个示例从服务器获取了一个大小为1.3兆的gzipped压缩的HTML文件,解压后大小为7.7兆。然而,结果并不是放置在缓存中。每个块的大小都被记录了下来,但是每个块本身则被垃圾回收了。

result.value是无论什么流都会提供的,他可以是任何东西:字符串、数字、日期对象、图片数据、DOM元素……但是,在fetch流中,他永远是二进制的Uint8Array。整个响应就是每一个Unit8Array被组合到一起。如果你想响应变成文本格式,你可以使用TextDecoder

var decoder = new TextDecoder();
var reader = response.body.getReader();

// read() returns a promise that resolves
// when a value has been received
reader.read().then(function processResult(result) {
  if (result.done) return;
  console.log(
    decoder.decode(result.value, {stream: true})
  );

  // Read some more, and recall this function
  return reader.read().then(processResult);
});

{stream:true}意味着如果result.value在通过一个UTF-8编码点的时候中断,解码器会保持一个缓冲,例如一个字符♥会用3个比特来表示:[0xE2, 0x99, 0xA5]

TextDecoder目前是有一些笨拙,但是他有可能在未来变为一个变换流(一旦变换流被定义)。变换流是一个拥有可写流.writable和可读流.readable的对象。它通过可写流获取块文件并且处理他们,然后通过可读流传出内容。使用变换流会是这样子的:

假设这是未来的代码:

var reader = response.body
  .pipeThrough(new TextDecoder()).getReader();

reader.read().then(result => {
  // result.value will be a string
});

浏览器应该有能力优化到上述程度,因为响应流和TextDecoder变换流都是浏览器本身的。

取消fetch

我们可以利用stream.cancel()或者reader.cancel()来取消一个流(同样的对应fetch使用response.body.cancel())。fetch会对此作出停止下载的反应。

观看示例 (注意一下JSBin给予我的神器的随机URL).

这个示例是搜索一个大型文档里面的一个术语,每次只在内存中保留一小部分,一旦找到匹配的部分就停止获取。

无论如何,这些都是2015的东西了。下面将会是一些新的东西。

创建你的可读取流

在Chrome Canary版本中使用"实验网络平台功能",你可以创建属于你自己的流。

var stream = new ReadableStream({
  start(controller) {},
  pull(controller) {},
  cancel(reason) {}
}, queuingStrategy);
  • start会被马上调用。使用它去设置一些底层数据源(这意味着,你可以从任何地方去得到你的数据,数据可能是事件、其他流、一个变量、一个字符串)。如果你从这里返回一个promise对象并且reject,它会通过流发送一个错误。
  • pull会在你的流缓冲尚未满的时候被调用,他会被重复调用直至你的缓冲区已经满了。同样地,如果你从这里返回一个promise并且他被reject了,它会通过流发送一个错误。另外,在返回的promise完成前,pull不会被再次调用。
  • cancel会在stream被取消的时候调用。可以利用它去取消任意的底层数据。
  • queuingStrategy定义了这个流理想情况下可以缓冲多少数据,默认是一个 - 我不打算深入这一部分,规范上面有更多细节

至于说到controller

  • controller.enqueue(whatevet) - 让数据在流的缓冲区中排队
  • controller.close() - 标志了流的结束
  • conttoller.error(e) - 标志了一个终端错误
  • controller.desiredSize - 缓冲区中留有的数据数量,当缓冲区溢出的时候可能是负数。这个数据是利用queuingStrategy计算出的。

所以如果我想创造一个流可以每秒产生一个随机数,直到他产生的数字大于0.9.我会这么做:

var interval;
var stream = new ReadableStream({
  start(controller) {
    interval = setInterval(() => {
      var num = Math.random();

      // Add the number to the stream
      controller.enqueue(num);

      if (num > 0.9) {
        // Signal the end of the stream
        controller.close();
        clearInterval(interval);
      }
    }, 1000);
  },
  cancel() {
    // This is called if the reader cancels,
    //so we should stop generating numbers
    clearInterval(interval);
  }
});

观看运行示例. 注意: 你需要使用Chrome Canary版本并且启用chrome://flags/#enable-experimental-web-platform-features

你可以控制什么时候把数据传送给controller.enqueue。你可以在你拥有数据需要发送的时候调用,使你的流成为一个“推送源”。当然你也可以选择等待到pull被调用,然后使用它作为一个信号去收集底层数据然后让他enqueue,使你的流成为“拉取源”。或者你可以结合两种方式,无论你想用哪种。

遵循controller.desiredSize意味着流正在用最有效率的方式传输数据。这被称作“背压支持”(backpressure support),意味着你的流会对读取器的读取速率作出反应(和之前提到的视频解码例子一样)。然而,忽略掉desiredSize并不会破坏什么东西,除了你可能会消耗掉整个设备的内存。在规范上面有创造一个背压支持的流的例子。

创建一个自己的流并不是一件十分有趣的是,因为他们是新的,所以没有特别多的API支持他们,不过有这么一条

new Response(readableStream)

你可以创建一个HTTP响应对象,他的body是一个流,然后你可以把这个对象应用到service worker上。

缓慢地提供一个字符串

观看示例. 注意: 你需要使用Chrome Canary版本并且启用chrome://flags/#enable-experimental-web-platform-features

你会看到一个被(故意)渲染的非常慢的HTML页面。这个响应完全由service worker生成。下面是代码:

// In the service worker:
self.addEventListener('fetch', event => {
  var html = '…html to serve…';

  var stream = new ReadableStream({
    start(controller) {
      var encoder = new TextEncoder();
      // 我们目前的位置是在HTML中
      var pos = 0;
      // 每一次服务器推送的个数
      var chunkSize = 1;

      function push() {
        // 推送完毕了吗
        if (pos >= html.length) {
          controller.close();
          return;
        }

        // 推送一些html。并且把它转化为一个utf-8数据的Unit8Array
        controller.enqueue(
          encoder.encode(html.slice(pos, pos + chunkSize))
        );

        // 移动位置
        pos += chunkSize;
        // 5毫秒后再次推送
        setTimeout(push, 5);
      }

      // 出发
      push();
    }
  });

  return new Response(stream, {
    headers: {'Content-Type': 'text/html'}
  });
});

当浏览器读取到相应的内容他希望读取到Unit8Array块,如果传入了其他格式的数据比如一个空白的字符串,他会读取失败。幸运的是,TextEncoder可以传入一个字符串,然后返回一个Unit8Array格式的比特代表这是字符串。

诸如TextDecoderTextEncoder在以后会成为一个变换流。

提供一个变换流

像我说的那样,变换流尚未被定义,但是你可以通过从其他数据源创造一个可读流来实现相同目的。

“云”变为“屁股”(cloud to butt)

观看示例. 注意: 你需要使用Chrome Canary版本并且启用chrome://flags/#enable-experimental-web-platform-features

这个页面摘自维基百科的云计算相关的文章,但是在里面每一个“云”的单词都被“屁股”所代替了。这样做的好处是,你在从源头下载数据的同时你可以变换其中的内容。

这里是代码, 包括了一些edge的样例。

MPEG到GIF

视频代码是十分高效的,但是在手机上他不能自动播放。GIF格式在手机上可以自动播放,但是他十分巨大。好吧,以下是一个非常傻的解决方式:

观看示例. 注意: 你需要使用Chrome Canary版本并且启用chrome://flags/#enable-experimental-web-platform-features

在这里流十分有用,因为那样子我们可以在MPEG帧仍在解码的时候播放GIF的第一帧。

所以你就这么做吧!一个26兆的GIF只需要用0.9兆的MPEG就可以传输了!完美!但是他不是实时的,并且会耗费大量CPU资源。浏览器应该允许在手机上自动播放视频,尤其是静音的视频。Chromes正在朝这方面努力着。

披露:其实在demo里面我感觉我有点被骗了,因为他是下载了整个MPEG后才开始播放的。我希望可以用流的方式获取,但是我碰到了OutOfSkillError。同样的,下载的时候GIF也不应该循环播放,这是需要我们研究的bug。

创造一个多源流来压缩页面渲染时间

这可能是流+service worker中最实际的应用了。单就性能表现而言,他的优点十分巨大

几个月前我构建了离线优先版本维基百科的样例。我希望能创造一个速度快,遵循逐步联网策略的网页应用,并且利用更多的现代功能去增强他。

我下面引用的关于性能的数字是基于OSX的网络模拟出的较差的3G网络。

缺少了service worker,他展示的内容是来自服务器的。我投入了大量的精力在性能优化方面,然而结果是这样的:

没有serviceworker优化

观看示例

不算太差,我加入了service worker来引入一些离线优先的优点从而使性能优化更多,结果呢?

使用serviceworker优化

观看示例

可以看到,首屏渲染加快了,但是在内容渲染方面仍然有巨大的改进空间。

最快的渲染方法是从缓存里加载整个页面,但是这意味你要缓存所有的维基百科。反之,我提供了一个页面包括CSS,JavaScript和header,从而得到了一个极快速的首屏渲染体验,然后我利用页面的JavaScript去获取文章内容。这就是我丢失性能的地方 - 客户端渲染。

HTML一旦下载它就会被渲染,无论是来自服务器或是service worker。但是我利用JavaScript来获取页面的内容,它会利用innerHTML来渲染而不是流解释器。正因为如此,这个内容部分直到他下载完毕才被渲染,这就是造成那两秒延迟的原因。你下载的内容越多,缺少流造成的影响就越大,而不幸的事,维基百科的文章真的很大(google的文章大小是100k)。

这就是为什么你看到我总会抱怨由JavaScript驱动的网络应用和框架 - 他们总是抛弃流从头开始,造成性能损耗极大。

我试图利用预取流和伪流来挽回一些性能。伪流是一个颇为hack的方法。页面获取到文章内容然后利用流的方式进行读取,当他读取到的数据量达到9k的时候,使用innerHTML的方法将内容写入,然后当其余的内容获得后,再次使用innerHTML写入。这其实是挺恐怖的,因为他会把一些元素创造两次,不过这是值得的。

使用hack的方式

观看示例

所以hacks改进了心梗,但是相比于服务端渲染,他还是落后了,这真是难以令人接受。此外,把内容通过innerHTML加入到页面中和常规的内容解析表现不大一样。值得注意的是,内联的