從flux到redux

  • 2019 年 12 月 5 日
  • 筆記

從flux到redux

flux既是一個前端架構,更是一種狀態管理的思想。

2013年,Facebook公司讓React亮相的同時,也推出了Flux框架,React和Flux相輔相成,Facebook認為兩者結合在一起才能構建大型的JavaScript應用。

MVC的缺陷

談起MVC,我們想到的傳統MVC架構可能是這樣的:

但是,Facebook的工程部門在應用MVC架構時逐漸發現,對於非常巨大的程式碼庫和龐大的組織,「MVC真的很快就變得非常複雜」。每當工程師想要增加一個新的功能時,對程式碼的修改很容易引入新的bug,因為不同模組之間的依賴關係讓系統變得「脆弱而且不可預測」。對於剛剛加入團隊的新手,更是舉步維艱。

Facebook工程師眼中MVC的畫風

傳統的MVC框架,為了讓數據流可控,Controller應該是中心,當View要傳遞消息給Model時,應該調用Controller的方法,同樣,當Model要更新View時,也應該通過Controller引發新的渲染。

而瀏覽器端MVC框架,存在用戶的交互處理,介面渲染出來之後,Model和View依然存在於瀏覽器中,這時候就會誘惑開發者為了簡便,讓現存的Model和View直接對話。

所以Facebook提出了他們眼中的解決方案,那就是flux。

•Dispatcher,處理動作分發,維持Store之間的依賴關係;•Store,負責存儲數據和處理數據相關邏輯;•Action,驅動Dispatcher的JavaScript對象;•View,視圖部分,負責顯示用戶介面。

如果非要把Flux和MVC做一個結構對比,那麼,Flux的Dispatcher相當於MVC的Controller,Flux的Store相當於MVC的Model,Flux的View當然就對應MVC的View了,至於多出來的這個Action,可以理解為對應給MVC框架的用戶請求。

在MVC框架中,系統能夠提供什麼樣的服務,通過Controller暴露函數來實現。每增加一個功能,Controller往往就要增加一個函數;在Flux的世界裡,新增加功能並不需要Dispatcher增加新的函數,實際上,Dispatcher自始至終只需要暴露一個函數Dispatch,當需要增加新的功能時,要做的是增加一種新的Action類型,Dispatcher的對外介面並不用改變。當需要擴充應用所能處理的「請求」時,MVC方法就需要增加新的Controller,而對於Flux則只是增加新的Action

在react中使用flux

現在用flux重構上篇文章創造的計數器。

npm i flux -S

頁面有三個參與計算的clickCounter組件,大致的思路是:每個組件都根據label獲取對應的狀態。

Dispatch

在src下創建Dispatcher:

// src/Dispatcher/index.js  import {Dispatcher} from 'flux';  export default new Dispatcher();

在這裡,我們引入了dispatch類,然後創造一個新的對象作為這個文件的默認輸出就足夠了。在其他程式碼中,將會引用這個全局唯一的Dispatcher對象。

Dispatcher存在的作用,就是用來派發action,接下來我們就來定義應用中涉及的action。

Action

action顧名思義代表一個「動作」,不過這個動作只是一個普通的JavaScript對象,代表一個動作的純數據,類似於DOM API中的事件(event)。甚至,和事件相比,action其實還是更加純粹的數據對象,因為事件往往還包含一些方法,比如點擊事件就有preventDefault方法,但是action對象不自帶方法,就是純粹的數據。

作為管理,action對象必須有一個名為type的欄位,代表這個action對象的類型,為了記錄日誌和debug方便,這個type應該是字元串類型。定義action通常需要兩個文件:

•第一個文件(src/Action/ActionTypes.js)定義action的類型:type:

export default {      INCREMENT:'increment',      DECREMENT:'decrement'  }

•第二個文件(src/Action/index.js)放構造函數,又名ActionCreator,它通過ActionTypes對象向store提交"事件"請求,這些事件名就是actionTypes內定義的名稱:

import ActionTypes from './ActionTypes';  import Dispatcher from '../Dispatcher'    const increment=(label)=>{      Dispatcher.dispatch({          type:ActionTypes.INCREMENT,          label      })  }    const decrement=(label)=>{      Dispatcher.dispatch({          type:ActionTypes.DECREMENT,          label      })  }    export default {      increment,      decrement  }  

