原文 发表于2016年1月25日
好吧好吧,在一月份宣布这是 什么什么是年度最佳这种话题 的确有点大胆,但是web stream API展现出来的潜力真是让我兴奋不已。
TL;DR:流可以用来做许多有趣的事情,诸如把“云”变成“屁股”,把MPEG格式编码为GIF格式,但是最重要的是,你可以结合service workers来成为最快速的提供内容的方式.
当然是某些东西啦
Promises是一个很好的方式去表示一个异步取回来的值,但是如果有多个值呢?或者那些从一个极大值中拆分出来,逐步到达的部分值呢?
假设我们想获取并展现一张图片,这包括了以下部分:
我们可以一步步的完成,又或者可以使用流处理的方式。
如果我们一比特一比特地处理响应,我们可以更快的渲染 部分 图像。我们甚至可以更快地渲染整个图像,因为处理和获取数据是并行的。这就是流处理!我们一边 读取 从网络过来的流,一边将其从压缩的数据 转换 像素数据,而同时我们把他 绘制 到屏幕上。
你可以用事件做到类似的效果,但是流有以下的优点:
最后一个优点十分重要。想象一下我们在下载并且展示一个视频。如果我们一秒内可以下载并且解码200帧,但是一秒内只能展示24帧,我们最终可能会因为解码帧的大量积压而耗尽了内存。
这就是我们需要流控制的地方。负责渲染的流每秒从解码流中拉取24次解码帧。解码器因此注意到他解码的速率远大于帧被读取的速率,因此降低了解码速率。接着,网络流注意到他获取数据的速率远大于数据被解码的速率,从而降低下载速率。
因为流和读取器之间紧密的关系,一个流只可以用一个读取器。然而,一个未被读取的流是可以被“teed”的,这意味着他可以分裂成两个拥有一样数据的流。在这种情况下三通处理器为两个读取器保存缓冲数据。
好的,这些只是理论,我知道你还不打算交出你心里的2016最佳的奖杯,但是请跟着我阅读。
浏览器默认会使用流来处理。当你看到浏览器一边下载,一边展示一个页面/图像/视频的时候,这要感谢流处理。然而,这只是最近,得益于 标准化工作 ,流已经暴露给脚本了。
Response 对象,就像 fetch规范 中定义的,你可以用多种格式来读取响应,但是 response.body 让你直接访问底层流。 response.body 已经被目前的chrome稳定浏览器版本所支持。
假设我想获取一个响应的内容长度,而不希望依赖于头部,也不想在内存中保存整个响应。我可以通过流这样做:
// fetch() 返回一个promise一旦头部被获取就resolvefetch(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 receivedreader.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 变换流都是浏览器本身的。
我们可以利用 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);
至于说到 controller :
所以如果我想创造一个流可以每秒产生一个随机数,直到他产生的数字大于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 格式的比特代表这是字符串。
诸如 TextDecoder , TextEncoder 在以后会成为一个变换流。
像我说的那样,变换流尚未被定义,但是你可以通过从其他数据源创造一个可读流来实现相同目的。
观看示例 . 注意: 你需要使用Chrome Canary版本并且启用 chrome://flags/#enable-experimental-web-platform-features 。
这个页面 摘自维基百科的云计算相关的文章,但是在里面每一个“云”的单词都被“屁股”所代替了。这样做的好处是,你在从源头下载数据的同时你可以变换其中的内容。
这里是代码 , 包括了一些edge的样例。
视频代码是十分高效的,但是在手机上他不能自动播放。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,他展示的内容是来自服务器的。我投入了大量的精力在性能优化方面,然而结果是这样的:
不算太差,我加入了service worker来引入一些离线优先的优点从而使性能优化更多,结果呢?
可以看到,首屏渲染加快了,但是在内容渲染方面仍然有巨大的改进空间。
最快的渲染方法是从缓存里加载整个页面,但是这意味你要缓存所有的维基百科。反之,我提供了一个页面包括CSS,JavaScript和header,从而得到了一个极快速的首屏渲染体验,然后我利用页面的JavaScript去获取文章内容。这就是我丢失性能的地方 - 客户端渲染。
HTML一旦下载它就会被渲染,无论是来自服务器或是service worker。但是我利用JavaScript来获取页面的内容,它会利用 innerHTML 来渲染而不是流解释器。正因为如此,这个内容部分直到他下载完毕才被渲染,这就是造成那两秒延迟的原因。你下载的内容越多,缺少流造成的影响就越大,而不幸的事,维基百科的文章真的很大(google的文章大小是100k)。
这就是为什么你看到我总会抱怨由JavaScript驱动的网络应用和框架 - 他们总是抛弃流从头开始,造成性能损耗极大。
我试图利用预取流和伪流来挽回一些性能。伪流是一个颇为hack的方法。页面获取到文章内容然后利用流的方式进行读取,当他读取到的数据量达到9k的时候,使用 innerHTML 的方法将内容写入,然后当其余的内容获得后,再次使用 innerHTML 写入。这其实是挺恐怖的,因为他会把一些元素创造两次,不过这是值得的。
所以hacks改进了心梗,但是相比于服务端渲染,他还是落后了,这真是难以令人接受。此外,把内容通过 innerHTML 加入到页面中和常规的内容解析表现不大一样。值得注意的是,内联的