Node stream 比較難理解,也比較難用,但 “流” 是個很重要而且會越來越常見的概念(fetch
返回值就是流),所以我們有必要認真學習 stream。
好在繼 node stream 之後,又推出了比較好用,好理解的 web streams API,我們結合 Web Streams Everywhere (and Fetch for Node.js)、2016 - the year of web streams、ReadableStream、WritableStream 這幾篇文章學一下。
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 慢網路環境下訪問網頁,這些情況下,如果我們只有 read
、write
API,那麼可能一個讀取命令需要 2 個小時才能返回,而一個寫入命令需要 3 個小時執行時間,同時對使用者來說,不論是看視訊還是看網頁,都無法接受這麼長的白屏時間。
但為什麼我們看視訊和看網頁的時候沒有等待這麼久?因為看網頁時,並不是等待所有資源都載入完畢才能瀏覽與互動的,許多資源都是在首屏渲染後再非同步載入的,視訊更是如此,我們不會載入完 30GB 的電影后再開始播放,而是先下載 300kb 片頭後就可以開始播放了。
無論是視訊還是網頁,為了快速響應內容,資源都是 在操作過程中持續載入的,如果我們設計一個支援這種模式的 API,無論資源大還是小都可以覆蓋,自然比 read
、wirte
設計更合理。
這種持續載入資源的行為就是 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,需要思考下面三個問題:
- readable streams 從哪來?
- 是否要使用 transform streams 進行中介軟體加工?
- 消費的 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
方法中,利用 TextDecoder
對 chunk
進行解碼,一次解碼一個字母,並列印到控制檯,然後過了 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 許可證)