从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也难以在服务端进行渲染。