第十章 流式数据

10.1 理解 Web Stream

如果不使用流,在网页上显示一张图片需要:

  1. 通过网络获取图片数据
  2. 处理数据并将其解压为原始像素数据
  3. 将结果数据渲染到页面中

如果使用流,可以一块块地返回下载结果并进行处理,使得屏幕上的渲染更快。还可以并行地获取及处理数据。

10.1.1 Web Stream 有什么优势

  • 知道开始与结束:流知道到它们从哪里开始、在哪里结束,尽管流也可能是无限的。
  • 缓冲:流可以缓冲尚未读取的值。不使用流,这些数据将会丢失。
  • 通过管道连接:可以用管道将流组合成一个异步序列。
  • 内置的错误处理:发生的任何错误都将沿管道进行传播。
  • 可取消:可以取消流并将其传回管道中。

10.1.2 可读流

可读流表示可以从中读取数据的数据源。可读流只允许数据流出,不允许流入。

可读流使用的数据源有两种类型:推送(push)源和读取(pull)源。

1
2
3
4
5
var stream = new ReadableStream({
start(controller) {},
pull(controller) {},
cancel(controller) {}
}, queuingStrategy)
  • start(controller) 方法,会立即调用它并用它来设置任何基础数据源,只有当这个 Promise 成功完成后才会调用 pull(controller)
  • pull(controller) 方法,当流的缓冲区未满时,会调用该方法,而且会重复调用,直到缓冲区满为止。只有当前一个 pull(controller) 成功完成后,才会调用下一个 pull(controller)
  • cancel(controller) 方法,当消费者表示他们不再对流感兴趣时,会调用该方法来取消任何基础数据源。
  • queuingStrategy 是一个对象,决定了流如何根据内部队列的状态来发出过载信号。

10.2 基础示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
self.addEventListener('fetch', event => {
event.respondWith(htmlStream()) // 进入fetch事件并用HTML流进行响应
})

function htmlStream() {
const html = 'html goes here...' // 将要返回的HTML字符串

const stream = new ReadableStream({ // 创建一个ReadableStream
start: controller => {
const encoder = new TextEncoder() // 需要使用TextEncoder将文本转换成字节
let pos = 0
let chunkSize = 1

function push() { // 将结果推送到Web Stream中
if (pos >= html.length) { // 检查是否超出了HTML的长度,如果超出,则关闭controller
controller.close()
return
}

controller.enqueue(encoder.encode(html.slice(pos, pos + chunkSize))) // 将下一个HTML块编码并放入队列中

pos += chunkSize
setTimeout(push, 50) // 强制延迟500ms,以降低渲染速度
}

push() // 开始推送流
}
})

return new Response(stream, { // 返回流的结果作为新的Response对象
headers: {
'Content-Type': 'text/html'
}
})
}

以上代码为了演示浏览器的流功能,使用 Web Stream 的同时还故意减缓了页面的渲染速度,实际应用中不这样做。

浏览器从刚接收到数据就开始渲染页面,而不是等到所有数据下载完才渲染。

10.3 页面渲染加速

↓ 在 Service Worker 安装期间将资源添加到缓存中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const cacheName = 'latestNews-v1'

self.addEventListener('install', event => {
self.skipWaiting() // Service Worker 应该尽早开始控制不受前一个 Service Worker 控制的客户端

event.waitUntil( // 在安装阶段缓存资源
caches.open(cacheName)
.then(cache => cache.addAll([
'./js/main.js',
'./images/newspaper.svg',
'./css/site.css',
'./header.html', // 在安装阶段缓存 header.html 和 footer.html
'/footer.html',
'offline-page.html'
]))
)
})

self.addEventListener('activate', event => {
self.clients.claim() // 强制激活当前的 Service Worker
})

↓ 在 Web Stream 中拼装 HTML

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function getQueryString(field, url = window.location.href) { // 从查询字符串中获取指定字段的值
const reg = new RegExp('[?&]' + field + '=([^&#]*)', 'i')
const result = reg.exec(url)
return result ? result[1] : null
}

self.addEventListener('fetch', event => { // 进入 fetch 事件
const url = new URL(event.request.url)

if (url.pathname.endsWith('/article.html')) { // 是否请求文章的路由
const articleId = getQueryString('id') // 获取文章的 ID
const articleUrl = `data-${articleId}` // 建立文章的 URL

event.respondWith(streamArticle(articleUrl)) // 使用流结果进行响应
}
})

↓ 在 Web Stream 响应中拼装 HTML

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
function streamArticle(url) {
try {
new ReadableStream({}) // 检查当前浏览器是否支持 Web Stream API
} catch (e) {
return new Response('Streams not supported')
}

const stream = new ReadableStream({ // 创建 ReadableStream
start(controller) {
const startFetch = caches.match('header.html') // 从缓存中获取 header.html
const bodyData = fetch(`data/${url}.html`) // 使用 Fetch API 来获取页面的主体部分
.catch(() => new Response('Body fetch failed'))
const endFetch = caches.match('footer.html') // 从缓存中获取 footer.html

function pushStream(stream) { // 使用 pushStream 函数来将下一个数据块推送到流中
const reader = stream.getReader()
function read() {
return reader.read().then(result => {
if (result.done) return
controller.enqueue(result.value)
return read()
})
}

return read()
}

startFetch // 开始获取标题数据并将其推送到流中
.then(response => pushStream(response.body))
.then(() => bodyData)
.then(response => pushStream(response.body)) // 开始获取正文数据并将其推送到流中
.then(() => endFetch)
.then(response => pushStream(response.body)) // 开始获取页脚数据并将其推送到流中
.then(() => controller.close())
}
})

return new Response(stream, { // 创建 Response 对象并返回流的结果
headers: { 'Content-Type': 'text/html' }
})
}

使用 Service Worker 缓存和流,将页面数据“缝合”在一起,这意味着首次渲染几乎是瞬时的,然后通过网络传输小块内容,这样做是比服务端渲染有优势的。因为内容会通过常规的 HTML 解析器,所以你得到的是流,而且与手动把内容添加到 DOM 中并没有任何行为上的差异。

10.4 Web Stream API 的未来

能够利用浏览器流的好处是可以开始使用 JavaScript 来访问如下内容:

  • Gzip/deflate
  • 音频/视频解码器
  • 图片解码器
  • HTML/XML 的流解析器