Vuex、Flux、Redux、Redux-saga、Dva、MobX

姜小抖發表於2018-12-18

這篇文章試著聊明白這一堆看起來挺複雜的東西。在聊之前,大家要始終記得一句話:一切前端概念,都是紙老虎

不管是Vue,還是 React,都需要管理狀態(state),比如元件之間都有共享狀態的需要。什麼是共享狀態?比如一個元件需要使用另一個元件的狀態,或者一個元件需要改變另一個元件的狀態,都是共享狀態。

父子元件之間,兄弟元件之間共享狀態,往往需要寫很多沒有必要的程式碼,比如把狀態提升到父元件裡,或者給兄弟元件寫一個父元件,聽聽就覺得挺囉嗦。

如果不對狀態進行有效的管理,狀態在什麼時候,由於什麼原因,如何變化就會不受控制,就很難跟蹤和測試了。如果沒有經歷過這方面的困擾,可以簡單理解為會搞得很亂就對了

在軟體開發裡,有些通用的思想,比如隔離變化,約定優於配置等,隔離變化就是說做好抽象,把一些容易變化的地方找到共性,隔離出來,不要去影響其他的程式碼。約定優於配置就是很多東西我們不一定要寫一大堆的配置,比如我們幾個人約定,view 資料夾裡只能放檢視,不能放過濾器,過濾器必須放到 filter 資料夾裡,那這就是一種約定,約定好之後,我們就不用寫一大堆配置檔案了,我們要找所有的檢視,直接從 view 資料夾裡找就行。

根據這些思想,對於狀態管理的解決思路就是:把元件之間需要共享的狀態抽取出來,遵循特定的約定,統一來管理,讓狀態的變化可以預測。根據這個思路,產生了很多的模式和庫,我們來挨個聊聊。

Store 模式

最簡單的處理就是把狀態存到一個外部變數裡面,比如:this.$root.$data,當然也可以是一個全域性變數。但是這樣有一個問題,就是資料改變後,不會留下變更過的記錄,這樣不利於除錯。

所以我們稍微搞得複雜一點,用一個簡單的 Store 模式:

var store = {
  state: {
    message: 'Hello!'
  },
  setMessageAction (newValue) {
    // 發生改變記錄點日誌啥的
    this.state.message = newValue
  },
  clearMessageAction () {
    this.state.message = ''
  }
}
複製程式碼

store 的 state 來存資料,store 裡面有一堆的 action,這些 action 來控制 state 的改變,也就是不直接去對 state 做改變,而是通過 action 來改變,因為都走 action,我們就可以知道到底改變(mutation)是如何被觸發的,出現錯誤,也可以記錄記錄日誌啥的。

image.png | center | 519x657

不過這裡沒有限制元件裡面不能修改 store 裡面的 state,萬一元件瞎胡修改,不通過 action,那我們也沒法跟蹤這些修改是怎麼發生的。所以就需要規定一下,元件不允許直接修改屬於 store 例項的 state,元件必須通過 action 來改變 state,也就是說,元件裡面應該執行 action 來分發 (dispatch) 事件通知 store 去改變。這樣約定的好處是,我們能夠記錄所有 store 中發生的 state 改變,同時實現能做到記錄變更 (mutation)、儲存狀態快照、歷史回滾/時光旅行的先進的除錯工具。

這樣進化了一下,一個簡單的 Flux 架構就實現了。

Flux

Flux其實是一種思想,就像MVC,MVVM之類的,他給出了一些基本概念,所有的框架都可以根據他的思想來做一些實現。

Flux把一個應用分成了4個部分:

  • View
  • Action
  • Dispatcher
  • Store

image.png | center | 827x250

比如我們搞一個應用,顯而易見,這個應用裡面會有一堆的 View,這個 View 可以是Vue的,也可以是 React的,啥框架都行,啥技術都行。

View 肯定是要展示資料的,所謂的資料,就是 Store,Store 很容易明白,就是存資料的地方。當然我們可以把 Store 都放到一起,也可以分開來放,所以就有一堆的 Store。但是這些 View 都有一個特點,就是 Store 變了得跟著變。

View 怎麼跟著變呢?一般 Store 一旦發生改變,都會往外面傳送一個事件,比如 change,通知所有的訂閱者。View 通過訂閱也好,監聽也好,不同的框架有不同的技術,反正 Store 變了,View 就會變。

View 不是光用來看的,一般都會有使用者操作,使用者點個按鈕,改個表單啥的,就需要修改 Store。Flux 要求,View 要想修改 Store,必須經過一套流程,有點像我們剛才 Store 模式裡面說的那樣。檢視先要告訴 Dispatcher,讓 Dispatcher dispatch 一個 action,Dispatcher 就像是個中轉站,收到 View 發出的 action,然後轉發給 Store。比如新建一個使用者,View 會發出一個叫 addUser 的 action 通過 Dispatcher 來轉發,Dispatcher 會把 addUser 這個 action 發給所有的 store,store 就會觸發 addUser 這個 action,來更新資料。資料一更新,那麼 View 也就跟著更新了。

這個過程有幾個需要注意的點:

  • Dispatcher 的作用是接收所有的 Action,然後發給所有的 Store。這裡的 Action 可能是 View 觸發的,也有可能是其他地方觸發的,比如測試用例。轉發的話也不是轉發給某個 Store,而是所有 Store。
  • Store 的改變只能通過 Action,不能通過其他方式。也就是說 Store 不應該有公開的 Setter,所有 Setter 都應該是私有的,只能有公開的 Getter。具體 Action 的處理邏輯一般放在 Store 裡。

