RxJS 五個常用 operator 的隱藏陷阱:值會卡住、值會消失、相等不是你以為的相等

文字解析 · 1 支影片 · 產生於 2026-10-01 00:54

🎧 語音解析📝 筆記✍️ 練習

🎧 語音解析 15m31s · TTS 講解與作者原聲交錯;點章節可跳。
邊聽邊看逐句講稿,點任一句從那裡開始
章節(11)

1. Outline

  1. 起點 · 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,等於用一行程式碼回答了整支影片提出的所有問題。

推理鏈:每段留給下一段的線索

每一列是一段,箭頭後的字是這段留給下一段的線索。點段落標題跳到該段。

  1. RxJS 的陷阱不在於難,而在於不明顯 → 既然篩選標準是「作者親身踩過」,那第一個要示範的就是他最近踩到的 buffering。而依照「先演符合直覺的版本」這個節奏,下一步得先看清楚 buffer 正常運作時到底長什麼樣子。
  2. buffer:把值攢起來再一起送 → 既然那個落單的 10 是靠來源 complete 才被沖出來的,那反過來問:如果來源永遠不會 complete,湊不滿一批的值會落到什麼下場?
  3. buffer 的坑:來源不 complete,值就卡住 → buffer 的坑是「值還在,只是出不來」——資料本身沒有損失。這留下一個更嚇人的可能性:有沒有哪個 operator 會讓值根本不再存在?
  4. throttle:反過來的問題,值被丟掉 → 這個例子裡被丟掉的值都在串流中間,丟了也就丟了。但如果那個空缺剛好落在最後一個值上呢——那個你最需要它的值?
  5. throttle 的坑:最後一個值可能被吃掉 → 前兩個坑都長在時間軸上——什麼時候送、送不送得出去。接下來作者換一種形狀:值準時送出來了,錯的是「這兩個值算不算重複」這個判斷本身。
  6. distinctUntilChanged 比的是參照不是內容 → 既然錯的不是 operator 而是它預設採用的那套相等定義,那接下來的問題就很自然:這套定義能不能換掉?
  7. 自訂比較函式,用內容取代參照 → 把預設行為換掉這一招在這裡只解決了一個小問題。最後一組 operator 會把它變成主角——但依照前面的節奏,得先看它壞掉的樣子。
  8. share:把 cold 變 hot,避免重複執行 → 這一切美好都建立在來源是 interval——一條會持續發射、不會結束的串流。那如果來源是一個立刻就完成的東西呢?
  9. share 的坑:來源立刻完成,refCount 歸零 → 問題的根源已經定位在「ref count 歸零就重置」。那有沒有一個 operator 會把值留下來,讓晚到的訂閱者不必重跑來源?
  10. shareReplay 補上了洞,也開了新的洞 → share 與 shareReplay 各在一種來源下出過事,兩次的差別都圍繞著「什麼時候該放手」。那這個放手的時機,究竟是由哪一個設定決定的?
  11. refCount 才是真正的開關

3. 逐段說明

RxJS operators and their dangerous consequences

1. RxJS 的陷阱不在於難,而在於不明顯 0:00–0:35

作者先立下前提:RxJS 很強大,但要知道的東西太多,有些坑特別隱蔽。他不打算窮舉一百多個 operator,而是挑五個他最近真的踩到的。這個「親身踩過」的篩選標準決定了後面每一段的敘事方式——先給正常行為,再給出乎意料的行為。

承上 接上前情提要裡「RxJS 有一百多個 operator、每個都有自己的行為細節」這個背景。作者把它當成不需要再解釋的共識,直接跳過「怎麼用」,只談「用了之後會出什麼事」。

推理因為 operator 太多而人不可能全記住,所以作者放棄窮舉,改用一個很個人的篩選標準:只講他最近真的被咬過的。這個標準看起來隨意,其實決定了整支影片的敘事節奏——被咬過表示「他原本以為會是 A,結果是 B」,所以每一段都會先演一次 A(符合直覺的行為),再演 B(出乎意料的行為)。

AI 補充值得先點破的是這五個坑的共同形狀:它們在絕大多數情況下的行為都是對的,只有在某個特定條件成立時才會偏離直覺。這就是作者說的 devious(隱蔽)——不是寫錯會馬上報錯的那種錯,而是測試環境跑得好好的、上線之後偶爾出事的那種。也因此,這支影片真正要你記住的不是五個 operator 的用法,而是五個「觸發條件」:來源會不會 complete、時間間隔對不對得上、比的是不是同一個物件、以及有沒有人還在訂閱。

術語:gotcha

RxJS

JavaScript 的響應式擴充函式庫

用 Observable 把「隨時間陸續出現的值」當成可以被組合、轉換的串流來處理的 JavaScript 函式庫。

RxJS 是 ReactiveX 在 JavaScript 上的實作,Angular 內建大量使用它。它的核心賣點是把非同步事件(點擊、HTTP 回應、計時器、WebSocket 訊息)統一成同一種型別,再用一整套 operator 去組合。強大的代價就是這支影片的主題:每個 operator 都有自己對「時間」與「結束」的假設,而這些假設通常不寫在你呼叫它的那一行程式碼上。

相關術語: operator (由它組成)

出處:第 1 段「RxJS 的陷阱不在於難,而在於不明顯」

operator

運算子/算子

一個接收 Observable、回傳新 Observable 的函式,用來描述串流要怎麼被轉換。

operator 幾乎都放在 pipe() 裡串接,每一個只做一件小事。它們是純函式:不會改動原本的串流,而是回傳一條新的。RxJS 官方 operator 超過一百個,這也是作者說「不可能全部講完」的原因。這支影片討論的每一個坑,本質上都是某個 operator 對「什麼時候該送值、什麼時候該停」做了一個你沒注意到的預設。

