用Flux实现TodoMVC

本文通过实现一个 TodoMVC 应用来说明一个 Flux 应用的结构是怎样的。本文会告诉你如何一步一步地实现这个应用,完整的源代码可以从 Github 下载。

首先,我们需要

  1. 基本的项目模板,方便把 jsx 文件编译为 js 文件
  2. 一个基于 CommonJS 的模块系统,因为JS本身并没有模块系统

我们可以从 react-boilerplate 这个模板开始。你应该已经安装了 Node.js 了吧,那么就直接从 github 将 react-boilerplate clone 下来,进入目录,依次运行 npm install npm run buildnpm start,然后我们的 jsx 文件就会被持续地编译为 js 文件了。

我们的 TodoMVC 应用也是基于这个模板。现在你可以按照 TodoMVC 的 package.json 来编辑你自己的 package.json 文件,确保你的文件结构和 dependencies 与 TodoMVC 的一致(译注:最好直接拷贝 package.json 到你自己的目录),否则下文中的代码可能无法运行。

目录结构

index.html 文件是应用的入口,它会加载 bundle.js,js 目录下的文件会被自动合并为 bundle.js,合成工作由 Browserify 负责。我们的项目目录的结构如下所示:

myapp
  |
  + ...
  + js
    |
    + actions
    + components // React 组件都放在这样,包括 views 和 controller-views
    + constants
    + dispatcher
    + stores
    + app.js
    + bundle.js // 由 Browserify 自动生成
  + index.html
  + ...

创建派发器(Dispatcher)

现在我们就可以创建派发器了。下面实现了一个简单的派发器,用到了 Promise,对于不支持 ES6 Promise 的浏览器,使用 es6-promise 来兼容。

// js/dispatcher/Dispatcher.js
var Promise = require('es6-promise').Promise;
var assign = require('object-assign');

var _callbacks = [];
var _promises = [];

var Dispatcher = function() {};
Dispatcher.prototype = assign({}, Dispatcher.prototype, {

  /**
   * 注册一个数据仓库的回调,可以被一个 action 触发。
   * Register a Store's callback so that it may be invoked by an action.
   * [@param](/user/param) {function} callback 需要注册的回调函数.
   * [@return](/user/return) {number} 返回 callback 在 _callback 数组中的索引.
   */
  register: function(callback) {
    _callbacks.push(callback);
    return _callbacks.length - 1; // index
  },

  /**
   * 派发
   * dispatch
   * [@param](/user/param)  {object} payload The data from the action.
   */
  dispatch: function(payload) {
    // First create array of promises for callbacks to reference.
    var resolves = [];
    var rejects = [];
    _promises = _callbacks.map(function(_, i) {
      return new Promise(function(resolve, reject) {
        resolves[i] = resolve;
        rejects[i] = reject;
      });
    });
    // Dispatch to callbacks and resolve/reject promises.
    _callbacks.forEach(function(callback, i) {
      // Callback can return an obj, to resolve, or a promise, to chain.
      // See waitFor() for why this might be useful.
      Promise.resolve(callback(payload)).then(function() {
        resolves[i](payload);
      }, function() {
        rejects[i](new Error('Dispatcher callback unsuccessful'));
      });
    });
    _promises = [];
  }
});

module.exports = Dispatcher;

这个派发器只有两个方法:注册(register)和派发(dispatch)。register() 用于注册一个回调函数。dispatch() 用于在动作(actions)发生后触发这些回调。

接下来我们创建 AppDispatcher,它基于 Dispatcher,只不过在 Dispatcher 的基础上添加了 handleViewAction 方法:

js/dispatcher/AppDispatcher.js
var Dispatcher = require('./Dispatcher');
var assign = require('object-assign');