聽聽描述看看圖,可以發現,Flux的最大特點就是資料都是單向流動的。

Redux

Flux 有一些缺點(特點),比如一個應用可以擁有多個 Store,多個Store之間可能有依賴關係;Store 封裝了資料還有處理資料的邏輯。

所以大家在使用的時候,一般會用 Redux,他和 Flux 思想比較類似,也有差別。

image.png | center | 827x380

Store

Redux 裡面只有一個 Store,整個應用的資料都在這個大 Store 裡面。Store 的 State 不能直接修改,每次只能返回一個新的 State。Redux 整了一個 createStore 函式來生成 Store。

import { createStore } from 'redux';
const store = createStore(fn);
複製程式碼

Store 允許使用  store.subscribe  方法設定監聽函式,一旦 State 發生變化,就自動執行這個函式。這樣不管 View 是用什麼實現的,只要把 View 的更新函式 subscribe 一下,就可以實現 State 變化之後,View 自動渲染了。比如在 React 裡,把元件的render方法或setState方法訂閱進去就行。

Action

和 Flux  一樣,Redux 裡面也有 Action,Action 就是 View 發出的通知,告訴 Store State 要改變。Action 必須有一個 type 屬性,代表 Action 的名稱,其他可以設定一堆屬性,作為引數供 State 變更時參考。

const action = {
  type: 'ADD_TODO',
  payload: 'Learn Redux'
};
複製程式碼

Redux 可以用 Action Creator 批量來生成一些 Action。

Reducer

Redux 沒有 Dispatcher 的概念,Store 裡面已經整合了 dispatch 方法。store.dispatch()是 View 發出 Action 的唯一方法。

import { createStore } from 'redux';
const store = createStore(fn);

store.dispatch({
  type: 'ADD_TODO',
  payload: 'Learn Redux'
});
複製程式碼

Redux 用一個叫做 Reducer 的純函式來處理事件。Store 收到 Action 以後,必須給出一個新的 State(就是剛才說的Store 的 State 不能直接修改,每次只能返回一個新的 State),這樣 View 才會發生變化。這種 State 的計算過程就叫做 Reducer。

什麼是純函式呢,就是說沒有任何的副作用,比如這樣一個函式:

function getAge(user) {
  user.age = user.age + 1;
  return user.age;
}
複製程式碼

這個函式就有副作用,每一次相同的輸入,都可能導致不同的輸出,而且還會影響輸入 user 的值,再比如:

let b = 10;
function compare(a) {
  return a >= b;
}
複製程式碼

這個函式也有副作用,就是依賴外部的環境,b 在別處被改變了,返回值對於相同的 a 就有可能不一樣。

而 Reducer 是一個純函式,對於相同的輸入,永遠都只會有相同的輸出,不會影響外部的變數,也不會被外部變數影響,不得改寫引數。它的作用大概就是這樣,根據應用的狀態和當前的 action 推匯出新的 state:

(previousState, action) => newState
複製程式碼

類比 Flux,Flux 有些像:

 (state, action) => state
複製程式碼

為什麼叫做 Reducer 呢?reduce  是一個函數語言程式設計的概念,經常和  map  放在一起說,簡單來說,map  就是對映,reduce  就是歸納。對映就是把一個列表按照一定規則對映成另一個列表,而 reduce 是把一個列表通過一定規則進行合併,也可以理解為對初始值進行一系列的操作,返回一個新的值。

比如  Array 就有一個方法叫 reduce,Array.prototype.reduce(reducer, ?initialValue),把 Array 整吧整吧弄成一個  newValue。

const array1 = [1, 2, 3, 4];
const reducer = (accumulator, currentValue) => accumulator + currentValue;

// 1 + 2 + 3 + 4
console.log(array1.reduce(reducer));
// expected output: 10

// 5 + 1 + 2 + 3 + 4
console.log(array1.reduce(reducer, 5));
// expected output: 15
複製程式碼

看起來和 Redux 的 Reducer 是不是好像好像,Redux 的 Reducer 就是 reduce 一個列表(action的列表)和一個 initialValue(初始的  State)到一個新的 value(新的  State)。

把上面的概念連起來,舉個例子:

下面的程式碼宣告瞭 reducer:

const defaultState = 0;
const reducer = (state = defaultState, action) => {
  switch (action.type) {
    case 'ADD':
      return state + action.payload;
    default: 
      return state;
  }
};
複製程式碼

createStore接受 Reducer 作為引數,生成一個新的 Store。以後每當store.dispatch傳送過來一個新的 Action,就會自動呼叫 Reducer,得到新的 State。

import { createStore } from 'redux';
const store = createStore(reducer);
複製程式碼

createStore 內部幹了什麼事兒呢?通過一個簡單的 createStore 的實現,可以瞭解大概的原理(可以略過不看):

const createStore = (reducer) => {
  let state;
  let listeners = [];

  const getState = () => state;

  const dispatch = (action) => {
    state = reducer(state, action);
    listeners.forEach(listener => listener());
  };

  const subscribe = (listener) => {
    listeners.push(listener);
    return () => {
      listeners = listeners.filter(l => l !== listener);
    }
  };

  dispatch({});

  return { getState, dispatch, subscribe };
};
複製程式碼

Redux 有很多的 Reducer,對於大型應用來說,State 必然十分龐大,導致 Reducer 函式也十分龐大,所以需要做拆分。Redux  裡每一個 Reducer 負責維護 State 樹裡面的一部分資料,多個 Reducer 可以通過 combineReducers 方法合成一個根 Reducer,這個根 Reducer 負責維護整個 State。

import { combineReducers } from 'redux';

