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