相關術語: RxJS (屬於)

出處:第 1 段「RxJS 的陷阱不在於難,而在於不明顯」

gotcha

隱藏陷阱

行為合法、文件上也寫著,但跟使用者的直覺不一致,因而容易釀成 bug 的設計細節。

gotcha 跟 bug 不同:程式沒有壞,是你的預期壞了。它的典型症狀是「大部分時候都對」,所以不會在開發階段被發現。作者強調有些 gotcha 比其他的更 devious(更隱蔽),指的就是那些只在特定資料量、特定時間差、特定訂閱順序下才會現形的。整支影片的價值就在於把這些觸發條件講出來,讓你下次寫程式時能主動問自己一句「這裡符合觸發條件嗎」。

相關術語: operator (藏在其中)

出處:第 1 段「RxJS 的陷阱不在於難,而在於不明顯」

留給下一段 既然篩選標準是「作者親身踩過」,那第一個要示範的就是他最近踩到的 buffering。而依照「先演符合直覺的版本」這個節奏,下一步得先看清楚 buffer 正常運作時到底長什麼樣子。

2. buffer:把值攢起來再一起送 0:35–1:13

第一組是 buffering operators:不要一個一個送,而是收集到一定數量再一起送出。作者用 marble diagram 示範每三個一批。畫面上出現一個湊不成一批的 10,它仍然被送出來了——因為來源 stream complete 時,剩下的值會被沖出來。

buffer 的 marble diagram:三個一批的分組,以及落單的 10 在 complete 時仍被送出——這張圖是後面所有推理的基準線。
0:55 · buffer 的 marble diagram:三個一批的分組,以及落單的 10 在 complete 時仍被送出——這張圖是後面所有推理的基準線。
承上 承上:先看 buffer 符合直覺的樣子。作者用一個最單純的例子建立基準線——把值收集成一批一批送出,結果完全如預期。

推理因為上一段說了每個坑都要先有基準線,所以作者刻意挑了一個「不會出錯」的來源:畫面上是 from([1,2,3,...,10]) 搭配 bufferCount(3),一個固定長度、一定會結束的陣列。輸出是 [1,2,3]、[4,5,6]、[7,8,9],然後是落單的 [10]。作者特別停下來指著那個 10 說「它沒有同伴可以湊成一批,但我們還是收到它了」——因為來源結束時,剩下的值會被沖出來。

AI 補充這裡有一步作者講得很快,卻是後面整段推理的樞紐:那個 10 之所以會出現,不是 bufferCount 保證「你送進去的一定會出來」,而是因為 from() 走完陣列之後會 complete,而 complete 這個訊號會讓 bufferCount 把手上沒湊滿的一批強制送出。換句話說,「不丟值」這個看起來理所當然的性質,其實是掛在來源會結束這個前提上的。畫面上另一個容易略過的細節是輸出的括號標註:前三筆是 (3),最後一筆是 (1)——大小不一樣,代表最後那批確實是被提早沖出來的,不是湊滿的。

術語:Observableemission

Observable

可觀察物件/串流

代表一串隨時間陸續送出的值,可以被訂閱,並以完成或錯誤作為結束。

Observable 是 RxJS 的核心型別。它有三種訊號:next(送出一個值)、complete(結束,之後不再有值)、error(出錯結束)。關鍵在於 Observable 本身只是「描述」,寫好它並不會執行任何事,要等到有人訂閱才會真的跑起來。這個「訂閱才執行」的性質是後面 share 那一組坑的根源。

相關術語: emission (送出)、complete (以此結束)

出處:第 2 段「buffer:把值攢起來再一起送」

emission

發射/送出的值

Observable 送出一個值這件事本身,也指被送出的那個值。

在 RxJS 的語彙裡,「emit an emission」是最常出現的動詞與名詞。之所以要有這個詞而不是直接說「回傳值」,是因為一個 Observable 可以送出零個、一個或無限多個值,而且是在不同時間點送的。這支影片討論的所有問題都可以用這個詞來描述形狀:emission 被卡住、emission 被跳過、重複的 emission 被誤判。

相關術語: Observable (來自)

出處:第 2 段「buffer:把值攢起來再一起送」

complete

完成

Observable 宣告「不會再有值了」的結束訊號,送出之後訂閱自動解除。

complete 是一個沒有攜帶值的訊號,跟 next 不同。有些來源天生會 complete(陣列走完、HTTP 回應收到),有些天生不會(使用者點擊、interval 計時器、Subject)。這個差別在寫程式時完全看不出來——兩者的型別都是 Observable——但它決定了 bufferCount 會不會沖出剩餘值,也決定了後面 share 會不會重置。整支影片有一半的坑都源自於此。

相關術語: Observable (的結束訊號)

出處:第 2 段「buffer:把值攢起來再一起送」

bufferCount

計數緩衝

把上游的值累積到指定數量後,包成一個陣列一次送出的 operator。

bufferCount(3) 表示每收滿三個值就送出一個長度為 3 的陣列。它屬於 buffer 家族,同族還有 bufferTime(按時間分批)與 bufferWhen / bufferToggle(按另一條串流的訊號分批)。典型用途是降低下游的處理頻率:與其對每一筆資料各發一次請求,不如攢一批一起發。要注意它的分批條件只有「數量」,不含任何逾時機制——這正是它可能把值無限期扣住的原因。

相關術語: complete (靠它沖出餘數)、emission (累積成批)

出處:第 2 段「buffer:把值攢起來再一起送」

留給下一段 既然那個落單的 10 是靠來源 complete 才被沖出來的,那反過來問:如果來源永遠不會 complete,湊不滿一批的值會落到什麼下場?

