老生常談之Flux與Redux思想

斯年發表於2019-03-25

Redux是一個通用的前端狀態管理庫,它不僅廣泛應用於 React App,在 Wepy、Flutter 等框架中也隨處可見它的身影,可謂是一招鮮吃遍天,它同時深受喜歡函數語言程式設計(Functional Programming)人們的追捧,今天我就來和大家聊一聊Redux的基本思想。

Flux

Flux是Facebook用於構建客戶端Web應用程式的基本架構,我們可以將Flux看做一種應用程式中的資料流的設計模式,而Redux正是基於Flux的核心思想實現的一套解決方案,它也得到了原作者的肯定。

首先,在Flux中會有以下幾個角色的出現:

  • Dispacher:排程器,接收到Action,並將它們傳送給Store。
  • Action:動作訊息,包含動作型別與動作描述。
  • Store:資料中心,持有應用程式的資料,並會響應Action訊息。
  • View:應用檢視,可展示Store資料,並實時響應Store的更新。

從通訊的角度還可將其視為Action請求層 -> Dispatcher傳輸層 -> Store處理層 -> View檢視層

單向資料流

Flux應用中的資料以單一方向流動:

  1. 檢視產生動作訊息,將動作傳遞給排程器。
  2. 排程器將動作訊息傳送給每一個資料中心。
  3. 資料中心再將資料傳遞給檢視。

老生常談之Flux與Redux思想

單一方向資料流還具有以下特點:

  • 集中化管理資料。常規應用可能會在檢視層的任何地方或回撥進行資料狀態的修改與儲存,而在Flux架構中,所有資料都只放在Store中進行儲存與管理。
  • 可預測性。在雙向繫結或響應式程式設計中,當一個物件改變時,可能會導致另一個物件發生改變,這樣會觸發多次級聯更新。對於Flux架構來講,一次Action觸發,只能引起一次資料流迴圈,這使得資料更加可預測。
  • 方便追蹤變化。所有引起資料變化的原因都可由Action進行描述,而Action只是一個純物件,因此十分易於序列化或檢視。

Flux的工作流

從上面的章節中我們大概知道了Flux中各個角色的職責,那現在我們再結合著簡單的程式碼示例講解一下他們是如何構成一整個工作流的:

b6682c2d.png

上圖中有一個Action Creator的概念,其實他們就是用於輔助建立Action物件,並傳遞給Dispatcher:

function addTodo(desc) {
  const action = {
    type: 'ADD_TODO',
    payload: {
      id: Date.now(),
      done: false,
      desciption: desc
    }
  }
  dispatcher(action)
}
複製程式碼

在這裡我還是希望通過程式碼的形式進行簡單的描述,會更直觀一點,首先初始化一個專案:

mkdir flux-demo && cd flux-demo
npm init -y && npm i react flux
touch index.js
複製程式碼

然後,我們建立一個Dispatcher物件,它的本質是Flux系統中的事件系統,用於觸發事件與響應回撥,而且在Flux中僅會有一個全域性的Dispatcher物件:

import { Dispatcher } from 'flux';

const TodoDispatcher = new Dispatcher();
複製程式碼

接著,註冊一個Store,響應Action方法:

import { ReduceStore } from 'flux/utils';

class TodoStore extends ReduceStore {
  constructor() {
    super(TodoDispatcher);
  }

  getInitialState() {
    return [];
  }

  reduce(state, action) {
    switch (action.type) {
      case 'ADD_TODO':
        return state.concat(action.payload);

      default:
        return state;
    }
  }
}
const TodoStore = new TodoStore();
複製程式碼

在Store的構造器中將TodoDispatcher傳遞給了父級構造器呼叫,其實是在Dispatcher上呼叫register方法註冊了Store,將其作為dispatch的回撥方法,用於響應每一個Action物件。

到了這裡幾乎已經完成了一個Flux示例,就剩下連線檢視了。當 Store 改變時,會觸發一個 Change 事件,通知檢視層進行更新操作,以下為完整程式碼:

const { Dispatcher } = require('flux');
const { ReduceStore } = require('flux/utils');