這個index.js導出了兩個action構造函數increment和decrement,當這兩個函數被調用的時候,創造了對應的action對象,並立即通過AppDispatcher.dispatch函數派發出去。

store

一個Store也是一個對象,這個對象存儲應用狀態,同時還要接受Dispatcher派發的動作,根據動作來決定是否要更新應用狀態。同時,它還可以被用來獲取狀態,並不一定非要儲存狀態。

counterStore

首先關注子組件計數器的狀態,在src/store/CounterStore.js新建:

import Dispatcher from '../Dispatcher';  import ActionTypes from '../Action/ActionTypes';  import { EventEmitter } from 'events';    const CHANGE_EVENT = 'changed';    // 初始狀態  const counterValues = {    firstCount: 0,    secoundCount: 0,    thirdCount: 0  }    const CounterStore = Object.assign({}, EventEmitter.prototype, {    // 獲取整個狀態    getCounterValues: function () {      return counterValues;    },    // 觸發事件    emitChange: function () {      this.emit(CHANGE_EVENT);    },    addChangeListener: function (callback) {      this.on(CHANGE_EVENT, callback)    },    removeChangeListener: function (callback) {      this.removeListener(callback)    }  });    CounterStore.dispatchToken = Dispatcher.register((action) => {    if (action.type === ActionTypes.INCREMENT) {      // 更新引用對象,再觸發changed事件      counterValues[action.label]++;      CounterStore.emitChange();    } else if (action.type === ActionTypes.DECREMENT) {      counterValues[action.label]--;      CounterStore.emitChange();    }  });    export default CounterStore;

上述的程式碼中,我們讓CounterStore擴展了EventEmitter.prototype,等於讓CounterStore成了EventEmitter對象,一個EventEmitter實例對象支援下列相關函數。

•emit函數,可以廣播一個特定事件,第一個參數是字元串類型的事件名稱;•on函數,可以增加一個掛在這個EventEmitter對象特定事件上的處理函數,第一個參數是字元串類型的事件名稱,第二個參數是處理函數;•removeListener函數,和on函數做的事情相反,刪除掛在這個EventEmitter對象特定事件上的處理函數,和on函數一樣,第一個參數是事件名稱,第二個參數是處理函數。要注意,如果要調用removeListener函數,就一定要保留對處理函數的引用。

對於CounterStore對象,emitChange、addChangeListener和removeChangeListener函數就是利用EventEmitter上述的三個函數完成對CounterStore狀態更新的廣播、添加監聽函數和刪除監聽函數等操作。

註冊到dispatcher

目前實現的Store只有註冊到Dispatcher實例上才能生效。Dispatcher有一個函數叫做register,接受一個回調函數作為參數。返回值是一個token,這個token可以用於Store之間的同步。

// 註冊的回調函數包含了業務方法  CounterStore.dispatchToken = Dispatcher.register((action) => {    if (action.type === ActionTypes.INCREMENT) {      counterValues[action.counterCaption] ++;      CounterStore.emitChange();    } else if (action.type === ActionTypes.DECREMENT) {      counterValues[action.counterCaption] --;      CounterStore.emitChange();    }  });

現在我們來仔細看看register接受的這個回調函數參數,這是Flux流程中最核心的部分,當通過register函數把一個回調函數註冊到Dispatcher之後,所有派發給Dispatcher的給Dispatcher的action對象,都會傳遞到這個回調函數中來。

總和計數器(SummaryStore)

總和計數器和counterStore的內容差不多,不同的地方在於就是提供了getSummary方法,此外,需要等待(waiteFor)CounterStore計算完畢後,才計算總和。

import Dispatcher from '../Dispatcher';  import ActionTypes from '../Action/ActionTypes';  import CounterStore from './CounterStore.js';  import { EventEmitter } from 'events';    const CHANGE_EVENT = 'changed';    function computeSummary(counterValues) {      let summary = 0;      for (const key in counterValues) {          if (counterValues.hasOwnProperty(key)) {              summary += counterValues[key];          }      }      return summary;  }    const SummaryStore = Object.assign({}, EventEmitter.prototype, {      getSummary: function () {          return computeSummary(CounterStore.getCounterValues());      },        emitChange: function () {          this.emit(CHANGE_EVENT);      },        addChangeListener: function (callback) {          this.on(CHANGE_EVENT, callback);      },        removeChangeListener: function (callback) {          this.removeListener(CHANGE_EVENT, callback);      }    });      SummaryStore.dispatchToken = Dispatcher.register((action) => {      if ((action.type === ActionTypes.INCREMENT) ||          (action.type === ActionTypes.DECREMENT)) {          Dispatcher.waitFor([CounterStore.dispatchToken]);          SummaryStore.emitChange();      }  });    export default SummaryStore;  