3. buffer 的坑:來源不 complete,值就卡住 1:13–2:19

上一段的「complete 會沖出剩餘值」反過來就是危險:來源不會 complete 時,湊不滿一批的值會永遠卡在 buffer 裡。作者用一個 Subject 示範 next 十一次,第十、十一個值就懸在那裡等第十二個。他自己也被咬過:為了效能做批次,結果初始資料裡除不盡的餘數卡住不出來。

Subject 在迴圈裡 next 十一次的程式碼:要看到「11 不能被 3 整除」才明白為什麼會剩下兩個值卡在 buffer。
1:32 · Subject 在迴圈裡 next 十一次的程式碼:要看到「11 不能被 3 整除」才明白為什麼會剩下兩個值卡在 buffer。
承上 承上:把「complete 會沖出剩餘值」這個前提直接拿掉。作者換掉來源,改用一個永遠不會結束的 Subject,來回答上一段留下的那個反問。

推理因為上一段確認了餘數是靠 complete 才出得來,所以作者只改一個變因:來源。程式碼從 from(陣列) 換成 new Subject(),用一個 for 迴圈 next 十一次。11 除以 3 餘 2,於是 console 只印出 [0,1,2]、[3,4,5]、[6,7,8] 三批,最後兩個值 9 和 10 就懸在 buffer 裡,要等第十二個值出現才會被送出——而 Subject 不會自己結束,所以那一天可能永遠不會來。

AI 補充畫面上作者自己寫的註解「won't emit last batch unless i < 12」把條件講死了,但真正該記住的是它在真實情境裡長什麼樣子:作者說他是為了效能把工作批次化,先灌一批初始資料進去、之後再陸續加新的。這種「先初始化、再持續追加」的資料流,剛好同時具備兩個觸發條件——初始筆數不見得被批次大小整除,而且來源不會結束。結果就是有幾筆資料看起來像是被吞了,直到很久以後有新資料進來才突然一起冒出來。這種 bug 特別難查,因為資料最終還是出現了,只是遲到;日誌上看起來像時序問題,實際上是餘數問題。要避開它,實務上會在 bufferCount 之外再加一個逾時或手動 flush 的觸發條件,讓「湊不滿」也有出路。

Subject

主體/可手動推值的串流

同時是 Observable 也是觀察者的物件,可以用 next() 從外部主動推值進去。

一般的 Observable 由建立它的邏輯決定何時送值,Subject 則把控制權交給你:任何拿到它的程式碼都能呼叫 next()。它同時是多播的——所有訂閱者共用同一串值。這支影片用它當範例的原因很單純:Subject 除非你自己呼叫 complete(),否則永遠不會結束,正好是「來源不 complete」的最短寫法。後面的 share 家族內部其實也是靠 Subject 實作多播的。

相關術語: complete (除非手動否則不會)、Observable (一種)

出處:第 3 段「buffer 的坑:來源不 complete,值就卡住」

留給下一段 buffer 的坑是「值還在,只是出不來」——資料本身沒有損失。這留下一個更嚇人的可能性:有沒有哪個 operator 會讓值根本不再存在?

4. throttle:反過來的問題,值被丟掉 2:19–2:58

buffer 是值卡住,throttle 是值直接消失。取一個值之後在一段時間內忽略所有後續 emission。作者用每 100 毫秒送一次、throttle 120 毫秒的 marble diagram 示範——這種等差關係下會漏掉哪些值,看圖一目了然。他也承認這個例子「看起來很明顯」,是為了鋪陳下一段不明顯的版本。

100ms 送值對上 120ms throttle 的 marble diagram:被跳過的值在圖上是空缺,這個空缺就是下一段那個 bug 的形狀。
2:42 · 100ms 送值對上 120ms throttle 的 marble diagram:被跳過的值在圖上是空缺,這個空缺就是下一段那個 bug 的形狀。
承上 承上:回答「有沒有 operator 會讓值直接消失」。作者說 throttle 是 buffer 的相反問題——buffer 是 stuck(卡住),throttle 是 skipped and lost entirely(跳過且徹底遺失)。

推理因為上一段的損害還可以挽回(值遲早會出來),所以作者接著把損害升級: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 的溫床。

throttleTime

時間節流

放行一個值之後,在指定的毫秒數內忽略上游所有後續的值。

throttleTime 常被拿來降低高頻事件的處理次數,例如捲動、滑鼠移動、感測器讀數。它預設是 leading(先放行、再關窗),可以透過設定改成 trailing(關窗期間記住最後一個值,窗口結束時補送)或兩者都要。它和 debounceTime 常被混用,但行為相反:throttle 保證固定頻率有輸出,debounce 則是等事件停下來才輸出一次。與 buffer 家族的差別在於,被 throttle 忽略的值不會被保存,預設情況下就是永久遺失。

相關術語: bufferCount (對照組)、emission (丟棄部分)

出處:第 4 段「throttle:反過來的問題,值被丟掉」

interval

定時發射

每隔固定毫秒送出一個遞增整數、且永遠不會結束的 Observable 建立函式。

interval(100) 會在第 100 毫秒送出 0、第 200 毫秒送出 1,依此類推,除非被取消訂閱否則不會停。它在教學裡幾乎是標準道具,因為它同時提供了「規律的時間軸」與「不會 complete 的來源」這兩個性質。留意第二個性質:這支影片後面談 share 時,interval 的「永不結束」會從方便變成麻煩。

相關術語: complete (永不)

出處:第 4 段「throttle:反過來的問題,值被丟掉」

留給下一段 這個例子裡被丟掉的值都在串流中間,丟了也就丟了。但如果那個空缺剛好落在最後一個值上呢——那個你最需要它的值?