// Dispatcher
const TodoDispatcher = new Dispatcher();

// Action Types
const ADD_TODO = 'ADD_TODO';

// Action Creator
function addTodo(desc) {
  const action = {
    type: 'ADD_TODO',
    payload: {
      id: Date.now(),
      done: false,
      desciption: desc
    }
  };
  TodoDispatcher.dispatch(action);
}

// Store
class TodoStore extends ReduceStore {
  constructor() {
    super(TodoDispatcher);
  }

  getInitialState() {
    return [];
  }

  reduce(state, action) {
    switch (action.type) {
      case ADD_TODO:
        return state.concat(action.payload);

      default:
        return state;
    }
  }
}
const todoStore = new TodoStore();

console.log(todoStore.getState()); // []
addTodo('早晨起來,擁抱太陽');
console.log(todoStore.getState()); // [ { id: 1553392929453, done: false, desciption: '早晨起來,擁抱太陽' } ]
複製程式碼

Flux與React

Flux 這樣的架構設計其實在很早之前就出現了,但是為什麼近幾年才盛行呢?我認為很大一部分因素取決於 React 框架的出現,正是因為 React 的 Virtual DOM 讓資料驅動成為了主流,再加上高效率的React diff,使得這樣的架構存在更加合理:

a837658f.png

在靠近檢視的頂層結構中,有一個特殊的檢視層,在這裡我們稱為檢視控制器( View Controller ),它用於從Store中獲取資料並將資料傳遞給檢視層及其後代,並負責監聽Store中的資料改變事件。

當接受到事件時,首先檢視控制器會從Store獲取最新的資料,並呼叫自身的setStateforceUpdate函式,這些函式會觸發View的render與所有後代的re-render方法。

通常我們會將整個Store物件傳遞到View鏈的頂層,再由View的父節點依次傳遞給後代所需要的Store資料,這樣能保證後代的元件更加的函式化,減少了Controller-View的個數也意味著使更好的效能。

Redux

Redux是JavaScript應用可預測的狀態管理容器,它具有以下特性:

  • 可預測性,使用Redux能幫助你編寫在不同的環境中編寫行為一致、便於測試的程式。
  • 集中性,集中化應用程式的狀態管理可以很方便的實現撤銷、恢復、狀態持久化等。
  • 可調式,Redux Devtools提供了強大的狀態追蹤功能,能很方便的做一個時間旅行者。
  • 靈活,Redux適用於任何UI層,並有一個龐大的生態系統。

它還有三大原則:

  • 單一資料來源。整個應用的State儲存在單個Store的物件樹中。
  • State狀態是隻讀的。您不應該直接修改State,而是通過觸發Action來修改它。Action是一個普通物件,因此它可以被列印、序列化與儲存。
  • 使用純函式進行修改狀態。為了指定State如何通過Action操作進行轉換,需要編寫reducers純函式來進行處理。reducers通過當前的狀態樹與動作進行計算,每次都會返回一個新的狀態物件。

與Flux的不同之處

123

Redux受到了Flux架構的啟發,但在實現上有一些不同:

  • Redux並沒有 dispatcher。它依賴純函式來替代事件處理器,也不需要額外的實體來管理它們。Flux嚐嚐被表述為:(state, action) => state,而純函式也是實現了這一思想。
  • Redux為不可變資料集。在每次Action請求觸發以後,Redux都會生成一個新的物件來更新State,而不是在當前狀態上進行更改。
  • Redux有且只有一個Store物件。它的Store儲存了整個應用程式的State。

Action

在Redux中,Action 是一個純粹的 JavaScript 物件,用於描述Store 的資料變更資訊,它們也是 Store 的資訊來源,簡單來說,所有資料變化都來源於 Actions 。

在 Action 物件中,必須有一個欄位type用於描述操作型別,他們的值為字串型別,通常我會將所有 Action 的 type 型別存放於同一個檔案中,便於維護(小專案可以不必這樣做):

// store/mutation-types.js
export const ADD_TODO = 'ADD_TODO'
export const REMOVE_TODO = 'REMOVE_TODO'

