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

Getting Started with React, Redux and Immutable: a Test-Driven Tutorial (Part 2)

翻译版本,原文请见

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

这是第二部分的内容.

在第一部分,我们罗列了app的UI,开发和单元测试的基础.

我们看到了app的state通过React的props向下传递到单个的组件,用户的actions声明为回调函数,因此app的逻辑和UI分离开来了.

Redux的工作流介绍

在这一点上,我们的UI是没有交互操作的:尽管我们已经测试了如果一个item如果被设定为completed,它将给文本划线,但是这里还没有方法邀请用户来完成它:

  1. state tree通过props定义了UI和action回调函数.
  2. 用户的actions,例如点击,被发送到action creator,action被它范式化.
  3. redux action被传递到reducer实现实际的app逻辑
  4. reducer更新state tree,dispatch state到store.
  5. UI根据store里的新state tree来更新UI
Redux working flos
Redux working flos

设定初始化state

这部分的代码提交在这里

我们的第一个action将会允许我们在Redux store里正确的设置初始化state
,我们将会创建store.

Redux中的action是一个信息的载体(payload).action由一个JSON对象有一个type属性,描述action到底是做什么的,还有一部分是app需要的信息.在我们的实例中,type被设定为SET_STATE,我们可以添加一个state对象包含需要的state:

{
  type: 'SET_STATE',
  state: {
    todos: [
      {id: 1, text: 'React', status: 'active', editing: false},
      {id: 2, text: 'Redux', status: 'active', editing: false},
      {id: 3, text: 'Immutable', status: 'active', editing: false},
    ],
    filter: 'all'
  }
}

这个action会被dispatch到一个reducer,reducer角色的是识别和实施和action对应的逻辑代码.

让我们为reducer来写单元测试代码
test/reducer_spec.js

 import {List, Map, fromJS} from 'immutable';
import {expect} from 'chai';

import reducer from '../src/reducer';

describe('reducer', () => {

  it('handles SET_STATE', () => {
    const initialState = Map();
    const action = {
      type: 'SET_STATE',
      state: Map({
        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 nextState = reducer(initialState, action);

    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'active'},
        {id: 3, text: 'Immutable', status: 'completed'}
      ]
    }));
  });

});

为了方便一点,state使用单纯JS对象,而不是使用Immutable数据结构.让我们的reducer来处理转变.最后,reducer将会优雅的处理undefined初始化state:
test/reducer_spec.js

 // ...
describe('reducer', () => {
  // ...
  it('handles SET_STATE with plain JS payload', () => {
    const initialState = Map();
    const action = {
      type: 'SET_STATE',
      state: {
        todos: [
          {id: 1, text: 'React', status: 'active'},
          {id: 2, text: 'Redux', status: 'active'},
          {id: 3, text: 'Immutable', status: 'completed'}
        ]
      }
    };
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'active'},
        {id: 3, text: 'Immutable', status: 'completed'}
      ]
    }));
  });

  it('handles SET_STATE without initial state', () => {
    const action = {
      type: 'SET_STATE',
      state: {
        todos: [
          {id: 1, text: 'React', status: 'active'},
          {id: 2, text: 'Redux', status: 'active'},
          {id: 3, text: 'Immutable', status: 'completed'}
        ]
      }
    };
    const nextState = reducer(undefined, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'active'},
        {id: 3, text: 'Immutable', status: 'completed'}
      ]
    }));
  });
});

我们的reducer将会匹配接收的actions的type,如果type是SET_STATE,当前的state和action运载的state融合在一起:
src/reducer.js

import {Map} from 'immutable';

function setState(state, newState) {
  return state.merge(newState);
}

export default function(state = Map(), action) {
  switch (action.type) {
    case 'SET_STATE':
      return setState(state, action.state);
  }
  return state;
}

现在我们不得不把reducer连接到我们的app,所以当app启动初始化state.这里实际是第一次使用Redux库,安装一下
npm install —save redux@3.3.1 react-redux@4.4.1

src/index.jsx

 import React from 'react';
import ReactDOM from 'react-dom';
import {List, Map} from 'immutable';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import {TodoAppContainer} from './components/TodoApp';

// We instantiate a new Redux store
const store = createStore(reducer);
// We dispatch the SET_STATE action holding the desired state
store.dispatch({
  type: 'SET_STATE',
  state: {
    todos: [
      {id: 1, text: 'React', status: 'active', editing: false},
      {id: 2, text: 'Redux', status: 'active', editing: false},
      {id: 3, text: 'Immutable', status: 'active', editing: false},
    ],
    filter: 'all'
  }
});

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

