精讀《web streams》

黃子毅發表於2021-10-26

Node stream 比較難理解,也比較難用,但 “流” 是個很重要而且會越來越常見的概念(fetch 返回值就是流),所以我們有必要認真學習 stream。

好在繼 node stream 之後,又推出了比較好用,好理解的 web streams API,我們結合 Web Streams Everywhere (and Fetch for Node.js)2016 - the year of web streamsReadableStreamWritableStream 這幾篇文章學一下。

node stream 與 web stream 可以相互轉換:.fromWeb() 將 web stream 轉換為 node stream;.toWeb() 將 node stream 轉換為 web stream。

精讀

stream(流)是什麼?

stream 是一種抽象 API。我們可以和 promise 做一下類比,如果說 promise 是非同步標準 API,則 stream 希望成為 I/O 的標準 API。

什麼是 I/O?就是輸入輸出,即資訊的讀取與寫入,比如看視訊、載入圖片、瀏覽網頁、編碼解碼器等等都屬於 I/O 場景,所以並不一定非要大資料量才算 I/O,比如讀取一個磁碟檔案算 I/O,同樣讀取 "hello world" 字串也可以算 I/O。

stream 就是當下對 I/O 的標準抽象。

為了更好理解 stream 的 API 設計,以及讓你理解的更深刻,我們先自己想一想一個標準 I/O API 應該如何設計?

I/O 場景應該如何抽象 API?

read()write() 是我們第一個想到的 API,繼續補充的話還有 open()close() 等等。

這些 API 確實可以稱得上 I/O 場景標準 API,而且也足夠簡單。但這些 API 有一個不足,就是缺乏對大資料量下讀寫的優化考慮。什麼是大資料量的讀寫?比如讀一個幾 GB 的視訊檔案,在 2G 慢網路環境下訪問網頁,這些情況下,如果我們只有 readwrite API,那麼可能一個讀取命令需要 2 個小時才能返回,而一個寫入命令需要 3 個小時執行時間,同時對使用者來說,不論是看視訊還是看網頁,都無法接受這麼長的白屏時間。

但為什麼我們看視訊和看網頁的時候沒有等待這麼久?因為看網頁時,並不是等待所有資源都載入完畢才能瀏覽與互動的,許多資源都是在首屏渲染後再非同步載入的,視訊更是如此,我們不會載入完 30GB 的電影后再開始播放,而是先下載 300kb 片頭後就可以開始播放了。

無論是視訊還是網頁,為了快速響應內容,資源都是 在操作過程中持續載入的,如果我們設計一個支援這種模式的 API,無論資源大還是小都可以覆蓋,自然比 readwirte 設計更合理。

這種持續載入資源的行為就是 stream(流)。

什麼是 stream

stream 可以認為在形容資源持續流動的狀態,我們需要把 I/O 場景看作一個持續的場景,就像把一條河的河水導流到另一條河。

做一個類比,我們在傳送 http 請求、瀏覽網頁、看視訊時,可以看作一個南水北調的過程,把 A 河的水持續調到 B 河。

在傳送 http 請求時,A 河就是後端伺服器,B 河就是客戶端;瀏覽網頁時,A 河就是別人的網站,B 河就是你的手機;看視訊時,A 河是網路上的視訊資源(當然也可能是本地的),B 河是你的視訊播放器。

所以流是一個持續的過程,而且可能有多個節點,不僅網路請求是流,資源載入到本地硬碟後,讀取到記憶體,視訊解碼也是流,所以這個南水北調過程中還有許多中途蓄水池節點。

將這些事情都考慮到一起,最後形成了 web stream API。

一共有三種流,分別是:writable streams、readable streams、transform streams,它們的關係如下:

<img width=400 src="https://z3.ax1x.com/2021/10/23/52vHne.png">

  • readable streams 代表 A 河流,是資料的源頭,因為是資料來源頭,所以只可讀不可寫。
  • writable streams 代表 B 河流,是資料的目的地,因為要持續蓄水,所以是隻可寫不可讀。
  • transform streams 是中間對資料進行變換的節點,比如 A 與 B 河中間有一個大壩,這個大壩可以通過蓄水的方式控制水運輸的速度,還可以安裝濾網淨化水源,所以它一頭是 writable streams 輸入 A 河流的水,另一頭提供 readable streams 供 B 河流讀取。

乍一看很複雜的概念,但對映到河水引流就非常自然了,stream 的設計非常貼近生活概念。

要理解 stream,需要思考下面三個問題:

  1. readable streams 從哪來?
  2. 是否要使用 transform streams 進行中介軟體加工?
  3. 消費的 writable streams 邏輯是什麼?

