RxJS 五個常用 operator 的隱藏陷阱:值會卡住、值會消失、相等不是你以為的相等
文字解析 · 1 支影片 · 產生於 2026-10-01 00:54
章節(11)
1. Outline
- 起點 · RxJS operators and their dangerous consequences
Joshua Morony 挑五個他親身踩過的 operator,每個都先演符合直覺的版本,再抽掉一個前提讓它出事,最後把所有 share 的怪異行為收斂到 refCount 這一個開關。
2. YouTuber 的思維推導
RxJS operators and their dangerous consequences
Joshua Morony · 8m53s · 字幕 en · vision=on (使用者指定 vision=true;且影片全程以 marble diagram 與程式碼畫面示範,作者多次說「like in this example」「notice that」指著畫面講,不看圖會漏掉推理的關鍵。) · YouTube
預估 vs 實際耗時
| 階段 | 預估 | 實際 |
|---|---|---|
| fetch | 6s | 2s |
| segment | 48s | 5m00s |
| shot | 22s | 3s |
| analyze | 3m45s | 8m01s |
| render | 5s | – |
作者的出發點不是「教你這些 operator 怎麼用」,而是「假設你已經會用,來看它們什麼時候會咬你」。他先立下篩選標準:不窮舉一百多個 operator,只挑五個他自己最近真的踩到的坑。接著他對每一個坑都用同一套推導手法——先跑一次「行為完全符合直覺」的例子,讓你在心裡建立一條基準線,再只改動一個變因(來源會不會 complete、值的間隔對不對得上、比的是內容還是參照、來源是 interval 還是 of),讓同一個 operator 在你眼前給出出乎意料的結果。
五個坑排出來剛好是三種形狀:值卡住不出來(buffer)、值直接消失(throttle)、相等的定義跟你想的不一樣(distinctUntilChanged),最後 share / shareReplay 把前面所有形狀收在一起——同一個 operator 在會 complete 與不會 complete 的來源下各壞一次。他的結論不是「別用這些 operator」,而是把所有怪異行為收斂到一個可設定的旋鈕:refCount。最後他把自己九成五情況都在用的那組設定包成一個自訂 operator shareWhileSubscribed,等於用一行程式碼回答了整支影片提出的所有問題。
推理鏈:每段留給下一段的線索
每一列是一段,箭頭後的字是這段留給下一段的線索。點段落標題跳到該段。
- RxJS 的陷阱不在於難,而在於不明顯 → 既然篩選標準是「作者親身踩過」,那第一個要示範的就是他最近踩到的 buffering。而依照「先演符合直覺的版本」這個節奏,下一步得先看清楚 buffer 正常運作時到底長什麼樣子。
- buffer:把值攢起來再一起送 → 既然那個落單的 10 是靠來源 complete 才被沖出來的,那反過來問:如果來源永遠不會 complete,湊不滿一批的值會落到什麼下場?
- buffer 的坑:來源不 complete,值就卡住 → buffer 的坑是「值還在,只是出不來」——資料本身沒有損失。這留下一個更嚇人的可能性:有沒有哪個 operator 會讓值根本不再存在?
- throttle:反過來的問題,值被丟掉 → 這個例子裡被丟掉的值都在串流中間,丟了也就丟了。但如果那個空缺剛好落在最後一個值上呢——那個你最需要它的值?
- throttle 的坑:最後一個值可能被吃掉 → 前兩個坑都長在時間軸上——什麼時候送、送不送得出去。接下來作者換一種形狀:值準時送出來了,錯的是「這兩個值算不算重複」這個判斷本身。
- distinctUntilChanged 比的是參照不是內容 → 既然錯的不是 operator 而是它預設採用的那套相等定義,那接下來的問題就很自然:這套定義能不能換掉?
- 自訂比較函式,用內容取代參照 → 把預設行為換掉這一招在這裡只解決了一個小問題。最後一組 operator 會把它變成主角——但依照前面的節奏,得先看它壞掉的樣子。
- share:把 cold 變 hot,避免重複執行 → 這一切美好都建立在來源是 interval——一條會持續發射、不會結束的串流。那如果來源是一個立刻就完成的東西呢?
- share 的坑:來源立刻完成,refCount 歸零 → 問題的根源已經定位在「ref count 歸零就重置」。那有沒有一個 operator 會把值留下來,讓晚到的訂閱者不必重跑來源?
- shareReplay 補上了洞,也開了新的洞 → share 與 shareReplay 各在一種來源下出過事,兩次的差別都圍繞著「什麼時候該放手」。那這個放手的時機,究竟是由哪一個設定決定的?
- refCount 才是真正的開關
3. 逐段說明
RxJS operators and their dangerous consequences
1. RxJS 的陷阱不在於難,而在於不明顯 0:00–0:35
作者先立下前提:RxJS 很強大,但要知道的東西太多,有些坑特別隱蔽。他不打算窮舉一百多個 operator,而是挑五個他最近真的踩到的。這個「親身踩過」的篩選標準決定了後面每一段的敘事方式——先給正常行為,再給出乎意料的行為。
推理因為 operator 太多而人不可能全記住,所以作者放棄窮舉,改用一個很個人的篩選標準:只講他最近真的被咬過的。這個標準看起來隨意,其實決定了整支影片的敘事節奏——被咬過表示「他原本以為會是 A,結果是 B」,所以每一段都會先演一次 A(符合直覺的行為),再演 B(出乎意料的行為)。
AI 補充值得先點破的是這五個坑的共同形狀:它們在絕大多數情況下的行為都是對的,只有在某個特定條件成立時才會偏離直覺。這就是作者說的 devious(隱蔽)——不是寫錯會馬上報錯的那種錯,而是測試環境跑得好好的、上線之後偶爾出事的那種。也因此,這支影片真正要你記住的不是五個 operator 的用法,而是五個「觸發條件」:來源會不會 complete、時間間隔對不對得上、比的是不是同一個物件、以及有沒有人還在訂閱。
術語:gotcha
2. buffer:把值攢起來再一起送 0:35–1:13
第一組是 buffering operators:不要一個一個送,而是收集到一定數量再一起送出。作者用 marble diagram 示範每三個一批。畫面上出現一個湊不成一批的 10,它仍然被送出來了——因為來源 stream complete 時,剩下的值會被沖出來。