5. throttle 的坑:最後一個值可能被吃掉 2:58–3:45

把上一段的空缺換到最糟的位置:state machine 頻繁送出狀態快照,用 throttle 減少反應次數;但它結束時送出的最後一個值,可能剛好落在 throttle 的窗口裡而被吃掉。更麻煩的是有時又剛好沒被吃掉——時好時壞的 bug 最難查。作者給的解法是把最終狀態改到 completion handler 處理。

最後一個值被 throttle 擋掉 vs 剛好通過的對照畫面:這個「有時發生有時不發生」正是它難以除錯的原因。
3:27 · 最後一個值被 throttle 擋掉 vs 剛好通過的對照畫面:這個「有時發生有時不發生」正是它難以除錯的原因。
承上 承上:把上一段「空缺會飄移」的性質,搬到最糟的位置——串流的最後一個值。作者說這個情境「可能是有感而發」,等於承認這就是他親身踩過的那一個。

推理因為上一段已經證明被丟掉的位置取決於時間對齊,所以作者只要建構一個「最後一個值特別重要」的場景,這個坑就成立了。他設想一個 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;不過還有一個更直接的選項他沒提,見下方勘誤。

take

取前 N 個

從上游取指定數量的值之後,主動讓串流 complete 的 operator。

take(10) 會在第十個值送出後立刻 complete,並自動解除對上游的訂閱。它是把一條不會結束的串流(例如 interval)變成會結束的串流最簡單的手段,也因此常被用來在範例裡製造出「最後一個值」。在這一段它扮演的角色很關鍵:沒有 take,就沒有最後一個值,也就沒有這個坑。

相關術語: interval (使其結束)、complete (觸發)

出處:第 5 段「throttle 的坑:最後一個值可能被吃掉」

completion handler

完成處理函式

訂閱時提供的第三個回呼,只在串流 complete 時被呼叫一次。

subscribe() 可以接一個物件,內含 next、error、complete 三個回呼;complete 那一個就是完成處理函式。它的重要性質是:它掛在串流結束這個事件上,不經過任何以時間為條件的 operator,所以 throttle、debounce、sample 都攔不住它。缺點是它拿不到值——complete 訊號本身不帶資料——所以實務上要嘛在 tap 裡先把最後的值存起來,要嘛改用 last() 這類 operator 取出結束前的最後一個值。

相關術語: complete (被它觸發)、throttleTime (繞過)

出處:第 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 值的行為
留給下一段 前兩個坑都長在時間軸上——什麼時候送、送不送得出去。接下來作者換一種形狀:值準時送出來了,錯的是「這兩個值算不算重複」這個判斷本身。

6. distinctUntilChanged 比的是參照不是內容 3:45–4:28

第三個坑換一種形狀:不是時間問題,是相等的定義問題。distinctUntilChanged 拿來擋重複值很好用,對 primitive 沒問題;但兩個屬性完全相同的物件,在它眼裡是兩個不同的值。反過來,透過同一個變數送出同一個物件,它就判定相等——因為它比的是物件參照。

兩個內容一樣但被判定為不同的物件:畫面上「看起來一樣卻通過了」的輸出是這段的反直覺核心。
4:05 · 兩個內容一樣但被判定為不同的物件:畫面上「看起來一樣卻通過了」的輸出是這段的反直覺核心。
改成參照同一個變數後被判定相等:和前一張並排看才知道差別只在參照,不在內容。
4:20 · 改成參照同一個變數後被判定相等:和前一張並排看才知道差別只在參照,不在內容。
承上 承上:離開時間軸,改看判斷本身。作者說 distinctUntilChanged 是個很棒的 operator,用來忽略串流上的重複值,尤其在處理 primitive 值的時候——但一換成物件就進入陷阱區。

推理因為前面兩個坑都可以歸咎於「時間沒對齊」,所以作者刻意挑一個跟時間無關的坑來擴大這支影片的覆蓋範圍。他的推導只有兩步,而且兩步的方向相反。第一步:畫面上 from() 裡連續放了兩個 { title: 'subscribe' },屬性和值一模一樣,console 卻把它們都印出來了,粉紅箭頭標著 different——內容相同卻被判定為不同。第二步:同一個陣列裡連續放了兩次 someObject(同一個變數),console 只印出一次 {title: 'hi'}——這次被判定為相等。

AI 補充把兩步合起來,答案就浮出來了:distinctUntilChanged 預設用的是 === 這個判斷,而 === 對物件比的是參照,不是內容。所以它的行為其實完全一致,只是這個一致性建立在「你手上是不是同一個物件」而不是「這兩個東西看起來像不像」。這件事在真實專案裡的殺傷力比範例大得多:只要你的資料經過任何一次 map(x => ({...x}))、經過一次 HTTP 回應的 JSON 解析、或是經過任何會產生新物件的狀態更新,前後兩個值就一定不是同一個參照,distinctUntilChanged 等於完全失效,卻不會有任何錯誤訊息。反過來,如果你在狀態管理裡就地修改同一個物件(mutation),它又會判定相等而把真正的變更擋掉——這是同一個機制造成的另一半災情,比誤放行更難察覺。

術語:reference equality

distinctUntilChanged

去除連續重複值

只在這次的值跟上一次送出的值不同時才放行的 operator。

注意名稱裡的 untilChanged:它只跟「上一個送出的值」比,不是跟歷史上所有值比。所以 A、A、B、A 會得到 A、B、A——第二個 A 會被放行,因為它前面是 B。若要跟所有出現過的值比,要用 distinct(),但那需要保存全部歷史,有記憶體成本。它常被放在狀態串流的尾端,用來避免下游做無謂的重繪或請求。

相關術語: reference equality (預設採用)、emission (過濾)

