w3ctech

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

原文发表于2016年1月25日

好吧好吧,在一月份宣布这是什么什么是年度最佳这种话题的确有点大胆,但是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加入到页面中和常规的内容解析表现不大一样。值得注意的是,内联的<script>并不会被执行。

这就是需要流的时候了。我放弃了仅仅提供一个空壳,让JS去填充它的这种做法。我让service worker去构建一个流,这个流的header部分来自于缓存,而body部分则从网络中获取。他的做法和服务器渲染是一致的,只不过我实在service worker中进行渲染。 This is where streams step in. Instead of serving an empty shell and letting JS populate it, I let the service worker construct a stream where the header comes from a cache, but the body comes from the network. It's like server-rendering, but in the service worker:

使用流

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

利用servce worker+流的方式,你可以得到一个几乎是瞬时的首屏体验,然后通过管道读取一小部分来自网络的内容从而成功击败了常规的服务器渲染。内容是通过常规HTML渲染的,所以你得到的是流,这样子你和正常的人工DOM添加没有任何差别。

渲染时间对比

穿越流

因为piping尚未被支持,因此必须手动去合成流,这让事情有一些混乱:

var stream = new ReadableStream({
  start(controller) {
       // 得到每一个页面部分的响应promise
    // 开始与结束来自于缓存
    var startFetch = caches.match('/page-start.inc');
    var endFetch = caches.match('/page-end.inc');
    // 中间部分来自于网络,使用了一个回调的方法
    var middleFetch = fetch('/page-middle.inc')
      .catch(() => caches.match('/page-offline-middle.inc'));

    function pushStream(stream) {
      // 在流上加锁
      var reader = stream.getReader();

      return reader.read().then(function process(result) {
        if (result.done) return;
        // 把值推向合成流
        controller.enqueue(result.value);
        // 继续读取处理
        return read().then(process);
      });
    }

    // 得到响应的开始部分
    startFetch
      // 把处理后的部分推向合成流
      .then(response => pushStream(response.body))
      // 得到中间的响应部分
      .then(() => middleFetch)
      // 把处理后的内容推向合成流
      .then(response => pushStream(response.body))
      // 得到相应的结尾部分
      .then(() => endFetch)
      // 把处理后的内容推向合成流
      .then(response => pushStream(response.body))
      // 结束流,我们完成了。
      .then(() => controller.close());
  }
});

有一些模板语言,例如Dust.js,是利用流输出他们的内容的,并且在模板中把流当成值进行处理,用流的方式传输内容甚至会进行HTML转移。唯一缺失的就是支持网络流。

流的未来

除了可读流,流规范仍然处于开发中。但是你现在能做的已经非常不可思议了。如果你仍然希望改进内容站点的性能并且提供一个更好的离线优先体验而又不愿意进行重构。构建一个使用流的service worker是最简单的办法。这就是我打算对这个博客所做的。

在网络上拥有一个原始流意味着我们可以通过所有浏览器兼容的方式获取脚本。包括:

  • Gzip/deflate
  • 音频/视频代码
  • 图像代码
  • 流媒体HTML/ XML解析器

虽然现在仍然很早,但是如果你想把你的API转为流,你可以参考在某种情况下进行一些polyfill。

流媒体是浏览器最大的资产,而2016年是把他向JavaScript解锁的一年。

感谢Dominic Szablewski的JS MPG1解码器 (看看他说了些什么),和Eugene Ware的GIF编码器。还得感谢Domenic Denicola, Takeshi Yoshino, Yutaka Hirano, Lyza Danger Gardner, Nicolás Bevacqua, 和 Anne van Kesteren的指正和想法。是的,这需要这么多人来找我的错误。

w3ctech微信

扫码关注w3ctech微信公众号

共收到2条回复

  • 和webrtc貌似有点冲突

    回复此楼
  • 看懂了~。~

    回复此楼