推理因為上一段說了每個坑都要先有基準線,所以作者刻意挑了一個「不會出錯」的來源:畫面上是 from([1,2,3,...,10]) 搭配 bufferCount(3),一個固定長度、一定會結束的陣列。輸出是 [1,2,3]、[4,5,6]、[7,8,9],然後是落單的 [10]。作者特別停下來指著那個 10 說「它沒有同伴可以湊成一批,但我們還是收到它了」——因為來源結束時,剩下的值會被沖出來。
- CbufferCount湊不滿一批時,靠來源complete把剩餘值沖出來→ 畫地圖
- Abuffer像蓄水池,要等閘門打開(complete)才會把剩餘的水一次放出來→ 批判類比
- E輸出括號標註前三筆(3)、最後一筆(1),證明最後一批是提早沖出來的→ 存+演練
AI 補充這裡有一步作者講得很快,卻是後面整段推理的樞紐:那個 10 之所以會出現,不是 bufferCount 保證「你送進去的一定會出來」,而是因為 from() 走完陣列之後會 complete,而 complete 這個訊號會讓 bufferCount 把手上沒湊滿的一批強制送出。換句話說,「不丟值」這個看起來理所當然的性質,其實是掛在來源會結束這個前提上的。畫面上另一個容易略過的細節是輸出的括號標註:前三筆是 (3),最後一筆是 (1)——大小不一樣,代表最後那批確實是被提早沖出來的,不是湊滿的。
術語:Observableemission
3. buffer 的坑:來源不 complete,值就卡住 1:13–2:19
上一段的「complete 會沖出剩餘值」反過來就是危險:來源不會 complete 時,湊不滿一批的值會永遠卡在 buffer 裡。作者用一個 Subject 示範 next 十一次,第十、十一個值就懸在那裡等第十二個。他自己也被咬過:為了效能做批次,結果初始資料裡除不盡的餘數卡住不出來。

