iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
JavaScript

30 天新世代 JavaScript 自我學習指南系列 第 13

Day 13|從 Iterator 走向 Web Streams 的世界: 認識 Readable、Writable、Transform

  • 分享至 

  • xImage
  •  

摘要

  • 一句摘要:Stream 描述資料隨時間逐步流動的模型;Web Streams 的三個名稱則描述資料管線中的角色與端點。一條 stream 不必同時經過三種角色,只有把端點接起來時才形成 pipeline。

  • 前置知識:可以先理解 Day 12 的 Async Iterator 可以幫助我們理解「資料為什麼會在未來才逐筆到來」;

  • 學習路線: 初步認識 Web Stream 建立自己的心智地圖。

  • 標籤Web Streams API ReadableStream WritableStream TransformStream pipeThrough() pipeTo()


今日學習目標

  1. 說明 Stream 為何適合處理尚未完整到齊的資料。
  2. 理解 ReadableStream 為何是有順序、消費式的介面,而不是可任意索引的集合
  3. 把 source、producer 與 consumer 放到正確的位置。
  4. 分清 ReadableStreamWritableStreamTransformStream 各自提供的端點。
  5. 先理解串流資料加工站同時有 輸入輸出 的管線概念

因為找了很多實務上 async iterator 應用會常常出現 Stream,所以就補補自己對 Web Streams 還不足的部分,從 JavaScript 的「迭代資料」,正式走進瀏覽器的「資料流」。

不過因為這個觀念我剛開始看很不好理解(太多名詞和流程),我覺得先建立自己的理解地圖;在應用 Web Streams 上才會比較順手,這裡死背會用得很痛苦😂。

https://ithelp.ithome.com.tw/upload/images/20260915/20145251bYCtLYceoi.png


一、為什麼 Web 需要 Stream?

身為一名前端工程師常常跟後端協作串接 API,我很容易把網路資料想成一個簡單完整結果:

發送 request
    ↓
等待完整 response
    ↓
拿到全部資料
    ↓
開始處理

但其實現實很多場景,資料不是一次送完回傳的,例如:

  • AI 回覆可能一個 token、一段文字逐步抵達。
  • 大型檔案會分成許多 chunk 傳輸。
  • SSE 事件可能持續數分鐘甚至數小時。
  • 壓縮、解壓縮可以在資料抵達途中進行。
  • 上傳資料也不一定要等所有內容先準備完成。

這時候適合的模型不是「完整資料 → 處理」,而是讓 consumer 在資料尚未全部完成時,持續處理每個抵達的 chunk

chunk
  ↓
chunk
  ↓
chunk
  ↓
consumer 持續處理

Stream 描述的就是這種「資料隨時間逐步流動」的模型。它的核心價值不只是快,而是:

資料還沒有全部完成時,consumer 就可以開始處理。

順帶一提,Web Streams 並不是因應 AI 串流才出現的。WHATWG 在 2013 年開始設計,Chrome 在 2015 年透過 response.body 提供 ReadableStream,2016 年起開發者也能自己建立它。之後其他瀏覽器才陸續跟上。


二、從 Iterator 到 Stream:開始學習處理隨時間抵達的資料

前幾天談 Iterator 時,我們建立了一個重要的心智模型:consumer 要資料時,再取得下一筆。

consumer
   ↓ next()
producer
   ↓
value

再到了 Async Iterator 加入了非同步概念,可以處理下一筆資料可能未來才會回傳的情境:

consumer
   ↓ await next()
producer
   ↓
未來某個時間
   ↓
value

這已經慢慢接近 Stream 的世界。Stream 再往前問的是:

如果資料會持續產生,而且 producer 與 consumer 的速度可能不同,Web 平台要怎麼建立一條 可管理的資料管線 來規範?


三、先從 response.body 看見 ReadableStream

ReadableStream 這個名字可能很陌生,但如果用過 Fetch API,其實可能早就遇過它。即使只是一般的 JSON API,response body 也會以 stream 暴露:

const response = await fetch('https://api.escuelajs.co/api/v1/products');

console.log(response instanceof Response);
// true

console.log(response.body);
// ReadableStream 或 null