// store/actions.js
import * as types from './mutation-types.js'

export function addItem(item) {
  return {
    type: types.ADD_TODO,
    // .. pass item
  }
}
複製程式碼

Action物件除了type以外,理論上其他的資料結構都可由自己自定義,在這裡推薦flux-standard-action這個Flux Action標準,簡單來說它規範了基本的Action物件結構資訊:

{
  type: 'ADD_TODO',
  payload: {
    text: 'Do something.'
  }
}
複製程式碼

還有用於表示錯誤的Action:

{
  type: 'ADD_TODO',
  payload: new Error(),
  error: true
}
複製程式碼

在構造 Action 時,我們需要使 Action 物件儘可能攜帶更少的資料資訊,比如可以通過傳遞 id 的方式取代整個物件。

Action Creator

我們將Action Creator與Action進行區分,避免混為一談。在Redux中,Action Creator是用於建立動作的函式,它會返回一個Action物件:

function addTodo(text) {
  return {
    type: 'ADD_TODO',
    payload: {
      text,
    }
  }
}
複製程式碼

Flux所不同的是,在Flux 中Action Creator 同時會負責觸發 dispatch 操作,而Redux只負責建立Action,實際的派發操作由store.dispatch方法執行:store.dispatch(addTodo('something')),這使得Action Creator的行為更簡單也便於測試。

bindActionCreators

通常我們不會直接使用store.dispatch方法派發 Action,而是使用connect方法獲取dispatch派發器,並使用bindActionCreators將Action Creators自動繫結到dispatch函式中:

import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';

function mapDispatchToProps(dispatch) {
  return bindActionCreators(
    { addTodo },
    dispatch
  );
}

const Todo = ({ addTodo }) => {}
export default connect(null, mapDispatchToProps)(Todo);
複製程式碼

通過bindActionCreators之後,我們可以將這些Action Creators傳遞給子元件,子元件不需要去獲取dispatch方法,而是直接呼叫該方法即可觸發Action。

Reducers

對於Action來講,它們只是描述了發生了什麼事情,而應用程式狀態的變化,全由Reducers進行操作更改。

在實現Reducer函式之前,首先需要定義應用程式中State的資料結構,它被儲存為一個單獨的物件中,因此在設計它的時候,儘量從全域性思維去考慮,並將其從邏輯上劃分為不同的模組,採用最小化、避免巢狀,並將資料與UI狀態分別儲存。

Reducer是一個純函式,它會結合先前的state狀態與Action物件來生成的新的應用程式狀態樹:

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

內部一般通過switch...case語句來處理不同的Action。

保持Reducer的純函式特性非常重要,Reducer需要做到以下幾點:

  • 不應該直接改變原有的State,而是在原有的State基礎上生成一個新的State。
  • 呼叫時不應該產生任何副作用,如API呼叫、路由跳轉等。
  • 當傳遞相同的引數時,每次呼叫的返回結果應該是一致的,所以也要避免使用Date.now()Math.random()這樣的非純函式。
combineReducers

Redux應用程式最常見的State形狀是一個普通的Javascript物件,其中包含每個頂級鍵的特定於域的資料的“切片”,每個“切片”都具有一個相同結構的reducer函式處理該域的資料更新,多個reducer也可同時響應同一個action,在需要的情況獨立更新他們的state。

正是因為這種模式很常見,Redux就提供了一個工具方法去實現這樣的行為:combineReducers。它只是用於簡化編寫Redux reducers最常見的示例,並規避一些常見的問題。它還有一個特性,當一個Action產生時,它會執行每一個切片的reducer,為切片提供更新狀態的機會。而傳統的單一Reducer無法做到這一點,因此在根Reducer下只可能執行一次該函式。

Reducer函式會作為createStore的第一個引數,並且在第一次呼叫reducer時,state引數為undefined,因此我們也需要有初始化State的方法。舉一個示例:

const initialState = { count: 0 }