推理因為上一段確認了餘數是靠 complete 才出得來,所以作者只改一個變因:來源。程式碼從 from(陣列) 換成 new Subject(),用一個 for 迴圈 next 十一次。11 除以 3 餘 2,於是 console 只印出 [0,1,2]、[3,4,5]、[6,7,8] 三批,最後兩個值 9 和 10 就懸在 buffer 裡,要等第十二個值出現才會被送出——而 Subject 不會自己結束,所以那一天可能永遠不會來。
- CSubject不會自己complete,湊不滿一批的值就永遠卡在buffer裡→ 畫地圖
- R作者在真實情境裡怎麼踩到buffer卡住這個坑→ 存+回想
- P檢查用bufferCount的Subject有沒有出路→ 練習
AI 補充畫面上作者自己寫的註解「won't emit last batch unless i < 12」把條件講死了,但真正該記住的是它在真實情境裡長什麼樣子:作者說他是為了效能把工作批次化,先灌一批初始資料進去、之後再陸續加新的。這種「先初始化、再持續追加」的資料流,剛好同時具備兩個觸發條件——初始筆數不見得被批次大小整除,而且來源不會結束。結果就是有幾筆資料看起來像是被吞了,直到很久以後有新資料進來才突然一起冒出來。這種 bug 特別難查,因為資料最終還是出現了,只是遲到;日誌上看起來像時序問題,實際上是餘數問題。要避開它,實務上會在 bufferCount 之外再加一個逾時或手動 flush 的觸發條件,讓「湊不滿」也有出路。
4. throttle:反過來的問題,值被丟掉 2:19–2:58
buffer 是值卡住,throttle 是值直接消失。取一個值之後在一段時間內忽略所有後續 emission。作者用每 100 毫秒送一次、throttle 120 毫秒的 marble diagram 示範——這種等差關係下會漏掉哪些值,看圖一目了然。他也承認這個例子「看起來很明顯」,是為了鋪陳下一段不明顯的版本。

推理因為上一段的損害還可以挽回(值遲早會出來),所以作者接著把損害升級:throttle 取一個值之後,會在一段時間內忽略來源的所有後續 emission,被忽略的值不會補送。示範刻意用了一組很好算的數字:interval(100) 每 100 毫秒送一次,throttleTime(120) 每 120 毫秒才放行一個。作者自己也說這個例子「看起來很明顯」——他知道,這是刻意的鋪陳。
AI 補充作者跳過了一步計算,但畫面把答案印出來了:console 上是 4、6、8、10、12…全是偶數,等於一半的值被丟掉。原因是 throttleTime 預設走 leading edge——第 0 個值立刻放行,接著關窗 120 毫秒;第 1 個值在第 100 毫秒到達,還在窗內,丟掉;第 2 個值在第 200 毫秒到達,窗已在第 120 毫秒關閉,放行,然後又關窗到第 320 毫秒;第 3 個值在第 300 毫秒到達,丟掉……於是穩定地變成隔一個放一個。這裡真正該帶走的認知是:被丟掉的是哪些值,取決於來源間隔與 throttle 間隔的相對關係,而不是某個固定規則。當兩者接近整數倍時,被丟掉的位置會非常規律;當兩者互質時,位置會飄移。而位置會飄移這件事,正是下一段那個 bug 的溫床。
5. throttle 的坑:最後一個值可能被吃掉 2:58–3:45
把上一段的空缺換到最糟的位置:state machine 頻繁送出狀態快照,用 throttle 減少反應次數;但它結束時送出的最後一個值,可能剛好落在 throttle 的窗口裡而被吃掉。更麻煩的是有時又剛好沒被吃掉——時好時壞的 bug 最難查。作者給的解法是把最終狀態改到 completion handler 處理。

