从 Chromium 105 开始,您可以使用 Streams API 在整个正文可用之前发起请求。
您可以使用此功能来:
- 预热服务器。 换句话说,您可以在用户将焦点移至文本输入字段后启动请求,并移除所有标头,然后等待用户按下“发送”按钮,再发送他们输入的数据。
- 逐步发送在客户端上生成的数据,例如音频、视频或输入数据。
- 通过 HTTP/2 或 HTTP/3 重新创建 Web 套接字。
不过,由于这是一项低级 Web 平台功能,因此请不要受我的想法的限制。您或许能想到更令人兴奋的请求流式传输用例。
演示
此图展示了如何将数据从用户流式传输到服务器,以及如何发回可实时处理的数据。
好的,这个示例不太有想象力,我只是想让它简单一点,可以吗?
不过,这项功能是如何运作的?
上回说到提取数据流的惊险历险
响应流已在所有现代浏览器中推出一段时间了。借助这些事件,您可以访问从服务器传入的响应部分:
const response = await fetch(url);
const reader = response.body.getReader();
while (true) {
const {value, done} = await reader.read();
if (done) break;
console.log('Received', value);
}
console.log('Response fully received');
每个 value
都是字节的 Uint8Array
。您获得的数组数量和数组大小取决于网络速度。如果您的网络连接速度较快,则会收到较少但较大的“数据块”。如果您的网络连接速度较慢,则会收到更多较小的分块。
如果您想将字节转换为文本,可以使用 TextDecoder
,或者使用较新的转换流(如果目标浏览器支持):
const response = await fetch(url);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
TextDecoderStream
是一种转换流,用于抓取所有这些 Uint8Array
分块并将其转换为字符串。
数据流非常棒,因为您可以开始在数据到达时对其执行操作。例如,如果您收到一个包含 100 个“结果”的列表,则可以收到第一个结果后立即显示该结果,而不是等待所有 100 个结果。
无论如何,这就是响应流,我要介绍的令人兴奋的新事物是请求流。
流式请求正文
请求可以包含正文:
await fetch(url, {
method: 'POST',
body: requestBody,
});
以前,您需要先准备好整个正文,然后才能发起请求,但现在在 Chromium 105 中,您可以提供自己的 ReadableStream
数据:
function wait(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
const stream = new ReadableStream({
async start(controller) {
await wait(1000);
controller.enqueue('This ');
await wait(1000);
controller.enqueue('is ');
await wait(1000);
controller.enqueue('a ');
await wait(1000);
controller.enqueue('slow ');
await wait(1000);
controller.enqueue('request.');
controller.close();
},
}).pipeThrough(new TextEncoderStream());
fetch(url, {
method: 'POST',
headers: {'Content-Type': 'text/plain'},
body: stream,
duplex: 'half',
});
上述代码会一次发送一个字词,将“This is a slow request”发送到服务器,每个字词之间会暂停一秒钟。
请求正文的每个分块都需要是 Uint8Array
字节,因此我将使用 pipeThrough(new TextEncoderStream())
来执行转换。
限制
流式传输请求是 Web 的一种新功能,因此存在一些限制:
半双工?
如需允许在请求中使用串流,需要将 duplex
请求选项设置为 'half'
。
HTTP 的一个鲜为人知的功能(不过,这是否属于标准行为取决于您咨询的对象)是,您可以在发送请求的同时开始接收响应。不过,由于它鲜为人知,因此服务器对其支持不佳,并且任何浏览器都不支持它。
在浏览器中,除非请求正文已完全发送,否则系统绝不会提供响应,即使服务器提前发送响应也是如此。这适用于所有浏览器提取。
这种默认模式称为“半双工”。不过,某些实现(例如 Deno 中的 fetch
)在流式提取时默认采用“全双工”模式,这意味着响应可以在请求完成之前可用。
因此,为了解决此兼容性问题,在浏览器中,需要针对包含流式传输正文的请求指定 duplex: 'half'
。
未来,浏览器可能会支持 duplex: 'full'
,以便处理流式请求和非流式请求。
与双工通信相比,下一个最佳方案是使用流式请求进行一次提取,然后再进行一次提取以接收流式响应。服务器需要通过某种方式关联这两个请求,例如在网址中使用 ID。这就是演示版的运作方式。
受限重定向
某些形式的 HTTP 重定向需要浏览器将请求正文重新发送到另一个网址。为了支持此功能,浏览器必须缓冲数据流的内容,这有点违背了此功能的初衷,因此浏览器不会这样做。
相反,如果请求包含流式正文,并且响应是 303 以外的 HTTP 重定向,则系统会拒绝提取,并且不会遵循重定向。
允许使用 303 重定向,因为它们会明确将方法更改为 GET
并舍弃请求正文。
需要 CORS 并触发预检
流式传输请求具有正文,但没有 Content-Length
标头。这是一种新类型的请求,因此需要 CORS,并且这些请求始终会触发预检。
不允许使用流式 no-cors
请求。
不适用于 HTTP/1.x
如果连接是 HTTP/1.x,则提取将被拒绝。
这是因为,根据 HTTP/1.1 规则,请求和响应正文需要发送 Content-Length
标头,以便对方知道自己将收到多少数据,或者更改消息格式以使用分块编码。使用分块编码时,正文会拆分为多个部分,每个部分都有自己的内容长度。
分块编码在 HTTP/1.1 响应中非常常见,但在请求中非常罕见,因此存在太大的兼容性风险。
潜在问题
这项功能是新推出的,目前在互联网上使用率较低。以下是一些需要注意的问题:
服务器端不兼容
某些应用服务器不支持流式请求,而是会等待收到完整请求,然后再让您查看任何请求,这有点违背了流式请求的初衷。请改用支持流式传输的应用服务器,例如 NodeJS 或 Deno。
不过,您还没有完全脱离险境! 应用服务器(例如 NodeJS)通常位于另一个服务器(通常称为“前端服务器”)后面,后者可能位于 CDN 后面。如果其中任何服务器决定在将请求传递给链中的下一个服务器之前对其进行缓冲,您将无法获得请求串流的好处。
不兼容问题超出您的控制范围
由于此功能仅通过 HTTPS 运行,因此您无需担心您与用户之间的代理,但用户可能会在其机器上运行代理。 某些互联网保护软件会这样做,以便监控浏览器和网络之间的所有内容,在某些情况下,此类软件可能会缓冲请求正文。
如果您想防范这种情况,可以创建类似于上方演示的“功能测试”,在其中尝试在不关闭数据流的情况下流式传输一些数据。如果服务器收到数据,则可以通过其他提取方式进行响应。发生这种情况后,您就知道客户端支持端到端流式传输请求。
功能检测
const supportsRequestStreams = (() => {
let duplexAccessed = false;
const hasContentType = new Request('', {
body: new ReadableStream(),
method: 'POST',
get duplex() {
duplexAccessed = true;
return 'half';
},
}).headers.has('Content-Type');
return duplexAccessed && !hasContentType;
})();
if (supportsRequestStreams) {
// …
} else {
// …
}
如果您有兴趣,可以了解一下地图项检测的工作原理:
如果浏览器不支持特定的 body
类型,则会对该对象调用 toString()
,并将结果用作正文。因此,如果浏览器不支持请求流,请求正文将变为字符串 "[object ReadableStream]"
。使用字符串作为正文时,可以方便地将 Content-Type
标头设置为 text/plain;charset=UTF-8
。因此,如果设置了该标头,我们就知道浏览器不支持请求对象中的串流,并且可以提前退出。
Safari 支持在请求对象中使用串流,但不允许将其与 fetch
搭配使用,因此我们测试了 duplex
选项(Safari 目前不支持该选项)。
与可写入流搭配使用
有时,如果您有 WritableStream
,则更容易处理数据流。您可以使用“身份”流来实现此目的,它是一个可读/可写对,会接受传递到其可写端的所有内容,并将其发送到可读端。您可以通过创建不带任何参数的 TransformStream
来创建其中一种:
const {readable, writable} = new TransformStream();
const responsePromise = fetch(url, {
method: 'POST',
body: readable,
});
现在,您发送到可写入流的任何内容都将成为请求的一部分。这样,您就可以将多个数据流组合在一起。例如,下面这个愚蠢的示例会从一个网址提取数据,对其进行压缩,然后将其发送到另一个网址:
// Get from url1:
const response = await fetch(url1);
const {readable, writable} = new TransformStream();
// Compress the data from url1:
response.body.pipeThrough(new CompressionStream('gzip')).pipeTo(writable);
// Post to url2:
await fetch(url2, {
method: 'POST',
body: readable,
});
上述示例使用压缩流通过 gzip 压缩任意数据。