// 注意這種簡寫形式,State 的屬性名必須與子 Reducer 同名
const chatReducer = combineReducers({
  Reducer1,
  Reducer2,
  Reducer3
})
複製程式碼

combineReducers 幹了什麼事兒呢?通過簡單的 combineReducers 的實現,可以瞭解大概的原理(可以略過不看):

const combineReducers = reducers => {
  return (state = {}, action) => {
    return Object.keys(reducers).reduce(
      (nextState, key) => {
        nextState[key] = reducers[key](state[key], action);
        return nextState;
      },
      {} 
    );
  };
};
複製程式碼

流程

image.png | center | 827x380

再回顧一下剛才的流程圖,嘗試走一遍  Redux  流程:

1、使用者通過 View 發出 Action:

store.dispatch(action);
複製程式碼

2、然後 Store 自動呼叫 Reducer,並且傳入兩個引數:當前 State 和收到的 Action。 Reducer 會返回新的 State 。

let nextState = xxxReducer(previousState, action);
複製程式碼

3、State 一旦有變化,Store 就會呼叫監聽函式。

store.subscribe(listener);
複製程式碼

4、listener可以通過  store.getState()  得到當前狀態。如果使用的是 React,這時可以觸發重新渲染 View。

function listerner() {
  let newState = store.getState();
  component.setState(newState);   
}
複製程式碼

對比 Flux

和  Flux  比較一下:Flux 中 Store 是各自為戰的,每個 Store 只對對應的 View 負責,每次更新都只通知對應的View:

image.png | left | 827x429

Redux 中各子 Reducer 都是由根 Reducer 統一管理的,每個子 Reducer 的變化都要經過根 Reducer 的整合:

image.png | left | 827x395

簡單來說,Redux有三大原則:

  • 單一資料來源:Flux 的資料來源可以是多個。
  • State 是隻讀的:Flux 的 State 可以隨便改。
  • 使用純函式來執行修改:Flux 執行修改的不一定是純函式。

Redux 和 Flux 一樣都是單向資料流

中介軟體

剛才說到的都是比較理想的同步狀態。在實際專案中,一般都會有同步和非同步操作,所以 Flux、Redux 之類的思想,最終都要落地到同步非同步的處理中來。

在  Redux  中,同步的表現就是:Action 發出以後,Reducer 立即算出 State。那麼非同步的表現就是:Action 發出以後,過一段時間再執行 Reducer。

那怎麼才能 Reducer 在非同步操作結束後自動執行呢?Redux 引入了中介軟體 Middleware 的概念。

其實我們重新回顧一下剛才的流程,可以發現每一個步驟都很純粹,都不太適合加入非同步的操作,比如 Reducer,純函式,肯定不能承擔非同步操作,那樣會被外部IO干擾。Action呢,就是一個純物件,放不了操作。那想來想去,只能在 View 裡傳送 Action 的時候,加上一些非同步操作了。比如下面的程式碼,給原來的  dispatch  方法包裹了一層,加上了一些日誌列印的功能:

let next = store.dispatch;
store.dispatch = function dispatchAndLog(action) {
  console.log('dispatching', action);
  next(action);
  console.log('next state', store.getState());
}
複製程式碼

既然能加日誌列印,當然也能加入非同步操作。所以中介軟體簡單來說,就是對 store.dispatch 方法進行一些改造的函式。不展開說了,所以如果想詳細瞭解中介軟體,可以點這裡

Redux 提供了一個 applyMiddleware 方法來應用中介軟體:

const store = createStore(
  reducer,
  applyMiddleware(thunk, promise, logger)
);
複製程式碼

這個方法主要就是把所有的中介軟體組成一個陣列,依次執行。也就是說,任何被髮送到 store 的 action 現在都會經過thunk,promise,logger 這幾個中介軟體了。

處理非同步

對於非同步操作來說,有兩個非常關鍵的時刻:發起請求的時刻,和接收到響應的時刻(可能成功,也可能失敗或者超時),這兩個時刻都可能會更改應用的 state。一般是這樣一個過程:

  1. 請求開始時,dispatch  一個請求開始 Action,觸發 State 更新為“正在請求”狀態,View 重新渲染,比如展現個Loading啥的。
  2. 請求結束後,如果成功,dispatch  一個請求成功 Action,隱藏掉  Loading,把新的資料更新到  State;如果失敗,dispatch  一個請求失敗 Action,隱藏掉  Loading,給個失敗提示。

顯然,用  Redux  處理非同步,可以自己寫中介軟體來處理,當然大多數人會選擇一些現成的支援非同步處理的中介軟體。比如 redux-thunk 或 redux-promise 。

Redux-thunk

thunk 比較簡單,沒有做太多的封裝,把大部分自主權交給了使用者:

const createFetchDataAction = function(id) {
    return function(dispatch, getState) {
        // 開始請求,dispatch 一個 FETCH_DATA_START action
        dispatch({
            type: FETCH_DATA_START, 
            payload: id
        })
        api.fetchData(id) 
            .then(response => {
                // 請求成功,dispatch 一個 FETCH_DATA_SUCCESS action
                dispatch({
                    type: FETCH_DATA_SUCCESS,
                    payload: response
                })
            })
            .catch(error => {
                // 請求失敗,dispatch 一個 FETCH_DATA_FAILED action   
                dispatch({
                    type: FETCH_DATA_FAILED,
                    payload: error
                })
            }) 
    }
}

//reducer
const reducer = function(oldState, action) {
    switch(action.type) {
    case FETCH_DATA_START : 
        // 處理 loading 等
    case FETCH_DATA_SUCCESS : 
        // 更新 store 等
    case FETCH_DATA_FAILED : 
        // 提示異常
    }
}
複製程式碼