functino reducer(state = initialState, action) {
  switch (action.type) {
    case: 'INCREMENT':
      return { count: state.count + 1 }
    case: 'DECREMENT':
      return { count: state.count - 1 }
    default:
      return state;
  }
}
複製程式碼

對於常規應用來講,State中會儲存各種各樣的狀態,從而會造成單一Reducer函式很快變得難以維護:

  ...
  case: 'LOADING':
    ...
  case: 'UI_DISPLAY':
    ...
  ...
複製程式碼

因此我們的核心目標是將函式拆分得儘可能短並滿足單一職責原則,這樣不僅易於維護,還方便進行擴充套件,接下來我們來看一個簡單的TODO示例:

const initialState = {
  visibilityFilter: 'SHOW_ALL',
  todos: []
}

function appReducer(state = initialState, action) {
  switch (action.type) {
    case 'SET_VISIBILITY_FILTER': {
      return Object.assign({}, state, {
        visibilityFilter: action.filter
      })
    }
    case 'ADD_TODO': {
      return Object.assign({}, state, {
        todos: state.todos.concat({
          id: action.id,
          text: action.text,
          completed: false
        })
      })
    }
    default:
      return state
  }
}
複製程式碼

這個函式內包含了兩個獨立的邏輯:過濾欄位的設定與TODO物件操作邏輯,如果繼續擴充套件下去會使得Reducer函式越來越龐大,因此我們需要將這兩個邏輯拆分開進行單獨維護:

function appReducer(state = initialState, action) {
  return {
    todos: todosReducer(state.todos, action),
    visibilityFilter: visibilityReducer(state.visibilityFilter, action)
  }
}

function todosReducer(todosState = [], action) {
  switch (action.type) {
    case 'ADD_TODO': {
      return Object.assign({}, state, {
        todos: state.todos.concat({
          id: action.id,
          text: action.text,
          completed: false
        })
      })
    }
    default:
      return todosState
  }
}

function visibilityReducer(visibilityState = 'SHOW_ALL', action) {
  switch (action.type) {
    case 'SET_VISIBILITY_FILTER':
      return setVisibilityFilter(visibilityState, action)
    default:
      return visibilityState
  }
}
複製程式碼

我們將整個Reducer物件拆為兩部分,並且他們獨自維護自己部分的狀態,這樣的設計模式使得整個Reducer分散為獨立的切片。Redux內建了一個combineReducers工具函式,鼓勵我們這樣去切分頂層Reducer,它會將所有切片組織成為一個新的Reducer函式:

const rootReducer = combineReducers({
  todos: todosReducer,
  visibilityFilter: visibilityReducer
})
複製程式碼

在 combineReducers 返回的state物件中,每個鍵名都代表著傳入時子Reducer的鍵名,他們作為子Reducer中 State 的名稱空間。

Store

在Redux應用中只有一個單一的store,通過createStore進行建立。Store物件用於將Actions與Reducers結合在一起,它具有有以下職責:

  • 儲存應用的State,並允許通過getState()方法訪問State。
  • 提供dispatch(action)方法將Action派發到Reducer函式,以此來更新State。
  • 通過subscribe(listener)監聽狀態更改。

對於subscribe來講,每次呼叫dispatch方法後都會被觸發,此時狀態樹的某一部分可能發生了改變,我們可以在訂閱方法的回撥函式裡使用getStatedispatch方法,但需要謹慎使用。subscribe在呼叫後還會返回一個函式unsubscribe函式用於取消訂閱。

Redux Middleware

對於中介軟體的概念相信大家通過其他應用有一定的概念瞭解,對於Redux來講,當我們在談論中介軟體時,往往指的是從一個Action發起直到它到達Reducer之前的這一段時間裡所做的事情,Redux通過Middleware機制提供給三方程式擴充套件的能力。

為了更好的說明中介軟體,我先用Redux初始化一個最簡例項:

const { createStore } = require('redux');

const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';

function reducer(state = 0, action) {
  switch (action.type) {
    case INCREMENT:
      return state + 1;
    case DECREMENT:
      throw new Error('decrement error'); 
    default:
      return state;
  }
}

