理解資料狀態管理

橙紅年代發表於2019-03-08

該文章同步在個人部落格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觸發的動作分為了ActionMutation兩種

  • 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。
  • reduxflutter_redux,沒錯,在flutter中也可以使用redux~
  • Bloc,使用StreamBuilderwidget來管理資料流,不再維護對資料流的訂閱和widget的重繪

由於學習時間不長,客戶端的狀態管理方案還沒有進一步深入瞭解,這裡暫且不再深入了。

總結

使用資料狀態管理,就必須在專案中遵守相關約定

  • 單向資料流,state為純資料格式
  • 約定的actionType,使用普通的列舉值(或物件)描述action

這些約定不可避免地需要更多、更復雜的程式碼,在多人合作的專案中,遵守同一個約定,溝通和維護成本也會增加。這些成本帶來的好處就是,我們擁有了一個可預測、可維護的資料狀態。

在小型專案中,也許redux、vuex等庫並不是必須的,但是理解資料狀態管理的重要性是必須的。那麼,什麼時候才適合使用他們呢?用大佬的回覆結尾

你應當清楚何時需要 Flux。如果你不確定是否需要它,那麼其實你並不需要它。

我想修正一個觀點:當你在使用 React 遇到問題時,才使用 Redux。

相關文章