缺點就是使用者要寫的程式碼有點多,可以看到上面的程式碼比較囉嗦,一個請求就要搞這麼一套東西。

Redux-promise

redus-promise 和 redux-thunk 的思想類似,只不過做了一些簡化,成功失敗手動 dispatch 被封裝成自動了:

const FETCH_DATA = 'FETCH_DATA'
//action creator
const getData = function(id) {
    return {
        type: FETCH_DATA,
        payload: api.fetchData(id) // 直接將 promise 作為 payload
    }
}
//reducer
const reducer = function(oldState, action) {
    switch(action.type) {
    case FETCH_DATA: 
        if (action.status === 'success') {
             // 更新 store 等處理
        } else {
                // 提示異常
        }
    }
}
複製程式碼

剛才的什麼 then、catch 之類的被中介軟體自行處理了,程式碼簡單不少,不過要處理 Loading 啥的,還需要寫額外的程式碼。

其實任何時候都是這樣:**封裝少,自由度高,但是程式碼就會變複雜;封裝多,程式碼變簡單了,但是自由度就會變差。**redux-thunk 和 redux-promise 剛好就是代表這兩個面。

redux-thunk 和 redux-promise  的具體使用就不介紹了,這裡只聊一下大概的思路。大部分簡單的非同步業務場景,redux-thunk 或者 redux-promise 都可以滿足了。


上面說的 Flux 和 Redux,和具體的前端框架沒有什麼關係,只是思想和約定層面。下面就要和我們常用的 Vue 或 React 結合起來了:

Vuex

Vuex 主要用於 Vue,和 Flux,Redux 的思想很類似。

image.png | center | 701x551

Store

每一個 Vuex 裡面有一個全域性的 Store,包含著應用中的狀態 State,這個 State 只是需要在元件中共享的資料,不用放所有的 State,沒必要。這個 State 是單一的,和 Redux 類似,所以,一個應用僅會包含一個 Store 例項。單一狀態樹的好處是能夠直接地定位任一特定的狀態片段,在除錯的過程中也能輕易地取得整個當前應用狀態的快照。

Vuex通過 store 選項,把 state 注入到了整個應用中,這樣子元件能通過 this.$store 訪問到 state 了。

const app = new Vue({
  el: '#app',
  // 把 store 物件提供給 “store” 選項,這可以把 store 的例項注入所有的子元件
  store,
  components: { Counter },
  template: `
    <div class="app">
      <counter></counter>
    </div>
  `
})
複製程式碼
const Counter = {
  template: `<div>{{ count }}</div>`,
  computed: {
    count () {
      return this.$store.state.count
    }
  }
}
複製程式碼

State 改變,View 就會跟著改變,這個改變利用的是 Vue 的響應式機制。

Mutation

顯而易見,State 不能直接改,需要通過一個約定的方式,這個方式在 Vuex 裡面叫做 mutation,更改 Vuex 的 store 中的狀態的唯一方法是提交 mutation。Vuex 中的 mutation 非常類似於事件:每個 mutation 都有一個字串的 事件型別 (type) 和 一個 回撥函式 (handler)。

const store = new Vuex.Store({
  state: {
    count: 1
  },
  mutations: {
    increment (state) {
      // 變更狀態
      state.count++
    }
  }
})
複製程式碼

觸發 mutation 事件的方式不是直接呼叫,比如 increment(state)  是不行的,而要通過 store.commit 方法:

store.commit('increment')
複製程式碼

注意:mutation 都是同步事務

mutation 有些類似 Redux 的 Reducer,但是 Vuex 不要求每次都搞一個新的 State,可以直接修改 State,這塊兒又和 Flux 有些類似。具尤大的說法,Redux 強制的 immutability,在保證了每一次狀態變化都能追蹤的情況下強制的 immutability 帶來的收益很有限,為了同構而設計的 API 很繁瑣,必須依賴第三方庫才能相對高效率地獲得狀態樹的區域性狀態,這些都是 Redux 不足的地方,所以也被 Vuex 舍掉了。

到這裡,其實可以感覺到 Flux、Redux、Vuex 三個的思想都差不多,在具體細節上有一些差異,總的來說都是讓 View 通過某種方式觸發 Store 的事件或方法,Store 的事件或方法對 State 進行修改或返回一個新的 State,State 改變之後,View 發生響應式改變。

Action

到這裡又該處理非同步這塊兒了。mutation 是必須同步的,這個很好理解,和之前的  reducer 類似,不同步修改的話,會很難除錯,不知道改變什麼時候發生,也很難確定先後順序,A、B兩個 mutation,呼叫順序可能是 A -> B,但是最終改變 State 的結果可能是 B -> A。

對比Redux的中介軟體,Vuex 加入了 Action 這個東西來處理非同步,Vuex的想法是把同步和非同步拆分開,非同步操作想咋搞咋搞,但是不要干擾了同步操作。View 通過 store.dispatch('increment') 來觸發某個 Action,Action 裡面不管執行多少非同步操作,完事之後都通過 store.commit('increment') 來觸發 mutation,一個 Action 裡面可以觸發多個 mutation。所以 Vuex 的Action 類似於一個靈活好用的中介軟體。

Vuex 把同步和非同步操作通過 mutation 和 Action 來分開處理,是一種方式。但不代表是唯一的方式,還有很多方式,比如就不用 Action,而是在應用內部呼叫非同步請求,請求完畢直接 commit mutation,當然也可以。

Vuex 還引入了 Getter,這個可有可無,只不過是方便計算屬性的複用。