也可以使用網頁 dev tool 直接觀察 response.body,會出現 ReadableStream

https://ithelp.ithome.com.tw/upload/images/20260915/20145251L4UtdHqSJc.png

Response 本身不是 stream。它是包含 status、headers、URL 與 body 的 HTTP 回應物件;真正可逐步讀取的是 response.body

有內容時,它是瀏覽器收到 response bytes 後持續提供給 consumer 的可讀出口;沒有 body 的 response 則可能是 null

Response
├── status
├── headers
├── url
└── body → ReadableStream<Uint8Array> | null

所以,await fetch() 取得 Response 時,不代表完整 response body 已經全部下載完成。只要有內容,瀏覽器就可以透過 response.body,把逐步收到的 bytes 繼續交給前端程式。

差別只在於我們選擇怎麼消費這條 stream。


選擇一:一次讀完整個 body

平常呼叫的 response.text()response.json()response.blob(),可以理解成瀏覽器幫我們把 body stream 消費完,最後組成一個完整結果:

這裡也可以先注意著 response.json() 解析完讀取完資料,會被標記這個 response 被消費過。

const response = await fetch('/api/users');
const users = await response.json();

console.log(response.bodyUsed);
// true

response.json() 為例,瀏覽器會先讀完整個 body,再把內容解析成 JavaScript value;它不是逐筆產生 JSON:

response.body(ReadableStream)
→ 讀完所有 bytes
→ 解碼文字
→ JSON.parse()
→ users

選擇二:自己逐 chunk 處理

如果不想等瀏覽器組成完整結果,也可以從 response.body 取得 reader,先讀取下一個 chunk:

const response = await fetch('/api/users');

if (!response.body) {
  throw new Error('這個 response 沒有 body');
}

const reader = response.body.getReader();
const result = await reader.read();

console.log(result);
// { value: Uint8Array(...), done: false }

reader.releaseLock();

這裡先觀察結果就好,知道 value 是這次取得的 bytes,done 表示 stream 是否已經結束即可。

所以並不是只有名稱寫著 streaming 的 API 才有 Stream:

普通 JSON API ─┐
圖片或檔案 API ├→ fetch() → Response.body → ReadableStream
SSE / AI API  ─┘

先初步透過API回應結果理解三件事就行:

  • Response 本身不是 stream,response.body 才是。
  • response.body 的型別是 ReadableStream | null;例如 HEAD204 No Content 等沒有 body 的回應可能是 null
  • Stream 的 chunk 不等於一筆 JSON 資料。普通 JSON 可能被切成任意 byte chunk,因此通常仍使用 response.json() 一次讀完。

使用 Fetch API 時,即使是一般 JSON API,response body 也會以 ReadableStream 暴露;平常呼叫 response.json(),只是選擇讓瀏覽器替我們把這條 stream 全部讀完並解析


四、為什麼 Web Streams 要拆成三種角色?

第一次跟我一樣初步接觸 Streams 概念,在 MDN-Stream API 或其他文章都看到三種 Stream,很容易以為資料被規定要走完三個步驟,然後看得一頭霧水😵‍💫😵:

ReadableStream → TransformStream → WritableStream

其實 Web Streams 拆開的不是「處理步驟」,而是資料流動方向與端點能力

ReadableStream:資料從這裡出來
WritableStream:資料往這裡進去
TransformStream:資料從一端進去,加工後由另一端出來

https://ithelp.ithome.com.tw/upload/images/20260916/20145251vzIyRjwVt3.png

真實世界的資料來源不一定同時能讀、能寫、又能轉換。例如 response.body 的責任是交出伺服器回傳的 bytes;consumer 不應該透過它把資料寫回 HTTP response。因此,它只需要暴露「可讀」能力。

反過來,一個接收 log、儲存檔案或送出資料的目的地,可能只需要接收資料,不需要讓上游再從相同端點把資料讀回來。這種角色只需要「可寫」能力。

在資料管線裡,「提供資料」、「接收資料」與「加工後再提供資料」是不同的端點能力。Web Streams 分別用 ReadableStream、WritableStream 和 TransformStream 表達它們。