var AppDispatcher = assign({}, Dispatcher.prototype, {

  /**
   * A bridge function between the views and the dispatcher, marking the action
   * as a view action.  Another variant here could be handleServerAction.
   * [@param](/user/param)  {object} action The data coming from the view.
   */
  handleViewAction: function(action) {
    this.dispatch({
      source: 'VIEW_ACTION',
      action: action
    });
  }

});

module.exports = AppDispatcher;

现在我们拥有了一个简单好用的派发器,它有一个 handleViewAction 方法用来处理视图(View)中发出的动作(Action)。如果之后我们需要向服务器发出请求,可以再添加一个方法,不过现在这样已经可以了。

创建数据仓库(Store)

你可以借助 Node 的事件发射器(EventEmitter)来创建数据仓库。事件发射器用来向控制视图(Controller-View)广播 change 事件。代码如下,为了简洁我删除了部分代码,完整的代码见 TodoStore.js

var AppDispatcher = require('../dispatcher/AppDispatcher');
var EventEmitter = require('events').EventEmitter;
var TodoConstants = require('../constants/TodoConstants');
var assign = require('object-assign');

var CHANGE_EVENT = 'change';

var _todos = {}; // collection of todo items

/**
 * 创建一个 Todo 项
 * [@param](/user/param) {string} text The content of the TODO
 */
function create(text) {
  // Using the current timestamp in place of a real id.
  var id = Date.now();
  _todos[id] = {
    id: id,
    complete: false,
    text: text
  };
}

/**
 * 删除一个 Todo 项
 * [@param](/user/param) {string} id
 */
function destroy(id) {
  delete _todos[id];
}

var TodoStore = assign({}, EventEmitter.prototype, {

  /**
   * 获取所有 Todo 项
   * [@return](/user/return) {object}
   */
  getAll: function() {
    return _todos;
  },

  emitChange: function() {
    this.emit(CHANGE_EVENT);
  },

  /**
   * [@param](/user/param) {function} callback
   */
  addChangeListener: function(callback) {
    this.on(CHANGE_EVENT, callback);
  },

  /**
   * [@param](/user/param) {function} callback
   */
  removeChangeListener: function(callback) {
    this.removeListener(CHANGE_EVENT, callback);
  },

  dispatcherIndex: AppDispatcher.register(function(payload) {
    var action = payload.action;
    var text;

    switch(action.actionType) {
      case TodoConstants.TODO_CREATE:
        text = action.text.trim();
        if (text !== '') {
          create(text);
          TodoStore.emitChange();
        }
        break;

      case TodoConstants.TODO_DESTROY:
        destroy(action.id);
        TodoStore.emitChange();
        break;

      // add more cases for other actionTypes, like TODO_UPDATE, etc.
    }

    return true; // No errors. Needed by promise in Dispatcher.
  })

});

module.exports = TodoStore;

代码中有一下几点需要注意:

一,我们用一个私有数组 _todos 来存储所有 Todo 项。由于它是闭包内的一个局部变量,所以外部不能直接访问它,只能通过动作(action)来更新数据。借此我们就控制了数据的流动(the flow of data)。

二,注意数据仓库是如何注册回调的:把回调函数传给 AppDispather.register() ,然后保留派发器的索引(index)。目前回调函数只处理两种动作(TODO_CREATETODO_DESTROY),不过很快我们就会添加更多动作。

用控制视图(Controller-View)监听数据变化

我们需要在组件的顶层添加一个组件来监听数据的所有变化。在大型项目中,你可能需要不止一个这样的组件,比如为页面的每个区块创建一个控制视图。在 Facebook 的广告创建工具中,我们有很多这样的控制视图,每个视图负责页面上的一块 UI。在我们的视频编辑器项目中,我们只有两个这样的组件,一个负责动画预览界面,一个负责图片选取界面。

下面是我们的实现。同样它被简化过,完整的代码见 TodoApp.react.js

var Footer = require('./Footer.react');
var Header = require('./Header.react');
var MainSection = require('./MainSection.react');
var React = require('react');
var TodoStore = require('../stores/TodoStore');