Vuex 單一狀態樹並不影響模組化,把 State 拆了,最後組合在一起就行。Vuex 引入了 Module 的概念,每個 Module 有自己的 state、mutation、action、getter,其實就是把一個大的 Store 拆開。

總的來看,Vuex 的方式比較清晰,適合 Vue 的思想,在實際開發中也比較方便。

對比Redux

Redux: view——>actions——>reducer——>state變化——>view變化(同步非同步一樣)

Vuex: view——>commit——>mutations——>state變化——>view變化(同步操作) view——>dispatch——>actions——>mutations——>state變化——>view變化(非同步操作)

React-redux

Redux 和 Flux 類似,只是一種思想或者規範,它和 React 之間沒有關係。Redux 支援 React、Angular、Ember、jQuery 甚至純 JavaScript。

但是因為 React 包含函式式的思想,也是單向資料流,和 Redux 很搭,所以一般都用  Redux 來進行狀態管理。為了簡單處理  Redux  和 React  UI  的繫結,一般通過一個叫 react-redux 的庫和 React 配合使用,這個是  react  官方出的(如果不用 react-redux,那麼手動處理 Redux 和 UI 的繫結,需要寫很多重複的程式碼,很容易出錯,而且有很多 UI 渲染邏輯的優化不一定能處理好)。

Redux將React元件分為容器型元件和展示型元件,容器型元件一般通過connect函式生成,它訂閱了全域性狀態的變化,通過mapStateToProps函式,可以對全域性狀態進行過濾,而展示型元件不直接從global state獲取資料,其資料來源於父元件。

image.png | center | 827x268

如果一個元件既需要UI呈現,又需要業務邏輯處理,那就得拆,拆成一個容器元件包著一個展示元件。

因為 react-redux 只是 redux 和 react 結合的一種實現,除了剛才說的元件拆分,並沒有什麼新奇的東西,所以只拿一個簡單TODO專案的部分程式碼來舉例:

入口檔案 index.js,把 redux 的相關 store、reducer 通過 Provider 註冊到 App 裡面,這樣子元件就可以拿到  store  了。

import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import rootReducer from './reducers'
import App from './components/App'

const store = createStore(rootReducer)

render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)
複製程式碼

actions/index.js,建立 Action:

let nextTodoId = 0
export const addTodo = text => ({
  type: 'ADD_TODO',
  id: nextTodoId++,
  text
})

export const setVisibilityFilter = filter => ({
  type: 'SET_VISIBILITY_FILTER',
  filter
})

export const toggleTodo = id => ({
  type: 'TOGGLE_TODO',
  id
})

export const VisibilityFilters = {
  SHOW_ALL: 'SHOW_ALL',
  SHOW_COMPLETED: 'SHOW_COMPLETED',
  SHOW_ACTIVE: 'SHOW_ACTIVE'
}
複製程式碼

reducers/todos.js,建立 Reducers:

const todos = (state = [], action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return [
        ...state,
        {
          id: action.id,
          text: action.text,
          completed: false
        }
      ]
    case 'TOGGLE_TODO':
      return state.map(todo =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      )
    default:
      return state
  }
}

export default todos
複製程式碼

reducers/index.js,把所有的 Reducers 繫結到一起:

import { combineReducers } from 'redux'
import todos from './todos'
import visibilityFilter from './visibilityFilter'

export default combineReducers({
  todos,
  visibilityFilter,
  ...
})
複製程式碼

containers/VisibleTodoList.js,容器元件,connect 負責連線React元件和Redux Store:

import { connect } from 'react-redux'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'

const getVisibleTodos = (todos, filter) => {
  switch (filter) {
    case 'SHOW_COMPLETED':
      return todos.filter(t => t.completed)
    case 'SHOW_ACTIVE':
      return todos.filter(t => !t.completed)
    case 'SHOW_ALL':
    default:
      return todos
  }
}

// mapStateToProps 函式指定如何把當前 Redux store state 對映到展示元件的 props 中
const mapStateToProps = state => ({
  todos: getVisibleTodos(state.todos, state.visibilityFilter)
})

// mapDispatchToProps 方法接收 dispatch() 方法並返回期望注入到展示元件的 props 中的回撥方法。
const mapDispatchToProps = dispatch => ({
  toggleTodo: id => dispatch(toggleTodo(id))
})

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)
複製程式碼

簡單來說,react-redux 就是多了個 connect 方法連線容器元件和UI元件,這裡的“連線”就是一種對映:

  • mapStateToProps  把容器元件的 state 對映到UI元件的 props
  • mapDispatchToProps 把UI元件的事件對映到 dispatch 方法

Redux-saga

剛才介紹了兩個Redux 處理非同步的中介軟體 redux-thunk 和 redux-promise,當然 redux 的非同步中介軟體還有很多,他們可以處理大部分場景,這些中介軟體的思想基本上都是把非同步請求部分放在了  action  creator  中,理解起來比較簡單。

redux-saga 採用了另外一種思路,它沒有把非同步操作放在 action creator 中,也沒有去處理 reductor,而是把所有的非同步操作看成“執行緒”,可以通過普通的action去觸發它,當操作完成時也會觸發action作為輸出。saga 的意思本來就是一連串的事件。

redux-saga 把非同步獲取資料這類的操作都叫做副作用(Side  Effect),它的目標就是把這些副作用管理好,讓他們執行更高效,測試更簡單,在處理故障時更容易。

在聊 redux-saga 之前,需要熟悉一些預備知識,那就是 ES6 的 Generator

