翻译|开启React,Redux和Immutable之旅:测试驱动教程(part1)

翻译版本,原文请见,第一部分,第二部分

Image 提供:[egghead.io](http://egghead.io/)
Image 提供:[egghead.io](http://egghead.io/)

几周以前,我正在漫无目的的浏览Hacker News,读到一篇关于Redux的头条新闻,Redux的内容我是了解,但是另一个谈到的问题javascript fatigue(JavaScript 疲劳)已经困扰我了,所以我没有太关心,知道读到Redux的几个特征.

  • 强化了函数式编程,确保app行为的可预测性
  • 允许app的同构,客户端和服务端的大多数逻辑都可以共享
  • 时间旅行的debugger?有可能吗?

Redux似乎是React程序state管理的优雅方法,再者谁说的时间旅行不可能?所以我读了文档和一篇非常精彩的教程@teropa:A Comprehensive Guide to Test-First Development with Redux,React,and Immutable(这一篇也是我写作的主要灵感来源).

我喜欢Redux,代码非常优雅,debugger令人疯狂的伟大.我的意思是-看看这个
todo-action

接下来的教程第一部分希望引导你理解Redux运行的原则.教程的目的仅限于(客户端,没有同构,是比较简单的app)保持教程的简明扼要.如果你想发掘的更深一点,我仅建议你阅读上面提高的那个教程.对比版的Github repo在这里,共享代码贴合教程的步骤.如果你对代码或者教程有任何问题和建议,最好能留下留言.

编辑按:文章已经更新为ES2015版的句法.

APP

为了符合教程的目的,我们将建一个经典的TodoMVC,为了记录需要,需求如下:

  • 每一个todo可以激活或者完成
  • 可以添加,编辑,删除一个todo
  • 可以根据它的status来过滤筛选todos
  • 激活的todos的数目显示在底部
  • 完成的Todo理解可以删除

Reudux和Immutable:使用函数编程去营救

回到几个月前,我正在开发一个webapp包含仪表板. 随着app的成长,我们注意到越来越多的有害的bugs,藏在代码角落里,很难发现.类似:“如果你要导航到这一页,点击按钮,然后回到主页,喝一杯咖啡,回到这一页然后点击两次,奇怪的事情发生了.”这些bug的来源要么是异步操作(side effects)或者逻辑:一个action可能在app中有意想不到的影响,这个有时候我们还发现不了.

这就是Redux之所以存在的威力:整个app的state是一个单一的数据结构,state tree.这一点意思是说:在任何时刻,展示给用户的内容仅仅是state tree结果,这就是单一来源的真相(用户界面的显示内容是由state tree来决定的).每一个action接收state,应用相应的修改(例如,添加一个todo),输出更新的state tree.更新的结果渲染展示给用户.里面没有模糊的异步操作,没有变量的引用引起的不经意的修改.这个步骤使得app有了更好的结构,分离关注点,dubugging也更好用了.

Immutable是有Facebook开发的助手函数库,提供一些工具去创建和操作immutable数据结构.尽管在Redux也不是一定要使用它,但是它通过禁止对象的修改,强化了函数式编程方法.有了immutable,当我们想更新一个对象,实际上我们修改的是一个新创建的的对象,原先的对象保持不变.

这里是“Immutable文档”里面的例子:

 var map1 = Immutable.Map({a:1, b:2, c:3});
var map2 = map1.set('b', 2);
assert(map1 === map2); // no change
var map3 = map1.set('b', 50);
assert(map1 !== map3); // change

我们更新了map1的一个值,map1对象保持不变,一个新的对象map3被创建了.

Immutable在store中被用来储存我们的app的state tree.很快我们会看到Immutable提供了一下操作对象的简单和有效的方法.

配置项目

声明:一些配置有@terops的教程启发.

注意事项:推荐使用Node.js>=4.0.0.你可以使用nvm(node version manager)来切换不同的node.js的版本.

这里是比较版本的提交

开始配置项目:

mkdir redux-todomvc
cd redux-todomvc
npm init -y

项目的目录结构如下:

├── dist
│   ├── bundle.js
│   └── index.html
├── node_modules
├── package.json
├── src
├── test
└── webpack.config.js

首先创建一个简单的HTML页面,用来运行我们的app
dist/index.html

 <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>React TodoMVC</title>
</head>
<body>
  <div id="app"></div>
  <script src="bundle.js"></script>
</body>
</html>

有了这个文件,我们写一个简单的脚本文件看看包安装的情况
src/index.js

console.log('Hello world !');

我们将会使用[Webpack]来打包成为bundle.js文件.Webpack的特性是速度,容易配置,大部分是热更新的.代码的更新不需要重新加载,意味着app的state保持热加载.

让我们安装webpack:

npm install —save-dev webpack@1.12.14 webpack-dev-server@1.14.1

app使用ES2015的语法,带来一些优异的特性和一些语法糖.如果你想了解更多的ES2015内容,这个recap是一个不错的资源.

Babel用来把ES2015的语法改变为普通的JS语法:
npm install —save-dev babel-core@6.5.2 babel-loader@6.2.4 babel-preset-es2015@6.5.0

我们将使用JSX语法编写React组件,所以让我们安装Babel React package:
npm install —save-dev babel-preset-react@6.5.0

配置webpack输出源文件:
package.json

 "babel": {
  "presets": ["es2015", "react"]
}

webpack.config.js

 module.exports = {
  entry: [
    './src/index.js'
  ],
  module: {
    loaders: [{
      test: /\.jsx?$/,
      exclude: /node_modules/,
      loader: 'babel'
    }]
  },
  resolve: {
    extensions: ['', '.js', '.jsx']
  },
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'bundle.js'
  },
  devServer: {
    contentBase: './dist'
  }
};

现在添加React和React热加载组件到项目中:

 npm install --save react@0.14.7 react-dom@0.14.7
 npm install --save-dev react-hot-loader@1.3.0

为了让热加载能运行,webpack.config.js文件中要做一些修改.

webpack.config.js

 var webpack = require('webpack'); // Requiring the webpack lib

module.exports = {
  entry: [
    'webpack-dev-server/client?http://localhost:8080', // Setting the URL for the hot reload
    'webpack/hot/only-dev-server', // Reload only the dev server
    './src/index.js'
  ],
  module: {
    loaders: [{
      test: /\.jsx?$/,
      exclude: /node_modules/,
      loader: 'react-hot!babel' // Include the react-hot loader
    }]
  },
  resolve: {
    extensions: ['', '.js', '.jsx']
  },
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'bundle.js'
  },
  devServer: {
    contentBase: './dist',
    hot: true // Activate hot loading
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin() // Wire in the hot loading plugin
  ]
};