function getTodoState() {
  return {
    allTodos: TodoStore.getAll()
  };
}

var TodoApp = React.createClass({

  getInitialState: function() {
    return getTodoState();
  },

  componentDidMount: function() {
    TodoStore.addChangeListener(this._onChange);
  },

  componentWillUnmount: function() {
    TodoStore.removeChangeListener(this._onChange);
  },

  /**
   * [@return](/user/return) {object}
   */
  render: function() {
    return (
      <div>
        <Header />
        <MainSection
          allTodos={this.state.allTodos}
          areAllComplete={this.state.areAllComplete}
        />
        <Footer allTodos={this.state.allTodos} />
      </div>
    );
  },

  _onChange: function() {
    this.setState(getTodoState());
  }

});

module.exports = TodoApp;

你终于看到了你熟悉的 React 代码了,这里用到了 React 的诸多与组件生命周期相关的方法:

  • getInitialSate() 中对视图进行初始化
  • componentDidMount() 中创建事件监听
  • componentWillUnmount() 中清理现场
  • 然后从 TodoStore 中拿到所有数据,填充到一个 div 容器中,最终渲染到页面上

Header 组件只包含文字输入框,不需要数据; MainSection 组件和 Footer 组件则需要数据。

更多视图

我们整个应用的 React 组件结构是这样的:

<TodoApp>
  <Header>
    <TodoTextInput />
  </Header>

  <MainSection>
    <ul>
      <TodoItem />
    </ul>
  </MainSection>

</TodoApp>

当 TodoItem 组件处于编辑状态时,它还会渲染出一个 TodoTextInput 组件作为其子元素。

现在我们来看看

  1. 这些组件是如何将 props 中是数据展现出来的。
  2. 这些组件是如何通过动作来与派发器通信的。

MainSection 通过遍历 TodoApp 传来的数据,来创建一组 TodoItem。在 MainSection 的 render() 方法如下:

var allTodos = this.props.allTodos;

for (var key in allTodos) {
  todos.push(<TodoItem key={key} todo={allTodos[key]} />);
}

return (
  <section id="main">
    <ul id="todo-list">{todos}</ul>
  </section>
);

每个 TodoItem 显示自己的内容,触发动作时会带上自己的 ID。本文不打算把 TodoItem 触发的所有动作都讲到,只以删除动作为例。下面是一个 TodoItem 的简单实现:

var React = require('react');
var TodoActions = require('../actions/TodoActions');
var TodoTextInput = require('./TodoTextInput.react');

var TodoItem = React.createClass({

  propTypes: {
    todo: React.PropTypes.object.isRequired
  },

  render: function() {
    var todo = this.props.todo;

    return (
      <li
        key={todo.id}>
        <label>
          {todo.text}
        </label>
        <button className="destroy" onClick={this._onDestroyClick} />
      </li>
    );
  },

  _onDestroyClick: function() {
    TodoActions.destroy(this.props.todo.id);
  }

});

module.exports = TodoItem;

TodoActions 已经实现了 destroy 方法,而且数据仓库会负责更新数据,这样我们就能很容易地将用户的操作与应用的状态(Application State)连接起来。我们只需在点击事件里调用 destroy 方法,并传入 Todo 项的 ID,就行了。

现在用户一点击删除按钮,Flux 的数据流就会启动,页面的状态就会相应地发生变化。

输入框稍微复杂一点,因为你要在 TodoTextInput 里单独维护组件自己的状态。那么我们就来看看应该如何来实现。

React 建议一旦输入框的值有变化,组件的状态就应该立即做出相应的变化。所以我们可以把值保存在组件的状态中。你要记住这是组件的状态(UI State),而不是应用的状态(Application State)。

应用的状态永远存在数据仓库中,而组件的状态由组件自行维护。理想的情况下,组件的状态应该尽量少。