void function main() {
  const store = createStore(reducer);
  store.dispatch({ type: INCREMENT });
  console.log(store.getState()); // 列印 1
}()

複製程式碼

Step 1. 手動新增列印日誌的中介軟體

為了深刻的理解Redux中介軟體,我們一步步去實現具有中介軟體功能的函式。為了追蹤程式的狀態變化,可能我們需要實現一個日誌列印中介軟體機制,用於列印Action與執行後的State變化。我們首先通過store物件建立一個logger物件,在dispatch的前後進行日誌列印:

void (function main() {
  const store = createStore(reducer);
  const logger = loggerMiddleware(store);
  logger({ type: INCREMENT });

  function loggerMiddleware(store) {
    return action => {
      console.log('dispatching', action);
      let result = store.dispatch(action);
      console.log('next state', store.getState());
      return result;
    };
  }
})();

// 程式執行結果
dispatching { type: 'INCREMENT' }
next state 1
複製程式碼

Step 2. 再新增一個錯誤列印的中介軟體

為了監控應用程式的狀態,我們還需要實現一箇中介軟體,當在應用程式dispatch過程中發生錯誤時,中介軟體能及時捕獲錯誤並上報(通常可上報至Sentry,但在這裡就簡單列印錯誤了):

void (function main() {
  const store = createStore(reducer);
  const crasher = crashMiddleware(store);
  crasher({ type: DECREMENT });

  function crashMiddleware(store) {
    return action => {
      try {
        return dispatch(action);
      } catch (err) {
        console.error('Caught an exception!', err);
      }
    };
  }
})();
複製程式碼

執行程式後,可在命令列內看到函式正確的捕獲DECREMENT中的錯誤

Caught an exception! ReferenceError: dispatch is not defined
複製程式碼

Step 3. 將2箇中介軟體串聯在一起

在應用程式中一般都會有多箇中介軟體,而將不同的中介軟體串聯在一起是十分關鍵的一步操作,若你讀過Koa2的原始碼,你大概瞭解一種被稱之為compose的函式,它將負責處理中介軟體的級聯工作。

在這裡,為了理解其原理,我們還是一步一步進行分析。前面兩個中介軟體的核心目標在於將Dispatch方法進行了一層包裝,這樣來說,我們只需要將dispatch一層層進行包裹,並傳入最深層的中介軟體進行呼叫,即可滿足我們程式的要求:

dispatch = store.dispatch

↓↓↓

// 沒有中介軟體的情況
dispatch(action)

↓↓↓

// 當新增上LoggerMiddleware
LoggerDispatch = action => {
  // LoggerMiddleware TODO
  dispatch(action)
  // LoggerMiddleware TODO
}
dispatch(action)

↓↓↓

// 當新增上CrashMiddleware
CrashDispatch = action => {
  // CrashMiddleware TODO
  LoggerDispatch(action)
  // CrashMiddleware TODO
}

複製程式碼

如果你熟悉使用高階函式,相信上述思路並不難以理解,那讓我們通過修改原始碼,嘗試一下通過這樣的方式,是否能使兩個中介軟體正常工作:

void function main() {
  const store = createStore(reducer);
  let dispatch = store.dispatch
  dispatch = loggerMiddleware(store)(dispatch)
  dispatch = crashMiddleware(store)(dispatch)
  dispatch({ type: INCREMENT });
  dispatch({ type: DECREMENT });

  function loggerMiddleware(store) {
    return dispatch => {
      return action => {
        console.log('dispatching', action);
        let result = dispatch(action);
        console.log('next state', store.getState());
        return result;
      };
    };
  }

  function crashMiddleware(store) {
    return dispatch => {
      return action => {
        try {
          return dispatch(action);
        } catch (err) {
          console.error('Caught an exception!', err);
        }
      };
    };
  }
}();
複製程式碼

此時列印結果為(符合預期):

dispatching { type: 'INCREMENT' }
next state 1
dispatching { type: 'DECREMENT' }
Caught an exception! Error: decrement error
複製程式碼