如果從沒接觸過 Generator 的話,看著下面的程式碼,給你個1分鐘傻瓜式速成,函式加個星號就是 Generator 函式了,Generator 就是個罵街生成器,Generator 函式裡可以寫一堆 yield 關鍵字,可以記成“丫的”,Generator 函式執行的時候,啥都不幹,就等著呼叫 next 方法,按照順序把標記為“丫的”的地方一個一個拎出來罵(遍歷執行),罵到最後沒有“丫的”標記了,就返回最後的return值,然後標記為 done: true,也就是罵完了(上面只是幫助初學者記憶,別噴~)。

function* helloWorldGenerator() {
  yield 'hello';
  yield 'world';
  return 'ending';
}

var hw = helloWorldGenerator();

hw.next() // 先把 'hello' 拎出來,done: false 代表還沒罵完
// { value: 'hello', done: false } next() 方法有固定的格式,value 是返回值,done 代表是否遍歷結束

hw.next() // 再把 'world' 拎出來,done: false 代表還沒罵完
// { value: 'world', done: false }

hw.next() // 沒有 yield 了,就把最後的 return 'ending' 拎出來,done: true 代表罵完了
// { value: 'ending', done: true }

hw.next() // 沒有 yield,也沒有 return 了,真的罵完了,只能擠出來一個 undefined 了,done: true 代表罵完了
// { value: undefined, done: true }
複製程式碼

這樣搞有啥好處呢?我們發現 Generator 函式的很多程式碼可以被延緩執行,也就是具備了暫停和記憶的功能:遇到yield表示式,就暫停執行後面的操作,並將緊跟在yield後面的那個表示式的值,作為返回的物件的value屬性值,等著下一次呼叫next方法時,再繼續往下執行。用 Generator 來寫非同步程式碼,大概長這樣:

function* gen(){
  var url = 'https://api.github.com/users/github';
  var jsonData = yield fetch(url);
  console.log(jsonData);
}

var g = gen();
var result = g.next(); 
// 這裡的result是 { value: fetch('https://api.github.com/users/github'), done: true }

// fetch(url) 是一個 Promise,所以需要 then 來執行下一步
result.value.then(function(data){
  return data.json();
}).then(function(data){
  // 獲取到 json data,然後作為引數呼叫 next,相當於把 data 傳給了 jsonData,然後執行 console.log(jsonData);
  g.next(data);
});
複製程式碼

再回到 redux-saga 來,可以把 saga 想象成開了一個以最快速度不斷地呼叫 next 方法並嘗試獲取所有 yield 表示式值的執行緒。舉個例子:

// saga.js
import { take, put } from 'redux-saga/effects'

function* mySaga(){ 
    // 阻塞: take方法就是等待 USER_INTERACTED_WITH_UI_ACTION 這個 action 執行
    yield take(USER_INTERACTED_WITH_UI_ACTION);
    // 阻塞: put方法將同步發起一個 action
    yield put(SHOW_LOADING_ACTION, {isLoading: true});
    // 阻塞: 將等待 FetchFn 結束,等待返回的 Promise
    const data = yield call(FetchFn, 'https://my.server.com/getdata');
    // 阻塞: 將同步發起 action (使用剛才返回的 Promise.then)
    yield put(SHOW_DATA_ACTION, {data: data});
}
複製程式碼

這裡用了好幾個yield,簡單理解,也就是每個 yield 都發起了阻塞,saga 會等待執行結果返回,再執行下一指令。也就是相當於take、put、call、put 這幾個方法的呼叫變成了同步的,上面的全部完成返回了,才會執行下面的,類似於 await。

用了 saga,我們就可以很細粒度的控制各個副作用每一部的操作,可以把非同步操作和同步發起 action 一起,隨便的排列組合。saga 還提供 takeEvery、takeLatest 之類的輔助函式,來控制是否允許多個非同步請求同時執行,尤其是 takeLatest,方便處理由於網路延遲造成的多次請求資料衝突或混亂的問題。

saga 看起來很複雜,主要原因可能是因為大家不熟悉 Generator 的語法,還有需要學習一堆新增的 API 。如果拋開這些記憶的東西,改造一下,再來看一下程式碼:

function mySaga(){ 
    if (action.type === 'USER_INTERACTED_WITH_UI_ACTION') {
        store.dispatch({ type: 'SHOW_LOADING_ACTION', isLoading: true});
        const data = await Fetch('https://my.server.com/getdata');
        store.dispatch({ type: 'SHOW_DATA_ACTION', data: data});
    }
}
複製程式碼

上面的程式碼就很清晰了吧,全部都是同步的寫法,無比順暢,當然直接這樣寫是不支援的,所以那些 Generator 語法和API,無非就是做一些適配而已。

saga 還能很方便的並行執行非同步任務,或者讓兩個非同步任務競爭:

// 並行執行,並等待所有的結果,類似 Promise.all 的行為
const [users, repos] = yield [
  call(fetch, '/users'),
  call(fetch, '/repos')
]

// 並行執行,哪個先完成返回哪個,剩下的就取消掉了
const {posts, timeout} = yield race({
  posts: call(fetchApi, '/posts'),
  timeout: call(delay, 1000)
})
複製程式碼

saga 的每一步都可以做一些斷言(assert)之類的,所以非常方便測試。而且很容易測試到不同的分支。

這裡不討論更多 saga 的細節,大家瞭解 saga 的思想就行,細節請看文件

對比 Redux-thunk

image.png | center | 827x271

比較一下 redux-thunk 和 redux-saga 的程式碼:

image.png | center

image.png | center