出處:第 6 段「distinctUntilChanged 比的是參照不是內容」

primitive

原始型別值

字串、數字、布林、null、undefined、symbol、bigint 這類直接以值本身比較相等的型別。

primitive 跟物件最關鍵的差別就是相等的定義:'abc' === 'abc' 為真,因為比的是內容;而 { a: 1 } === { a: 1 } 為假,因為比的是兩個不同的位址。這條 JavaScript 的基本規則本身沒有什麼玄機,玄機在於它會靜靜地滲進 RxJS operator 的預設行為裡——你呼叫 distinctUntilChanged() 時看不到任何跟相等有關的參數,但它已經替你決定了。

相關術語: reference equality (相反)

出處:第 6 段「distinctUntilChanged 比的是參照不是內容」

reference equality

參照相等

兩個變數指向記憶體中同一個物件時才算相等,與屬性內容無關。

參照相等的對照組是結構相等(structural equality),也就是逐一比較屬性與值。JavaScript 的 === 對物件只提供參照相等,語言本身沒有內建的結構相等,所以每個函式庫都得自己決定要怎麼比——這也是為什麼 React 的 memo、Angular 的 OnPush、以及這裡的 distinctUntilChanged 都要求你理解這件事。一個實用的判準是:只要這個值在上游被重新建立過一次,它就不可能參照相等。

相關術語: primitive (相反)、distinctUntilChanged (被它使用)

出處:第 6 段「distinctUntilChanged 比的是參照不是內容」

留給下一段 既然錯的不是 operator 而是它預設採用的那套相等定義,那接下來的問題就很自然:這套定義能不能換掉?

7. 自訂比較函式,用內容取代參照 4:28–4:47

既然預設比的是參照,那就把比較方式換掉:distinctUntilChanged 可以接一個自訂的相等函式。作者用最省事的 JSON.stringify 把兩個物件字串化再比,等於改成按屬性與值比較。這是整支影片第一個「你可以主動改變 operator 行為」的例子。

傳入 JSON.stringify 比較函式的程式碼:要看到它接在 distinctUntilChanged 的第幾個參數位置才能照抄。
4:37 · 傳入 JSON.stringify 比較函式的程式碼:要看到它接在 distinctUntilChanged 的第幾個參數位置才能照抄。
承上 承上:回答「相等的定義能不能換掉」。作者說可以——distinctUntilChanged 接受我們自己提供的相等函式,想怎麼比就怎麼比。

推理因為上一段把問題定位在「預設用參照比」,所以解法自然是把比較這件事外包出去。畫面上的寫法是 distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)):把兩個物件都字串化再比字串,等於改成按屬性與值比較。console 的輸出從四筆變成三筆——兩個 {title: 'subscribe'} 終於被合併掉了。

AI 補充這一段在整支影片裡的地位比它的長度重要得多:它是第一次出現「operator 的預設行為是可以被你接管的」。前面兩個坑作者給的都是繞路(改用完成處理函式、換個 operator),這一次是直接把旋鈕轉過來。這個觀念會在最後一組 operator 上被推到極致——share 家族的所有怪異行為,最後也是靠一組設定物件解決的。另外值得補一句作者沒說的:這個 comparator 收到的兩個參數順序是 (previous, current),也就是先前送出的值在前;如果你要寫的是不對稱的比較邏輯(例如只在版本號變大時才放行),順序就不能寫錯。

comparator

比較函式

傳給 operator 的自訂函式,接收前後兩個值並回傳它們是否相等。

distinctUntilChanged 的 comparator 簽章是 (previous, current) => boolean,回傳 true 表示「視為相同」,因此當前這個值會被擋掉。RxJS 裡採用類似設計的還有 distinct 的 keySelector、groupBy 的 keySelector 等等——都是把「什麼算同一個」這件事交還給呼叫端決定。實務上更常見的做法不是整個物件比,而是只比你在意的那個欄位,例如 (a, b) => a.id === b.id,既精準又便宜。

相關術語: distinctUntilChanged (傳入它)、reference equality (用來取代)

出處:第 7 段「自訂比較函式,用內容取代參照」

JSON.stringify

序列化成 JSON 字串

把 JavaScript 值轉成 JSON 字串的內建函式,這裡被借來當作物件的內容比較。

拿它比物件的好處只有一個:一行就寫完,不必為每個型別寫比較邏輯。代價則不少——輸出取決於屬性的插入順序,所以 {a:1,b:2} 和 {b:2,a:1} 會被判定為不同;值為 undefined 的屬性與函式會被整個略過;Date 會變成字串、Map 與 Set 會變成空物件;遇到循環參照會直接丟例外;而且每比一次就要把整個物件序列化一遍,在高頻串流上成本不低。當成 demo 很好,放進正式程式碼前要先確定你的資料沒踩到上面任何一條。

相關術語: comparator (當作它使用)

出處:第 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/函式/循環參照的處理規則;屬性順序影響輸出字串
留給下一段 把預設行為換掉這一招在這裡只解決了一個小問題。最後一組 operator 會把它變成主角——但依照前面的節奏,得先看它壞掉的樣子。

8. share:把 cold 變 hot,避免重複執行 4:47–5:49

最後一組是 share 與 shareReplay。作者先示範沒有 share 的 cold observable:兩個訂閱、各三次 emission,但來源裡的 console.log 跑了六次——每次訂閱都會重跑一次來源邏輯。加上 share 之後兩個訂閱共用同一條 hot stream,log 只在每次 interval 發射時跑一次。到這裡一切都很美好。