配置单元测试框架

我们将使用Mocha和Chai来进行测试工作.这两个工具广泛的被使用,他们的输出内容对于测试驱动开发非常的好.Chai-immutable是一个chai插件,用来处理immutable数据结构.

npm install --save immutable@3.7.6
npm install --save-dev mocha@2.4.5 chai@3.5.0 chai-immutable@1.5.3

在我们的例子中,我们不会依赖浏览器为基础的测试运行器例如Karma-替代方案是我们使用jsdom库,它将会使用纯javascirpt创建一个假DOM,这样做让我们的测试更加快速.

npm install —save-dev jsdom@8.0.4

我们也需要为测试写一个启动脚本,要考虑到下面的内容.

  • 模拟documentwindow对象,通常是由浏览器提供
  • 通过chia-immutable告诉chai组件我们要使用immutable数据结构

test/setup.js

 import jsdom from 'jsdom';
import chai from 'chai';
import chaiImmutable from 'chai-immutable';

const doc = jsdom.jsdom('<!doctype html><html><body></body></html>');
const win = doc.defaultView;

global.document = doc;
global.window = win;

Object.keys(window).forEach((key) => {
  if (!(key in global)) {
    global[key] = window[key];
  }
});

chai.use(chaiImmutable);

更新一下npm test脚本
package.json

 "scripts": {
  "test": "mocha --compilers js:babel-core/register --require ./test/setup.js 'test/**/*.@(js|jsx)'",
  "test:watch": "npm run test -- --watch --watch-extensions jsx"
},

npm run test:watch命令在windows操作系统下似乎不能工作.

现在,如果我们运行npm run test:watch,所有test目录里的.js,.jsx文件在更新自身或者源文件的时候,将会运行mocha测试.

配置完成了:我们可以在终端中运行webpack-dev-server,打开另一个终端,npm run test:watch.在浏览器中打开localhost:8080.检查hello world!是否出现在终端中.

构建state tree

之前提到过,state tree是能提供app所有信息的数据结构.这个结构需要在实际开发之前经过深思熟虑,因为它将影响一些代码的结构和交互作用.

