計(jì)模式:用狀態(tài)機(jī)分離上下文與模式,告別if-else)
1. 從“context-mode”這個(gè)詞說起它到底在解決什么問題第一次看到“context-mode”這個(gè)標(biāo)題很多人會(huì)下意識(shí)地把它當(dāng)成某個(gè)框架里的配置項(xiàng)或者某個(gè)庫的開關(guān)參數(shù)。但如果你真的在工程一線待過幾年就會(huì)發(fā)現(xiàn)這個(gè)詞背后藏著一個(gè)非常樸素、卻又極其容易被忽視的訴求同一段邏輯在不同上下文里需要表現(xiàn)出不同的行為而這件事如果處理不好代碼會(huì)迅速腐爛。我最早接觸類似概念是在做前端組件庫的時(shí)候。一個(gè)按鈕組件在表單里是提交按鈕在彈窗里是確認(rèn)按鈕在工具欄里是圖標(biāo)按鈕。如果每個(gè)場景都寫一個(gè)獨(dú)立組件維護(hù)成本會(huì)爆炸如果用一個(gè)組件加一堆if-else代碼又會(huì)變成意大利面條。后來我意識(shí)到真正的問題不在于組件本身而在于上下文沒有被顯式建模。context-mode 這個(gè)標(biāo)題本質(zhì)上就是在討論“如何把上下文從隱式狀態(tài)變成顯式模式”。這個(gè)主題適合誰看如果你正在維護(hù)一個(gè)中大型項(xiàng)目發(fā)現(xiàn)同一份邏輯被復(fù)制粘貼到多個(gè)地方或者你的代碼里充滿了if (isEditMode)、if (isPreview)、if (isMobile)這類判斷那這篇文章就是寫給你的。它不依賴特定語言或框架而是一種通用的架構(gòu)思路。我會(huì)從核心概念拆解開始然后給出可落地的實(shí)現(xiàn)方案最后分享幾個(gè)我在實(shí)際項(xiàng)目中踩過的坑。提示本文討論的 context-mode 是一種設(shè)計(jì)模式層面的概念不是某個(gè)具體庫的 API。不同技術(shù)棧下的實(shí)現(xiàn)方式會(huì)有差異但核心思想是相通的。2. 拆解 context-mode 的核心上下文與模式的分離2.1 什么是“上下文”什么是“模式”在軟件系統(tǒng)里上下文context指的是當(dāng)前操作所處的環(huán)境狀態(tài)。比如用戶當(dāng)前是在編輯還是查看、設(shè)備是桌面還是移動(dòng)、數(shù)據(jù)是草稿還是已發(fā)布、網(wǎng)絡(luò)是在線還是離線。這些狀態(tài)共同決定了“現(xiàn)在是什么情況”。而模式mode是對上下文的一種歸納和抽象。它不是簡單地把所有狀態(tài)變量堆在一起而是把那些會(huì)導(dǎo)致行為分叉的狀態(tài)組合提煉成有限的幾個(gè)離散值。比如把“編輯中 有未保存修改”歸納為dirty模式把“查看中 只讀”歸納為readonly模式。這兩者的關(guān)系可以用一句話概括上下文是原始數(shù)據(jù)模式是經(jīng)過語義壓縮后的決策依據(jù)。直接依賴上下文做判斷代碼會(huì)散落大量條件分支先歸約為模式再基于模式做決策邏輯會(huì)清晰得多。2.2 為什么不能直接用布爾標(biāo)志位很多人會(huì)問我用isEditMode、isPreviewMode這樣的布爾變量不也能解決問題嗎短期看可以但長期會(huì)出問題。原因有三點(diǎn)。第一布爾標(biāo)志位之間會(huì)產(chǎn)生非法組合。比如isEditMode true和isPreviewMode true同時(shí)為真這在業(yè)務(wù)上可能毫無意義但代碼里沒有任何機(jī)制阻止這種狀態(tài)出現(xiàn)。一旦出現(xiàn)行為就不可預(yù)測。第二標(biāo)志位數(shù)量會(huì)膨脹。每加一個(gè)場景就加一個(gè)布爾變量最后變成isEditMode !isPreviewMode isMobile !isOffline這種表達(dá)式可讀性極差而且很容易漏掉某個(gè)組合。第三標(biāo)志位無法攜帶附加信息。模式往往需要附帶數(shù)據(jù)比如editing模式下需要知道正在編輯哪個(gè)字段error模式下需要知道錯(cuò)誤碼。布爾變量做不到這一點(diǎn)只能再額外維護(hù)一堆變量。用枚舉或聯(lián)合類型來表示模式就能天然避免非法組合同時(shí)可以攜帶模式相關(guān)的數(shù)據(jù)。這是 context-mode 設(shè)計(jì)的第一條原則。2.3 模式切換的觸發(fā)條件應(yīng)該集中管理我見過很多項(xiàng)目模式切換的邏輯散落在各個(gè)事件處理函數(shù)里。點(diǎn)擊編輯按鈕時(shí)設(shè)置isEditMode true點(diǎn)擊取消時(shí)設(shè)置isEditMode false網(wǎng)絡(luò)斷開時(shí)又去改另一個(gè)標(biāo)志位。這種分散式切換的后果是你永遠(yuǎn)無法完整列出所有可能的狀態(tài)遷移路徑。正確的做法是把模式切換收斂到一個(gè)地方通常是一個(gè)狀態(tài)機(jī)或 reducer。所有觸發(fā)條件都通過派發(fā)事件的方式進(jìn)入由統(tǒng)一的邏輯決定下一個(gè)模式是什么。這樣做的好處是你可以畫出完整的狀態(tài)遷移圖測試也可以針對每條遷移路徑編寫用例。3. 落地實(shí)現(xiàn)從狀態(tài)機(jī)到代碼結(jié)構(gòu)3.1 用有限狀態(tài)機(jī)建模模式遷移有限狀態(tài)機(jī)FSM是實(shí)現(xiàn) context-mode 最自然的工具。它要求你明確定義有哪些狀態(tài)模式、哪些事件、以及每個(gè)狀態(tài)下收到每個(gè)事件后遷移到哪個(gè)狀態(tài)。以一個(gè)內(nèi)容編輯器為例模式可以定義為模式含義允許的操作idle空閑未開始編輯進(jìn)入編輯editing編輯中無未保存修改修改內(nèi)容、保存、取消dirty編輯中有未保存修改修改內(nèi)容、保存、取消需確認(rèn)saving保存請求進(jìn)行中無等待結(jié)果error保存失敗重試、取消事件包括START_EDIT、CHANGE、SAVE、SAVE_SUCCESS、SAVE_FAIL、CANCEL、CONFIRM_CANCEL。遷移規(guī)則用表格表示當(dāng)前模式事件下一個(gè)模式idleSTART_EDITeditingeditingCHANGEdirtydirtySAVEsavingsavingSAVE_SUCCESSidlesavingSAVE_FAILerrorerrorSAVEsavingerrorCANCELidledirtyCANCEL彈出確認(rèn)確認(rèn)后到 idle這張表一旦畫出來代碼實(shí)現(xiàn)就變得機(jī)械而可靠。任何不在表中的“當(dāng)前模式 事件”組合都視為非法操作直接忽略或拋出警告。3.2 代碼結(jié)構(gòu)把模式判斷從業(yè)務(wù)邏輯中抽離有了狀態(tài)機(jī)之后業(yè)務(wù)邏輯里就不應(yīng)該再出現(xiàn)if (isEditMode)這樣的判斷了。取而代之的是根據(jù)當(dāng)前模式選擇不同的處理策略。一種常見的做法是策略模式為每個(gè)模式定義一個(gè)處理對象對象里包含該模式下各個(gè)操作的具體實(shí)現(xiàn)。調(diào)用方只需要說“執(zhí)行保存操作”由當(dāng)前模式對應(yīng)的策略決定具體怎么做。const modeStrategies { idle: { save: () { /* 空閑狀態(tài)下保存無意義忽略 */ }, cancel: () { /* 無需取消 */ } }, editing: { save: () dispatch({ type: SAVE }), cancel: () dispatch({ type: CANCEL }) }, dirty: { save: () dispatch({ type: SAVE }), cancel: () showConfirmDialog() }, saving: { save: () { /* 正在保存忽略重復(fù)請求 */ }, cancel: () { /* 保存中不允許取消 */ } }, error: { save: () dispatch({ type: SAVE }), cancel: () dispatch({ type: CANCEL }) } }; function handleSave() { const strategy modeStrategies[currentMode]; strategy.save(); }這種寫法的好處是新增一個(gè)模式時(shí)只需要在策略表里加一項(xiàng)不需要去修改每個(gè)業(yè)務(wù)函數(shù)。開閉原則在這里體現(xiàn)得很自然。3.3 模式與 UI 的映射關(guān)系context-mode 不僅影響邏輯也影響界面。不同模式下按鈕的可用性、文案、甚至布局都可能不同。我建議把模式到 UI 的映射也集中管理而不是散落在各個(gè)組件的模板里。一個(gè)實(shí)用的做法是定義一個(gè)modeUIConfig對象const modeUIConfig { idle: { saveButton: { visible: false }, cancelButton: { visible: false }, editorReadonly: true }, editing: { saveButton: { visible: true, disabled: false, text: 保存 }, cancelButton: { visible: true, text: 取消 }, editorReadonly: false }, dirty: { saveButton: { visible: true, disabled: false, text: 保存 }, cancelButton: { visible: true, text: 放棄修改 }, editorReadonly: false }, saving: { saveButton: { visible: true, disabled: true, text: 保存中... }, cancelButton: { visible: false }, editorReadonly: true }, error: { saveButton: { visible: true, disabled: false, text: 重試 }, cancelButton: { visible: true, text: 取消 }, editorReadonly: false } };組件里只需要讀取當(dāng)前模式對應(yīng)的配置不需要寫任何條件判斷。這樣 UI 和邏輯都圍繞同一個(gè)模式源不會(huì)出現(xiàn)“邏輯認(rèn)為在編輯中但按鈕顯示的是保存中”這種不一致。4. 實(shí)際項(xiàng)目中的坑與應(yīng)對策略4.1 模式爆炸什么時(shí)候該合并模式我做過一個(gè)后臺(tái)管理系統(tǒng)最初定義了十幾種模式結(jié)果狀態(tài)遷移圖復(fù)雜到?jīng)]人能看懂。后來復(fù)盤發(fā)現(xiàn)很多模式之間的差異其實(shí)很小完全可以合并。判斷兩個(gè)模式是否該合并我的經(jīng)驗(yàn)是看三點(diǎn)它們的允許操作集合是否幾乎相同、它們的 UI 表現(xiàn)是否幾乎相同、它們之間的遷移是否總是成對出現(xiàn)。如果三點(diǎn)都滿足就應(yīng)該合并成一個(gè)模式把差異部分用模式附帶的數(shù)據(jù)來表達(dá)。比如editing和dirty在操作集合上只差一個(gè)“取消時(shí)是否需要確認(rèn)”這完全可以用一個(gè)hasUnsavedChanges字段來表達(dá)而不必拆成兩個(gè)模式。模式數(shù)量控制在 5 到 7 個(gè)以內(nèi)是大多數(shù)場景下比較舒服的范圍。4.2 異步操作與模式競爭異步操作是 context-mode 最容易出問題的地方。典型場景是用戶點(diǎn)擊保存進(jìn)入saving模式請求發(fā)出后用戶又點(diǎn)了取消。如果取消邏輯沒有檢查當(dāng)前模式就可能出現(xiàn)“保存請求還在飛界面已經(jīng)回到 idle”的競態(tài)。我的處理原則是異步操作發(fā)起前記錄當(dāng)前模式回調(diào)返回時(shí)先校驗(yàn)?zāi)J绞欠袢匀皇前l(fā)起時(shí)的模式。如果不是說明期間發(fā)生了其他遷移這個(gè)回調(diào)的結(jié)果應(yīng)該被丟棄或走補(bǔ)償邏輯。async function save() { const modeAtRequest currentMode; dispatch({ type: SAVE }); try { await api.save(data); if (currentMode ! saving) return; // 模式已變丟棄結(jié)果 dispatch({ type: SAVE_SUCCESS }); } catch (e) { if (currentMode ! saving) return; dispatch({ type: SAVE_FAIL, error: e }); } }這個(gè)校驗(yàn)看起來簡單但能避免大量詭異 bug。我在沒有加這個(gè)校驗(yàn)之前遇到過“保存失敗提示彈了兩次”“取消后仍然跳轉(zhuǎn)到成功頁”這類問題排查起來非常費(fèi)勁。4.3 模式持久化與恢復(fù)有些場景下模式需要跨會(huì)話保持。比如用戶編輯到一半刷新頁面重新打開時(shí)應(yīng)該恢復(fù)到dirty模式而不是回到idle。這時(shí)候就需要把模式連同上下文數(shù)據(jù)一起持久化。但要注意不是所有模式都適合持久化。像saving這種瞬時(shí)模式持久化后恢復(fù)是沒有意義的因?yàn)檎埱笤缇蛿嗔恕N业淖龇ㄊ侵怀志没胺€(wěn)定模式”即那些可以長時(shí)間停留的模式比如editing、dirty、error。恢復(fù)時(shí)如果發(fā)現(xiàn)持久化的模式是瞬時(shí)模式就降級(jí)到它最接近的穩(wěn)定模式。另外持久化的數(shù)據(jù)要帶版本號(hào)。模式定義可能會(huì)隨版本迭代變化舊版本的數(shù)據(jù)在新版本里可能無法直接映射。帶版本號(hào)可以在恢復(fù)時(shí)做遷移或直接丟棄。5. 測試與可觀測性讓模式行為可驗(yàn)證5.1 針對狀態(tài)遷移編寫測試用例context-mode 的一大優(yōu)勢是可測試性。因?yàn)槟J竭w移是顯式定義的你可以直接針對遷移表編寫測試而不需要去模擬復(fù)雜的 UI 交互。我通常會(huì)用參數(shù)化測試的方式把遷移表里的每一行變成一個(gè)測試用例給定初始模式和事件斷言下一個(gè)模式是否符合預(yù)期。這種測試寫起來快覆蓋率高而且當(dāng)遷移規(guī)則變更時(shí)測試會(huì)立刻告訴你哪些行為被影響了。const transitions [ [idle, START_EDIT, editing], [editing, CHANGE, dirty], [dirty, SAVE, saving], [saving, SAVE_SUCCESS, idle], [saving, SAVE_FAIL, error], [error, SAVE, saving], [error, CANCEL, idle] ]; transitions.forEach(([from, event, expected]) { test(${from} ${event} - ${expected}, () { const next transition(from, event); expect(next).toBe(expected); }); });除了遷移測試還要針對每個(gè)模式下的操作策略編寫測試。比如在saving模式下調(diào)用保存應(yīng)該被忽略在dirty模式下調(diào)用取消應(yīng)該彈出確認(rèn)。這些測試能保證策略表被正確實(shí)現(xiàn)。5.2 在日志中記錄模式變化線上排查問題時(shí)模式變化日志往往是最有價(jià)值的線索。我建議在每次模式遷移時(shí)打一條結(jié)構(gòu)化日志包含時(shí)間戳、舊模式、新模式、觸發(fā)事件、以及關(guān)鍵上下文數(shù)據(jù)。這條日志在排查“用戶說點(diǎn)了保存但沒反應(yīng)”這類問題時(shí)特別有用。你可以直接看到用戶操作時(shí)系統(tǒng)處于什么模式事件是否被正確處理遷移是否發(fā)生。如果沒有這條日志你只能靠猜。注意日志里不要記錄敏感數(shù)據(jù)比如用戶輸入的內(nèi)容。只記錄模式名和事件名就夠了。5.3 用模式覆蓋率衡量測試完整性一個(gè)實(shí)用的指標(biāo)是“模式-事件覆蓋率”遷移表里定義了多少種合法的“模式 事件”組合測試?yán)锔采w了多少種。理想情況下應(yīng)該達(dá)到 100%。對于非法組合也應(yīng)該有測試驗(yàn)證它們被正確忽略或拒絕。這個(gè)指標(biāo)可以加到 CI 里作為質(zhì)量門禁。當(dāng)有人新增了一個(gè)模式或事件但沒有補(bǔ)充對應(yīng)測試時(shí)覆蓋率會(huì)下降CI 就會(huì)提醒。這比靠代碼審查去發(fā)現(xiàn)遺漏要可靠得多。6. 一些延伸思考與個(gè)人體會(huì)context-mode 這個(gè)思路不僅適用于前端 UI在后端服務(wù)、工作流引擎、甚至游戲開發(fā)里都能用。核心思想是一致的把隱式的上下文判斷轉(zhuǎn)化為顯式的模式建模再基于模式做決策。我在實(shí)際項(xiàng)目里最大的體會(huì)是引入 context-mode 之后代碼審查變得容易了。以前審查者要在大腦里模擬各種狀態(tài)組合現(xiàn)在只需要對照遷移表和策略表看實(shí)現(xiàn)是否一致。新人上手也快因?yàn)槟J蕉x本身就是最好的文檔。另一個(gè)體會(huì)是不要一開始就追求完美的模式劃分。我通常的做法是先按最粗的粒度劃分兩三個(gè)模式跑起來之后當(dāng)發(fā)現(xiàn)某個(gè)模式內(nèi)部條件分支過多時(shí)再考慮拆分。模式設(shè)計(jì)是一個(gè)迭代過程過早細(xì)化反而會(huì)增加理解成本。最后分享一個(gè)小技巧在開發(fā)階段可以加一個(gè)調(diào)試面板實(shí)時(shí)顯示當(dāng)前模式和最近幾次遷移記錄。這個(gè)面板在聯(lián)調(diào)和演示時(shí)特別有用能讓非技術(shù)人員也直觀理解系統(tǒng)當(dāng)前處于什么狀態(tài)。我做過幾個(gè)項(xiàng)目產(chǎn)品經(jīng)理看到這個(gè)面板后提的需求都變得更具體了因?yàn)樗麄兘K于能看懂狀態(tài)是怎么流轉(zhuǎn)的。