ReactDOM.render(
  // We wrap our app in a Provider component to pass the store down to the components
  <Provider store={store}>
    <TodoAppContainer />
  </Provider>,
  document.getElementById('app')
);

如果你看看上面的代码段,你可以注意到我们的TodoApp组件实际是被TodoAppContainer代替.在Redux里,有两种类型的组件:展示组件和容器.我推荐你阅读一下由Dan Abramov(Redux的作者)写作的高信息量的文章,强调了展示组件和容器的差异性.

如果我想总结得快一点,我将引用Redux 文档的内容:

“展示组件是关于事件的样子(模板和样式),容器组件是关于事情是怎么工作的(数据获取,state更新)”.

所以我们创建store,传递给TodoAppContainer.然而为了子组件可以使用store,我们把state映射成为React组件TodoAppprops.
src/components/TodoApp.jsx

 // ...
import {connect} from 'react-redux';

export class TodoApp extends React.Component {
// ...
}
function mapStateToProps(state) {
  return {
    todos: state.get('todos'),
    filter: state.get('filter')
  };
}

export const TodoAppContainer = connect(mapStateToProps)(TodoApp);

如果你在浏览器中重新加载app,你应该可以看到它初始化和之前一样,不过现在使用Redux tools.

Redux dev 工具

这一部分的提交代码

现在我们已经配置了redux store和reducer.我们可以配置Redux dev tools来展现数据流开发.

首先,获取Redux dev tools Chrome extension

dev tools可以在Store创建的时候可以加载.

src/index.jsx

 // ...
import {compose, createStore} from 'redux';

const createStoreDevTools = compose(
  window.devToolsExtension ? window.devToolsExtension() : f => f
)(createStore);
const store = createStoreDevTools(reducer);
// ...
Redux dev tools
Redux dev tools

重新加载app,点击Redux图标,有了.

有三个不同的监视器可以使用:Diff监视器,日志监视器,Slider监视器.

使用Action Creators配置我们的actions

切换item的不同状态.

这部分的提交代码在这里

下一步是允许用户在activecompleted之前切换状态:
test/reducer_spec.js

 import {List, Map, fromJS} from 'immutable';
import {expect} from 'chai';

import reducer from '../src/reducer';

describe('reducer', () => {
// ...
  it('handles TOGGLE_COMPLETE by changing the status from active to completed', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'active'},
        {id: 3, text: 'Immutable', status: 'completed'}
      ]
    });
    const action = {
      type: 'TOGGLE_COMPLETE',
      itemId: 1
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'completed'},
        {id: 2, text: 'Redux', status: 'active'},
        {id: 3, text: 'Immutable', status: 'completed'}
      ]
    }));
  });

  it('handles TOGGLE_COMPLETE by changing the status from completed to active', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'active'},
        {id: 3, text: 'Immutable', status: 'completed'}
      ]
    });
    const action = {
      type: 'TOGGLE_COMPLETE',
      itemId: 3
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'active'},
        {id: 3, text: 'Immutable', status: 'active'}
      ]
    }));
  });
});

为了通过这些测试,我们更新reducer:
src/reducer.js

// ...
function toggleComplete(state, itemId) {
  // We find the index associated with the itemId
  const itemIndex = state.get('todos').findIndex(
    (item) => item.get('id') === itemId
  );
  // We update the todo at this index
  const updatedItem = state.get('todos')
    .get(itemIndex)
    .update('status', status => status === 'active' ? 'completed' : 'active');

  // We update the state to account for the modified todo
  return state.update('todos', todos => todos.set(itemIndex, updatedItem));
}

export default function(state = Map(), action) {
  switch (action.type) {
    case 'SET_STATE':
      return setState(state, action.state);
    case 'TOGGLE_COMPLETE':
      return toggleComplete(state, action.itemId);
  }
  return state;
}

SET_STATE的action同一个地方,我们需要让TodoAppContainer组件感知到action,所以toggleComplete回调函数会被传递到TodoItem组件(实际调用函数的地方).

在Redux中,有标准的方法来做这件事:Action Creators.

action creators是简单的函数,返回合适的action,这些韩式是React的props的一些映射之一.
让我们创建第一个action creator:
src/action_creators.js

export function toggleComplete(itemId) {
  return {
    type: 'TOGGLE_COMPLETE',
    itemId
  }
}

现在,尽管TodoAppcontainer组件中的connect函数的调用可以用来获取store,我们告诉组件使用映射props的回调函数:
src/components/TodoApp.jsx