和 redux-thunk 等其他非同步中介軟體對比來說,redux-saga 主要有下面幾個特點:

  • 非同步資料獲取的相關業務邏輯放在了單獨的 saga.js 中,不再是摻雜在 action.js 或 component.js 中。
  • dispatch 的引數是標準的  action,沒有魔法。
  • saga 程式碼採用類似同步的方式書寫,程式碼變得更易讀。
  • 程式碼異常/請求失敗 都可以直接通過 try/catch 語法直接捕獲處理。
  • 很容易測試,如果是 thunk 的 Promise,測試的話就需要不停的 mock 不同的資料。

其實 redux-saga 是用一些學習的複雜度,換來了程式碼的高可維護性,還是很值得在專案中使用的。

Dva

Dva是什麼呢?官方的定義是:dva 首先是一個基於 redux 和 redux-saga 的資料流方案,然後為了簡化開發體驗,dva 還額外內建了 react-router 和 fetch,所以也可以理解為一個輕量級的應用框架。

簡單理解,就是讓使用 react-redux 和 redux-saga 編寫的程式碼組織起來更合理,維護起來更方便。

之前我們聊了 redux、react-redux、redux-saga 之類的概念,大家肯定覺得頭昏腦漲的,什麼 action、reducer、saga 之類的,寫一個功能要在這些js檔案裡面不停的切換。

dva 做的事情很簡單,就是讓這些東西可以寫到一起,不用分開來寫了。比如:

app.model({
  // namespace - 對應 reducer 在 combine 到 rootReducer 時的 key 值
  namespace: 'products',
  // state - 對應 reducer 的 initialState
  state: {
    list: [],
    loading: false,
  },
  // subscription - 在 dom ready 後執行
  subscriptions: [
    function(dispatch) {
      dispatch({type: 'products/query'});
    },
  ],
  // effects - 對應 saga,並簡化了使用
  effects: {
    ['products/query']: function*() {
      yield call(delay(800));
      yield put({
        type: 'products/query/success',
        payload: ['ant-tool', 'roof'],
      });
    },
  },
  // reducers - 就是傳統的 reducers
  reducers: {
    ['products/query'](state) {
      return { ...state, loading: true, };
    },
    ['products/query/success'](state, { payload }) {
      return { ...state, loading: false, list: payload };
    },
  },
});
複製程式碼

以前書寫的方式是建立  sagas/products.js, reducers/products.js 和 actions/products.js,然後把 saga、action、reducer 啥的分開來寫,來回切換,現在寫在一起就方便多了。

比如傳統的 TODO 應用,用 redux + redux-saga 來表示結構,就是這樣:

image.png | center | 827x558

saga 攔截 add 這個 action, 發起 http 請求, 如果請求成功, 則繼續向 reducer 發一個 addTodoSuccess 的 action, 提示建立成功, 反之則傳送 addTodoFail 的 action 即可。

如果使用 Dva,那麼結構圖如下:

image.png | center | 827x542

整個結構變化不大,最主要的就是把 store 及 saga 統一為一個 model 的概念(有點類似 Vuex 的 Module),寫在了一個 js 檔案裡。增加了一個 Subscriptions, 用於收集其他來源的 action,比如快捷鍵操作。

app.model({
  namespace: 'count',
  state: {
    record: 0,
    current: 0,
  },
  reducers: {
    add(state) {
      const newCurrent = state.current + 1;
      return { ...state,
        record: newCurrent > state.record ? newCurrent : state.record,
        current: newCurrent,
      };
    },
    minus(state) {
      return { ...state, current: state.current - 1};
    },
  },
  effects: {
    *add(action, { call, put }) {
      yield call(delay, 1000);
      yield put({ type: 'minus' });
    },
  },
  subscriptions: {
    keyboardWatcher({ dispatch }) {
      key('⌘+up, ctrl+up', () => { dispatch({type:'add'}) });
    },
  },
});
複製程式碼

之前我們說過約定優於配置的思想,Dva正式借鑑了這個思想。

MobX

前面扯了這麼多,其實還都是 Flux 體系的,都是單向資料流方案。接下來要說的 MobX,就和他們不太一樣了。

我們先清空一下大腦,回到初心,什麼是初心?就是我們最初要解決的問題是什麼?最初我們其實為了解決應用狀態管理的問題,不管是 Redux 還是 MobX,把狀態管理好是前提。什麼叫把狀態管理好,簡單來說就是:統一維護公共的應用狀態,以統一併且可控的方式更新狀態,狀態更新後,View跟著更新。不管是什麼思想,達成這個目標就ok。

Flux 體系的狀態管理方式,只是一個選項,但並不代表是唯一的選項。MobX 就是另一個選項。

MobX背後的哲學很簡單:**任何源自應用狀態的東西都應該自動地獲得。**譯成人話就是狀態只要一變,其他用到狀態的地方就都跟著自動變。

image.png | center

看這篇文章的人,大概率會對物件導向的思想比較熟悉,而對函數語言程式設計的思想略陌生。Flux 或者說 Redux 的思想主要就是函數語言程式設計(FP)的思想,所以學習起來會覺得累一些。而 MobX 更接近於物件導向程式設計,它把 state 包裝成可觀察的物件,這個物件會驅動各種改變。什麼是可觀察?就是 MobX 老大哥在看著 state 呢。state 只要一改變,所有用到它的地方就都跟著改變了。這樣整個 View 可以被 state 來驅動。

const obj = observable({
    a: 1,
    b: 2
})

autoRun(() => {
    console.log(obj.a)
})

obj.b = 3 // 什麼都沒有發生
obj.a = 2 // observe 函式的回撥觸發了,控制檯輸出:2
複製程式碼

