降低前端业务复杂度新视角:状态机范式
無論做業務需求還是做平臺需求的同學,隨著需求的不斷迭代,通常都會出現邏輯復雜、狀態混亂的現象,維護和新增功能的成本也變的十分巨大,苦不堪言。下圖用需求、業務代碼、測試代碼做對比:
圖中分了 3 個階段:
- 階段 1:正常,都是線性增長。
- 階段 2:需求數正常增長,業務代碼行數開始增長,測試代碼行數大幅度增長。
- 階段 3:業務代碼行數開始大幅增長,測試代碼行數劇增(超出屏幕),而需求數開始下降。
這可以很好的表達出,從業務最開始,到長期迭代后,復雜度提升帶來的問題。做一個相同的需求,最開始可能 1 天就可以搞定,但長期迭代后,可能要 3 天,甚至更多,這并不是開發人員主觀上導致的,而是代碼狀態的維護成本太高,做到最后經常會出現牽一發而動全身。也側面抑制了業務的迭代速度。
所以對于長期迭代的產品,切記不要簡單做,否則都是給后面挖的坑。
當然,看問題還是要去看本質。
根據復雜度守恒定律(泰斯勒定律),每個應用程序都具有其內在的、無法簡化的復雜度。這一固有的復雜度都無法依照我們的意愿去除,只能設法調整、平衡。而現在前端的復雜度拆分主要包括:框架、通用組件、業務組件和業務邏輯,如下圖所示:
上圖中可以看到,當把框架和通用組件建設完成后,能夠承擔的復雜度基本穩定了,未來無輪再怎么改善或者更換其他框架,也很難再去突破天花板,對業務的復雜度的改變也微乎其微了(如果你的業務經歷過底層框架更換,你就能體會到它到底對你的業務復雜度有沒有帶來變化了)。
我們就要去思考,到底哪里還能把復雜度給降下來。換個角度,是不是可以從業務共有的 “業務邏輯” 側去進行突破?
目前發現的,做業務側提效的方案中,很少有從 “業務邏輯” 視角為出發點去做的,更多的是聚焦在場景化上的提效。
把視角聚焦到 “業務邏輯” 側,這里就要看所有業務中都會面臨的問題,是什么讓業務復雜度提升上去了。這里主要存在兩點,如下:
- 代碼層面
- 各種各樣的業務狀態導致的 flag 變量的劇增:即便是自己,寫多了這種變量,也很難清楚的知道每個 flag 是干什么用的。
- 各種判斷業務狀態的 if/else:if/else 嵌套地獄估計在很多大型的業務產品中都能看到吧。還有內部的各種邏輯判斷,如 isA && isB || !(isC || !isD && isE),完全看不懂,即便問 PD,時間久了她也不知道了。還有因此可能導致一些意識不到的 Bug。
- 協作層面
- 做業務的同學很難有全局業務視角,所以面對 PD 的需求很難有話語權。如果需求設計不合理,只能等到你做完了,在 UAT 的階段才能發現,然后 PD 會給你提一個新需求,讓你再去修正(雖然是 PD 的問題,但缺乏避免 PD 犯錯的途徑)。
- 測試同學,測試的內容范圍,多數情況下,取決于前端同學給定的測試范圍。而很多時候代碼的改動,前端也不確定到底哪些頁面會受影響。所以要么導致測試同學測試不完整,要么導致測試同學需要全量回歸,這可是非常巨大的測試成本。
- 當其他前端開發人員,參與到項目中時,面臨這種復雜的項目也是頭大,需要花費很大的成本梳理清楚業務與代碼的關聯。導致合作或者交接項目時,困難。
我們需要通過發現的這些問題,來尋找合適的解決方案。
1. 解決代碼層面的問題
代碼層面的問題,主要來源于 flag 變量過多,及 if/else 的嵌套及大量分支,導致難以修改和擴展,任何改動和變化都是致命的。其實這類問題,在設計模式中是有合適的方案——狀態模式。
1.1. 狀態模式
狀態模式主要解決的是,當控制一個對象狀態轉換的條件表達式過于復雜時的情況。把狀態的判斷邏輯轉移到表示不同狀態的一系列類當中,減少相互間的依賴,可以把復雜的判斷邏輯簡化。
狀態模式是一種行為模式,在不同的狀態下有不同的行為,它將狀態和行為解耦。
從類圖中可以看到,狀態模式是多態特性和面向接口的完美體現,State 是一個接口,表示狀態的抽象,ConcreteStateA 和 ConcreteStateB 是具體的狀態實現類,表示兩種狀態的行為,Context 的 request() 方法將會根據狀態的變更從而調用不同 State 接口實現類的具體行為方法。
狀態模式的好處是,將與特定狀態相關的行為局部化,并且將不同狀態的行為分割開來。這樣這些對象就可以不依賴于其他對象而獨立變化了,未來增加或修改狀態流程,就不是困難的事了。
當一個對象的行為取決于它的狀態,并且它必須在運行時刻根據狀態改變它的行為時,就可以考慮使用狀態模式了。
1.2. 狀態機
狀態機,全稱有限狀態機(finite-state machine,縮寫:FSM),又稱有限狀態自動機(finite-state automaton,縮寫:FSA),是現實事物運行規則抽象而成的一個數學模型,并不是指一臺實際機器。狀態機是圖靈機的一個子集。它是一種認知論。從某種角度來說,我們的現實世界就是一個有限狀態機。
有限狀態自動機在很多不同領域中是重要的,包括電子工程、語言學、計算機科學、哲學、生物學、數學和邏輯學。有限狀態機是在自動機理論和計算理論中研究的一類自動機。在計算機科學中,有限狀態機被廣泛用于建模應用行為、硬件電路系統設計、軟件工程,編譯器、網絡協議、和計算與語言的研究。它是非常成熟的一套方法論。
有限狀態機包含五個重要部分:
- 初始狀態值 (initial state)
- 有限的一組狀態 (states)
- 有限的一組事件 (events)
- 由事件驅動的一組狀態轉移關系 (transitions)
- 有限的一組最終狀態 (final states)
更簡潔的總結,就三個部分:
- 狀態 State
- 事件 Event
- 轉換 Transition
同一時刻,只可能存在一個狀態。例如,人有 “睡著” 和 “醒著” 兩個狀態,同一時刻,要么 “睡著” 要么 “醒著”,不可能存在 “半睡半醒” 的狀態。
邏輯學中說,現實生活中描述的事物都可以抽象為命題。命題本質上就是狀態機的 State,Event 就是命題的條件,通過命題和條件推導過程。而 Transition 就是命題推導完成的結論。
所以當我們拿到需求的時候,首先要分離出哪些是已知的命題(State),哪些是條件(Event),哪些是結論(Transition)。而我們要通過這些已知命題和條件,推導出結論的過程。
1.2.1. 拿我們經常用到的 Fetch API 來舉例子
fetch(url).then().catch()有限的一組狀態:
初始狀態:
有限的一組最終狀態:
有限的一組事件:
- Idle 狀態只處理 FETCH 事件
- Pending 狀態只處理 RESOLVE 和 REJECT 事件
由事件驅動的一組狀態轉移關系:
1.3. 狀態機 VS 傳統編碼 示例
下面采用一個小需求來對比一下區別。
1.3.1. 需求描述
根據輸入的關鍵字進行搜索,并將搜索結果顯示出來。如下圖所示:
1.3.2. 基于傳統編碼
根據關鍵字拿到請求結果,再將結果塞回去就行了,代碼如下:
function onSearch(keyword) {fetch(SEARCH_URL + "?keyword=" + keyword).then((data) => {this.setState({ data });}); }看似幾行代碼就把這個需求搞定了,但其實還有一些其他問題要處理。如果接口響應比較慢,則需要給一個用戶預期的交互,如 Loading 效果:
function onSearch(keyword) {this.setState({isLoading: true,});fetch(SEARCH_URL + "?keyword=" + keyword).then((data) => {this.setState({ data, isLoading: false });}); }還會發生出請求出錯的情況:
function onSearch(keyword) {this.setState({isLoading: true,});fetch(SEARCH_URL + "?keyword=" + keyword).then((data) => {this.setState({ data, isLoading: false });}).catch((e) => {this.setState({isError: true,});}); }當然,不能忘記把 Loading 關掉:
function onSearch(keyword) {this.setState({isLoading: true,});fetch(SEARCH_URL + "?keyword=" + keyword).then((data) => {this.setState({ data, isLoading: false });}).catch((e) => {this.setState({isError: true,isLoading: false,});}); }我們每次搜索時,還需要把錯誤清除:
function onSearch(keyword) {this.setState({isLoading: true,isError: false,});fetch(SEARCH_URL + "?keyword=" + keyword).then((data) => {this.setState({ data, isLoading: false });}).catch((e) => {this.setState({isError: true,isLoading: false,});}); }這就結束了么,是不是我們把所有的 Bug 都考慮進去了?并沒有。當用戶在等待搜素請求的時候,不應該再去搜索,所以搜索結果返回前,禁止再次發送請求:
function onSearch(keyword) {if (this.state.isLoading) {return;}this.setState({isLoading: true,isError: false,});fetch(SEARCH_URL + "?keyword=" + keyword).then((data) => {this.setState({ data, isLoading: false });}).catch((e) => {this.setState({isError: true,isLoading: false,});}); }可以看到,應用的復雜度在不斷變大,可能你經歷的場景比這個小示例還要復雜的多的多。如果因為搜索接口特別慢,用戶希望有一個中斷搜索的功能,那么新的需求又來了:
function onSearch(keyword) {if (this.state.isLoading) {return;}this.fetchAbort = new AbortController();this.setState({isLoading: true,isError: false,});fetch(SEARCH_URL + "?keyword=" + keyword, {signal: this.fetchAbort.signal,}).then((data) => {this.setState({ data, isLoading: false });}).catch((e) => {this.setState({isError: true,isLoading: false,});}); }function onCancel() {this.fetchAbort.abort(); }不能落下對 catch 的特殊處理,因為中斷請求會觸發 catch:
function onSearch(keyword) {if (this.state.isLoading) {return;}this.fetchAbort = new AbortController();this.setState({isLoading: true,isError: false,});fetch(SEARCH_URL + "?keyword=" + keyword, {signal: this.fetchAbort.signal,}).then((data) => {this.setState({ data, isLoading: false });}).catch((e) => {if (e.name == "AbortError") {this.setState({isLoading: false,});} else {this.setState({isError: true,isLoading: false,});}}); }function onCancel() {this.fetchAbort.abort(); }最后還要處理沒有值的情況:
function onSearch(keyword) {if (this.state.isLoading) {return;}this.fetchAbort = new AbortController();this.setState({isLoading: true,isError: false,});fetch(SEARCH_URL + "?keyword=" + keyword, {signal: this.fetchAbort.signal,}).then((data) => {this.setState({ data, isLoading: false });}).catch((e) => {if (e && e.name == "AbortError") {this.setState({isLoading: false,});} else {this.setState({isError: true,isLoading: false,});}}); }function onCancel() {if (this.fetchAbort.abort &&typeof this.fetchAbort.abort == "function") {this.fetchAbort.abort();} }僅僅這么簡單的一個小需求,從開始幾行代碼就可以完成,到最終判斷各種邊界完成的代碼,對比一下,如下圖所示:
可以看到,這種包含各種 flag 變量和嵌套著各種 if/else 的代碼,會越來越難維護,所有的邏輯只存在于你的腦子里。當你寫測試的時候必須從頭再梳理一遍代碼邏輯,才能寫出來。
由于業務的高頻變化,很多業務開發人員是不寫單元測試的,因為成本太高太高,這也導致了交接代碼時,別人去理解你的代碼是一件很困難的事。寫久了,你自己都可能讀不懂代碼里面的邏輯了。
上面的編碼方式其實有專業術語的,叫“Bottom-up Approach”。這種方式 從解決問題的最基本層面開始,然后逐步擴展解決方案的多個部分。
這樣會導致:
- 難以測試
- 難以閱讀
- 可能含有隱藏的 Bug
- 難以擴展
- 新功能增加時還會使邏輯進一步混亂
1.3.3. 基于狀態機
看一下我們用狀態機的做法。記住流程:梳理出有哪些狀態,每個狀態有哪些事件,經歷了這些事件又會轉換到什么狀態。
下面是用 XState 狀態機工具的 JSON 描述:
{initial: "空閑",states: {空閑:{on:{搜索: '搜索中'}},搜索中:{on:{搜索成功: '成功',搜索失敗: '失敗',取消: '空閑'}},成功:{on:{搜索: '搜索中'}},失敗:{on:{搜索: '搜索中'}}}, }沒錯,就這幾行代碼就描述清楚所有的關系了。并且,可以把它可視化出來,如下圖所示:
可以看到狀態之間表達的非常清晰,結合到 View 中,也不需要再去編寫復雜的 flag 及 if/else 了,View 中只需要知道當前是什么狀態,已及將事件發送到狀態機就可以了,其他什么都不需要做。在新增或者修改需求的情況下,只需要對狀態進行新增或者編排就可以了。
而且可視化后,有以下變化:
- 清晰的看到有哪些狀態
- 清晰的看到每個狀態可以接受哪些事件
- 清晰的看到接受到事件后會轉移到什么狀態
- 清晰的看到到達某個狀態的路徑是怎么樣的
2. 解決協作的問題
另一個很大的問題是解決協作問題,主要包括:
- 與測試開人員的協作溝通
- 與 PD 人員的協作溝通
- 與其他前端開發人員的協作溝通
- 與用戶的協作溝通
這里就需要引用一個可視化的概念了。
可視化,是利用人眼的感知能力對數據進行交互的可視表達以增強認知的技術 。
所以很大程度上,可視化可以解決一大部分協作問題。當然,必須要確定把什么進行可視化才是有意義的。
要想可視化,狀態工具就需要具備可序列化的能力。這也是 Redux 之類的狀態管理工具缺乏的,主要有以下幾方面問題:
- 不具備可視化的能力
- 狀態和數據混在一起
- 所有的狀態都是平級的,無法描述狀態之間的關系
2.1. 狀態圖
回到狀態機。你單純用狀態機去寫代碼,需求數量上去了,狀態多了,會面臨 “狀態爆炸” 問題,依然很難維護,且閱讀成本巨大。
當然,這個場景其實很早之前就有人考慮到了,1987 年,Harel 就發表論文,解決復雜狀態機可視化的問題,在狀態機的基礎上進一步增強,提出狀態圖的概念。隨后,由微軟、IBM、惠普等多家公司,從 2005 到 2015 年花了 10 年時間制定了規范,并推出了 W3C 的 State Chart XML (SCXML) 規范,至此基本穩定,各家編程語言也基于此規范進行了狀態圖的封裝。
看一下,狀態機、狀態圖和手寫代碼復雜度的對比,如下圖所示:
從圖中可以看到:
- 傳統編碼方式,隨著狀態和邏輯的增加,復雜度是線性增長的。
- 使用狀態機,前期復雜度很底,但隨著狀態的增多,“狀態爆炸”現象的出現,復雜度也急劇增長。
- 使用狀態圖,雖然前期成本略高,但后期的狀態和邏輯的增長,基本不太會影響它的復雜度。
前面給狀態機畫的圖,就是狀態圖。
狀態圖大概長這樣,如下圖所示:
主要包括:
- 狀態
- 原子狀態
- 復合狀態
- 條件狀態
- 最終狀態
- 歷史狀態
- 初始狀態
- 并行狀態
- 偽/瞬間狀態
- 轉換
- 自動轉換
- 延遲轉換
- 自身轉換
- 內部轉換
- 操作
- 自定義操作
- 進入操作
- 退出操作
- 數據操作
- 日志操作
- 事件
- 生成事件
- 延遲時間
- 條件
- 數據
- 調用
即使狀態非常復雜,也可以通過狀態圖的模式進行聚合、分組、細化,還可以通過 Actor 模型進行劃分,不會發生 “狀態爆炸” 現象。
2.2. 文檔化
目前對項目需求的描述主要有:
- 產品需求文檔(PRD)
- 設計稿
而這兩個,在描述頁面行為上都不夠細致,PRD 幾乎不會去描述過于細節的交互行為,設計稿大概率也不會(因為業務交付周期上不允許在這上面花費太多的時間)。
而對于這些不清楚的、模糊的點,就帶來了后面的問題,針對于這些細節點,各個角色之間的溝通成本和拉通成本。
還有一個很嚴重的問題,就是同步問題。
很多時候在開發過程中,進行需求變動,而大多數情況下,這些變動不會重新對 PRD 和設計稿進行修改,不同角色之間去對焦及未來回顧,都是問題。
而如果你使用狀態機開發,那這兩個問題就可以迎刃而解。狀態機方式,要求你在開發之前必須把所有可能的狀態都羅列出來,狀態之間的關聯關系必須描述清晰。基于生成的狀態圖,是可以完全表達清楚所有的狀態交互及變化,且它是來源于代碼的,所以它是實時同步的,你代碼中怎么運行的,這個狀態圖就是怎么表達的。
2.3. 角色影響
回到前面說的,與不同角色協作的問題上。有了狀態圖的加持,會發生什么變化:
- 設計師可以根據狀態圖中的不同狀態,來確定哪種狀態合適用什么樣的 UI。
- 對于 PD,可以查看狀態圖,以了解系統行為,并驗證是否滿足要求。
- 對于測試和用戶,狀態圖完全充當說明書用,以前不知道如何才能到達某個狀態,現在一目了然。
- 對于測試還有一個很大的區別,因為基于狀態機去寫的,所以可以使用 Model-Based Testing,而這部分測試,可以由某些狀態機工具自動化掉。
- 對于交接的前端開發來說,有說明書在手,每個狀態都十分清晰,能做的事也十分清晰,在具備狀態機基礎的情況下,是可以快速上手的。
2.4. 提升用戶體驗度:用戶操作鏈路追蹤和分析
除了解決復雜度的問題,基于狀態機的特性,還可以帶來一些新的思路,如用戶操作鏈路追蹤和分析。
2.4.1. 常見分析用戶操作鏈路方法
目前,針對于分析用戶操作鏈路的方法,主要是在頁面中的可操作標簽上進行埋點,如,Button、Tab Item 等。有手動埋點和自動埋點。
- 手動埋點,可以按照你的意愿來收集特定區域的操作數據,但成本偏高,需要一個一個的手動接入,還可能需要自行上報數據。
- 自動埋點,通常是自動在一些常用的標簽上埋點,但會存在具體的標簽變更的問題,且不能覆蓋所有可操作的區域,數據精度不夠。
無論使用哪種埋點,都存在 回放噪音 的問題。
如,上報信息里包含,“查看詳情” 按鈕的操作,那么對應的 “詳情對話框” 一定會出來么?這個時候鏈路回放,只能去猜測,認為點擊了這個按鈕,就意味著這個對話框出來了。其實是不準確的。
如果,頁面上新增加了一個功能,要判斷這個新功能用戶的使用量,及用戶做了哪些操作才找到這個新功能。通過這個數據來判斷新的交互設計是否存合理。在這種不精準數據及 “噪音” 的回放中也是不準確的。
同樣,分析頁面中的哪些部分是高頻操作,也有類似的問題。
2.4.2. 基于狀態機的鏈路分析方法
狀態機做這種用戶鏈路分析,是天然合適的。因為用戶的所有操作,所有行為,本質上就是 “狀態在接收了什么事件,要變換到什么狀態” 上的過程。這是在 View 上埋點的方式缺乏的。
我們只需要在每次 “狀態” 發生轉換時,把狀態圖數據上報到分析平臺就可以。完全可以基于狀態的方式, 1:1 的回放用戶操作鏈路。
3. 總結
最后,總結一下狀態機方式帶來的好處和不足。
3.1. 優勢
- 比傳統的編碼方式,更容易理解。
- 基于行為建模,與視圖解耦。
- 更容易改變行為:組件中的行為被提取到了狀態機中,與 把行為和業務邏輯一起嵌入的組件相比,行為的更改相對容易。
- 更容易的理解代碼。
- 更容易測試
- 構建狀態圖的過程必須探索所有狀態,也是讓你具備業務全局視角的過程,它迫使你考慮所有可能發生的場景。
- 基于狀態圖的代碼比傳統代碼具有更少的 Bug 數。相關數據表示,錯誤減少了 80% 到 90%,剩下的錯誤也很少出現在狀態圖本身。
- 有助于處理可能會被忽視的特殊情況。
- 隨著復雜性的增加,狀態圖可以很好地擴展。
- 狀態圖是一個很好的交流工具。
3.2. 帶來的一些問題
- 需要學習新的東西,狀態機是一種范式的轉化,且容易有抵觸心里,不愿意走出舒適圈。
- 新的格式
- 新的重構技術
- 新的調試工具
- 部分人覺得可視化這種東西,沒什么用。
- 陌生的編碼方式,在團隊內可能出現不同的阻力。
- 雖然大多數人聽過狀態機,但實際的編程中離它遙遠,所以并不熟悉它。
- 編程方式的轉換,很多人需要弄清楚原來的代碼,現在該如何去寫,如何映射。
- 部分人會質疑它的有效性。
- 必須有人基于這種模式實踐過,對它非常了解才可以。
- 如果從來沒用過它,使用這種模式會無從下手,令人生畏。
3.3. 為什么用的人不多
狀態機已經發展幾十年了,前面也說過,在非常的多場景有使用,像電子、嵌入式、游戲、通訊等領域。那為什么前端上使用較少呢(限定國內)?
除了上面列出的 “帶來的一些問題” 中的一些點,我覺的還有以下問題導致的:
- 缺少指導圖書:現在搜索一下關于狀態圖的前端圖書或者教程,搜索結果告訴你 0 條。資料很少(嵌入式之類的狀態機資料還是挺多的)。
- “用最簡單的方式去實現” 的心態:很多人喜歡用 if/else/switch 來解決問題。
- “你覺得你不需要” 的心態:復雜度在每一個 flag 變量和布爾值中蔓延。就像溫水煮青蛙,溫水中的青蛙不會注意到溫度的緩慢升高一樣,開發人員也不會注意到復雜度的蔓延。在一些小的系統中運行的很好,但隨著系統的迭代和變大,一個個凌亂的 if/else/switch 語句,它修改了各種變量的狀態,以試圖維持它們的一致性。就好像你不需要狀態機,直到為時已晚。
- 就像 RxJS、函數式編程之類的一樣,大家都知道它很好,但就是不用它。
3.3. 總結
任何解決方案都不能解決一切問題,一定要找到它適合的場景。不過,現階段,狀態機確實是我能看到的,解決復雜業務邏輯最好的工具。
如果文中說的問題也發生在你身邊,且無法徹底解決,那推薦你可以嘗試一下,或許會有驚喜。
總結
以上是生活随笔為你收集整理的降低前端业务复杂度新视角:状态机范式的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: NotificationManagerS
- 下一篇: 一键导出所有微信联系人的小工具,搞私域、