作为示例,我们app是一个TODO list由几个条目组合而成

state tree 1
state tree 1

每一个条目有一个文本,为了便于操作,设一个id,此外每个item有两个状态之一:活动或者完成:最后一个条目需要一个可编辑的状态(当用户想编辑的文本的时候),
所以我们需要保持下面的数据结构:


state tree 2
state tree 2

也有可能基于他们的状态进行筛选,所以我们天剑filter条目来获取最终的state tree:

sate tree 3
sate tree 3

创建UI

首先我们把app分解为下面的组件:

  • TodoHeader组件是创建新todo的输入组件
  • TodoList组件是todo的列表
  • todoItem是一个todo
  • todoInput是编辑todo的输入框
  • TodoTools是显示未完成的条目数量,过滤器和清除完成的按钮
  • footer是显示信息的,没有具体的逻辑

我们也创建TodoApp组件组织所有的其他组件.

首次运行我们的组件

提示:运行这个版本

正如我们所见的,我们将会把所有组件放到合并成一个TodoApp组件.所以让我们添加组件到index.html文件的#appDIV中:
src/index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import {List, Map} from 'immutable';

import TodoApp from './components/TodoApp';

const todos = List.of(
  Map({id: 1, text: 'React', status: 'active', editing: false}),
  Map({id: 2, text: 'Redux', status: 'active', editing: false}),
  Map({id: 3, text: 'Immutable', status: 'completed', editing: false})
);

ReactDOM.render(
  <TodoApp todos={todos} />,
  document.getElementById('app')
);

因为我们在index.jsx文件中使用JSX语法,需要在wabpack中扩展.jsx.修改如下:
webpack.config.js

 entry: [
  'webpack-dev-server/client?http://localhost:8080',
  'webpack/hot/only-dev-server',
  './src/index.jsx' // Change the index file extension
],

编写todo list UI

现在我们编写第一版本的TodoApp组件,用来显示todo项目列表:
src/components/TodoApp.jsx

 import React from 'react';

export default class TodoApp extends React.Component {
  getItems() {
    return this.props.todos || [];
  }
  render() {
    return <div>
      <section className="todoapp">
        <section className="main">
          <ul className="todo-list">
            {this.getItems().map(item =>
              <li className="active" key={item.get('text')}>
                <div className="view">
                  <input type="checkbox"
                         className="toggle" />
                  <label htmlFor="todo">
                    {item.get('text')}
                  </label>
                  <button className="destroy"></button>
                </div>
              </li>
            )}
          </ul>
        </section>
      </section>
    </div>
  }
};

要记住两件事情:
第一个,如果你看到的结果不太好,修复它,我们将会使用todomvc-app-css包来补充一些需要的样式

npm install --save todomvc-app-css@2.0.4
npm install style-loader@0.13.0 css-loader@0.23.1 --save-dev

我们需要告诉webpack去加载css 样式文件:
webpack.config.js

// ...
module: {
  loaders: [{
    test: /\.jsx?$/,
    exclude: /node_modules/,
    loader: 'react-hot!babel'
  }, {
    test: /\.css$/,
    loader: 'style!css' // We add the css loader
  }]
},
//...

然后在inde.jsx文件中添加样式:
src/index.jsx

 // ...
require('../node_modules/todomvc-app-css/index.css');

ReactDOM.render(
  <TodoApp todos={todos} />,
  document.getElementById('app')
);

第二件事是:代码似乎很复杂,这就是我们为什么要创建两个或者多个组件的原因:TodoListTodoItem将会分别关注条目列表和单个的条目.

这一部分的提交代码

src/components/TodoApp.jsx

 import React from 'react';
import TodoList from './TodoList'

export default class TodoApp extends React.Component {
  render() {
    return <div>
      <section className="todoapp">
        <TodoList todos={this.props.todos} />
      </section>
    </div>
  }
};

TodoList组件中根据获取的props为每一个条目显示一个TodoItem组件.

src/components/TodoList.jsx

 import React from 'react';
import TodoItem from './TodoItem';

export default class TodoList extends React.Component {
  render() {
    return <section className="main">
      <ul className="todo-list">
        {this.props.todos.map(item =>
          <TodoItem key={item.get('text')}
                    text={item.get('text')} />
        )}
      </ul>
    </section>
  }
};

src/components/TodoItem.jsx

 import React from 'react';

