該文章同步在個人部落格shymean.com上,歡迎關注
前言
在傳統的web應用中,使用者和資料的狀態更多地放在服務端,每一個頁面的狀態都在路由切換後重新從服務端拉取即可,前端並不需要過多地考慮資料狀態的管理。隨著單頁應用的逐漸發展,前端需要管理越來越多的資料:資料的更新會導致UI的變化,UI的互動會觸發資料的更新,多個頁面之間可能會共享相同的資料,隨著應用的的規模增大,維護起來會十分麻煩。
這篇文章主要整理下幾種管理前端資料狀態的方案,以及進一步思考其背後的實現和意義。
下面先從最簡單的管理方案,然後理解flux模式,最後學習redux和vuex兩種在業務中最常用的狀態管理庫。參考
原始的狀態管理方案
如果應用足夠簡單,我們也許並不需要任何框架和資料狀態管理方案。
直接共享物件
如果你有一處需要被多個例項間共享的狀態,可以簡單地通過維護一份公共的資料來實現共享
// 多個例項共享一份資料
var data = {
msg: "hello"
};
J_btn.onclick = function() {
data.msg = 'hello world' // 修改資料,多個例項都會監聽到變化
};
var vm1 = new Vue({
el: "#app",
data
});
var vm2 = new Vue({
el: "#app2",
data
});
複製程式碼
在子元件中,也可以通過this.$root.$data
來訪問到公共的data資料。這種做法看起來十分方便,缺點是:我們應用中的任何部分,在任何資料改變後,都不會留下變更過的記錄。
簡單的store模式
我們可以在修改data資料之前進行記錄,為了避免在業務程式碼中打log,我們把資料的修改通過store來記錄
var state = {
msg: "hello"
};
var store = {
debug: true,
state: state,
// 不直接修改state,這樣可以在action中記錄相關的操作
setMessageAction(newValue) {
console.log("change mesage to:", newVal);
this.state.msg = newValue;
},
};
J_btn.onclick = function() {
store.setMessageAction("hello world"); // 通過action修改資料
};
var vm1 = new Vue({
el: "#app",
data: {
sharedState: store.state
}
});
var vm2 = new Vue({
el: "#app2",
data: {
sharedState: store.state
}
});
複製程式碼
這樣,我們通過呼叫store.setMessageAction
而不是直接修改store.state.msg
來修改資料,並且可以記錄修改資訊。
這種模式的問題在於,我們可能會在頁面上的多個地方呼叫store.setMessageAction
,從而無法區分改動的來源,一種解決辦法是在每次呼叫時傳入改動資訊
// 每次呼叫時傳入msg
setMessageAction(newValue, msg) {
console.log(msg);
this.state.msg = newValue;
}
// ...
store.setMessageAction("hello world", "change hello by btn click");
複製程式碼
這種做法不可避免地回到了在業務程式碼中打log的問題,看起來也不是那麼優雅。那麼,如果我們約定資料只能在同一個地方進行更改呢?
flux模式
flux在上面store的基礎之上,增加了單向資料流的概念,所謂的單向資料流,實際上是一個約定:檢視層元件不允許直接修改應用狀態(資料),只能觸發 action。應用的狀態必須獨立出來放到 store 裡面統一管理,通過偵聽 action 來執行具體的狀態操作。
根據這個約定,元件不允許直接修改屬於 store 例項的 state,而應執行 action 來分發 (dispatch) 事件通知 store 去改變。flux包含了View、Action、Dispatcher、Store等概念:
- View,一般指的是將應用的各個元件
- Action,根據view確定每個元件需要進行的操作
- 一般對於資料的操作無非就是增刪查改,根據業務來細分的話,還會有封裝的一次性操作多個資料的動作等
- 每個action都有一個具體的名字,並攜帶一些引數(通常是修改資料所需要的)
- 動作可能是同步的,也可能是非同步的
- Dispatcher,每個動作都有對應的邏輯來通知Store更新資料
- Store,可以初始化資料、更新資料,資料改動後,需要通知元件更新UI
為了理解flux的流程,我們在上面store模式的基礎上重寫demo,為了理解state的變化導致檢視更新,這裡並未使用Vue,而是手動去實現一個簡易的render函式。
var dispacter = {
registerAction(actionType, handler) {
this.actions[actionType] = handler;
},
dispatch(actionType, data) {
this.actions[actionType](data, this);
},
actions: {}
};
// 為store增加事件訂閱釋出功能,主要用於store通知view更新
var eventEmitter = {
eventList: {},
on(eventName, callback) {
if (!this.eventList[eventName]) {
this.eventList[eventName] = [];
}
this.eventList[eventName].push(callback);
},
emit(eventName, params) {
this.eventList[eventName] && this.eventList[eventName].forEach(callback => {
callback(params);
});
}
};
var store = Object.assign(eventEmitter, {
state: {
list: [1, 2, 3]
},
add(item) {
this.state.list.push(item);
},
delete(index) {
this.state.list.splice(index, 1);
}
});
// 註冊相關action及處理函式
dispacter.registerAction("addListItem", function (newVal) {
console.log(`add list item: ${newVal}`);
store.add(newVal);
store.emit("changeList")
});
dispacter.registerAction("deleteListItem", function (index) {
console.log(`delete list item index: ${index}`);
store.delete(index);
store.emit("changeList")
});
// View層
var app = {
data: null,
init(store, dispacter){
this.data = {
sharedState: store.state
}
// 響應store的資料更新
store.on("changeList", () => {
this.data.sharedState.list = store.state.list;
this.render()
})
// 在檢視上觸發action
J_btnAdd.onclick = function () {
dispacter.dispatch("addListItem", 100);
};
J_btnDelete.onclick = function () {
dispacter.dispatch("deleteListItem", 0);
};
},
render(){
var wrap = document.querySelector("#app")
var htm = ''
var list = this.data.sharedState.list
for(let i = 0 ; i < list.length; ++i) {
htm += `<p>${list[i]}</p>`
}
wrap.innerHTML = htm
}
}
app.init(store, dispacter)
app.render();
複製程式碼
在上面的例子中,我們好像實現了一個非常簡易的flux系統,其核心其實是一個釋出訂閱者模型,梳理一下流程
- store包含初始化的state,然後註冊用於處理不同state變化邏輯的action,在action的處理函式中,呼叫store提供的介面更新state
- view通過store.state獲取初始化資料並渲染,並訂閱了store的changeList事件
- 點選按鈕時,觸發dispacter.dispatch分發action,
- 找到對應action的處理函式,完成state的更新,通過然後store觸發changeList事件
- view接收到了changeList事件,重新完成渲染
可以看見整個流程中,資料的變化都是單向的。在如何理解 Facebook 的 flux 應用架構這篇文章裡,前幾個回答十分清晰,這裡引用一下尤大的回答
- 檢視元件變得很薄,只包含了渲染邏輯和觸發 action 這兩個職責
- 要理解一個 store 可能發生的狀態變化,只需要看它所註冊的 actions 回撥就可以
- 單向資料流約定,任何狀態的變化都必須通過 action 觸發,而 action 又必須通過dispatcher 分發,所以整個應用的每一次狀態變化都會從同一個地方(dispacter.dispatch)流過
那麼,flux到底解決了什麼問題呢?flux實現了View對於資料層的只讀使得它是可預測,可預測性表現在
- 避免了資料在多個地方修改,導致UI出現不可控制的問題,
- 因為任何可能發生的事情,都已經通過registerAction定義好了
那麼,flux帶來了什麼問題呢?flux的概念是美好的,但是整個操作看起來過於複雜,開發效率可能會降低。
Redux和Vuex
在業務專案中,使用較多的是REDUX和Vuex這兩個庫來管理應用的資料狀態,現在來看一下他們的基本使用。理解了上面的flux模式,學習redux和vuex就輕鬆很多了。
redux
Redux是基於Flux架構思想的一個庫的實現,從下面這個demo一睹redux的使用方式
/** Action Creators */
function inc(paylod) {
return { type: 'ADD_ITEM', payload };
}
function dec(paylod) {
return { type: 'DELETE_ITEM', payload };
}
// reducer集中處理不同型別的action,並返回新的state
function reducer(state, action) {
state = state || { list: [1, 2, 3] };
var list = state.list.slice()
switch (action.type) {
case 'ADD_ITEM':
list.push(action.payload)
return { list };
case 'DELETE_ITEM':
list.splice(action.payload, 1)
return { list };
default:
return state; // 無論如何都返回一個 state
}
}
var store = Redux.createStore(reducer);
// 通過store.getState()獲取state
var state = store.getState()
// 訂閱store的變化
store.subscribe(function(){
console.log('store.state change')
var newState = store.getState()
console.log(newState); // 獲取到新的state
console.log(newState !== state) // reducer返回的是一個全新的state,當然這取決你在reducer中的返回值
// setState({...}) 如果要觸發React的檢視更新,在這裡呼叫setState即可
})
store.subscribe(function(){
console.log('store.state change')
console.log(store.getState());
})
// 觸發不同的action.type,集中在reducer中進行處理
store.dispatch(inc(Math.random()));
store.dispatch(inc(Math.random()));
store.dispatch(dec(1));
// 從這個demo還可以看出,redux和react是沒有任何關係的!!
複製程式碼
關於redux的學習,可以移步redux-simple-tutorial。
與flux不同的是,redux引入了reducer
的概念,reducer是一個純函式,且一個應用只包含一個reducer,大致可以理解為
reducer(state, action) => newState
複製程式碼
通過dispatch傳送的action,傳入reducer進行處理,並返回新的state,。
redux並沒有區分同步action或者非同步的action(如api請求),關於非同步動作
- 一種做法是:應該是在非同步任務完成之後呼叫dispatch,這樣就需要在view層執行非同步邏輯,然後再觸發action,將非同步操作放在view進行操作看起來並不是很明智
- 另一種做法是:先發出action,由action決定是立即執行reducer,還是等待非同步任務完成後再執行reducer
為了實現action決定執行reducer的時機,我們可以做如下改動,通過擴充套件store.dispatch
,使dispatch支援Promise型別的action。
function incAsync() {
return new Promise((resolve, reject) => {
setInterval(() => {
resolve({ type: "ADD_ITEM", paylod: 100 });
}, 1000);
});
}
// reducer 跟上面一致
var store = Redux.createStore(reducer);
((store) => {
let next = store.dispatch;
store.dispatch = function dispatchAndLog(action) {
if (action instanceof Promise) {
action.then(act => {
console.log(act);
next(act);
});
} else {
next(action);
}
};
})(store);
store.dispatch(incAsync());
複製程式碼
事實上,更正規的做法是在中介軟體中處理非同步action,社群還提供了redux-thunk用於處理非同步動作。
Vuex
跟redux不同的是,Vuex只能在vue中使用。Vuex 是一個專為 Vue開發的狀態管理模式。它採用集中式儲存管理應用的所有元件的狀態。下面是vuex的使用demo,可以看見vuex與redux的一些區別
const store = new Vuex.Store({
// 資料
state: {
list: [1, 2, 3]
},
// 通過觸發mutation修改state
mutations: {
addItem(state, item) {
state.list.push(item)
},
deleteItem(state, index) {
state.list.splice(index, 1)
}
},
// 在非同步任務中通過提交mutation修改state
actions: {
deleteItemAsync(context) {
console.log('deleteItemAsync wait for 1s')
setTimeout(() => {
context.commit("deleteItem", 1)
}, 1000);
}
}
})
J_btnAdd.onclick = function () {
store.commit("addItem", Math.random().toFixed(2))
}
J_btnDelete.onclick = function () {
store.dispatch("deleteItemAsync")
}
var vm1 = new Vue({
el: "#app",
store: store, // 為元件及其所有子元件注入store,避免在元件中引入全域性store
computed: {
list() {
return this.$store.state.list
}
}
});
var vm2 = new Vue({
el: "#app2",
store: store, // store是唯一的,跨元件和例項
computed: {
list() {
return this.$store.state.list
}
}
});
複製程式碼
vuex將view觸發的動作分為了Action
和Mutation
兩種
- mutation表示同步動作,用於記錄並修改state,觸發mutation使用的是
store.commit
- action表示非同步動作,並在非同步任務完成後提交mutation,而不是直接修改state,觸發action使用的是
store.dispatch
vuex在flux的基礎上,簡化了需要定義actionType的工作,細化了同步任務和非同步任務,此外借助vue本身的響應式系統,避免了需要在元件中訂閱(subscribe)的步驟,可以理解為是為Vue高度定製的一個狀態管理方案。
隔壁APP的狀態管理
在客戶端中,一般通過EventBus來實現全域性通訊,包括應用程式內各元件間、元件與後臺執行緒間的通訊,其實現原理也是釋出訂閱模型。
最近一直在倒騰flutter,發現實際上APP也有資料狀態管理的需求和解決方案,在flutter中,大概有下面幾種解決方案
- 在小規模的狀態控制中使用ScopedModel,先建立Model物件,通過
ScopedModelDescendant
型別的widget來響應model的變化,然後在需要的時候呼叫notifyListeners
方法,此時就會更新對應資料的widget。 - redux和flutter_redux,沒錯,在flutter中也可以使用redux~
- Bloc,使用
StreamBuilder
widget來管理資料流,不再維護對資料流的訂閱和widget的重繪
由於學習時間不長,客戶端的狀態管理方案還沒有進一步深入瞭解,這裡暫且不再深入了。
總結
使用資料狀態管理,就必須在專案中遵守相關約定
- 單向資料流,state為純資料格式
- 約定的actionType,使用普通的列舉值(或物件)描述action
這些約定不可避免地需要更多、更復雜的程式碼,在多人合作的專案中,遵守同一個約定,溝通和維護成本也會增加。這些成本帶來的好處就是,我們擁有了一個可預測、可維護的資料狀態。
在小型專案中,也許redux、vuex等庫並不是必須的,但是理解資料狀態管理的重要性是必須的。那麼,什麼時候才適合使用他們呢?用大佬的回覆結尾
你應當清楚何時需要 Flux。如果你不確定是否需要它,那麼其實你並不需要它。
我想修正一個觀點:當你在使用 React 遇到問題時,才使用 Redux。