![]()
React 19還沒捂熱,社區里一組實驗代碼的Star數已經悄悄破了4000。不是新框架,是有人把Signals塞進了React——而且沒動一行React源碼。
這事有意思的地方在于:React團隊自己也在搞Signals(就是那個叫use的Hook),但進度慢得像在擠牙膏。社區等不及了,直接上手造了個能跑的生產級方案。今天這篇就是作者系列的第四篇,講清楚一個關鍵問題——數據副作用和UI副作用,到底該誰管?
7行代碼,拆出兩條完全不同的生命周期
先看這段被轉發最多的代碼:
// data/heartbeat.ts import { signal } from "../core/signal"; import { createEffect, onCleanup } from "../core/effect"; export const intervalMs = signal(1000); export const heartbeat = signal(null); createEffect(() => { const ms = intervalMs.get(); const id = setInterval(() => { heartbeat.set(new Date()); }, ms); onCleanup(() => clearInterval(id)); });
7行核心邏輯,干了一件React里很別扭的事:讓一個定時器跟著數據走,而不是跟著組件走。
作者管這叫"數據層的心跳"——intervalMs是個信號,改它的時候,舊的定時器自動清理,新的自動啟動。整個過程沒有組件參與,頁面切走了它還在跑,頁面切回來數據還是熱的。
對比React原生的寫法,差別立刻顯現。以前你要么把定時器塞useEffect里跟著組件生死,要么上Redux-Saga、React Query這種重型方案。現在7行代碼搞定,而且類型安全。
光標閃爍:為什么必須用React的useEffect?
作者緊接著拋了另一個例子,刻意和上面的形成對照:
// ui/Blinker.tsx export function Blinker({ enabled = true }) { const [on, setOn] = useState(false); useEffect(() => { if (!enabled) return; const id = setInterval(() => setOn(v => !v), 500); return () => clearInterval(id); }, [enabled]); return |; }
同樣是定時器,這次老老實實用了React的useEffect。為什么?
![]()
因為光標閃爍是純視覺行為,它依賴React的渲染周期——enabled prop變了要立刻停,組件卸載要立刻清。這些時機必須對齊React的commit階段,而不是數據的任意變更。
作者的原話很直接:「這是純粹的UI/視覺行為,它的清理時機應該跟隨React的提交周期。」
兩個例子擺在一起,分界線就清楚了:createEffect管數據流的生命周期,useEffect管DOM的生命周期。以前這兩件事被混在一個Hook里,現在物理隔離。
Dashboard組件:兩條河怎么匯到一處
真正用起來的時候,開發者面對的其實是混合場景。看作者的App.tsx:
export function Dashboard() { const lastBeat = useSignalValue(heartbeat); const ms = useSignalValue(intervalMs); return (
Last heartbeat: {lastBeat?.toLocaleTimeString() ?? "—"}
Polling every {ms} ms
這里用了個叫useSignalValue的橋接Hook——信號的值被轉換成React能消費的state,但信號的訂閱關系還在數據層自己手里。
結果是:改intervalMs的時候,createEffect那邊自動重跑定時器,Dashboard組件只收到最新的ms值,不需要關心定時器的創建和銷毀。而Blinker組件里的光標,該閃還是閃,該停還是停,兩條線互不干擾。
作者特意強調了行為差異:Timer polling(createEffect)獨立于任何組件,頁面導航時繼續運行;UI blinking(useEffect)隨組件掛載/卸載創建和清理。
這個設計在解決什么真問題?
熟悉React歷史的人知道,useEffect的批評聲音從來沒停過。Dan Abramov自己寫過一篇《useEffect完整指南》,底下最高贊評論是"我還是不懂"。
核心矛盾在于:useEffect被迫同時干兩件事——同步外部系統(數據),和同步瀏覽器API(DOM)。這兩件事的時序要求完全不同,但API長得一模一樣,依賴數組的語義還隨場景變化。
![]()
Signals方案把第一層抽走了。數據相關的副作用跟著信號走,有獨立的創建-更新-銷毀生命周期;UI相關的副作用留在React里,跟著渲染周期走。兩邊都用onCleanup,但執行的時機由各自的運行時保證。
這不是什么理論潔癖。作者舉的實際場景是:一個輪詢心跳,一個光標閃爍。在生產環境里,這可能是WebSocket重連策略和加載動畫的關系,是后臺同步狀態和Toast提示的關系——以前寫在一起必然互相干擾,現在可以分開測試、分開優化。
社區對這個方案的反應很分裂。一部分人覺得終于不用在useEffect里寫一堆防御性代碼了,另一部分人擔心又多了一層概念負擔。但Star數的增長是真實的,4000多個開發者用實際行動投了票。
React官方的Signals實現還在RFC階段,具體語法變了好幾稿。社區方案的優勢是現在就可用,而且API設計明顯借鑒了Solid.js的成熟經驗——createEffect、onCleanup、signal.get()/set(),幾乎照搬。
風險也有。這個方案依賴React的訂閱機制做橋接,如果官方最終定的API差異太大,遷移成本不會小。但作者似乎不太在意,系列文章已經寫到第四篇,每一篇都在補全邊緣場景的處理。
一個值得注意的細節:作者的代碼里沒有任何"魔法"。signal、createEffect都是普通函數,沒有編譯時轉換,沒有Babel插件。這意味著你可以逐行調試,可以在瀏覽器控制臺里手動調heartbeat.set()看效果。
這種可觀測性在現在的前端生態里反而成了稀缺品。太多方案藏在編譯器后面,開發者遇到問題只能猜。
回到開頭那個問題:React團隊知道社區在這么干嗎?
知道。React核心成員Andrew Clark去年在Twitter上回復過類似方案,說"我們也在探索這個方向,但想確保和并發特性兼容"。翻譯一下:官方認可問題存在,但解法要保守。
保守有保守的道理。React的并發渲染(Concurrent Rendering)讓時機問題變得極其復雜,一個信號更新如果在渲染中途觸發,會不會導致死循環?會不會破壞時間切片?
社區方案目前的答案是:createEffect在微任務隊列里調度,故意不和React的渲染幀搶資源。這個 trade-off 犧牲了最低延遲,換來了安全性。夠不夠用,取決于你的場景。
作者沒說的是:這個方案已經在某個生產環境里跑了多久、撐住了多少流量。但代碼的完整度和測試覆蓋率暗示這不是玩具項目——有完整的TypeScript定義,有React適配層的邊界情況處理,甚至還有和Next.js App Router的兼容說明。
如果你現在就想試,作者提供了現成的模板。但更值得觀察的是這個模式的演化:Signals會不會成為React的標配?官方和社區方案最終是合并還是分叉?以及,有多少開發者愿意為了"更干凈的數據流"承擔額外的學習成本?
最后一個問題留給正在讀的你:在你的項目里,有多少useEffect其實是在管數據而不是管DOM?數清楚這個數字,可能比選哪個方案更重要。
特別聲明:以上內容(如有圖片或視頻亦包括在內)為自媒體平臺“網易號”用戶上傳并發布,本平臺僅提供信息存儲服務。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.