export default class TodoItem extends React.Component {
  render() {
    return <li className="todo">
      <div className="view">
        <input type="checkbox"
               className="toggle" />
        <label htmlFor="todo">
          {this.props.text}
        </label>
        <button className="destroy"></button>
      </div>
    </li>
  }
};

在我们深入用户的交互操作之前,我们先在组件TodoItem中添加一个input用于编辑
src/componensts/TodoItem.jsx

 import React from 'react';

import TextInput from './TextInput';

export default class TodoItem extends React.Component {

  render() {
    return <li className="todo">
      <div className="view">
        <input type="checkbox"
               className="toggle" />
        <label htmlFor="todo">
          {this.props.text}
        </label>
        <button className="destroy"></button>
      </div>
      <TextInput /> // We add the TextInput component
    </li>
  }
};

TextInput组件如下
src/compoents/TextInput.jsx

import React from 'react';

export default class TextInput extends React.Component {
  render() {
    return <input className="edit"
                  autoFocus={true}
                  type="text" />
  }
};

”纯“组件的好处:PureRenderMixin

这部分的提交代码

除了允许函数式编程的样式,我们的UI是单纯的,可以使用PureRenderMixin来提升速度,正如React 文档:
如果你的React的组件渲染函数是”纯“(换句话就是,如果使用相同的porps和state,总是会渲染出同样的结果),你可以使用mixin在同一个案例转给你来提升性能.

正如React文档(我们也会在第二部分看到TodoApp组件有额外的角色会阻止PureRenderMixin的使用)展示的mixin也非常容易的添加到我们的子组件中:
npm install --save react-addons-pure-render-mixin@0.14.7
src/components/TodoList.jsc

 import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin'
import TodoItem from './TodoItem';

export default class TodoList extends React.Component {
  constructor(props) {
    super(props);
    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
  }
  render() {
    // ...
  }
};

src/components/TodoItem/jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin'
import TextInput from './TextInput';

export default class TodoItem extends React.Component {
  constructor(props) {
    super(props);
    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
  }
  render() {
    // ...
  }
};

src/components/TextInput.jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin'

export default class TextInput extends React.Component {
  constructor(props) {
    super(props);
    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
  }
  render() {
    // ...
  }
};

在list组件中处理用户的actions

好了,现在我们配置好了list组件.然而我们没有考虑添加用户的actions和怎么添加进去组件.

props的力量

在React中,props对象是当我们实例化一个容器(container)的时候,通过设定的attributes来设定.例如,如果我们实例化一个TodoItem元素:

<TodoItem text={'Text of the item'} />

然后我们在TodoItem组件中获取this.props.text变量:

 // in TodoItem.jsx
console.log(this.props.text);
// outputs 'Text of the item'

Redux构架中强化使用props.基础的原理是state几乎都存在于他的props里面.换一种说法:对于同样一组props,两个元素的实例应该输出完全一样的结果.正如之前我们看到的,整个app的state都包含在一个state tree中:意思是说,state tree 如果通过props的方式传递到组件,将会完整和可预期的决定app的视觉输出.

TodoList组件

这一部分的代码修改

在这一部分和接下来的一部分,我们将会了解一个测试优先的方法.

为了帮助我们测试组件,React库提供了TestUtils工具插件,有一下方法:

  • renderIntoDocument,渲染组件到附加的DOM节点
  • scryRenderDOMComponentsWIthTag,使用提供的标签(例如li,input)在DOM中找到所有的组件实例.
  • scryRederDOMComponentsWithClass,同上使用的是类
  • Simulate,模拟用户的actions(例如 点击,按键,文本输入…)

TestUtils插件没有包含在react包中,所以需要单独安装
npm install --save-dev react-addons-test-utils@0.14.7

我们的第一个测试将确保Todolist组件中,如果filterprops被设置为active,将会展示所有的活动条目:
test/components/TodoList_spec.jsx

 import React from 'react';
import TestUtils from 'react-addons-test-utils';
import TodoList from '../../src/components/TodoList';
import {expect} from 'chai';
import {List, Map} from 'immutable';

const {renderIntoDocument,
     scryRenderedDOMComponentsWithTag} = TestUtils;