沒有 share 時 console 印六次的輸出:這個「六」是判斷來源邏輯有沒有被重複執行的證據。
5:22 · 沒有 share 時 console 印六次的輸出:這個「六」是判斷來源邏輯有沒有被重複執行的證據。
加上 share 之後 log 只印一次的對照輸出:和前一張比才看得出 share 到底改變了什麼。
5:43 · 加上 share 之後 log 只印一次的對照輸出:和前一張比才看得出 share 到底改變了什麼。
承上 承上:進入最後一組 operator——share 與 shareReplay。作者說他很常用它們,並且照慣例先示範沒有它們時會發生什麼事。

推理因為要說明 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

cold observable

冷串流

每次被訂閱都會重新執行一次自己的邏輯、各訂閱者拿到各自資料的 Observable。

冷的意思是「還沒開始,等你來才動」。HTTP 請求、from(陣列)、interval 都是冷的:訂閱兩次就送兩次請求、跑兩次計時器。這件事常常在 Angular 的樣板裡用兩次 async pipe 訂閱同一個請求時被發現——後端看到兩筆一模一樣的請求。冷不是缺點,它保證了每個訂閱者拿到完整且獨立的資料;只有當來源有副作用或成本時才會變成問題。

相關術語: hot observable (對照組)、Observable (一種)

出處:第 8 段「share:把 cold 變 hot,避免重複執行」

hot observable

熱串流

不論有沒有人訂閱都在運作、所有訂閱者共用同一份值的 Observable。

熱的意思是「演出已經在進行,你來就從現在開始看」。滑鼠事件、WebSocket 訊息天生是熱的;冷的串流則要靠 share 這類 operator 才會變熱。熱串流的代價是晚到的訂閱者會錯過先前的值——這正是 shareReplay 要處理的問題。另外要留意:熱不代表永遠在跑,能不能在沒人訂閱時停下來,是由 refCount 這個設定決定的。

相關術語: cold observable (相反)、multicast (藉此達成)

出處:第 8 段「share:把 cold 變 hot,避免重複執行」

multicast

多播

來源只執行一次,把同一份值同時分送給多個訂閱者的機制。

多播的對照組是單播(unicast),也就是冷串流的預設行為:一個訂閱一份執行。RxJS 的多播都是靠 Subject 實作的——來源訂閱 Subject,Subject 再面向所有下游。早期版本要自己組合 multicast()、publish()、refCount() 才做得到,RxJS 7 之後統一收進 share() 的設定物件裡,這也是為什麼舊教學和新教學的寫法差很多。

相關術語: Subject (實作於)、share (由它提供)

出處:第 8 段「share:把 cold 變 hot,避免重複執行」

share

共享

把冷串流轉成多播熱串流的 operator,讓多個訂閱者共用同一次來源執行。

share() 的預設行為是:第一個訂閱者到達時才訂閱來源,訂閱數掉到零時就取消對來源的訂閱並重置自己;來源 complete 或 error 時同樣會重置。RxJS 7 起它接受一個設定物件,可以分別關掉這三種重置(resetOnRefCountZero、resetOnComplete、resetOnError),也可以換掉內部使用的 Subject。這支影片後半段所有的怪異行為,最後都會回到這幾個設定上。

相關術語: multicast (提供)、cold observable (轉成熱的)

出處:第 8 段「share:把 cold 變 hot,避免重複執行」

tap

旁路副作用

在不改變串流內容的前提下執行副作用(例如 log)的 operator。

tap 是觀察串流的標準工具:它拿到每個值、原封不動放行,只是順便做點別的事。在這支影片裡它扮演證人的角色——console.log('I am here') 印了幾次,就代表來源邏輯被執行了幾次。這個技巧在除錯真實專案時同樣好用:想知道某段管線是不是被重複訂閱,在來源後面插一個 tap 印 log 就能立刻看出來。

相關術語: operator (一種)

出處:第 8 段「share:把 cold 變 hot,避免重複執行」

留給下一段 這一切美好都建立在來源是 interval——一條會持續發射、不會結束的串流。那如果來源是一個立刻就完成的東西呢?

9. share 的坑:來源立刻完成,refCount 歸零 5:49–6:56

上一段的美好建立在來源會持續發射(interval)。換成會立刻完成的來源(例如 HTTP 請求,用 of 模擬),明明用了 share,map 裡的 log 還是跑了兩次。原因是 share 用 refCount 追蹤訂閱數,歸零就 reset;來源立刻完成使第一個訂閱馬上結束,refCount 在第二個訂閱建立之前就掉到零。

用 of 模擬立刻完成的來源、console 卻印兩次:這個「用了 share 還是兩次」的畫面是整段要解釋的謎題。
6:20 · 用 of 模擬立刻完成的來源、console 卻印兩次:這個「用了 share 還是兩次」的畫面是整段要解釋的謎題。
承上 承上:把來源從 interval 換成會立刻完成的東西。作者說真實情境裡這通常是一個 HTTP 請求,範例裡用 of() 模擬。

推理因為上一段的成功建立在「來源持續發射」這個前提,所以作者照例只改這一個變因。畫面上是 of(1).pipe(map(...), share()),map 裡印 'doing something that should only happen once',然後建立兩個訂閱。意圖很清楚:這段邏輯只該跑一次、兩個訂閱共用結果。但 console 印了兩次。明明用了 share,還是跑了兩遍。接著作者一步步推出原因:share 用 ref count 追蹤目前有幾個訂閱掛在它身上,數字掉到零就重置,重置之後任何新訂閱都會重新觸發來源邏輯;而 of() 立刻完成,使第一個訂閱馬上結束,ref count 在第二個訂閱建立之前就掉到零。