還是再解釋一下,為什麼相比 read()write(),stream 要多這三個思考:stream 既然將 I/O 抽象為流的概念,也就是具有持續性,那麼讀取的資源就必須是一個 readable 流,所以我們要構造一個 readable streams(未來可能越來越多函式返回值就是流,也就是在流的環境下工作,就不用考慮如何構造流了)。對流的讀取是一個持續的過程,所以不是呼叫一個函式一次性讀取那麼簡單,因此 writable streams 也有一定 API 語法。正是因為對資源進行了抽象,所以無論是讀取還是消費,都被包裝了一層 stream API,而普通的 read 函式讀取的資源都是其本身,所以才沒有這些額外思維負擔。

好在 web streams API 設計都比較簡單易用,而且作為一種標準規範,更加有掌握的必要,下面分別說明:

readable streams

讀取流不可寫,所以只有初始化時才能設定值:

const readableStream = new ReadableStream({
  start(controller) {
    controller.enqueue('h')
    controller.enqueue('e')
    controller.enqueue('l')
    controller.enqueue('l')
    controller.enqueue('o')
    controller.close()
  }
})

controller.enqueue() 可以填入任意值,相當於是將值加入佇列,controller.close() 關閉後,就無法繼續 enqueue 了,並且這裡的關閉時機,會在 writable streams 的 close 回撥響應。

上面只是 mock 的例子,實際場景中,讀取流往往是一些呼叫函式返回的物件,最常見的就是 fetch 函式:

async function fetchStream() {
  const response = await fetch('https://example.com')
  const stream = response.body;
}

可見,fetch 函式返回的 response.body 就是一個 readable stream。

我們可以通過以下方式直接消費讀取流:

readableStream.getReader().read().then({ value, done } => {})

也可以 readableStream.pipeThrough(transformStream) 到一個轉換流,也可以 readableStream.pipeTo(writableStream) 到一個寫入流。

不管是手動 mock 還是函式返回,我們都能猜到,讀取流不一定一開始就充滿資料,比如 response.body 就可能因為讀的比較早而需要等待,就像接入的水管水流較慢,而源頭水池的水很多一樣。我們也可以手動模擬讀取較慢的情況:

const readableStream = new ReadableStream({
  start(controller) {
    controller.enqueue('h')
    controller.enqueue('e')

    setTimeout(() => {
      controller.enqueue('l')
      controller.enqueue('l')
      controller.enqueue('o')
      controller.close()
    }, 1000)
  }
})

上面例子中,如果我們一開始就用寫入流對接,必然要等待 1s 才能得到完整的 'hello' 資料,但如果 1s 後再對接寫入流,那麼瞬間就能讀取整個 'hello'。另外,寫入流可能處理的速度也會慢,如果寫入流處理每個單詞的時間都是 1s,那麼寫入流無論何時執行,都比讀取流更慢。

所以可以體會到,流的設計就是為了讓整個資料處理過程最大程度的高效,無論讀取流資料 ready 的多遲、開始對接寫入流的時間有多晚、寫入流處理的多慢,整個鏈路都是儘可能最高效的:

  • 如果 readableStream ready 的遲,我們可以晚一點對接,讓 readableStream 準備好再開始快速消費。
  • 如果 writableStream 處理的慢,也只是這一處消費的慢,對接的 “水管” readableStream 可能早就 ready 了,此時換一個高效消費的 writableStream 就能提升整體效率。

writable streams

寫入流不可讀,可以通過如下方式建立:

const writableStream = new WritableStream({
  write(chunk) {
    return new Promise(resolve => {
      // 消費的地方,可以執行插入 dom 等等操作
      console.log(chunk)

      resolve()
    });
  },
  close() {
    // 寫入流 controller.close() 時,這裡被呼叫
  },
})

寫入流不用關心讀取流是什麼,所以只要關心資料寫入就行了,實現寫入回撥 write

write 回撥需要返回一個 Promise,所以如果我們消費 chunk 的速度比較慢,寫入流執行速度就會變慢,我們可以理解為 A 河流引水到 B 河流,就算 A 河流的河道很寬,一下就把河水全部灌入了,但 B 河流的河道很窄,無法處理那麼大的水流量,所以受限於 B 河流河道寬度,整體水流速度還是比較慢的(當然這裡不可能發生洪災)。

那麼 writableStream 如何觸發寫入呢?可以通過 write() 函式直接寫入:

writableStream.getWriter().write('h')

也可以通過 pipeTo() 直接對接 readableStream,就像本來是手動滴水,現在直接對接一個水管,這樣我們只管處理寫入就行了:

readableStream.pipeTo(writableStream)

當然通過最原始的 API 也可以拼裝出 pipeTo 的效果,為了理解的更深刻,我們用原始方法模擬一個 pipeTo