describe('TodoList', () => {
it('renders a list with only the active items if the filter is active', () => {
  const todos = List.of(
    Map({id: 1, text: 'React', status: 'active'}),
    Map({id: 2, text: 'Redux', status: 'active'}),
    Map({id: 3, text: 'Immutable', status: 'completed'})
  );
  const filter = 'active';
  const component = renderIntoDocument(
    <TodoList filter={filter} todos={todos} />
  );
  const items = scryRenderedDOMComponentsWithTag(component, 'li');

  expect(items.length).to.equal(2);
  expect(items[0].textContent).to.contain('React');
  expect(items[1].textContent).to.contain('Redux');
});
});

我们可以看到测试失败了,期待的是两个活动条目,但是实际上是三个.这是再正常不过的了,因为我们没有编写实际筛选的逻辑:
src/components/TodoList.jsx

// ...
export default class TodoList extends React.Component {
  // Filters the items according to their status
  getItems() {
    if (this.props.todos) {
      return this.props.todos.filter(
        (item) => item.get('status') === this.props.filter
      );
    }
    return [];
  }
  render() {
    return <section className="main">
      <ul className="todo-list">
        // Only the filtered items are displayed
        {this.getItems().map(item =>
          <TodoItem key={item.get('text')}
                    text={item.get('text')} />
        )}
      </ul>
    </section>
  }
};

第一个测试通过了.别停下来,让我们添加筛选器:allcompleted:
test/components/TodoList_spec.js

// ...
describe('TodoList', () => {
// ...
it('renders a list with only completed items if the filter is completed', () => {
  const todos = List.of(
    Map({id: 1, text: 'React', status: 'active'}),
    Map({id: 2, text: 'Redux', status: 'active'}),
    Map({id: 3, text: 'Immutable', status: 'completed'})
  );
  const filter = 'completed';
  const component = renderIntoDocument(
    <TodoList filter={filter} todos={todos} />
  );
  const items = scryRenderedDOMComponentsWithTag(component, 'li');

  expect(items.length).to.equal(1);
  expect(items[0].textContent).to.contain('Immutable');
});

it('renders a list with all the items', () => {
  const todos = List.of(
    Map({id: 1, text: 'React', status: 'active'}),
    Map({id: 2, text: 'Redux', status: 'active'}),
    Map({id: 3, text: 'Immutable', status: 'completed'})
  );
  const filter = 'all';
  const component = renderIntoDocument(
    <TodoList filter={filter} todos={todos} />
  );
  const items = scryRenderedDOMComponentsWithTag(component, 'li');

  expect(items.length).to.equal(3);
  expect(items[0].textContent).to.contain('React');
  expect(items[1].textContent).to.contain('Redux');
  expect(items[2].textContent).to.contain('Immutable');
});
});

第三个测试失败了,因为all筛选器更新组件的逻辑稍稍有点不同
src/components/TodoList.jsx

 // ...
export default React.Component {
// Filters the items according to their status
getItems() {
  if (this.props.todos) {
    return this.props.todos.filter(
      (item) => this.props.filter === 'all' || item.get('status') === this.props.filter
    );
  }
  return [];
}
// ...
});

在这一点上,我们知道显示在app中的条目都是经过filter属性过滤的.如果在浏览器中看看结果,没有显示任何条目,因为我们还没有设置:
src/index.jsx

 // ...
const todos = List.of(
  Map({id: 1, text: 'React', status: 'active', editing: false}),
  Map({id: 2, text: 'Redux', status: 'active', editing: false}),
  Map({id: 3, text: 'Immutable', status: 'completed', editing: false})
);

const filter = 'all';

require('../node_modules/todomvc-app-css/index.css')

ReactDOM.render(
  <TodoApp todos={todos} filter = {filter}/>,
  document.getElementById('app')
);

src/components/TodoApp.jsx

// ...
export default class TodoApp extends React.Component {
  render() {
    return <div>
      <section className="todoapp">
        // We pass the filter props down to the TodoList component
        <TodoList todos={this.props.todos} filter={this.props.filter}/>
      </section>
    </div>
  }
};

使用在index.jsc文件中声明的filter常量过滤以后,我们的条目重新出现了.

TodoItem component

这部分的代码修改

现在,让我们关注一下TodoItem组件.首先,我们想确信TodoItem组件真正渲染一个条目.我们也想测试一下还没有测试的特性,就是当一个条目完成的时候,他的文本中间有一条线
test/components/TodoItem_spec.js

 import React from 'react';
import TestUtils from 'react-addons-test-utils';
import TodoItem from '../../src/components/TodoItem';
import {expect} from 'chai';

const {renderIntoDocument,
      scryRenderedDOMComponentsWithTag} = TestUtils;