Web Streams 強制區分的是「每個端點能做什麼」,不是強制應用程式一定要使用全部三種 Stream;需要兩種能力時,也可以把可讀端與可寫端組合起來。

建構子名稱 入口方法(writable) 出口方法(readable) 比喻
ReadableStream 取件口
WritableStream 收件箱
TransformStream 翻譯機

這裡在讀文件時,我自己也常看到這些資料流有提供 writable 方法,會直覺想成「寫出去、輸出」。

Web Streams 內部API的名字是站在「使用這個端點的程式」的角度 取的,避免跟 Stream 建構子名稱小小搞混:

  • writable:程式可以把資料寫進去的那一端,所以它是資料的入口
  • readable:程式可以把資料讀出來的那一端,所以它是資料的出口
你的程式 ──寫入──→ [ writable ]   資料從這裡「進去」
你的程式 ←─讀取── [ readable ]   資料從這裡「出來」

五、三種 Stream 分別能做什麼?

ReadableStream:只有出口

ReadableStream 面向 consumer。資料可能來自網路、檔案或程式內部,但 consumer 只需要知道一件事:可以從這裡依序拿到下一個 chunk。

const response = await fetch('/api/users');

response.body.getReader();    // ✅ 出口:可以讀
// response.body.getWriter(); // ❌ 沒有入口,沒有這個方法
source → producer → [ ReadableStream 出口 ] → consumer

ReadableStream 的兩個重要特性

ReadableStream 和陣列最大的不同是:陣列可以反覆讀,stream 不行~

有兩個主要兩個特性:

特性一:依序一次拿一個 chunk,拿過就不會回頭

每呼叫一次 read(),就依序拿走一個 chunk。被拿走的 chunk 會從 stream 裡消失,沒有辦法倒回去重讀。

可以把它想成迴轉壽司🍣🍣:盤子經過你面前,拿走了就沒了,不會再繞回來同一盤。

const letters = new ReadableStream({
  start(controller) {
    controller.enqueue('A');
    controller.enqueue('B');
    controller.enqueue('C');
    controller.close();
  },
});

// 第一個 reader 讀兩次
let reader = letters.getReader();
console.log(await reader.read()); // { value: 'A', done: false }
console.log(await reader.read()); // { value: 'B', done: false }
reader.releaseLock();

// 換一個新的 reader,會不會從 A 重新開始?
reader = letters.getReader();
console.log(await reader.read()); // { value: 'C', done: false } ← 不會!接著從 C 開始
console.log(await reader.read()); // { value: undefined, done: true }
text
[ A ][ B ][ C ] → read() 拿走 A → read() 拿走 B → 換 reader → read() 只剩 C

換一個 reader,不等於從頭開始。 A、B 已經被讀走,stream 裡只剩下 C。

這也是為什麼 response.json() 不能呼叫兩次:第一次已經把 response.body 讀到底了。

const response = await fetch('/api/users');

await response.json(); // ✅ 讀完整個 body
await response.json(); // ❌ TypeError:body 已經被讀過了

console.log(response.bodyUsed); // true

特性二:同一時間只能有一個 reader

一條 ReadableStream 呼叫 getReader() 後就會被鎖定(locked)。在這個 reader 釋放之前,其他人都拿不到 reader。

const letters = new ReadableStream({
  start(controller) {
    controller.enqueue('A');
    controller.close();
  },
});

const readerA = letters.getReader();
console.log(letters.locked); // true

const readerB = letters.getReader();
// ❌ TypeError:ReadableStream is locked

為什麼要這樣設計?

回到特性一:chunk 拿走就沒了。如果兩個 reader 可以同時讀,A 拿到第一個、B 拿到第二個,雙方都只拿到殘缺的資料,還不知道缺了什麼。鎖定就是在保證:同一時間,只有一個 consumer 在消費這條 stream。


WritableStream:只有入口的「收件箱」

WritableStream 的工作只有一個:收下資料,然後處理掉。

它可以把資料印出來、畫到畫面上、存進檔案,但資料進去之後,不會再從這個端點的其他地方出來。流程在這裡結束。