推理因為上一段已經證明被丟掉的位置取決於時間對齊,所以作者只要建構一個「最後一個值特別重要」的場景,這個坑就成立了。他設想一個 state machine 頻繁送出狀態快照,你不需要每次都反應,所以加了 throttle;但機器結束時會送出最後的狀態,而你對這個最後狀態有特別的處理。畫面上的程式碼把這個情境濃縮成:interval(100) 經過 map,第 9 個值標成 'final'、其餘標成 'normal',take(10) 取十個之後結束,外面再套 throttleTime(300)。console 印出五個 normal,然後畫面上一支粉紅箭頭指著空無一物的地方,標註寫著「final value never emitted」。
AI 補充這一段最值得記住的不是「最後一個值會被吃掉」,而是「它有時候會有時候不會」。作者用 unless I get lucky(除非我運氣好)來描述:throttle 的窗口是從上一個放行的值開始算的,而最後一個值出現的時刻由來源決定,兩者沒有任何協調機制。同樣一段程式碼,只要來源的節奏稍微變一點、或啟動時機差幾毫秒,結果就會翻面。這正是最難查的一種 bug——重現不了、寫不出穩定的測試、看起來像是別人的問題。作者給的解法是把最終狀態的處理移到串流的完成處理裡,這確實繞開了 throttle;不過還有一個更直接的選項他沒提,見下方勘誤。
「it might then make more sense to handle this final case in the completion Handler of the stream」
6. distinctUntilChanged 比的是參照不是內容 3:45–4:28
第三個坑換一種形狀:不是時間問題,是相等的定義問題。distinctUntilChanged 拿來擋重複值很好用,對 primitive 沒問題;但兩個屬性完全相同的物件,在它眼裡是兩個不同的值。反過來,透過同一個變數送出同一個物件,它就判定相等——因為它比的是物件參照。


推理因為前面兩個坑都可以歸咎於「時間沒對齊」,所以作者刻意挑一個跟時間無關的坑來擴大這支影片的覆蓋範圍。他的推導只有兩步,而且兩步的方向相反。第一步:畫面上 from() 裡連續放了兩個 { title: 'subscribe' },屬性和值一模一樣,console 卻把它們都印出來了,粉紅箭頭標著 different——內容相同卻被判定為不同。第二步:同一個陣列裡連續放了兩次 someObject(同一個變數),console 只印出一次 {title: 'hi'}——這次被判定為相等。
- CdistinctUntilChanged預設用===比對物件,比的是參照不是內容→ 畫地圖
- AdistinctUntilChanged比對象像檢查身分證字號,不是看長相像不像→ 批判類比
- E兩個內容相同的物件被印成different;同一個變數放兩次卻只印一次→ 存+演練
- RdistinctUntilChanged對mutation的反效果→ 存+回想
AI 補充把兩步合起來,答案就浮出來了:distinctUntilChanged 預設用的是 === 這個判斷,而 === 對物件比的是參照,不是內容。所以它的行為其實完全一致,只是這個一致性建立在「你手上是不是同一個物件」而不是「這兩個東西看起來像不像」。這件事在真實專案裡的殺傷力比範例大得多:只要你的資料經過任何一次 map(x => ({...x}))、經過一次 HTTP 回應的 JSON 解析、或是經過任何會產生新物件的狀態更新,前後兩個值就一定不是同一個參照,distinctUntilChanged 等於完全失效,卻不會有任何錯誤訊息。反過來,如果你在狀態管理裡就地修改同一個物件(mutation),它又會判定相等而把真正的變更擋掉——這是同一個機制造成的另一半災情,比誤放行更難察覺。
術語:reference equality
7. 自訂比較函式,用內容取代參照 4:28–4:47
既然預設比的是參照,那就把比較方式換掉:distinctUntilChanged 可以接一個自訂的相等函式。作者用最省事的 JSON.stringify 把兩個物件字串化再比,等於改成按屬性與值比較。這是整支影片第一個「你可以主動改變 operator 行為」的例子。