describe('TodoItem', () => {
 it('renders an item', () => {
   const text = 'React';
   const component = renderIntoDocument(
     <TodoItem text={text} />
   );
   const todo = scryRenderedDOMComponentsWithTag(component, 'li');

   expect(todo.length).to.equal(1);
   expect(todo[0].textContent).to.contain('React');
 });

 it('strikes through the item if it is completed', () => {
   const text = 'React';
   const component = renderIntoDocument(
     <TodoItem text={text} isCompleted={true}/>
   );
   const todo = scryRenderedDOMComponentsWithTag(component, 'li');

   expect(todo[0].classList.contains('completed')).to.equal(true);
 });
});

为了使第二个测试通过,如果条目的状态是complete我们使用了类complete,它将会通过props传递向下传递.我们将会使用classnames包来操作我们的DOM类.
npm install —save classnames

src/components/TodoItem.jsx

 import React from 'react';
// We need to import the classNames object
import classNames from 'classnames';

import TextInput from './TextInput';

export default class TodoItem extends React.Component {
render() {
  var itemClass = classNames({
    'todo': true,
    'completed': this.props.isCompleted
  });
  return <li className={itemClass}>
    // ...
  </li>
}
};

一个item在编辑的时候外观应该看起来不一样,由isEditingprops来包裹.
test/components/TodoItem_spec.js

 // ...
describe('TodoItem', () => {
//...

it('should look different when editing', () => {
  const text = 'React';
  const component = renderIntoDocument(
    <TodoItem text={text} isEditing={true}/>
  );
  const todo = scryRenderedDOMComponentsWithTag(component, 'li');

  expect(todo[0].classList.contains('editing')).to.equal(true);
});
});

为了使测试通过,我们仅仅需要更新itemClass对象:
src/components/TodoItem.jsx

 // ...
export default class TodoItem extends React.Component {
 render() {
   var itemClass = classNames({
     'todo': true,
     'completed': this.props.isCompleted,
     'editing': this.props.isEditing
   });
   return <li className={itemClass}>
     // ...
   </li>
 }
};

条目左侧的checkbox如果条目完成,应该标记位checked:
test/components/TodoItem_spec.js

 // ...
describe('TodoItem', () => {
 //...

 it('should be checked if the item is completed', () => {
   const text = 'React';
   const text2 = 'Redux';
   const component = renderIntoDocument(
     <TodoItem text={text} isCompleted={true}/>,
     <TodoItem text={text2} isCompleted={false}/>
   );
   const input = scryRenderedDOMComponentsWithTag(component, 'input');
   expect(input[0].checked).to.equal(true);
   expect(input[1].checked).to.equal(false);
 });
});

React有个设定checkbox输入state的方法:defaultChecked.
src/components/TodoItem.jsx

 // ...
export default class TodoItem extends React.Component {
render() {
  // ...
  return <li className={itemClass}>
    <div className="view">
      <input type="checkbox"
             className="toggle"
             defaultChecked={this.props.isCompleted}/>
      // ...
    </div>
  </li>
}
};

我们也从TodoList组件向下传递isCompletedisEditingprops.
src/components/TodoList.jsx

 // ...
export default class TodoList extends React.Component {
// ...
// This function checks whether an item is completed
isCompleted(item) {
  return item.get('status') === 'completed';
}
render() {
  return <section className="main">
    <ul className="todo-list">
      {this.getItems().map(item =>
        <TodoItem key={item.get('text')}
                  text={item.get('text')}
                  // We pass down the info on completion and editing
                  isCompleted={this.isCompleted(item)}
                  isEditing={item.get('editing')} />
      )}
    </ul>
  </section>
}
};

截止目前,我们已经能够在组件中反映出state:例如,完成的条目将会被划线.然而一个webapp将会处理诸如点击按钮的操作.在Redux的模式中,这个操作也通过porps来执行,稍稍特殊的是通过在props中传递回调函数来完成.通过这种方式,我们再次把UI和App的逻辑处理分离开:组件根本不需要知道按钮点击的操作具体是什么,仅仅是点击触发了一些事情.

为了描述这个原理,我们将会测试如果用户点击了delete按钮(红色X),delteItem函数将会被调用.

这部分的代码修改

test/components/TodoItem_spec.jsx

 / ...
// The Simulate helper allows us to simulate a user clicking
const {renderIntoDocument,
      scryRenderedDOMComponentsWithTag,
      Simulate} = TestUtils;