可能你會好奇,目前CounterStore已經把所有的狀態都儲存了,為什麼還會有一個SummaryStore?

的確,SummaryStore並沒有存儲自己的狀態,當getSummary被調用時,它是直接從CounterStore里獲取狀態計算的。CounterStore提供了getCounterValues函數讓其他模組能夠獲得所有計數器的值,SummaryStore也提供了getSummary讓其他模組可以獲得所有計數器當前值的總和。不過,既然總可以通過CounterStore.getCounterValues函數獲取最新鮮的數據,SummaryStore似乎也就沒有必要把計數器當前值總和存儲到某個變數里。

事實上,可以看到SummaryStore並不像CounterStore一樣用一個變數counterValues存儲數據,SummaryStore不存儲數據,而是每次對getSummary的調用,都實時讀取CounterStore.getCounterValues,然後實時計算出總和返回給調用者。可見,雖然名為Store,但並不表示一個Store必須要存儲什麼東西,Store只是提供獲取數據的方法,而Store提供的數據完全可以另一個Store計算得來

總和計數器關注點在於註冊方法用了一個waitFor:

SummaryStore.dispatchToken = AppDispatcher.register((action) => {    if ((action.type === ActionTypes.INCREMENT) ||        (action.type === ActionTypes.DECREMENT)) {      AppDispatcher.waitFor([CounterStore.dispatchToken]);      SummaryStore.emitChange();    }  });

這樣我們已經註冊了兩個store到Disparcher上,派發事件的回調函數的順序是怎樣的呢?

可以認為Dispatcher調用回調函數的順序完全是無法預期的,不要假設它會按照我們期望的順序逐個調用。

flux的Dispatcher的waitFor方法。在SummaryStore的回調函數中,之前在CounterStore中註冊回調函數時保存下來的dispatchToken終於派上了用場。Dispatcher的waitFor可以接受一個數組作為參數,數組中每個元素都是一個Dispatcher.gister函數的返回結果,也就所謂的dispatchToken。這個waitFor函數告訴Dispatcher,當前的處理必須要暫停,直到dispatchToken代表的那些已註冊回調函數執行結束才能繼續。

在源碼實現上:當一個派發動作發生後,Dispatcher會檢查weitFor中的狀態回調函數是否被執行了,只有被執行了,才會根據新的狀態來計算。因此,即使SummaryStore比CounterStore提前接收到了action對象,在emitChange中調用waitFor,也就能夠保證在emitChange函數被調用的時候,CounterStore也已經處理過這個action對象,一切完美解決。

這裡要注意一個事實,Dispatcher的register函數,只提供了註冊一個回調函數的功能,但卻不能讓調用者在register時選擇只監聽某些action,換句話說,每個register的調用者只能這樣請求:「當有任何動作被派發時,請調用我。」但不能夠這麼請求:「當這種類型還有那種類型的動作被派發的時候,請調用我。」

當一個動作被派發的時候,Dispatcher就是簡單地把所有註冊的回調函數全都調用一遍,至於這個動作是不是對方關心的,Flux的Dispatcher不關心,要求每個回調函數去鑒別。

view

view並不是非得使用react,你可以使用任何喜歡的哪怕是自創的頁面框架。但無論是哪個架構,都少不了以下流程:

•創建時要讀取Store上狀態來初始化組件內部狀態;•當Store上狀態發生變化時,組件要立刻同步更新內部狀態保持一致;•View如果要改變Store狀態,必須而且只能派發action。

回到總體的頁面,我們不需要再把子組件的計算邏輯放到最上層來了。

import React, { Component } from 'react';  import ClickCounter from './ClickCounter'  import AllCount from './AllCount';  const styles={    app:{      width:250,      margin:'100px auto'    }  }    class App extends Component {    constructor(props) {      super(props)    }      render() {      return (        <div style={styles.app}>          <ClickCounter          label={'firstCount'} />          <ClickCounter          label={'secoundCount'}/>          <ClickCounter          label={'thirdCount'} />          <hr />          <AllCount />        </div>      );    }  }    export default App;

