怎么切換:手寫實現(xiàn)狀態(tài)管理避開90%的坑)
雙系統(tǒng)怎么切換:手寫實現(xiàn)狀態(tài)管理避開90%的坑
看了一堆教程還是不會寫項目?別怪教程,是你沒動手手寫實現(xiàn)過核心邏輯。
很多開發(fā)者在面試或接手老項目時,遇到“雙系統(tǒng)怎么切換”的需求,第一反應是找現(xiàn)成的庫。結(jié)果呢?庫版本不兼容、狀態(tài)不同步、內(nèi)存泄漏,改了半天還是崩。
其實,雙系統(tǒng)切換的本質(zhì)就是狀態(tài)同步與上下文隔離。今天我不講虛的,直接帶你從底層原理出發(fā),手寫一個極簡但健壯的雙系統(tǒng)切換器??赐赀@篇,你再也不會被 undefined 和 stale closure 折磨。
坑的現(xiàn)象:為什么你的切換總是“半截子”
在實際開發(fā)中,雙系統(tǒng)(比如:傳統(tǒng)后端 API + 微服務(wù)網(wǎng)關(guān),或者:React 18 + Vue 3 混合架構(gòu),甚至只是同一個頁面里的兩套渲染引擎)切換時,最常見的翻車現(xiàn)場有三個:狀態(tài)丟失:用戶點擊切換,頁面白屏或數(shù)據(jù)重置。
內(nèi)存泄漏:切換多次后,瀏覽器內(nèi)存飆升,舊系統(tǒng)的定時器、事件監(jiān)聽器沒解綁。
競態(tài)條件:A系統(tǒng)請求還沒回來,用戶切到了B系統(tǒng),結(jié)果A的響應覆蓋了B的狀態(tài)。我見過最離譜的案例,是一個電商中臺項目,為了兼容舊版小程序和新版Web,搞了個“雙通道”。每次用戶從舊版跳轉(zhuǎn)新版,購物車數(shù)據(jù)就丟。排查了一周,發(fā)現(xiàn)是因為舊版用的是 localStorage,新版用的是 Pinia,兩者沒有做序列化對齊,且切換時沒有等待舊系統(tǒng)清理完畢。
這就是典型的異步時序錯誤。你以為切換是瞬時的,但在計算機眼里,它是分步驟的:卸載舊組件 - 清理資源 - 初始化新組件 - 掛載新組件。任何一個環(huán)節(jié)卡住,整個流程就斷了。
根本原因:你忽略了“生命周期”的邊界
很多初學者喜歡用全局變量或者 window 對象來共享狀態(tài)。比如:
window.currentUser = { id: 1, name: 'Alice' };
// 切換系統(tǒng)時
window.currentUser = null; // 以為這樣就清干凈了?大錯特錯。
根本原因在于,你沒有明確誰負責清理,以及清理的時機。在雙系統(tǒng)架構(gòu)中,系統(tǒng)A和系統(tǒng)B是獨立的“生命體”。它們有自己的初始化邏輯(Init),也有自己的銷毀邏輯(Destroy)。
如果你只是簡單地替換 DOM 或者重新路由,而沒有觸發(fā)系統(tǒng)A的 Destroy 鉤子,那么系統(tǒng)A持有的引用(比如 WebSocket 連接、Redux Store、React Context Provider)依然活在內(nèi)存里。
更深層的原因是引用計數(shù)和閉包陷阱。當系統(tǒng)A的一個組件里有個 setInterval,而系統(tǒng)B掛載時,如果系統(tǒng)A的組件樹沒有完全卸載,這個定時器就會繼續(xù)跑。如果這個定時器里引用了系統(tǒng)B的狀態(tài),恭喜你,你制造了一個跨系統(tǒng)的“幽靈數(shù)據(jù)”。
MDN Web Docs 中對 EventTarget 和 removeEventListener 的描述很明確:事件監(jiān)聽器必須被顯式移除,否則會導致內(nèi)存泄漏。但在雙系統(tǒng)切換中,我們不僅要移除事件,還要移除數(shù)據(jù)訂閱。
正確寫法對比:手寫實現(xiàn)的狀態(tài)機
別急著上庫,先手寫一個最簡版本。假設(shè)我們有 SystemA 和 SystemB,我們需要一個 Switcher 來管理它們的切換。
錯誤寫法:粗暴替換
// ? 錯誤示范:沒有清理機制
let currentSystem = 'A';function switchSystem(target) {if (currentSystem === target) return;// 直接切換,舊系統(tǒng)的資源還在內(nèi)存里if (target === 'B') {document.getElementById('app').innerHTML = SystemB.render();currentSystem = 'B';} else {document.getElementById('app').innerHTML = SystemA.render();currentSystem = 'A';}
}這段代碼的問題顯而易見:innerHTML 替換不會觸發(fā) React/Vue 的卸載鉤子。
如果 SystemA.render() 內(nèi)部有 useEffect 或 onMounted,它們的清理函數(shù)(cleanup)永遠不會被調(diào)用。
沒有防抖,快速點擊切換會導致狀態(tài)混亂。正確寫法:帶生命周期的狀態(tài)機
我們要實現(xiàn)的核心邏輯是:切換前必須清理舊系統(tǒng),切換后必須初始化新系統(tǒng),且整個過程是原子的(不可中斷的)。
// ? 正確示范:手寫實現(xiàn)的雙系統(tǒng)切換器
class DualSystemSwitcher {constructor() {this.currentSystem = null;this.isSwitching = false;this.systems = {A: { init: this.initSystemA, destroy: this.destroySystemA },B: { init: this.initSystemB, destroy: this.destroySystemB },};}async switchTo(target) {// 1. 防止并發(fā)切換if (this.isSwitching || this.currentSystem === target) return;this.isSwitching = true;try {// 2. 銷毀當前系統(tǒng)(關(guān)鍵步驟)if (this.currentSystem) {await this.systems[this.currentSystem].destroy();}// 3. 短暫延遲,確保 DOM 清理完成(可選,視情況而定)await this.nextFrame();// 4. 初始化目標系統(tǒng)this.currentSystem = target;await this.systems[target].init();} catch (error) {console.error('System switch failed:', error);// 回滾邏輯:如果初始化失敗,嘗試恢復上一個狀態(tài)if (this.currentSystem) {await this.systems[this.currentSystem].destroy();this.currentSystem = null;}throw error;} finally {this.isSwitching = false;}}// 模擬系統(tǒng)A的初始化與銷毀initSystemA() {console.log('System A Initializing...');// 這里掛載 React/Vue 實例,或加載舊版邏輯// 注冊全局事件監(jiān)聽window.addEventListener('resize', this.handleResizeA);return Promise.resolve();}destroySystemA() {console.log('System A Destroying...');// 關(guān)鍵:移除事件監(jiān)聽window.removeEventListener('resize', this.handleResizeA);// 清除定時器// clearInterval(this.timerA);return Promise.resolve();}initSystemB() {console.log('System B Initializing...');// 掛載新版邏輯return Promise.resolve();}destroySystemB() {console.log('System B Destroying...');// 清除新版邏輯的資源return Promise.resolve();}handleResizeA = () = {console.log('System A handle resize');};// 利用 requestAnimationFrame 確保在下一幀執(zhí)行nextFrame() {return new Promise(resolve = requestAnimationFrame(resolve));}
}逐行講解關(guān)鍵點:isSwitching 標志位:這是防抖的核心。在異步操作完成前,禁止再次切換。這解決了快速點擊導致的競態(tài)問題。
destroy 是 async 的:很多清理操作(如網(wǎng)絡(luò)請求取消、DOM 動畫結(jié)束)是異步的。必須 await 它們,確保資源真正釋放。
nextFrame:這是一個小技巧。在銷毀舊 DOM 和創(chuàng)建新 DOM 之間插入一幀,可以避免瀏覽器重排(Reflow)帶來的性能抖動,特別是在切換重型組件時。
回滾機制:在 catch 塊中,如果新系統(tǒng)初始化失敗,我們嘗試銷毀當前(可能是半初始化狀態(tài))的系統(tǒng),并將狀態(tài)置空。這比直接拋錯更友好,用戶至少不會看到白屏,而是可以重試。復現(xiàn)與修復代碼:處理“幽靈定時器”
上面是框架級的切換,但真正的坑往往藏在業(yè)務(wù)代碼里。比如,系統(tǒng)A有一個輪詢接口,每5秒請求一次訂單狀態(tài)。
錯誤場景:
用戶在系統(tǒng)A中,訂單輪詢正在運行。用戶切換到系統(tǒng)B。系統(tǒng)A的 destroy 被調(diào)用了,但是 clearInterval 寫在了組件的 useEffect 清理函數(shù)里,而由于某種原因(比如路由跳轉(zhuǎn)太快),useEffect 的清理函數(shù)沒被執(zhí)行。
修復代碼:
我們要把定時器的 ID 提升到 Switcher 或一個獨立的 ResourceRegistry 中,而不是散落在各個組件里。
class ResourceRegistry {constructor() {this.timers = new Set();this.listeners = new Map(); // key: event, value: Set of {target, handler}}addTimer(id) {this.timers.add(id);}clearAllTimers() {this.timers.forEach(id = clearInterval(id));this.timers.clear();}addListener(target, type, handler) {if (!this.listeners.has(type)) {this.listeners.set(type, new Set());}const set = this.listeners.get(type);// 使用弱引用或者手動管理,這里簡化處理set.add({ target, handler });target.addEventListener(type, handler);}removeAllListeners() {this.listeners.forEach((set, type) = {set.forEach(({ target, handler }) = {target.removeEventListener(type, handler);});set.clear();});this.listeners.clear();}destroy() {this.clearAllTimers();this.removeAllListeners();}
}// 在 DualSystemSwitcher 中集成
class DualSystemSwitcher {constructor() {// ... 其他屬性this.registry = new ResourceRegistry();}async destroySystemA() {console.log('System A Destroying...');// 關(guān)鍵:調(diào)用 registry 清理所有注冊的資源this.registry.destroy();return Promise.resolve();}// 假設(shè) SystemA 的組件內(nèi)部使用 registry// useEffect(() = {// const id = setInterval(() = { /* poll */ }, 5000);// switcher.registry.addTimer(id);// return () = {// // 組件卸載時,雖然清理了,但 Switcher 的 destroy 會兜底// };// }, []);
}通過 ResourceRegistry,我們確保了即使組件內(nèi)部的清理邏輯失效,Switcher 的 destroy 方法也能兜底清理所有已知的定時器和監(jiān)聽器。這是一種“防御性編程”的思路。
規(guī)避建議:從架構(gòu)層面減少切換成本
手寫實現(xiàn)是為了理解原理,但在生產(chǎn)環(huán)境中,你需要考慮以下建議:微前端隔離:如果使用 Qiankun 或 Micro-App,利用它們的 unmount 生命周期。確保你的業(yè)務(wù)代碼嚴格遵守 mount 和 unmount 的對稱性。
狀態(tài)持久化:雙系統(tǒng)切換時,用戶數(shù)據(jù)(如登錄態(tài)、購物車)不應依賴內(nèi)存。使用 IndexedDB 或后端 Session 進行持久化。切換時,從持久層讀取狀態(tài),而不是依賴前一個系統(tǒng)的內(nèi)存狀態(tài)。
懶加載與預加載:不要等到用戶點擊切換時才加載系統(tǒng)B的資源??梢栽谙到y(tǒng)A空閑時,預加載系統(tǒng)B的 JS 和 CSS。這樣切換時的“初始化”階段會變快,用戶體驗更平滑。
日志埋點:在 switchTo 的開始、destroy 的完成、init 的完成處打點。監(jiān)控切換耗時。如果 destroy 耗時超過 50ms,說明你的清理邏輯太重,需要優(yōu)化。最后,再強調(diào)一點:
雙系統(tǒng)切換不是一個“功能”,而是一個基礎(chǔ)設(shè)施。它像數(shù)據(jù)庫的事務(wù)一樣,需要 ACID 特性(原子性、一致性、隔離性、持久性)。如果你的切換邏輯不能保證“要么完全切過去,要么完全沒切”,那你就是在制造 Bug。
手寫一遍,比看十篇博客都管用。
你公司項目里是怎么處理雙系統(tǒng)切換的?是用微前端框架,還是自己封裝的?有沒有遇到過“切過去數(shù)據(jù)丟了”或者“內(nèi)存泄漏”的情況?歡迎在評論區(qū)聊聊你的踩坑經(jīng)歷,我們一起避坑。