const inbox = new WritableStream({
  // 每收到一筆資料,就會呼叫一次 write
  write(chunk) {
    console.log('收到:', chunk);
  },
});

const writer = inbox.getWriter(); // 拿到「投遞工具」

await writer.write('A'); // 收到:A
await writer.write('B'); // 收到:B
await writer.close();    // 告訴它:沒有資料了
'A' ──→ [ WritableStream 入口 ] ──→ console.log('收到:A')
                                      (流程結束)

這段程式裡有兩個角色,不要混在一起

  • inboxWritableStream:收件箱本身,決定「收到資料後要做什麼」。
  • writer:投遞工具,負責「把資料放進收件箱」。

為什麼要先 getWriter()
因為 WritableStream 本身沒有 write() 方法,要先拿到 writer 才能投遞。拿到 writer 的同時,這個收件箱會被鎖定,避免其他地方同時寫入。之後改用 pipeTo() 時,Web Streams 會替我們處理寫入,就不必自己拿 writer。

WritableStream 沒有 getReader()
它只有入口、沒有出口。如果你希望資料「處理完還要交給下一站」,要用的是 TransformStream


TransformStream:入口 + 出口的「翻譯機」

TransformStream中間加工站:一邊放原料進去,另一邊拿成品出來。

它和 WritableStream 最大的差別是:身上同時有兩個端點。

const transform = new TransformStream(/* 加工邏輯 */);

transform.writable; // 入口:一個 WritableStream
transform.readable; // 出口:一個 ReadableStream
                  TransformStream
       ┌───────────────────────────────────┐
原料 ─→ │ writable ──→  加工  ──→ readable  │ ─→ 成品
       │ (入口)                 (出口)  │
       └───────────────────────────────────┘

這是今天最關鍵的一句話:
TransformStream 不是全新的東西,它就是「一個 WritableStream 入口」加上「一個 ReadableStream 出口」,中間夾著加工邏輯。

所以 transform.writable 裡的 writable,和上一小節的 WritableStream 是同一種東西。差別只在:

  • 收件箱收到資料,流程就結束
  • 翻譯機收到資料,會把加工結果送到 readable 那一側,讓下一站繼續拿。

實際例子:TextDecoderStream

瀏覽器內建的 TextDecoderStream 就是一台 TransformStream,工作是:

把 bytes(Uint8Array)翻譯成文字(string)。

            TextDecoderStream
       ┌────────────────────────────┐
bytes →│ writable → 解碼 → readable │→ string
       └────────────────────────────┘

實務上我們很少手動操作它。但為了看清楚「入口寫、出口讀」,這裡刻意手動做一次:

const decoder = new TextDecoderStream();

// 入口:從 writable 拿 writer,準備放 bytes 進去
const writer = decoder.writable.getWriter();

// 出口:從 readable 拿 reader,準備讀出文字
const reader = decoder.readable.getReader();

// 準備一筆 bytes(「你好」編碼後的結果)
const bytes = new TextEncoder().encode('你好');

// 先在出口等著拿,再從入口放進去
const readPromise = reader.read();
await writer.write(bytes);

const { value } = await readPromise;
console.log(value); // '你好'

看這段程式時,只要注意兩件事:

  1. 寫入用 decoder.writable,讀取用 decoder.readable 同一台翻譯機,兩個端點分工完全不同。
  2. 放進去的是 bytes,拿出來的是 string。 中間的「解碼」就是這台翻譯機的加工邏輯。

為什麼要「先讀、再寫」?
TransformStream 的出口預設不會預先存放成品:出口沒有人在等,入口的 await write() 就會一直等下去。 所以這裡先呼叫 reader.read() 表示「我在出口等了」,現在只要記得順序即可。

一句話分清 WritableStreamTransformStream

兩者都能寫處理邏輯,差別不是「能不能執行程式碼」,而是有沒有出口

判斷只需要問一個問題:資料處理完,還要不要往下傳?

  • 不用,到這裡就結束 → WritableStream
  • ,加工後繼續往下傳 → TransformStream

六、把端點接起來,形成 pipeline 流程觀念

有了三種角色之後,把「出口」接到下一站的「入口」,就是一條 pipeline

三種角色不需要每次都全部出現,可以依需求組合:

ReadableStream → consumer 自己讀

producer 自己寫 → WritableStream

ReadableStream → TransformStream → consumer 自己讀

ReadableStream → WritableStream

ReadableStream → TransformStream → WritableStream

上一節我們手動拿 writer、拿 reader,是為了看清楚每個端點。實際開發時,通常交給兩個 pipe 方法幫我們接:

方法 用途 回傳 之後還能接嗎?
pipeThrough(transform) 經過加工站 加工站的出口(ReadableStream
pipeTo(writable) 抵達終點 Promise,全部送完時 resolve

pipeThrough():經過加工站

const textStream = response.body.pipeThrough(
  new TextDecoderStream(),
);

pipeThrough() 在幕後幫我們做了兩件事

  1. response.body 的出口,接到 TextDecoderStream入口.writable)。
  2. TextDecoderStream出口.readable)回傳給我們。
response.body(bytes)
        ↓ 自動接到入口
TextDecoderStream.writable
        ↓ 解碼
TextDecoderStream.readable
        ↓ 回傳出來
textStream(string)

pipeThrough() 回傳的仍然是 ReadableStream
所以 textStream 可以交給 consumer 自己讀,也可以再接下一個 pipeThrough()pipeTo(),一直串下去。


pipeTo():抵達終點

const screen = new WritableStream({
  write(text) {
    console.log('顯示:', text);
  },
});

await textStream.pipeTo(screen);
textStream(string)── pipeTo() ──→ screen(WritableStream)

pipeTo() 會把資料一筆一筆送進 screen不需要自己呼叫 getWriter()write()。它回傳的 Promise 會在所有資料送完後 resolve,所以可以 await 等整段傳完。

這裡刻意新建一個 screen,沒有沿用前面的 inboxinbox 已經被 getWriter() 鎖定並呼叫過 close(),再拿去 pipeTo() 會出錯。


串成一條完整的 pipeline

await response.body
  .pipeThrough(new TextDecoderStream()) // 經過翻譯機:bytes → string
  .pipeTo(screen);                      // 抵達收件箱:顯示文字
response.body ─pipeThrough→ [ TextDecoderStream ] ─pipeTo→ [ screen ]
   出口 (bytes)                 入口 → 解碼 → 出口            入口 (顯示)

簡單記法:中途加工用 pipeThrough(),抵達終點用 pipeTo()


今日總結

先記住大方向,不要先背 API:

名稱 一句話 比喻
writable 資料的入口,程式把資料寫進去 投信口
readable 資料的出口,程式把資料讀出來 取件口
ReadableStream 只有出口,給 consumer 讀 取件口
WritableStream 只有入口,資料送到這裡就結束 收件箱
TransformStream 入口 + 出口,加工後繼續往下傳 翻譯機
pipeThrough() 經過加工站,回傳出口,還能繼續接 中途站
pipeTo() 抵達終點,回傳 Promise 終點站

這些問題都需要先有一張共同地圖:哪一端是入口、哪一端是出口、加工站同時有兩個端點。 地圖先建好,後面才不會把角色和操作混在一起。

pipeline 則是端點被接起來之後才出現的資料路徑;它不一定要以 WritableStream 結束,consumer 自己讀也可以。

如果前面 Iterator 系列學的是:

不要先問資料有多少,而是問下一筆資料怎麼取得。

那 Web Streams 再往前一步:

當資料持續產生時,怎麼讓它在 producer、transform 與 consumer 之間安全地流動?

今天讀了好多以前不太熟悉的東西,雖然知識量有點大,平常工作滿少看到算是挑戰新知識。

我覺得不用逼自己每一步都要立刻搞懂,知道這一串資料處理流程怎麼走,還有 Stream API 的一些特性我覺得對於前端初中階使用應該滿夠的,希望接下來能多找些範例當參考,看這類 Web API 在開源上應用程度如何~繼續加油 😇


參考資料


上一篇
Day 12|從 async Iterator 協議到理解 for await...of
下一篇
Day 14|怎麼讀一條 ReadableStream?從 getReader、read 到 for await...of
系列文
30 天新世代 JavaScript 自我學習指南14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言