上面的obj,他的 obj.a 屬性被使用了,那麼只要 obj.a 屬性一變,所有使用的地方都會被呼叫。autoRun 就是這個老大哥,他看著所有依賴 obj.a 的地方,也就是收集所有對 obj.a 的依賴。當 obj.a 改變時,老大哥就會觸發所有依賴去更新。

MobX 允許有多個 store,而且這些 store 裡的 state 可以直接修改,不用像 Redux 那樣每次還返回個新的。這個有點像 Vuex,自由度更高,寫的程式碼更少。不過它也會讓程式碼不好維護。

MobX 和 Flux、Redux 一樣,都是和具體的前端框架無關的,也就是說可以用於 React(mobx-react) 或者 Vue(mobx-vue)。一般來說,用到 React 比較常見,很少用於 Vue,因為 Vuex 本身就類似 MobX,很靈活。如果我們把 MobX 用於 React  或者  Vue,可以看到很多 setState() 和 this.state.xxx = 這樣的處理都可以省了。

還是和上面一樣,只介紹思想。具體 MobX 的使用,可以看這裡

對比 Redux

我們直觀地上兩坨實現計數器程式碼:

0febb7ccd7aa4fc6838748948fd1299e.gif | center

Redux:

import React, { Component } from 'react';
import {
  createStore,
  bindActionCreators,
} from 'redux';
import { Provider, connect } from 'react-redux';

// ①action types
const COUNTER_ADD = 'counter_add';
const COUNTER_DEC = 'counter_dec';

const initialState = {a: 0};
// ②reducers
function reducers(state = initialState, action) {
  switch (action.type) {
  case COUNTER_ADD:
    return {...state, a: state.a+1};
  case COUNTER_DEC:
    return {...state, a: state.a-1};
  default:
    return state
  }
}

// ③action creator
const incA = () => ({ type: COUNTER_ADD });
const decA = () => ({ type: COUNTER_DEC });
const Actions = {incA, decA};

class Demo extends Component {
  render() {
    const { store, actions } = this.props;
    return (
      <div>
        <p>a = {store.a}</p>
        <p>
          <button className="ui-btn" onClick={actions.incA}>增加 a</button>
          <button className="ui-btn" onClick={actions.decA}>減少 a</button>
        </p>
      </div>
    );
  }
}

// ④將state、actions 對映到元件 props
const mapStateToProps = state => ({store: state});
const mapDispatchToProps = dispatch => ({
  // ⑤bindActionCreators 簡化 dispatch
  actions: bindActionCreators(Actions, dispatch)
})
// ⑥connect產生容器元件
const Root = connect(
  mapStateToProps,
  mapDispatchToProps
)(Demo)

const store = createStore(reducers)
export default class App extends Component {
  render() {
    return (
      <Provider store={store}>
        <Root />
      </Provider>
    )
  }
}
複製程式碼

MobX:

import React, { Component } from 'react';
import { observable, action } from 'mobx';
import { Provider, observer, inject } from 'mobx-react';

// 定義資料結構
class Store {
  // ① 使用 observable decorator 
  @observable a = 0;
}

// 定義對資料的操作
class Actions {
  constructor({store}) {
    this.store = store;
  }
  // ② 使用 action decorator 
  @action
  incA = () => {
    this.store.a++;
  }
  @action
  decA = () => {
    this.store.a--;
  }
}

// ③例項化單一資料來源
const store = new Store();
// ④例項化 actions,並且和 store 進行關聯
const actions = new Actions({store});

// inject 向業務元件注入 store,actions,和 Provider 配合使用
// ⑤ 使用 inject decorator 和 observer decorator
@inject('store', 'actions')
@observer
class Demo extends Component {
  render() {
    const { store, actions } = this.props;
    return (
      <div>
        <p>a = {store.a}</p>
        <p>
          <button className="ui-btn" onClick={actions.incA}>增加 a</button>
          <button className="ui-btn" onClick={actions.decA}>減少 a</button>
        </p>
      </div>
    );
  }
}

class App extends Component {
  render() {
    // ⑥使用Provider 在被 inject 的子元件裡,可以通過 props.store props.actions 訪問
    return (
      <Provider store={store} actions={actions}>
        <Demo />
      </Provider>
    )
  }
}

export default App;
複製程式碼

比較一下:

  • Redux 資料流流動很自然,可以充分利用時間回溯的特徵,增強業務的可預測性;MobX 沒有那麼自然的資料流動,也沒有時間回溯的能力,但是 View 更新很精確,粒度控制很細。
  • Redux 通過引入一些中介軟體來處理副作用;MobX  沒有中介軟體,副作用的處理比較自由,比如依靠 autorunAsync 之類的方法。
  • Redux 的樣板程式碼更多,看起來就像是我們要做頓飯,需要先買個調料盒裝調料,再買個架子放刀叉。。。做一大堆準備工作,然後才開始炒菜;而 MobX 基本沒啥多餘程式碼,直接硬來,拿著炊具調料就開幹,搞出來為止。

但其實 Redux 和 MobX 並沒有孰優孰劣,Redux 比 Mobx 更多的樣板程式碼,是因為特定的設計約束。如果專案比較小的話,使用 MobX 會比較靈活,但是大型專案,像 MobX 這樣沒有約束,沒有最佳實踐的方式,會造成程式碼很難維護,各有利弊。一般來說,小專案建議 MobX 就夠了,大專案還是用 Redux 比較合適。

總結

時光荏苒,歲月如梭。每一個框架或者庫只能陪你走一段路,最終都會逝去。留在你心中的,不是一條一條的語法規則,而是一個一個的思想,這些思想才是推動進步的源泉。

帥哥美女,如果你都看到這裡了,那麼不點個贊,你的良心過得去麼?

參考連結

相關文章