clickCounter組件,需要在不同生命周期更新:

import React, { Component } from 'react'  import CounterStore from './stores/CounterStore';  import Actions from './Action'    const styles = {      counter: {          width: 250,          display: 'flex'      },      showbox: {          width: 80,          textAlign: 'center'      },      label: {          width: 120,      }  }      class ClickCounter extends Component {      constructor(props) {          super(props)            根據label從總體狀態中拿到屬於自己的`FIRST/SECOUND/THIRD`count          this.state = {              count: CounterStore.getCounterValues()[props.label]          }      }          componentDidMount() {            // 開啟監聽          CounterStore.addChangeListener(this.onChange);      }        componentWillUnmount() {          // 防止記憶體泄漏          CounterStore.removeChangeListener(this.onChange);      }        onChange = () => {          const newCount = CounterStore.getCounterValues()[this.props.label];          this.setState({ count: newCount });      }        onClickIncrementButton = () => {          Actions.increment(this.props.label);      }        onClickDecrementButton = () => {          Actions.decrement(this.props.label);      }        render() {            return (              <div style={styles.counter}>                  <div style={styles.label}>{this.props.label}</div>                  <button onClick={this.onClickDecrementButton}>-</button>                  <div style={styles.showbox}>{this.state.count}</div>                  <button onClick={this.onClickIncrementButton}>+</button>              </div>          )      }  }    export default ClickCounter;

在總和計數器上,就比較簡單了:

import React, { Component } from 'react'  import SummaryStore from './stores/SummaryStore';    export default class extends Component {      constructor(props) {          super(props);          this.state = {              sum: SummaryStore.getSummary()          }      }        componentDidMount() {          // 監聽變化          SummaryStore.addChangeListener(this.onUpdate);      }        componentWillUnmount() {          SummaryStore.removeChangeListener(this.onUpdate);      }        onUpdate = () => {          this.setState({              sum: SummaryStore.getSummary()          })      }        render() {          return (              <div>Total Count: {this.state.sum}</div>          );      }  }

flux的優與劣

和純react的state維護相比,flux架構下的計數器有了明顯變化。

回顧一下純React實現的版本,應用的狀態數據只存在於React組件之中,每個組件都要維護驅動自己渲染的狀態數據,單個組件的狀態還好維護,但是如果多個組件之間的狀態有關聯,那就麻煩了。

比如ClickCounter組件和AllCount組件,AllCount組件需要維護所有ClickCounter組件計數值的總和,ClickCounter組件和AllCount分別維護自己的狀態,如何同步AllCount和ClickCounter狀態就成了問題,React只提供了props方法讓組件之間通訊,組件之間關係稍微複雜一點,這種方式就顯得非常笨拙。

Flux的架構下,應用的狀態被放在了Store中,React組件只是扮演View的作用,被動根據Store的狀態來渲染。在上面的例子中,React組件依然有自己的狀態。但是已經淪落為store的一個映射,而不是主動變化的數據。

因此flux的優勢可以歸結為"單向數據流"。

在Flux的理念里,如果要改變介面,必須改變Store中的狀態,如果要改變Store中的狀態,必須派發一個action對象,這就是規矩。在這個規矩之下,想要追溯一個應用的邏輯就變得非常容易。

MVC最大的問題就是無法禁絕View和Model之間的直接對話,對應於MVC中View就是Flux中的View,對應於MVC中的Model的就是Flux中的Store,在Flux中,Store只有get方法,沒有set方法,根本不可能直接去修改其內部狀態,View只能通過get方法獲取Store的狀態,無法直接去修改狀態,如果View想要修改Store狀態的話,只有派發一個action對象給Dispatcher。

flux也存在一些缺點:

在Flux的體系中,如果兩個Store之間有邏輯依賴關係,就必須用上Dispatcher的waitFor函數。要通過waitFor函數告訴Dispatcher,先讓CounterStore處理這些action對象,只有CounterStore搞定之後SummaryStore才繼續。那麼,SummaryStore如何標識CounterStore呢?靠的是register函數的返回值dispatchToken,而dispatchToken的產生,當然是CounterStore控制的,換句話說,要這樣設計:

•CounterStore必須要把註冊回調函數時產生的dispatchToken公之於眾;•SummaryStore必須要在程式碼里建立對CounterStore的dispatchToken的依賴。

此外flux也難以在服務端進行渲染。