理解数据状态管理

在传统的web应用中,用户和数据的状态更多地放在服务端,每一个页面的状态都在路由切换后重新从服务端拉取即可,前端并不需要过多地考虑数据状态的管理。随着单页应用的逐渐发展,前端需要管理越来越多的数据:数据的更新会导致UI的变化,UI的交互会触发数据的更新,多个页面之间可能会共享相同的数据,随着应用的的规模增大,维护起来会十分麻烦。

这篇文章主要整理下几种管理前端数据状态的方案,以及进一步思考其背后的实现和意义。

<!--more-->

下面先从最简单的管理方案,然后理解flux模式,最后学习redux和vuex两种在业务中最常用的状态管理库。参考

1. 原始的状态管理方案

如果应用足够简单,我们也许并不需要任何框架和数据状态管理方案。

1.1. 直接共享对象

如果你有一处需要被多个实例间共享的状态,可以简单地通过维护一份公共的数据来实现共享

// 多个实例共享一份数据
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数据。这种做法看起来十分方便,缺点是:我们应用中的任何部分,在任何数据改变后,都不会留下变更过的记录。

1.2. 简单的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的问题,看起来也不是那么优雅。那么,如果我们约定数据只能在同一个地方进行更改呢?

2. 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的概念是美好的,但是整个操作看起来过于复杂,开发效率可能会降低。

3. Redux和Vuex

在业务项目中,使用较多的是REDUX和Vuex这两个库来管理应用的数据状态,现在来看一下他们的基本使用。理解了上面的flux模式,学习redux和vuex就轻松很多了。

3.1. 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用于处理异步动作。

3.2. 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高度定制的一个状态管理方案。

4. 隔壁APP的状态管理

在客户端中,一般通过EventBus来实现全局通信,包括应用程序内各组件间、组件与后台线程间的通信,其实现原理也是发布订阅模型。

最近一直在倒腾flutter,发现实际上APP也有数据状态管理的需求和解决方案,在flutter中,大概有下面几种解决方案

  • 在小规模的状态控制中使用ScopedModel,先创建Model对象,通过ScopedModelDescendant类型的widget来响应model的变化,然后在需要的时候调用notifyListeners方法,此时就会更新对应数据的widget。
  • reduxflutter_redux,没错,在flutter中也可以使用redux~
  • Bloc,使用StreamBuilderwidget来管理数据流,不再维护对数据流的订阅和widget的重绘

由于学习时间不长,客户端的状态管理方案还没有进一步深入了解,这里暂且不再深入了。

5. 总结

使用数据状态管理,就必须在项目中遵守相关约定

  • 单向数据流,state为纯数据格式
  • 约定的actionType,使用普通的枚举值(或对象)描述action

这些约定不可避免地需要更多、更复杂的代码,在多人合作的项目中,遵守同一个约定,沟通和维护成本也会增加。这些成本带来的好处就是,我们拥有了一个可预测、可维护的数据状态。

在小型项目中,也许redux、vuex等库并不是必须的,但是理解数据状态管理的重要性是必须的。那么,什么时候才适合使用他们呢?用大佬的回复结尾

你应当清楚何时需要 Flux。如果你不确定是否需要它,那么其实你并不需要它。

我想修正一个观点:当你在使用 React 遇到问题时,才使用 Redux。