由于 TodoTextInput 会在应用的多个地方用到,每个地方可能有不同的行为,所以我们需要父组件把 onSave 作为 prop 参数传给 TodoTextInput。

var React = require('react');
var ReactPropTypes = React.PropTypes;

var ENTER_KEY_CODE = 13;

var TodoTextInput = React.createClass({

  propTypes: {
    className: ReactPropTypes.string,
    id: ReactPropTypes.string,
    placeholder: ReactPropTypes.string,
    onSave: ReactPropTypes.func.isRequired,
    value: ReactPropTypes.string
  },

  getInitialState: function() {
    return {
      value: this.props.value || ''
    };
  },

  /**
   * [@return](/user/return) {object}
   */
  render: function() /*object*/ {
    return (
      <input
        className={this.props.className}
        id={this.props.id}
        placeholder={this.props.placeholder}
        onBlur={this._save}
        onChange={this._onChange}
        onKeyDown={this._onKeyDown}
        value={this.state.value}
        autoFocus={true}
      />
    );
  },

  /**
   * Invokes the callback passed in as onSave, allowing this component to be
   * used in different ways.
   */
  _save: function() {
    this.props.onSave(this.state.value);
    this.setState({
      value: ''
    });
  },

  /**
   * [@param](/user/param) {object} event
   */
  _onChange: function(/*object*/ event) {
    this.setState({
      value: event.target.value
    });
  },

  /**
   * [@param](/user/param) {object} event
   */

  _onKeyDown: function(event) {
    if (event.keyCode === ENTER_KEY_CODE) {
      this._save();
    }
  }

});

module.exports = TodoTextInput;

Header 组件传给 TodoTextInput 的 onSave 会新建一个 Todo 项。

var React = require('react');
var TodoActions = require('../actions/TodoActions');
var TodoTextInput = require('./TodoTextInput.react');

var Header = React.createClass({

  /**
   * [@return](/user/return) {object}
   */
  render: function() {
    return (
      <header id="header">
        <h1>todos</h1>
        <TodoTextInput
          id="new-todo"
          placeholder="What needs to be done?"
          onSave={this._onSave}
        />
      </header>
    );
  },

  /**
   * Event handler called within TodoTextInput.
   * Defining this here allows TodoTextInput to be used in multiple places
   * in different ways.
   * [@param](/user/param) {string} text
   */
  _onSave: function(text) {
    TodoActions.create(text);
  }

});

module.exports = Header;

以此类推,在 TodoItem 的编辑模式中,我们可以把 TodoActions.update(text) 作为 onSave 的值。

创建语意化的动作

下面我们用到的两个动作 create 和 destroy 的简单实现:

/**
 * TodoActions
 */

var AppDispatcher = require('../dispatcher/AppDispatcher');
var TodoConstants = require('../constants/TodoConstants');

var TodoActions = {

  /**
   * [@param](/user/param)  {string} text
   */
  create: function(text) {
    AppDispatcher.handleViewAction({
      actionType: TodoConstants.TODO_CREATE,
      text: text
    });
  },

  /**
   * [@param](/user/param)  {string} id
   */
  destroy: function(id) {
    AppDispatcher.handleViewAction({
      actionType: TodoConstants.TODO_DESTROY,
      id: id
    });
  },

};

module.exports = TodoActions;

你可能会说,AppDispatcher.handleViewActionTodoActions.create() 是多余的。确实,理论上我们可以直接调用 AppDispatcher.dispatch() 来达到目的。但是随着我们的项目越来越大,添加这些方法能保持代码干净易读。

传给 TodoAction.create() 的数据结构如下:

{
  source: 'VIEW_ACTION',
  action: {
    type: 'TODO_CREATE',
    text: 'Write blog post about Flux'
  }
}

