人從出生到死亡走的這段路程,稱為生命週期。
應用從啟動到關閉經歷的這段過程,也稱為生命週期——因此這是一個仿生概念,基於相應結構的應用也會有與人類相似的行為特點。
初接觸時,我們會為如何去更好的在這個過程中去實踐狀態管理焦頭爛額,糾結於不同架構的各個節點對應的職責,特別是在涉及非同步和副作用的處理的過程中,很難快速找到一個“最佳實踐”。那麼,既然應用具有仿生設計,我們自然可以從基於作為人類的自身的角度去理解它。
狀態(State)
先來看一張圖:
從上圖可以看到,如果將一些過程和行為抽象出來,人與 App 是具有高度的相似性的。
其中:
Stage | Human | App |
---|---|---|
Born/Startup | 新生兒出現,有了人類初始特徵,大腦開始工作後,便逐漸的產生本能、認知、意識、反應、情緒等元素,它們便是我們的初始狀態。 | 註冊程式或執行緒,靜態資源載入,支撐應用的各個要素就緒,根據快取或預置規則定義應用的初始狀態 |
EE/FID | 早期教育,為投身到更復雜的環境層層遞進的準備 | 獲取初始資料,為更定製化的執行策略做準備 |
WL/RI | 不斷學習、工作、修身、社交,處理事件 | 在執行過程中與後臺互動、與使用者互動、檢視與狀態的同步、與其他應用的互動,處理事件 |
Retirement/Unmount | 退休、處理各項工作時的羈絆、夕陽紅、遺產處理 | 解除安裝服務、處理副作用、也啟動一些服務(最令人髮指的)、快取等資源的處理 |
從書面意思理解:
狀態是人或事物表現出來的形態。是指現實(或虛擬)事物處於生成、生存、發展、消亡時期或各轉化臨界點時的形態或事物態勢。
簡單來說,就是任一物件在特定狀況下的存在形態。 具體到人,可以是情緒、職業、資產等。 具體到應用,可以是一些布林值、狀態碼、具體資料等。
由這些可以對物件進行描述的單元結合,就構成人或應用。
全域性狀態(Global State)
- 在人類的角度來看,我們剛形成胚胎便會有了性別、膚色、瞳色等基因決定的特徵,我們因而為人,這些生理特徵體現在我們生命歷程中的任何一個時刻。
- 對於應用來說,靜態資源被執行環境執行的過程,就好比胚胎的生長過程,然後到了初始化狀態容器(Store)的時候,便開始獲取初始資料,這些資料就包含了一系列初始狀態,它們可以是可用性、登入狀態、角色策略、顏色主題、語言環境等等。
在人身上,本能、認知等因素往往是伴隨一生的,他們的有效性是覆蓋到所有其他情形下的,比如我吃飯的時候不知道美國總統是川普,那麼我上廁所睡覺的時候同樣不會知道;有一天我在吃飯的時候得知了這個訊息,那麼從此我上廁所睡覺的時候同樣也知道了。
在應用中,登入狀態、顏色主題、語言環境等也有這樣的特點。在一個應用週期內,每一次修改這些狀態,都是會應用到全域性的,至少是主體同步。
對於這些狀態,我們統稱為全域性狀態
區域性狀態(Module/Feature/Partial)
我們上廁所的時候,一般會向”抽紙盒“發起請求,然後拿幾張紙,這是在如廁時的”後事“預備狀態;而我們在大街上則不會同樣拿著紙準備擦屁股,我們可能因為口渴拿著水,因為購物拿著包袋。
在應用裡,具有不同職責的頁面展示的內容也不同,我們不會沒事兒在首頁展示使用者的優惠券詳情,也不會沒事兒在課程詳情頁展示使用者餘額。
在這些具有不同職責的場景下“獨有”的狀態,我們稱為區域性狀態
全域性狀態和區域性狀態的劃分
兩種狀態的特點其實很好理解,其實它們的劃分才是難點。
狀態本身就具有“全域性性”,因為狀態一旦拿到,那麼不管是否是在對應場景,它都存在於本次生命週期中,它隨時可能在新需求來到的時候被其他場景需要,而你不一定總是能夠事先知曉。
當然了,像使用者資訊、登入時間、語言環境等因素是很好區分的,但更多更細的狀態是否需要放到全域性,或者說由公共性更高的模組來管理,就很難一次性下定論了。
因此界定某個狀態的型別,並不是一蹴而就的,而是要在長期的迭代中進行總結。
對於人來說,這個問題不算是個問題,因為我們擁有強大的複雜問題處理能力,而計算機幾乎是沒有這樣的能力的,它們處理問題的方式都是人為定義的,即便 AI、ML 等技術蓬勃如今,也遠遠達不到人類的思維水平。
我們以一個真實應用裡的一些實現為例:
這是 WPS精品課 產品的移動端 Web App。以職責劃分
第一張圖中,在兩個功能不同的頁面(場景)中都出現了分類這一資料形式,並且資料是一致的,也就是說,這兩個頁面出現了公共狀態。而這兩個公共狀態總是覆蓋全域性的,那麼我們就應當將它們提升到更高一層的狀態模組中。
注意,提升到更高一層並不意味著提升到 global 的級別。有時候,可能分類並不是一個簡單的資料,它可能是根據不同的使用者策略進行展示的,對於不同的使用者級別,分類可能會呈現多型(比如普通使用者看不到 VIP 專屬的類別)。從前後端互動的角度來看,分類相關的介面往往也是獨立於其他資料的。因此,當分類具有了一定的複雜性和具體規則,它應當有屬於自己的管理單元,使得資料的吞吐和處理有更加清晰的思路,而不是去破壞性的影響全域性狀態的職責。
就像人類在左右腦的統一調配下,有視覺中樞、聽覺中樞、運動中樞。如果它們產生了紊亂,使得腦功能失調,人就會出現各種各樣的問題,如少兒多動症、認知障礙等。
以路由劃分
為什麼是一個圓圈呢?
其實這個頁面雖然常見,但在狀態管理中,它的確比較特殊,因為它既是一個路由單元,又是一個狀態單元。如果說一個頁面通常是由多個狀態組合而成的,那麼“我的”頁面可能就只需要一個狀態就夠了——即使用者狀態。它往往包含了使用者基本資訊,信用卡資訊,功能定製等——是的,往往我們就把它們放在 global 中。當然根據應用的型別和複雜度不同,使用者資訊也可能劃分成若干單元,因此,如此形式的按路由劃分,其實是按職責劃分的一個變種。
然而,還有一種情況就不同了。比如某些活動型頁面,它們可能只包含一些運營內容,有一套自己的邏輯和互動,獨立於任何其他的頁面,但頁面本身的生命週期或許只有幾個星期甚至幾天,這時候可能就沒有必要為其設計和維護一個狀態單元了,得不償失。
好比一個人要出國旅遊一段時間,立馬給手機開了一系列便捷的境外服務,但回國後往往就立馬停掉了,而不是為這些長期用不到的東西付費。
改變狀態
我們已經探討了關於狀態的一些基礎內容,現在問題來到了如何對狀態進行“改查”。
首先,狀態是物件在特定環境和具體時機下的某種存在表現,隨著環境和時機的改變,它便會發生相應的更新。
我們拿“時間”舉例,它是最客觀最不可阻擋的狀態流。
對於人類,在一個時間單元內(指,年、月、日等)我們會根據具體的時間點調整我們自身的狀態——睡覺、起床、工作、小憩等等;
對於應用,最常見的就是一些即時服務的開關。比如某購物 App,白天一直到晚上九點會有針對會員的“一小時送達”的服務,但過了這個時間點,這個服務便進入休眠狀態。
那麼從外部條件改變到物件自身的狀態更改,中間經歷了什麼呢?
我們來看幾個當下炙手可熱的前端資料流模型:
So You See!
這裡面似乎有一個恆定的正規化:
Action - Update state
Store 和 State
Store
是狀態中心,而State
就是這裡面的一個個狀態集合。
在 Flux 和 Redux 的模型中,我們可以顯式的看到Store
節點,而 Mobx 和 Vuex 裡這個節點似乎由State
代替了。這個是由於兩種風格不同的狀態宣告方式導致的,這裡以最常見的 Redux 和 Vuex 的Store
構建方式為例:
可以看到,源於 Flux 思想的 Redux 的Store
宣告過程更像是將各個獨立的狀態單元(Reducer,詳見後文)整合(combine)在一起,形成一個自Store
而下的狀態樹,實現單向資料流。其工作特點是所有的行為都要經過Store
。
PS:其中的Action Handlers
的實際形式其實是switch
語法下的一個個模式,並非具體函式或方法,這裡只是根據其職責進行了類比理解。
而 Vuex 呢,其實也是源於 Flux 的,但它吸取了 Redux 樣板程式碼繁瑣的“教訓”,將combine
的過程用宣告的方式規避了,同時將狀態單元細分成各個module
,每個module
包含了一套State
和對應的規則,比起 Redux 來說,是一種“高類聚、低耦合”的方案,節省了一些宣告和管理狀態的成本。工作特點眼下就是各個module
各司其職,隻影響自己的State
。
然而,Vuex 在實際的工作過程中,其實還是由Store
作為中心進行分發,只是其構建方式讓我們覺得Store
並沒有被總是調起。
總的來說,Store
的地位如同我們的大腦,我們的任何決策、行為都會經過大腦進行評估、加工。但隨著某種刺激的不斷觸發,其對應的反應行為也會出現得越來越快,等到形成相對固定的正規化的時候,我們可能就感覺不到思維在這個過程中的行動了,體現為“反應快”。
對於普通應用開發來說,我們則可以直接定義這種“正規化”,這更有利於我們整體上的把握應用的規則,強化和優化應用的邏輯。良好的狀態管理實踐會讓應用更加高效,也更好維護。
Action
在上面的幾種資料流模型中,在對狀態進行修改前,都會經過一個叫Action的節點,這個節點我們可以理解成行為。
Action 即是向Store
發起更新請求的最小單元。
它的結構通常是:
// pureObject
const myAction = {
type: 'GO_TO_BED',
payload: Medicine.Estazolam
}
// functional
const myFunctionalAction = arg => {
let payload
// TODO
return {
type: 'GO_TO_BED',
payload
}
}
複製程式碼
其中:
- type: 對這個行為的描述,Store 根據這個欄位去尋找對應的處理方案
- payload?:荷載,攜帶實現該行為要使用的一些資料
這個比較好理解,要做一件事,得先明確這是什麼事,如果有需要還要帶上相應的東西。比如:大便要帶紙;而小便可能帶,也可能不帶;只是去洗手就什麼都不用帶了。
可見,Action
最終只是一個物件,那它如何傳遞給Store
呢?
Dispatch
我們完成一個“刺激——反應”的時候,通常先是神經末梢收到接收刺激,然後大腦得到神經末梢發來的資訊,做出反應。在這個過程中,攜帶資訊的介質被稱為神經遞質,它活動在突觸之間。
而在各類應用狀態管理的模型中,通常都會有一個dispatch
方法,它就宣告在Store
上,負責呼叫各個Action
,然後由Store
上對應的分發機制進行處理。同時,非同步Action
的實現,即是將這個方法作為引數傳給對應的ActionCreator
,然後等到非同步工作流完成後,將最終的Action
傳遞給Store
。例如構建一個redux-thunk
中的非同步Action
:
const asyncAction = id => {
// 整合 redux-thunk 後,redux 會將 dispatch 等一系列方法傳遞給 actionCreator 返回的函式,供非同步工作完成後 actionCreator 能配合 Redux 進行工作
return dispatch => {
fetch('/getData?id=' + id)
.then(response => response.json())
.then(data => {
dispatch({
type: 'SET_DATA',
payload: data
})
})
}
}
複製程式碼
Reducer / Mutation
現在到了更新狀態的時候了,簡單抽象出來就是newState = updatedState
,不難理解,主要看下實現。
在 Flux 和 Mobx 的模型中,對狀態的修改比較直接,不多贅述,那麼“矯情”一些的 Redux 和 Vuex 是如何實踐的呢。
我們從其實現上分別說明它們的作用
Reducer
先來看一個簡單 Reducer 實現:
function myReducer (state = { age: 1 }, action) {
switch (action.type) {
case 'HappyBirthDay': return {
age: ++state.age
}
default: return Object.assign({}, state)
}
}
複製程式碼
Reducer 的工作方式是,接收一個Action
,然後在 switch 流中匹配action.type
,做出相應處理,然後返回一個新的物件。其原始碼可以看這裡
為什麼是新的物件呢?
因為 Redux 是一個實踐函數語言程式設計(FP)理念的庫。函數語言程式設計有個要素就是——純函式不能有副作用,而副作用簡單概括來說就是對該函式內部環境以外的變數進行了修改、銷燬等操作。
回過頭來,在 Redux 中,Reducer 原則上就是一個純函式。
這有什麼意義呢?
答案是資料不可變,它也是函數語言程式設計中的一個要點。
函數語言程式設計認為可變和共享是“萬惡之源”,原資料的更新只能通過返回新的資料。否則隨意修改的資料可能讓應用產生難以預料的問題,而“共享”加“可變”帶來的副作用更是容易容易讓我們得到錯誤並且難以捕獲的內容。
Mutaion
Vuex 是在Mutation
中修改狀態的,其程式碼一般如下:
// module
export default {
//...
mutations: {
SET_DATA (state, payload) {
state.data = payload
}
},
actions: {
async getData ({ commit }, payload) {
const res = await api.data.get(payload.id)()
commit('SET_DATA', res.data)
}
}
}
// component
export default {
mounted () {
store.dispatch('getData', this.id)
}
}
複製程式碼
其中,觸發一個Action
依然是通過dispatch
方法,然而,修改狀態為什麼需要commit
一下呢?
其實我們直須將mutations
和actions
中的各個成員都理解成Action
,因為你也可以直接在Store
上呼叫commit
來修改狀態。 commit
的職責相當簡單,就是修改本地狀態。
而Action
的職責在於可以實現非同步流和Action流(在action中dispatch又一個(可以是自己)action),最後提交到Mutation
中來修改狀態。但從原始碼來看,其實 Vuex 同樣賦予了Action
改變狀態的能力,它將State
作為第一個引數的其中一個屬性傳遞給了Action
,其目的是為了你可以使用State
上的狀態和資料,原則上這是隻讀的,但結合 MVVM 的特點,你在這裡修改它,同樣也會引起檢視的改變。
原始碼裡是這樣寫的:
function registerMutation (store, type, handler, local) {
const entry = store._mutations[type] || (store._mutations[type] = [])
entry.push(function wrappedMutationHandler (payload) {
// store 會呼叫 commit 方法來啟用這個 mutation,並且只傳入了本地 state 和一系列荷載
handler.call(store, local.state, payload)
})
}
function registerAction (store, type, handler, local) {
const entry = store._actions[type] || (store._actions[type] = [])
entry.push(function wrappedActionHandler (payload, cb) {
// action 就比較厲害了,這麼多...
let res = handler.call(store, {
dispatch: local.dispatch,
commit: local.commit,
getters: local.getters,
state: local.state,
rootGetters: store.getters,
rootState: store.state
}, payload, cb)
if (!isPromise(res)) {
res = Promise.resolve(res)
}
if (store._devtoolHook) {
return res.catch(err => {
store._devtoolHook.emit('vuex:error', err)
throw err
})
} else {
return res
}
})
}
複製程式碼
可見,你甚至可以不顧一切的在Action
中修改全域性的State
。
在行為心理學中,其中一種行為的分類方式即是將行為分為外顯行為和內隱行為。外顯行為就是我們肉眼可見的,有明確外在表現的行為;內隱行為則是外表之下,發生於機體內部的情緒變化、思維運作、激素分泌等不會彰顯出來的行為。但往往我們改變大腦中的某個狀態,使之顯於或不顯於我們的姿態的時候,這些內隱行為是不可能避免的,因為我們的大腦活動就是各類遞質工作下的一系列的化學反應。
這與Action
和Mutation
的關係很像,Vuex 告訴我們修改狀態的唯一方法是提交Mutation
,也就是說你不應該在Action
中的直接修改State
,就好像我們的外顯行為總是要經過內隱行為來提交給大腦一樣。
當然了,既然職責不同,角色肯定就不同,理解成Action
是為了我們便於理解。
再次提醒,Vuex 明確告訴我們改變狀態的唯一方法是提交Mutation
,因此我們應當遵循這個原則,將Action
中的各個響應式引用視為只讀,以保證應用的邏輯性不會被破壞。(當然,直接 commit 啦,想想mapMutations
方法!)
總結
通過一張圖來梳理一下狀態管理與人類行為的共通之處:
PS
這不是一篇論述,狀態管理也遠遠不止這幾種模型,小生僅僅在前端應用及其比較有代表性的狀態管理方案的背景下分享了這個角度,因此這個理解方式必然有一定的侷限性或者是未被完全論證。如果能幫助到讀者,小生就非常榮幸了。
理解狀態管理的方式有很多,這只是其中一種思路,或許這種思路能在應用開發的同時也鍛鍊我們的邏輯思維。
同時,實際場景下的狀態管理必然一個更加複雜的東西,隨著應用的規模和深度越來越大,我們需要更深刻思考它,如何劃分模組?如何共享模組?如何構建容器?如何提升效率?這都是需要逐步探索的,當然,最快的方式,就是在已有的狀態管理正規化中思考,組織、優化。
最後,要記住的是:
你可能不需要狀態管理