AI 補充這段推理有一個關鍵前提作者沒有明說:of(1) 是同步的。第一次 subscribe 那一行還沒執行完,值就已經送出、串流就已經 complete、訂閱就已經解除了——下一行才輪到第二個 subscribe。所以這裡不是兩個訂閱「差一點點沒接上」,而是它們在時間上根本沒有重疊過。這也說明了為什麼真實的 HTTP 請求(非同步)通常不會踩到這個坑:第二個訂閱會在回應回來之前就建立好,ref count 從 1 變 2 而不是掉到 0。換句話說,這個坑的觸發條件比作者講的更窄,但也更陰險——用假資料測試時會出事,換成真的 API 又好了,或者反過來。另外要補的是,share 預設的重置條件不只 refCount 歸零一個,來源 complete 本身也會讓它重置(resetOnComplete 預設為 true);在 of() 這個例子裡兩個條件同時成立,而知道有兩個條件,才能理解為什麼最後那個自訂 operator 需要同時關掉其中一個、留下另一個。

of

同步送值

把給定的參數依序同步送出、然後立刻完成的 Observable 建立函式。

of(1) 會在被訂閱的當下就送出 1 並 complete,整個過程沒有任何非同步等待。它常被拿來當測試替身,模擬「已經拿到結果」的 HTTP 請求。但要注意 of 與真實請求有一個重大差異:真實請求是非同步的。這個差異在大部分程式碼裡無關緊要,卻剛好會改變這一段所有跟訂閱時序有關的結論。

相關術語: complete (立刻觸發)、interval (對照組)

出處:第 9 段「share 的坑:來源立刻完成,refCount 歸零」

refCount

訂閱計數

多播串流目前掛著幾個訂閱者的計數,歸零時可用來決定是否斷開來源。

refCount 是引用計數的概念用在串流上:第一個訂閱者到達時把來源接上,最後一個離開時把來源斷開,這樣就不會有沒人在聽卻還在跑的串流。share() 預設啟用它;shareReplay() 預設不啟用。理解這個詞是理解整支影片後半段的鑰匙——所有「為什麼又跑了一次」或「為什麼還在跑」的問題,答案都是 refCount 在什麼時候歸零,以及歸零之後要不要重置。

相關術語: share (預設使用)、multicast (用來管理)

出處:第 9 段「share 的坑:來源立刻完成,refCount 歸零」

map

轉換

把上游的每個值套用一個函式後送出轉換結果的 operator。

map 是最常用的 operator,語意跟陣列的 map 完全一致,只是作用在時間軸上的值。這一段用它來裝一段「只該執行一次的初始化邏輯」,這其實是刻意示範一個反例:map 應該是純函式,把有副作用的初始化寫在裡面本來就不理想;作者這樣寫只是為了讓副作用被執行幾次能夠被 console 直接數出來。

相關術語: tap (對照組)

出處:第 9 段「share 的坑:來源立刻完成,refCount 歸零」

留給下一段 問題的根源已經定位在「ref count 歸零就重置」。那有沒有一個 operator 會把值留下來,讓晚到的訂閱者不必重跑來源?

10. shareReplay 補上了洞,也開了新的洞 6:56–7:39

shareReplay 會把值存下來重播給晚到的訂閱者,剛好補上 refCount 歸零的洞:init 邏輯只跑一次,兩個訂閱共用同一個值。但把來源換回不會完成的 interval,問題翻面了——兩個訂閱都取消之後,來源還在永遠發射。這可能正合你意,也可能是記憶體洩漏。

取消訂閱後來源仍持續輸出的畫面:這種「沒人聽了還在講」的狀態就是記憶體洩漏的樣子。
7:25 · 取消訂閱後來源仍持續輸出的畫面:這種「沒人聽了還在講」的狀態就是記憶體洩漏的樣子。
承上 承上:回答「有沒有東西能把值留給晚到的訂閱者」。作者說有,就是 shareReplay——它會把值存起來,重播給後來才加入的訂閱者。

推理因為上一段的病灶是「重置之後晚到的訂閱者只能重跑來源」,所以只要讓值被保存下來,晚到的人就有東西可拿。作者把 of() 那個例子的 share 換成 shareReplay,初始化邏輯果然只跑一次,兩個訂閱共用同一個值。但他沒有停在這裡——他把來源換回 interval(1000) 再跑一次,畫面上是 interval(1000).pipe(tap(...), shareReplay(1)),兩個訂閱在三秒後都被取消,然後 console 上的 I am here 一行接一行繼續冒出來,永遠不停。作者的語氣在這裡轉了個彎:這有可能正是你要的,也有可能是 bug 或記憶體洩漏。

AI 補充把第 8、9 兩段跟這一段擺在一起看,圖形就完整了:同一組 operator,在會 complete 的來源與不會 complete 的來源下,各壞掉一次,而且壞的方向剛好相反——share 遇上立刻完成的來源會過度重置(該共用的沒共用),shareReplay 遇上永不完成的來源則完全不重置(該停的沒停)。這說明兩者的差別不是「一個會存值一個不會」那麼表面,而是它們對「什麼時候該放手」有相反的預設。至於為什麼沒人訂閱還繼續跑會構成記憶體洩漏:來源仍持有訂閱、計時器仍在排程、shareReplay 的緩衝區仍握著最後一個值不放,這些都不會被垃圾回收;如果這條串流是隨著某個元件建立的,元件關掉之後它還活著,開開關關幾次就累積成一串永遠不會消失的殭屍串流。順帶一提,作者這裡用的是 shareReplay(1),括號裡的 1 是緩衝多少個值;緩衝數字開大或不設上限,洩漏的量也會跟著放大。

shareReplay

共享並重播

在共享串流的基礎上保存最近 N 個值,重播給後來加入的訂閱者的 operator。

