使用 Fetch API 執行串流要求

Jake Archibald
Jake Archibald

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

你可以使用這項功能執行下列操作:

  • 讓伺服器暖機。 換句話說,您可以在使用者將焦點放在文字輸入欄位時啟動要求,並清除所有標題,然後等待使用者按下「傳送」再傳送輸入的資料。
  • 逐步傳送在用戶端產生的資料,例如音訊、視訊或輸入資料。
  • 透過 HTTP/2 或 HTTP/3 重新建立 WebSocket。

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

先前在精彩的擷取串流冒險中

回應串流已在所有新式瀏覽器中推出一段時間。 您可以在伺服器傳送回應時存取部分內容:

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 個「結果」的清單,可以立即顯示第一個結果,不必等待所有結果傳回。

總之,這就是回應串流,而我想要談論的令人興奮的新事物是要求串流。

串流要求主體

要求可以有主體:

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()) 進行轉換。

限制

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

半雙工?

如要在要求中使用串流,請將 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 處理串流會比較輕鬆。 您可以使用「身分」串流執行這項操作,這是一組可讀取/寫入的配對,可接收傳遞至可寫入端的任何內容,並傳送至可讀取端。 如要建立其中一個,請建立不含任何引數的 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 壓縮任意資料。