// ...
import * as actionCreators from '../action_creators';
export class TodoApp extends React.Component {
  // ...
  render() {
    return <div>
      // ...
        // We use the spread operator for better lisibility
        <TodoList  {...this.props} />
      // ...
    </div>
  }
};

export const TodoAppContainer = connect(mapStateToProps, actionCreators)(TodoApp);

重启你的webserver,刷新一下你的浏览器:当当.在条目上点击现在可以切换它的状态.如果你查看Redux dev tools,你可以看到触发的action和后继的更新.

改变目前的过滤器

相关代码在在这里

现在每件事情都已经配置完毕,写其他的action是件小事.我们继续创建你希望的CHANGE_FILTERaction,改变当前state的filter,由此仅仅显示过滤过的条目.
开始创建action creator:
src/action_creators.js

 // ...
export function changeFilter(filter) {
  return {
    type: 'CHANGE_FILTER',
    filter
  }
}

现在写reducer的单元测试:
test/reducer_spec.js

// ...
describe('reducer', () => {
  // ...
  it('handles CHANGE_FILTER by changing the filter', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
      ],
      filter: 'all'
    });
    const action = {
      type: 'CHANGE_FILTER',
      filter: 'active'
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
      ],
      filter: 'active'
    }));
  });
});

关联的reducer函数:
src/reducer.js

 // ...
function changeFilter(state, filter) {
  return state.set('filter', filter);
}

export default function(state = Map(), action) {
  switch (action.type) {
    case 'SET_STATE':
      return setState(state, action.state);
    case 'TOGGLE_COMPLETE':
      return toggleComplete(state, action.itemId);
    case 'CHANGE_FILTER':
      return changeFilter(state, action.filter);
  }
  return state;
}

最后我们把changeFilter回调函数传递给TodoTools组件:
TodoApp.jsx

// ...
export class TodoApp extends React.Component {
  // ...
  render() {
    return <div>
      <section className="todoapp">
        // ...
        <TodoTools changeFilter={this.props.changeFilter}
                   filter={this.props.filter}
                   nbActiveItems={this.getNbActiveItems()} />
      </section>
      <Footer />
    </div>
  }
};

完成了,第一个filter selector工作完美

Item编辑

代码在这里
当用户编辑一个条目,实际上是两个actions触发的三个可能性:

  • 用户输入编辑模式:EDIT_ITEM
  • 用户退出编辑模式(不保存变化):CANCEL_EDITING
  • 用户验证他的编辑(保存变化):DONE_EDITING

我们可以为三个actions编写action creators:
src/action_creators.js

// ...
export function editItem(itemId) {
  return {
    type: 'EDIT_ITEM',
    itemId
  }
}

export function cancelEditing(itemId) {
  return {
    type: 'CANCEL_EDITING',
    itemId
  }
}

export function doneEditing(itemId, newText) {
  return {
    type: 'DONE_EDITING',
    itemId,
    newText
  }
}

现在为这些actions编写单元测试:
test/reducer_spec.js

// ...
describe('reducer', () => {
  // ...
  it('handles EDIT_ITEM by setting editing to true', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active', editing: false},
      ]
    });
    const action = {
      type: 'EDIT_ITEM',
      itemId: 1
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active', editing: true},
      ]
    }));
  });

  it('handles CANCEL_EDITING by setting editing to false', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active', editing: true},
      ]
    });
    const action = {
      type: 'CANCEL_EDITING',
      itemId: 1
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active', editing: false},
      ]
    }));
  });

  it('handles DONE_EDITING by setting by updating the text', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active', editing: true},
      ]
    });
    const action = {
      type: 'DONE_EDITING',
      itemId: 1,
      newText: 'Redux',
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'Redux', status: 'active', editing: false},
      ]
    }));
  });
});

现在我们可以开发reducer函数,实际操作三个actions:
src/reducer.js

function findItemIndex(state, itemId) {
  return state.get('todos').findIndex(
    (item) => item.get('id') === itemId
  );
}

// We can refactor the toggleComplete function to use findItemIndex
function toggleComplete(state, itemId) {
  const itemIndex = findItemIndex(state, itemId);
  const updatedItem = state.get('todos')
    .get(itemIndex)
    .update('status', status => status === 'active' ? 'completed' : 'active');

  return state.update('todos', todos => todos.set(itemIndex, updatedItem));
}