shareReplay(1) 是最常見的寫法,表示只留最後一個值——通常用來快取一次性的請求結果,讓後續訂閱者不必重打 API。它跟 share 的差別有兩層:一是內部用 ReplaySubject 而非 Subject,所以有記憶;二是預設不做 refCount,所以沒人訂閱時來源照跑。第二層才是這一段的重點,也是它最常被誤用的地方。

相關術語: share (加上記憶)、refCount (預設不用)

出處:第 10 段「shareReplay 補上了洞,也開了新的洞」

memory leak

記憶體洩漏

已經沒有用途的資源仍被持有而無法回收,隨時間累積並拖垮應用程式。

在 RxJS 裡最典型的洩漏來源就是沒有被取消的訂閱:只要訂閱還在,來源的計時器、事件監聽、以及回呼所閉包住的一切物件都不會被回收。這一段的狀況更隱蔽一點——訂閱明明取消了,但 shareReplay 自己還掛在來源上,等於在你和來源之間留了一個誰都沒注意到的中間人。實務上的判準很簡單:問這條串流在最後一個訂閱者離開後還需不需要活著,答案是否,就要讓它能夠停下來。

相關術語: refCount (可用來避免)、shareReplay (可能造成)

出處:第 10 段「shareReplay 補上了洞,也開了新的洞」

留給下一段 share 與 shareReplay 各在一種來源下出過事,兩次的差別都圍繞著「什麼時候該放手」。那這個放手的時機,究竟是由哪一個設定決定的?

11. refCount 才是真正的開關 7:39–8:53

所有 share 的怪異行為收斂到一個設定:shareReplay 預設 refCount 為 false,所以沒人訂閱也照發不誤;share 則預設用 refCount 且歸零就 reset。把 shareReplay 的 refCount 設成 true,就同時拿到「沒人訂閱就停」與「晚到者拿得到最新值」。作者把這組設定包成自訂 operator,並說九成五以上的情況他都用這個。

把 shareReplay refCount 設定包成自訂 operator 的程式碼:這是整支影片唯一可以直接複製進專案的成品。
8:20 · 把 shareReplay refCount 設定包成自訂 operator 的程式碼:這是整支影片唯一可以直接複製進專案的成品。
承上 承上:回答「放手的時機由哪個設定決定」。作者直接給出答案——shareReplay 預設把 refCount 設成 false,而 share 預設用 refCount 追蹤訂閱數、歸零就重置。

推理因為前面所有怪異行為都可以歸到同一個旋鈕上,所以作者不再逐個 operator 討論,而是直接調它。他把 shareReplay 的 refCount 設成 true,行為立刻變成「跟 share 一樣,沒人訂閱來源就停下來,但晚到的訂閱者仍拿得到最新的值」——等於同時要到了前兩段各自缺的那一半。接著他承認自己懶得每次都打那個設定物件,於是包成一個自訂 operator。畫面上那個檔案叫 shareWhileSubscribed.ts,內容是 share({ connector: () => new ReplaySubject(bufferSize), resetOnError: false, resetOnComplete: false, resetOnRefCountZero: true }),最後他說這組設定涵蓋了他九成五以上的共享情境。

AI 補充畫面上的實作值得逐行對照,因為它比作者口頭說的更精確——他用的不是 shareReplay({ refCount: true }),而是直接展開成 share() 的設定物件,這讓每一個重置條件都被獨立地表態:connector 換成 ReplaySubject 換來記憶(這是 shareReplay 的部分);resetOnRefCountZero 保持 true,所以沒人訂閱時來源會停(這是第 10 段那個永不停止的洩漏的解藥);resetOnComplete 關成 false,所以來源完成後不會重置(這正是第 9 段裡 of() 那個坑的另一半病因,寫 shareReplay({ refCount: true }) 是繞不開它的);resetOnError 關成 false 則讓錯誤結果也被記住,不會讓下一個訂閱者靜靜地重打一次失敗的請求。也就是說,作者其實是用一個設定物件把這支影片後半段的三個問題一次全部回答完。最後要留意一個容易誤讀的地方:開了 refCount 之後,「晚到的訂閱者拿得到最新值」只在還有其他訂閱者在線上時成立,一旦人數歸零,緩衝的值也會跟著被丟掉——見下方勘誤。

ReplaySubject

重播主體

會把收到的最近 N 個值記下來,並在有新訂閱者加入時立刻重播給他的 Subject。

它是 shareReplay 內部使用的元件,也是把記憶塞進多播串流的標準手段。緩衝大小由建構參數決定,不給就是無上限——在長時間運作的串流上等同於把所有歷史值都留在記憶體裡。同族的還有 BehaviorSubject(永遠只記最後一個值,且必須給初始值)與普通 Subject(完全不記)。畫面上那個自訂 operator 之所以要親手 new 一個 ReplaySubject 傳給 connector,就是為了在使用 share() 的同時取得 shareReplay 的記憶能力。

相關術語: Subject (一種)、shareReplay (實作於)

出處:第 11 段「refCount 才是真正的開關」

resetOnRefCountZero

訂閱歸零時重置

share 的設定項,決定訂閱數掉到零時要不要斷開來源並清掉共享狀態。

它是 RxJS 7 起 share() 設定物件的一員,同組還有 resetOnComplete 與 resetOnError,三者分別對應三種「該不該放手」的時機。share() 預設三者皆為 true,shareReplay(n) 則相當於把三者的組合調成不做 refCount。把它們拆開來單獨設定的能力,正是作者那個自訂 operator 能同時解掉重複執行與記憶體洩漏兩個問題的原因。它也可以接受一個數字或 Observable 作為延遲重置的依據,適用於「短暫斷線後很快又會有人訂閱」的情境。

相關術語: refCount (設定它)、share (的設定項)

出處:第 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
留給下一段 總結收束。

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

📄 全部影片 · 主題區: 前端資料流