const reader = readableStream.getReader()
const writer = writableStream.getWriter()

function tryRead() {
  reader.read().then(({ done, value }) => {
    if (done) {
      return
    }

    writer.ready().then(() => writer.write(value))

    tryRead()
  })
}

tryRead()

transform streams

轉換流內部是一個寫入流 + 讀取流,建立轉換流的方式如下:

const decoder = new TextDecoder()
const decodeStream = new TransformStream({
  transform(chunk, controller) {
    controller.enqueue(decoder.decode(chunk, {stream: true}))
  }
})

chunk 是 writableStream 拿到的包,controller.enqueue 是 readableStream 的入列方法,所以它其實底層實現就是兩個流的疊加,API 上簡化為 transform 了,可以一邊寫入讀到的資料,一邊轉化為讀取流,供後面的寫入流消費。

當然有很多原生的轉換流可以用,比如 TextDecoderStream

const textDecoderStream = TextDecoderStream()

readable to writable streams

下面是一個包含了編碼轉碼的完整例子:

// 建立讀取流
const readableStream = new ReadableStream({
  start(controller) {
    const textEncoder = new TextEncoder()
    const chunks = textEncoder.encode('hello', { stream: true })
    chunks.forEach(chunk => controller.enqueue(chunk))
    controller.close()
  }
})

// 建立寫入流
const writableStream = new WritableStream({
  write(chunk) {
    const textDecoder = new TextDecoder()
    return new Promise(resolve => {
      const buffer = new ArrayBuffer(2);
      const view = new Uint16Array(buffer);
      view[0] = chunk;
      const decoded = textDecoder.decode(view, { stream: true });
      console.log('decoded', decoded)

      setTimeout(() => {
        resolve()
      }, 1000)
    });
  },
  close() {
    console.log('writable stream close')
  },
})

readableStream.pipeTo(writableStream)

首先 readableStream 利用 TextEncoder 以極快的速度瞬間將 hello 這 5 個字母加入佇列,並執行 controller.close(),意味著這個 readableStream 瞬間就完成了初始化,並且後面無法修改,只能讀取了。

我們在 writableStream 的 write 方法中,利用 TextDecoderchunk 進行解碼,一次解碼一個字母,並列印到控制檯,然後過了 1s 才 resolve,所以寫入流會每隔 1s 列印一個字母:

h
# 1s later
e
# 1s later
l
# 1s later
l
# 1s later
o
writable stream close

這個例子轉碼解碼處理的還不夠優雅,我們不需要將轉碼與解碼寫在流函式裡,而是寫在轉換流中,比如:

readableStream
  .pipeThrough(new TextEncoderStream())
  .pipeThrough(customStream)
  .pipeThrough(new TextDecoderStream())
  .pipeTo(writableStream)

這樣 readableStream 與 writableStream 都不需要處理編碼與解碼,但流在中間被轉化為了 Uint8Array,方便被其它轉換流處理,最後經過解碼轉換流轉換為文字後,再 pipeTo 給寫入流,這樣寫入流拿到的就是文字了。

但也並不總是這樣,比如我們要傳輸一個視訊流,可能 readableStream 原始值就已經是 Uint8Array,所以具體要不要對接轉換流看情況。

總結

streams 是對 I/O 抽象的標準處理 API,其支援持續小片段資料處理的特性並不是偶然,而是對 I/O 場景進行抽象後的必然。

我們通過水流的例子類比了 streams 的概念,當 I/O 發生時,源頭的流轉換是有固定速度的 x M/s,目標客戶端比如視訊的轉換也是有固定速度的 y M/s,網路請求也有速度並且是個持續的過程,所以 fetch 天然也是一個流,速度時 z M/s,我們最終看到視訊的速度就是 min(x, y, z),當然如果伺服器提前將 readableStream 提供好,那麼 x 的速度就可以忽略,此時看到視訊的速度是 min(y, z)

不僅視訊如此,開啟檔案、開啟網頁等等都是如此,瀏覽器處理 html 也是一個流的過程:

new Response(stream, {
  headers: { 'Content-Type': 'text/html' },
})

如果這個 readableStream 的 controller.enqueue 過程被刻意處理的比較慢,網頁甚至可以一個字一個字的逐步呈現:Serving a string, slowly Demo

儘管流的場景如此普遍,但也沒有必要將所有程式碼都改成流式處理,因為程式碼在記憶體中執行速度很快,變數的賦值是沒必要使用流處理的,但如果這個變數的值來自於一個開啟的檔案,或者網路請求,那麼使用流進行處理是最高效的。

討論地址是:精讀《web streams》· Issue #363 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章