function editItem(state, itemId) {
  const itemIndex = findItemIndex(state, itemId);
  const updatedItem = state.get('todos')
    .get(itemIndex)
    .set('editing', true);

  return state.update('todos', todos => todos.set(itemIndex, updatedItem));
}

function cancelEditing(state, itemId) {
  const itemIndex = findItemIndex(state, itemId);
  const updatedItem = state.get('todos')
    .get(itemIndex)
    .set('editing', false);

  return state.update('todos', todos => todos.set(itemIndex, updatedItem));
}

function doneEditing(state, itemId, newText) {
  const itemIndex = findItemIndex(state, itemId);
  const updatedItem = state.get('todos')
    .get(itemIndex)
    .set('editing', false)
    .set('text', newText);

  return state.update('todos', todos => todos.set(itemIndex, updatedItem));
}

export default function(state = Map(), action) {
  switch (action.type) {
    // ...
    case 'EDIT_ITEM':
      return editItem(state, action.itemId);
    case 'CANCEL_EDITING':
      return cancelEditing(state, action.itemId);
    case 'DONE_EDITING':
      return doneEditing(state, action.itemId, action.newText);
  }
  return state;
}

清除完成,添加和删除条目

代码在这里

三个剩下的action是:

  1. CLEAR_COMPLETED,在TodoTools组件中触发,从列表中清除完成的条目
  2. ADD_ITEM,在TodoHeader中触发,根据用户的的输入文本来添加条目
  3. DELETE_ITEM,相似TodoItem中调用,删除一个条目

我们现在使用的工作流是:添加action creators,单元测试reducer和代码逻辑,最终通过props传递回调函数:
src/action_creators.js

// ...
export function clearCompleted() {
  return {
    type: 'CLEAR_COMPLETED'
  }
}

export function addItem(text) {
  return {
    type: 'ADD_ITEM',
    text
  }
}

export function deleteItem(itemId) {
  return {
    type: 'DELETE_ITEM',
    itemId
  }
}

test/reducer_spec.js

 // ...
describe('reducer', () => {
  // ...
  it('handles CLEAR_COMPLETED by removing all the completed items', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'completed'},
      ]
    });
    const action = {
      type: 'CLEAR_COMPLETED'
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
      ]
    }));
  });

  it('handles ADD_ITEM by adding the item', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'}
      ]
    });
    const action = {
      type: 'ADD_ITEM',
      text: 'Redux'
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'active'},
      ]
    }));
  });

  it('handles DELETE_ITEM by removing the item', () => {
    const initialState = fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
        {id: 2, text: 'Redux', status: 'completed'},
      ]
    });
    const action = {
      type: 'DELETE_ITEM',
      itemId: 2
    }
    const nextState = reducer(initialState, action);
    expect(nextState).to.equal(fromJS({
      todos: [
        {id: 1, text: 'React', status: 'active'},
      ]
    }));
  });
});

src/reducer.js

function clearCompleted(state) {
  return state.update('todos',
    (todos) => todos.filterNot(
      (item) => item.get('status') === 'completed'
    )
  );
}

function addItem(state, text) {
  const itemId = state.get('todos').reduce((maxId, item) => Math.max(maxId,item.get('id')), 0) + 1;
  const newItem = Map({id: itemId, text: text, status: 'active'});
  return state.update('todos', (todos) => todos.push(newItem));
}

function deleteItem(state, itemId) {
  return state.update('todos',
    (todos) => todos.filterNot(
      (item) => item.get('id') === itemId
    )
  );
}

export default function(state = Map(), action) {
  switch (action.type) {
    // ...
    case 'CLEAR_COMPLETED':
      return clearCompleted(state);
    case 'ADD_ITEM':
      return addItem(state, action.text);
    case 'DELETE_ITEM':
      return deleteItem(state, action.itemId);
  }
  return state;
}

src/components/TodoApp.jsx

 // ...
export class TodoApp extends React.Component {
  // ...
  render() {
    return <div>
      <section className="todoapp">
        // We pass down the addItem callback
        <TodoHeader addItem={this.props.addItem}/>
        <TodoList {...this.props} />
        // We pass down the clearCompleted callback
        <TodoTools changeFilter={this.props.changeFilter}
                    filter={this.props.filter}
                    nbActiveItems={this.getNbActiveItems()}
                    clearCompleted={this.props.clearCompleted}/>
      </section>
      <Footer />
    </div>
  }
};

我们的TodoMVC app现在完成了.

包装起来

这我们的测试驱动的React,Redux&Immutable 技术栈

如果你想了解更多内容,有更多的事情等着你去挖掘
例如:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容