describe('TodoItem', () => {
 // ...
 it('invokes callback when the delete button is clicked', () => {
   const text = 'React';
   var deleted = false;
   // We define a mock deleteItem function
   const deleteItem = () => deleted = true;
   const component = renderIntoDocument(
     <TodoItem text={text} deleteItem={deleteItem}/>
   );
   const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
   Simulate.click(buttons[0]);

   // We verify that the deleteItem function has been called
   expect(deleted).to.equal(true);
 });
});

为了是这个测试通过,我们必须在delete按钮声明一个onClick句柄,他将会调用经过props传递的deleteItem函数.

src/components/TodoItem.jsx

 // ...
export default class TodoItem extends React.Component {
 render() {
   // ...
   return <li className={itemClass}>
     <div className="view">
       // ...
       // The onClick handler will call the deleteItem function given in the props
       <button className="destroy"
               onClick={() => this.props.deleteItem(this.props.id)}></button>
     </div>
     <TextInput />
   </li>
 }
};

重要的一点:实际删除的逻辑还没有实施,这个将是Redux的主要作用.
在同一个model,我们可以测试和实施下面的特性:

  • 点击checkbox将会调用toggleComplete函数
  • 双击条目标签,将会调用editItem函数

test/components/TodoItem_spec.js

 // ...
describe('TodoItem', () => {
 // ...
 it('invokes callback when checkbox is clicked', () => {
   const text = 'React';
   var isChecked = false;
   const toggleComplete = () => isChecked = true;
   const component = renderIntoDocument(
     <TodoItem text={text} toggleComplete={toggleComplete}/>
   );
   const checkboxes = scryRenderedDOMComponentsWithTag(component, 'input');
   Simulate.click(checkboxes[0]);

   expect(isChecked).to.equal(true);
 });

 it('calls a callback when text is double clicked', () => {
   var text = 'React';
   const editItem = () => text = 'Redux';
   const component = renderIntoDocument(
     <TodoItem text={text} editItem={editItem}/>
   );
   const label = component.refs.text
   Simulate.doubleClick(label);

   expect(text).to.equal('Redux');
 });
});

src/compoents/TodoItem.jsx

 // ...
render() {
 // ...
 return <li className={itemClass}>
   <div className="view">
     // We add an onClick handler on the checkbox
     <input type="checkbox"
            className="toggle"
            defaultChecked={this.props.isCompleted}
            onClick={() => this.props.toggleComplete(this.props.id)}/>
     // We add a ref attribute to the label to facilitate the testing
     // The onDoubleClick handler is unsurprisingly called on double clicks
     <label htmlFor="todo"
            ref="text"
            onDoubleClick={() => this.props.editItem(this.props.id)}>
       {this.props.text}
     </label>
     <button className="destroy"
             onClick={() => this.props.deleteItem(this.props.id)}></button>
   </div>
   <TextInput />
 </li>

我们也从TodoList组件借助props向下传递editItem,deleteItemtoggleComplete函数.
src/components/TodoList.jsx

 // ...
export default class TodoList extends React.Component {
 // ...
 render() {
     return <section className="main">
       <ul className="todo-list">
         {this.getItems().map(item =>
           <TodoItem key={item.get('text')}
                     text={item.get('text')}
                     isCompleted={this.isCompleted(item)}
                     isEditing={item.get('editing')}
                     // We pass down the callback functions
                     toggleComplete={this.props.toggleComplete}
                     deleteItem={this.props.deleteItem}
                     editItem={this.props.editItem}/>
         )}
       </ul>
     </section>
   }
};

配置其他组件

现在,你可能对流程有些熟悉了.为了让本文不要太长,我邀请你看看组件的代码,
TextInput(相关提交),TodoHeader(相关提交),TodotoolsFooter(相关提交)组件.如果你有任何问题,请留下评论,或着在repo的issue中留下评论.

你可能主要到一些函数,例如editItem,toggleComplete诸如此类的,还没有被定义.这些内容将会在教程的下一部分作为Redux actions的组成来定义,所以如果遇到错误,不要担心.

包装起来

在这篇文章中,我已经演示了我的第一个React,Redux和Immutable webapp.我们的UI是模块化的.完全通过测试,准备和实际的app逻辑联系起来.怎么来连接?这些傻瓜组件什么都不知道,怎么让我们可以写出时间旅行的app?

教程的第二部分在这里.

推荐阅读更多精彩内容