使用 Fetch API 執行串流要求

Jake Archibald
Jake Archibald

從 Chromium 105 開始,您可以使用 Streams API,在取得完整主體之前啟動要求。

您可以使用這項功能來:

  • 讓伺服器預熱。換句話說,您可以在使用者將焦點放在文字輸入欄位後,開始要求操作,並移除所有標頭,然後等到使用者按下「傳送」後,再傳送他們輸入的資料。
  • 逐步傳送在用戶端產生的資料,例如音訊、視訊或輸入資料。
  • 透過 HTTP/2 或 HTTP/3 重新建立網路 Socket。

但由於這是低階網路平台功能,請不要受限於我的想法。或許您可以想出更令人興奮的應用程式串流用途。

示範

這張圖顯示如何從使用者串流資料至伺服器,並傳回可即時處理的資料。

沒錯,這不是最有想像力的例子,我只是想讓它簡單一點,好嗎?

總之,這項功能的運作方式為何?

先前在「探索動態資料流」的驚奇旅程

Response 串流已在所有新式瀏覽器中提供一段時間。讓您在回應從伺服器傳送到應用程式時,存取回應的部分內容:

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,
});

先前,您必須先準備好整個 body,才能啟動要求,但在 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()) 進行轉換。

限制

串流要求是網路的新功能,因此有幾項限制:

半雙工?

如要在要求中使用串流,請將 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 回應中,分段編碼相當常見,但在要求中則極為罕見,因此會造成過多的相容性風險。

潛在問題

這是一項新功能,目前在網路上使用率偏低。請注意以下幾點:

伺服器端不相容

部分應用程式伺服器不支援串流要求,而是等到收到完整要求後,才會讓您查看任何內容,這有點違背原意。請改用支援串流的應用程式伺服器,例如 NodeJSDeno

不過,你還沒完全脫離險境!應用程式伺服器 (例如 NodeJS) 通常會位於另一個伺服器 (通常稱為「前端伺服器」) 後方,而前端伺服器可能會位於 CDN 後方。如果這些服務決定在將要求緩衝後再交給鏈結中的下一個伺服器,您就無法享有要求串流的優點。

不相容性問題不在您的掌控範圍內

由於這項功能僅適用於 HTTPS,因此您不必擔心自己和使用者之間的 Proxy,但使用者可能會在自己的電腦上執行 Proxy。某些網際網路防護軟體會這麼做,以便監控瀏覽器和網路之間的所有連線,而且在某些情況下,這類軟體可能會快取要求內容。

如要避免這種情況,您可以建立類似上述示範的「功能測試」,嘗試串流一些資料,但不關閉串流。如果伺服器收到資料,則可以透過其他擷取作業做出回應。發生這種情況時,您就知道用戶端支援端對端串流要求。

特徵偵測

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,處理串流會更容易。您可以使用「identity」串流執行這項操作,這是可讀/可寫的組合,可將傳遞至可寫端的任何內容,傳送至可讀端。您可以建立 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 壓縮任意資料。