推理因為上一段把問題定位在「預設用參照比」,所以解法自然是把比較這件事外包出去。畫面上的寫法是 distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)):把兩個物件都字串化再比字串,等於改成按屬性與值比較。console 的輸出從四筆變成三筆——兩個 {title: 'subscribe'} 終於被合併掉了。
AI 補充這一段在整支影片裡的地位比它的長度重要得多:它是第一次出現「operator 的預設行為是可以被你接管的」。前面兩個坑作者給的都是繞路(改用完成處理函式、換個 operator),這一次是直接把旋鈕轉過來。這個觀念會在最後一組 operator 上被推到極致——share 家族的所有怪異行為,最後也是靠一組設定物件解決的。另外值得補一句作者沒說的:這個 comparator 收到的兩個參數順序是 (previous, current),也就是先前送出的值在前;如果你要寫的是不對稱的比較邏輯(例如只在版本號變大時才放行),順序就不能寫錯。
「commonly it is useful to use json. stringify to compare the equality of two objects」
8. share:把 cold 變 hot,避免重複執行 4:47–5:49
最後一組是 share 與 shareReplay。作者先示範沒有 share 的 cold observable:兩個訂閱、各三次 emission,但來源裡的 console.log 跑了六次——每次訂閱都會重跑一次來源邏輯。加上 share 之後兩個訂閱共用同一條 hot stream,log 只在每次 interval 發射時跑一次。到這裡一切都很美好。


推理因為要說明 share 解決了什麼,就得先讓問題現形,所以作者先跑一個普通的 cold observable:interval(1000) 裡塞了一個 tap(() => console.log('I am here')),然後建立兩個訂閱,三秒後同時取消。畫面上的 console 交錯印著 I am here 與 from subscription 1 / 2——每一秒 I am here 出現兩次,三秒下來共六次。作者要你注意的就是這個六:來源裡的任何邏輯,都會為每一個訂閱各執行一遍。加上 share() 之後對照畫面變成 from subscription 1、from subscription 2、I am here 各一輪——兩個訂閱共用同一條串流,來源邏輯每秒只跑一次。
AI 補充這裡要補的是 cold 為什麼會這樣。Observable 預設是一份「執行藍圖」而不是一個「正在跑的東西」,每次 subscribe 都會照著藍圖重新跑一次,包含裡面的 tap、HTTP 請求、計時器。所以兩個訂閱不是在看同一場演出,而是各自開了一場。share() 做的事是在中間插一個 Subject(正是第 3 段那個可以手動推值的東西):來源只被訂閱一次,值推進 Subject,再由 Subject 分給所有下游。這也解釋了畫面上另一個容易忽略的細節——加上 share 之後,兩個訂閱收到的數字是同步的(都是 0、都是 1),而在 cold 的版本裡它們各自從 0 開始數自己的。作者最後補了一句「取消訂閱之後來源就停止發射」——這句話現在聽起來只是順帶一提,但它其實已經把下一個坑的機制先講出來了。
術語:hot observablemulticast
9. share 的坑:來源立刻完成,refCount 歸零 5:49–6:56
上一段的美好建立在來源會持續發射(interval)。換成會立刻完成的來源(例如 HTTP 請求,用 of 模擬),明明用了 share,map 裡的 log 還是跑了兩次。原因是 share 用 refCount 追蹤訂閱數,歸零就 reset;來源立刻完成使第一個訂閱馬上結束,refCount 在第二個訂閱建立之前就掉到零。

