使用 Fetch API 執行串流要求

阿奇巴德 (Jake Archibald)
Jake Archibald

自 Chromium 105 版起,你可以在保留所有內文前,使用 Streams API 提出要求。

這項功能可以:

  • 暖機伺服器。換句話說,您可以在使用者聚焦文字輸入欄位後開始要求,並取得所有標頭,然後等到使用者按下「send」後才能傳送輸入的資料。
  • 逐步傳送在用戶端上產生的資料,例如音訊、影片或輸入資料。
  • 透過 HTTP/2 或 HTTP/3 重新建立網路通訊端。

但由於這是低階網路平台功能,因此不會受到 my 提案的限制。你也許可以想到更多有趣的用途,要求直播。

示範

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

這個例子並不是最有想像力的例子,我只是想保持簡單,好嗎?

或者,這如何運作?

之前想在遊戲中的精彩冒險旅程中展開新作品

目前有一段時間,所有新版瀏覽器已支援回應串流。 這些表單可讓您在伺服器收到回應時,存取回應的部分:

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

上述指令會將「這個要求速度緩慢」,一次一個字詞,每個字詞之間出現 1 秒的暫停時間。

要求主體的每個區塊都必須是位元組的 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 壓縮任意資料。