数据是通过 TodoStore 注册的回调函数送达给 TodoStore 的。然后 TodoStore 广播 change 事件,MainSection 听到消息,拿到最新数据,更新自己的状态。状态的更新使得 TodoApp 组件的 render() 方法被触发,TodoApp 所有后代组件的 render() 方法也被触发。

启动 React

应用的启动文件是 app.js,其内容很简单,就是拿到 TodoApp,然后在网页中渲染它。

var React = require('react');

var TodoApp = require('./components/TodoApp.react');

React.render(
  <TodoApp />,
  document.getElementById('todoapp')
);

为 Dispatcher 添加依赖管理

上文说过,我们实现的 Dispatcher 太简单。虽然它可以用,但是对大多数应用来说,它还不够好。因为我们需要管理储存时的依赖关系,有些数据的存储要等其他数据存完了才能进行。那么我们给 Dispatcher 添加一个 waitFor() 方法吧。

waitFor() 是一个公共方法,返回一个 Promise 对象:

/**
   * [@param](/user/param)  {array} promisesIndexes
   * [@param](/user/param)  {function} callback
   */
  waitFor: function(promiseIndexes, callback) {
    var selectedPromises = promiseIndexes.map(function(index) {
      return _promises[index];
    });
    return Promise.all(selectedPromises).then(callback);
  }

现在我们就可以在 TodoStore 里,等数据更新好了之后,再继续其他操作。

不过,有可能会出现循环依赖。一个更加健壮的 Dispatcher 应该在遇到循环依赖时,在控制台里发出警告。

未来会做的事情

很多人问 Facebook 是否会将 Flux 开源。请不要搞笑,Flux 只是一种架构,不是框架,如何发布呢?不过发布一套 Flux 模板倒是有可能的,前提是有足够多的人需要。如果你有需要,请告诉我们。

原文(有删改)

(全文完)

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏大前端开发

使用mpvue开发小程序教程(六)

在上一章节中,我们列举了在Vue中能用但在mpvue中不能用或需要特别注意的特性,在实际开发前了解一下还是很有必要的,可以避免浪费找错误的时间。

824
来自专栏狮乐园

codereview-s8

之后再efficiencyView方法中调用stopPropagation方法阻止事件冒泡

363
来自专栏jiajia_deng

react-router 环境使用锚点的方法

1102
来自专栏向治洪

React Native控件只TextInput

TextInput是一个允许用户在应用中通过键盘输入文本的基本组件。本组件的属性提供了多种特性的配置,譬如自动完成、自动大小写、占位文字,以及多种不同的键盘类型...

1918
来自专栏程序猿的那些趣事

web前端开发规范总结

Web前端作为开发团队中不可或缺的一部分,需要按照相关规定进行合理编写(一部分不良习惯可能给自己和他人造成不必要的麻烦)。不同公司不同团队具有不同的规范和文档。...

962
来自专栏jiajia_deng

react-router 环境使用锚点的方法

994
来自专栏转载gongluck的CSDN博客

MFC ActiveX (ocx)控件的开发

前言 ActiveX是Microsoft对于一系列策略性面向对象程序技术和工具的称呼,其中主要的技术是组件对象模型(COM)。 ActiveX控件是一种实现...

3957
来自专栏Bingo的深度学习杂货店

HTML5新特性

本章的主要内容有: ---- [1] 用于媒体回放的 video 和audio 元素 [2] HTML5拖放 [3] canvas简单应用 [4] Web存储:...

3185
来自专栏编程

从源码的角度再看 React JS 中的 setState

在上一篇手记「深入理解 React JS 中的 setState」中,我们简单地理解了 React 中 setState “诡异”表现的原因。 在这一篇文章中,...

18410
来自专栏函数式编程语言及工具

Akka(8): 分布式运算:Remoting-远程查找式

  Akka是一种消息驱动运算模式,它实现跨JVM程序运算的方式是通过能跨JVM的消息系统来调动分布在不同JVM上ActorSystem中的Actor进行运算,...

3099

扫码关注云+社区