推理因為上一段的成功建立在「來源持續發射」這個前提,所以作者照例只改這一個變因。畫面上是 of(1).pipe(map(...), share()),map 裡印 'doing something that should only happen once',然後建立兩個訂閱。意圖很清楚:這段邏輯只該跑一次、兩個訂閱共用結果。但 console 印了兩次。明明用了 share,還是跑了兩遍。接著作者一步步推出原因:share 用 ref count 追蹤目前有幾個訂閱掛在它身上,數字掉到零就重置,重置之後任何新訂閱都會重新觸發來源邏輯;而 of() 立刻完成,使第一個訂閱馬上結束,ref count 在第二個訂閱建立之前就掉到零。
- Cshare用refCount追蹤訂閱數,歸零就重置,遇到立刻完成的來源會重跑→ 畫地圖
- Eof(1)同步完成:第一次subscribe那行還沒執行完,訂閱就已經解除→ 存+演練
- R為什麼真實HTTP請求通常不會踩到這個坑→ 存+回想
- Rshare預設的重置條件除了refCount歸零還有什麼→ 存+回想
AI 補充這段推理有一個關鍵前提作者沒有明說:of(1) 是同步的。第一次 subscribe 那一行還沒執行完,值就已經送出、串流就已經 complete、訂閱就已經解除了——下一行才輪到第二個 subscribe。所以這裡不是兩個訂閱「差一點點沒接上」,而是它們在時間上根本沒有重疊過。這也說明了為什麼真實的 HTTP 請求(非同步)通常不會踩到這個坑:第二個訂閱會在回應回來之前就建立好,ref count 從 1 變 2 而不是掉到 0。換句話說,這個坑的觸發條件比作者講的更窄,但也更陰險——用假資料測試時會出事,換成真的 API 又好了,或者反過來。另外要補的是,share 預設的重置條件不只 refCount 歸零一個,來源 complete 本身也會讓它重置(resetOnComplete 預設為 true);在 of() 這個例子裡兩個條件同時成立,而知道有兩個條件,才能理解為什麼最後那個自訂 operator 需要同時關掉其中一個、留下另一個。
10. shareReplay 補上了洞,也開了新的洞 6:56–7:39
shareReplay 會把值存下來重播給晚到的訂閱者,剛好補上 refCount 歸零的洞:init 邏輯只跑一次,兩個訂閱共用同一個值。但把來源換回不會完成的 interval,問題翻面了——兩個訂閱都取消之後,來源還在永遠發射。這可能正合你意,也可能是記憶體洩漏。

推理因為上一段的病灶是「重置之後晚到的訂閱者只能重跑來源」,所以只要讓值被保存下來,晚到的人就有東西可拿。作者把 of() 那個例子的 share 換成 shareReplay,初始化邏輯果然只跑一次,兩個訂閱共用同一個值。但他沒有停在這裡——他把來源換回 interval(1000) 再跑一次,畫面上是 interval(1000).pipe(tap(...), shareReplay(1)),兩個訂閱在三秒後都被取消,然後 console 上的 I am here 一行接一行繼續冒出來,永遠不停。作者的語氣在這裡轉了個彎:這有可能正是你要的,也有可能是 bug 或記憶體洩漏。
- CshareReplay補上晚到訂閱者的洞,但預設refCount為false會永不放手→ 畫地圖
- ArefCount像共享單車最後一人還車才收走;shareReplay預設沒人還車也不收→ 批判類比
- RshareReplay(1)的1是什麼,開大這個數字有什麼風險→ 存+回想
AI 補充把第 8、9 兩段跟這一段擺在一起看,圖形就完整了:同一組 operator,在會 complete 的來源與不會 complete 的來源下,各壞掉一次,而且壞的方向剛好相反——share 遇上立刻完成的來源會過度重置(該共用的沒共用),shareReplay 遇上永不完成的來源則完全不重置(該停的沒停)。這說明兩者的差別不是「一個會存值一個不會」那麼表面,而是它們對「什麼時候該放手」有相反的預設。至於為什麼沒人訂閱還繼續跑會構成記憶體洩漏:來源仍持有訂閱、計時器仍在排程、shareReplay 的緩衝區仍握著最後一個值不放,這些都不會被垃圾回收;如果這條串流是隨著某個元件建立的,元件關掉之後它還活著,開開關關幾次就累積成一串永遠不會消失的殭屍串流。順帶一提,作者這裡用的是 shareReplay(1),括號裡的 1 是緩衝多少個值;緩衝數字開大或不設上限,洩漏的量也會跟著放大。
11. refCount 才是真正的開關 7:39–8:53
所有 share 的怪異行為收斂到一個設定:shareReplay 預設 refCount 為 false,所以沒人訂閱也照發不誤;share 則預設用 refCount 且歸零就 reset。把 shareReplay 的 refCount 設成 true,就同時拿到「沒人訂閱就停」與「晚到者拿得到最新值」。作者把這組設定包成自訂 operator,並說九成五以上的情況他都用這個。