當然,我們希望以更優雅的方式生成與呼叫dispatch,我會期望在建立時,通過傳遞一箇中介軟體陣列,以此來生成Store物件:

// 簡單實現
function createStoreWithMiddleware(reducer, middlewares) {
  const store = createStore(reducer);
  let dispatch = store.dispatch;
  middlewares.forEach(middleware => {
    dispatch = middleware(store)(dispatch);
  });
  return Object.assign({}, store, { dispatch });
}


void function main() {
  const middlewares = [loggerMiddleware, crashMiddleware];
  const store = createStoreWithMiddleware(reducer, middlewares);
  store.dispatch({ type: INCREMENT });
  store.dispatch({ type: DECREMENT });
  // ...
}()
複製程式碼

Step 4. back to Redux

通過Step 1 ~ 3 的探索,我們大概是照瓢畫葫實現了Redux的中介軟體機制,現在讓我們來看看Redux本身提供的中介軟體介面。

createStore方法中,支援一個enhancer引數,意味著三方擴充套件,目前支援的擴充套件僅為通過applyMiddleware方法建立的中介軟體。

applyMiddleware支援傳入多個符合Redux middleware API的Middleware,每個Middleware的形式為:({ dispatch, getState }) => next => action。讓我們稍作修改,通過applyMiddleware與createStore介面實現(只需要修改建立store的步驟):

  // ...
  const middlewares = [loggerMiddleware, crashMiddleware];
  const store = createStore(reducer, applyMiddleware(...middlewares));
  // ...
複製程式碼

通過applyMiddleware方法,將多個 middleware 組合到一起使用,形成 middleware 鏈。其中,每個 middleware 都不需要關心鏈中它前後的 middleware 的任何資訊。 Middleware最常見的場景是實現非同步actions方法,如redux-thunkredux-saga

非同步Action

對於一個標準的Redux應用程式來說,我們只能簡單的通過派發Action執行同步更新,為了達到非同步派發的能力,官方的標準做法是使用 redux-thunk 中介軟體。

為了明白什麼是 redux-thunk ,先回想一下上文介紹的Middleware API:({ dispatch, getState }) => next => action,藉由靈活的中介軟體機制,它提供給 redux-thunk 延遲派發Action的能力,允許了人們在編寫Action Creator時,可以不用馬上返回一個Action物件,而是返回一個函式進行非同步排程,於是稱之為Async Action Creator

// synchronous, Action Creator
function increment() {
	return {
    type: 'INCREMENT'
	}
}

// asynchronous, Async Action Creator
function incrementAsync() {
  return dispatch => {
    setTimeout(() => dispatch({ type: 'INCREMENT' }), 1000)
  }
}
複製程式碼

而 redux-thunk 原始碼也不過10行左右:


function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;
複製程式碼

通過dispatch(ActionCreator())進行呼叫時,函式會判斷引數的型別:

  1. 若為物件,走正常的觸發流程,直接派發Action。
  2. 若為函式,則將其視為Async Action Creator,將dispatch方法與getState方法作為引數注入,如果全域性註冊了withExtraArgument的話也會作為第三個引數進行傳入。

至於為什麼稱其為"thunk",它是來源於"think",i變為了u,意味著將絕對權從我轉交給你,這是我認為較好的解釋。如果要溯源的話,其實這是一種“求值策略”的模式,即函式引數到底應該何時求值,比如一個函式:

function test(y) { return y + 1 }
const x = 1;
test(x + 1);
複製程式碼

這時人們有兩種爭論點:

  • 傳值呼叫,即在進入函式體之前,就計算x + 1 = 2,再將值傳入函式;
  • 傳名呼叫,即直接將表示式x + 1傳入函式,需要用到時再計算表示式的值。

而通常編譯器的“傳名呼叫”的實現,往往是將引數放到一個臨時函式中,再將臨時函式傳入函式體內,而這個函式就被稱之為 Thunk ,若採取傳名呼叫,上面的函式呼叫會轉化為 Thunk 傳參形式:

const thunk = () => (x + 1)
function test(thunk) {
  return thunk() + 1;
}
複製程式碼

參考資料

相關文章