推理因為前面所有怪異行為都可以歸到同一個旋鈕上,所以作者不再逐個 operator 討論,而是直接調它。他把 shareReplay 的 refCount 設成 true,行為立刻變成「跟 share 一樣,沒人訂閱來源就停下來,但晚到的訂閱者仍拿得到最新的值」——等於同時要到了前兩段各自缺的那一半。接著他承認自己懶得每次都打那個設定物件,於是包成一個自訂 operator。畫面上那個檔案叫 shareWhileSubscribed.ts,內容是 share({ connector: () => new ReplaySubject(bufferSize), resetOnError: false, resetOnComplete: false, resetOnRefCountZero: true }),最後他說這組設定涵蓋了他九成五以上的共享情境。
- CresetOnRefCountZero=true+resetOnComplete=false,補齊share與shareReplay各自的洞→ 畫地圖
- P把share/shareReplay的設定包成自訂operator重複使用→ 練習
- R開了refCount之後,晚到訂閱者拿得到最新值的但書→ 存+回想
AI 補充畫面上的實作值得逐行對照,因為它比作者口頭說的更精確——他用的不是 shareReplay({ refCount: true }),而是直接展開成 share() 的設定物件,這讓每一個重置條件都被獨立地表態:connector 換成 ReplaySubject 換來記憶(這是 shareReplay 的部分);resetOnRefCountZero 保持 true,所以沒人訂閱時來源會停(這是第 10 段那個永不停止的洩漏的解藥);resetOnComplete 關成 false,所以來源完成後不會重置(這正是第 9 段裡 of() 那個坑的另一半病因,寫 shareReplay({ refCount: true }) 是繞不開它的);resetOnError 關成 false 則讓錯誤結果也被記住,不會讓下一個訂閱者靜靜地重打一次失敗的請求。也就是說,作者其實是用一個設定物件把這支影片後半段的三個問題一次全部回答完。最後要留意一個容易誤讀的地方:開了 refCount 之後,「晚到的訂閱者拿得到最新值」只在還有其他訂閱者在線上時成立,一旦人數歸零,緩衝的值也會跟著被丟掉——見下方勘誤。
「late subscribers will also still be able to get the latest value from the observable」
4. 總結
作者用同一套節奏走完五個 operator:先建立基準線,再抽掉一個你沒注意到的前提。buffer 正常運作靠的是「來源 complete 時會沖出剩餘值」,抽掉 complete,湊不滿一批的值就永遠卡在 buffer 裡——資料還在,只是出不來。這留下一個更嚇人的問題:有沒有 operator 會讓值根本不存在?throttle 就是,它是 buffer 的鏡像,值不是卡住而是直接被丟掉;把這個空缺挪到串流的最後一個值上,就變成一個時好時壞、極難查的 bug,解法是把最終狀態改到 completion handler 處理。前兩個坑都長在時間軸上,distinctUntilChanged 換了一種形狀:值準時送出來了,錯的是「這兩個算不算重複」的判斷——它比的是物件參照而不是內容。既然錯的是預設的相等定義,那就換掉它:傳一個自訂比較函式進去。這一招在最後一組 operator 變成主角。share 把 cold 變 hot、避免來源邏輯重複執行,但它用 refCount 追蹤訂閱數、歸零就重置;當來源立刻完成(例如 HTTP 請求),第一個訂閱馬上結束、refCount 在第二個訂閱建立前就掉到零,來源又被跑了一次。shareReplay 把值留給晚到的訂閱者,補上這個洞,卻因為預設 refCount 為 false,在不會完成的來源下永遠不放手,可能變成記憶體洩漏。兩次事故的差別都圍繞「什麼時候該放手」,而那個開關就是 refCount——作者把「shareReplay 加 refCount true」包成自訂 operator,說九成五以上的情況他都用這個。
勘誤總整理
確定錯誤/已過時見仁見智(取決於版本或情境)
| 段落 | 原話(transcript 逐字) | 說明 |
|---|---|---|
| 5. throttle 的坑:最後一個值可能被吃掉 3:39 |
「it might then make more sense to handle this final case in the completion Handler of the stream」 | 把最終狀態移到完成處理函式是可行的繞法,但不是唯一、也未必是最直接的解法。throttleTime 本身可以設定 trailing:throttleTime(300, asyncScheduler, { leading: true, trailing: true }) 會在窗口結束時補送窗內的最後一個值,而且 RxJS 7 的 throttle 在來源 complete 時會先把待送的 trailing 值送出才結束,所以那個 'final' 值不會被吃掉。用哪一種取決於你要的是「最後一個值」還是「串流結束這個事件」,作者的建議在後者成立,在前者則多繞了一圈。 依據: RxJS 7.x throttle/throttleTime 的 ThrottleConfig(leading / trailing),以及 throttle 在 complete 時 flush trailing 值的行為 |
| 7. 自訂比較函式,用內容取代參照 4:30 |
「commonly it is useful to use json. stringify to compare the equality of two objects」 | 當示範可以,當通用建議則有風險。JSON.stringify 的結果依賴屬性的插入順序,{a:1,b:2} 與 {b:2,a:1} 會被判為不同;undefined 值與函式會被略過、Date 被轉成字串、Map/Set 變成空物件、循環參照直接丟 TypeError,而且在高頻串流上每次比較都要序列化整個物件。實務上更穩的做法是只比你真正在意的欄位(例如 (a, b) => a.id === b.id && a.version === b.version),或使用專門的深層比較函式。 依據: ECMA-262 JSON.stringify 對 undefined/函式/循環參照的處理規則;屬性順序影響輸出字串 |
| 11. refCount 才是真正的開關 8:10 |
「late subscribers will also still be able to get the latest value from the observable」 | 這句話只在還有其他訂閱者在線上時成立。一旦開啟 refCount(resetOnRefCountZero: true),訂閱數掉到零時內部的 ReplaySubject 會連同緩衝一起被丟棄,之後才來的訂閱者拿不到任何舊值,而是重新訂閱來源、重跑一次邏輯。所以正確的說法是「在共享期間晚加入的訂閱者拿得到最新值」,而不是「任何晚到的訂閱者」。如果需要的是跨越空窗期的快取,就得保留 refCount 為 false,或改用有明確過期策略的快取層。 依據: RxJS 7.x share 的 resetOnRefCountZero 行為:重置時會丟棄 connector 建立的 Subject |
5. 推薦三個下一步
1. 往下挖深:把 share 系列拆回它底層的 Subject 機制
影片把所有 share 的怪異行為收斂到 refCount 這個設定,但沒說 refCount 到底在管什麼——它管的是底層 Subject 的生命週期。看懂 share 其實是 connectable observable 加一個 Subject,前面每一個陷阱都會從「要背的規則」變成「推得出來的結論」。
YouTube 搜尋:RxJS Subject vs BehaviorSubject vs ReplaySubject RxJS connectable observable share internals RxJS multicast refCount explained
2. 往旁邊對照:throttle、debounce、audit、sample 的取捨差異
影片只示範了 throttle 丟掉最後一個值,但同一類「限流」問題還有四五個 operator,各自在「取第一個還是最後一個」「窗口怎麼算」上做了不同選擇。把它們並排看,才知道那個 bug 該換 operator 還是該改設定。
YouTube 搜尋:RxJS throttleTime vs debounceTime vs auditTime vs sampleTime RxJS marble diagram rate limiting operators RxJS throttle leading trailing config
3. 往上應用:Angular 裡這些陷阱長什麼樣子
作者的例子都是抽象的 interval 與 Subject,但他踩坑的現場是 Angular 應用。HTTP 請求被重複發送、元件銷毀後 stream 還活著造成記憶體洩漏,正是本片第九、第十段的實戰版本。看實務案例才知道這些坑在哪一行程式碼上出現。
YouTube 搜尋:Angular RxJS memory leak shareReplay Angular HttpClient shareReplay duplicate requests Angular takeUntilDestroyed unsubscribe pattern
- 接著看 ←Map, switchMap, mergeMap, flatMap, concatMap, exhaustMap in RxJS - what is the difference? · 前站挑對 operator,本站講同一批 operator 在邊界情況的陷阱
- 相關 —JavaScript Visualized - Closures · 同樣是「沒人用了卻還活著」的記憶體洩漏,一個出在 closure,一個出在 shareReplay