翻譯|開啟React,Redux和Immutable之旅:測試驅(qū)動(dòng)教程(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/)

這是第二部分的內(nèi)容.

在第一部分,我們羅列了app的UI,開發(fā)和單元測試的基礎(chǔ).

我們看到了app的state通過React的props向下傳遞到單個(gè)的組件,用戶的actions聲明為回調(diào)函數(shù),因此app的邏輯和UI分離開來了.

Redux的工作流介紹

在這一點(diǎn)上,我們的UI是沒有交互操作的:盡管我們已經(jīng)測試了如果一個(gè)item如果被設(shè)定為completed,它將給文本劃線,但是這里還沒有方法邀請用戶來完成它:

  1. state tree通過props定義了UI和action回調(diào)函數(shù).
  2. 用戶的actions,例如點(diǎn)擊,被發(fā)送到action creator,action被它范式化.
  3. redux action被傳遞到reducer實(shí)現(xiàn)實(shí)際的app邏輯
  4. reducer更新state tree,dispatch state到store.
  5. UI根據(jù)store里的新state tree來更新UI
Redux working flos
Redux working flos

設(shè)定初始化state

這部分的代碼提交在這里

我們的第一個(gè)action將會(huì)允許我們在Redux store里正確的設(shè)置初始化state
,我們將會(huì)創(chuàng)建store.

Redux中的action是一個(gè)信息的載體(payload).action由一個(gè)JSON對象有一個(gè)type屬性,描述action到底是做什么的,還有一部分是app需要的信息.在我們的實(shí)例中,type被設(shè)定為SET_STATE,我們可以添加一個(gè)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'
  }
}

這個(gè)action會(huì)被dispatch到一個(gè)reducer,reducer角色的是識(shí)別和實(shí)施和action對應(yīng)的邏輯代碼.

讓我們?yōu)閞educer來寫單元測試代碼
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'}
      ]
    }));
  });

});

為了方便一點(diǎn),state使用單純JS對象,而不是使用Immutable數(shù)據(jù)結(jié)構(gòu).讓我們的reducer來處理轉(zhuǎn)變.最后,reducer將會(huì)優(yōu)雅的處理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將會(huì)匹配接收的actions的type,如果type是SET_STATE,當(dāng)前的state和action運(yùn)載的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;
}

現(xiàn)在我們不得不把reducer連接到我們的app,所以當(dāng)app啟動(dòng)初始化state.這里實(shí)際是第一次使用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組件實(shí)際是被TodoAppContainer代替.在Redux里,有兩種類型的組件:展示組件和容器.我推薦你閱讀一下由Dan Abramov(Redux的作者)寫作的高信息量的文章,強(qiáng)調(diào)了展示組件和容器的差異性.

如果我想總結(jié)得快一點(diǎn),我將引用Redux 文檔的內(nèi)容:

“展示組件是關(guān)于事件的樣子(模板和樣式),容器組件是關(guān)于事情是怎么工作的(數(shù)據(jù)獲取,state更新)”.

所以我們創(chuàng)建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,你應(yīng)該可以看到它初始化和之前一樣,不過現(xiàn)在使用Redux tools.

Redux dev 工具

這一部分的提交代碼

現(xiàn)在我們已經(jīng)配置了redux store和reducer.我們可以配置Redux dev tools來展現(xiàn)數(shù)據(jù)流開發(fā).

首先,獲取Redux dev tools Chrome extension

dev tools可以在Store創(chuàng)建的時(shí)候可以加載.

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,點(diǎn)擊Redux圖標(biāo),有了.

有三個(gè)不同的監(jiān)視器可以使用:Diff監(jiān)視器,日志監(jiān)視器,Slider監(jiān)視器.

使用Action Creators配置我們的actions

切換item的不同狀態(tài).

這部分的提交代碼在這里

下一步是允許用戶在activecompleted之前切換狀態(tài):
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同一個(gè)地方,我們需要讓TodoAppContainer組件感知到action,所以toggleComplete回調(diào)函數(shù)會(huì)被傳遞到TodoItem組件(實(shí)際調(diào)用函數(shù)的地方).

在Redux中,有標(biāo)準(zhǔn)的方法來做這件事:Action Creators.

action creators是簡單的函數(shù),返回合適的action,這些韓式是React的props的一些映射之一.
讓我們創(chuàng)建第一個(gè)action creator:
src/action_creators.js

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

現(xiàn)在,盡管TodoAppcontainer組件中的connect函數(shù)的調(diào)用可以用來獲取store,我們告訴組件使用映射props的回調(diào)函數(shù):
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,刷新一下你的瀏覽器:當(dāng)當(dāng).在條目上點(diǎn)擊現(xiàn)在可以切換它的狀態(tài).如果你查看Redux dev tools,你可以看到觸發(fā)的action和后繼的更新.

改變目前的過濾器

相關(guān)代碼在在這里

現(xiàn)在每件事情都已經(jīng)配置完畢,寫其他的action是件小事.我們繼續(xù)創(chuàng)建你希望的CHANGE_FILTERaction,改變當(dāng)前state的filter,由此僅僅顯示過濾過的條目.
開始創(chuàng)建action creator:
src/action_creators.js

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

現(xiàn)在寫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'
    }));
  });
});

關(guān)聯(lián)的reducer函數(shù):
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回調(diào)函數(shù)傳遞給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>
  }
};

完成了,第一個(gè)filter selector工作完美

Item編輯

代碼在這里
當(dāng)用戶編輯一個(gè)條目,實(shí)際上是兩個(gè)actions觸發(fā)的三個(gè)可能性:

  • 用戶輸入編輯模式:EDIT_ITEM
  • 用戶退出編輯模式(不保存變化):CANCEL_EDITING
  • 用戶驗(yàn)證他的編輯(保存變化):DONE_EDITING

我們可以為三個(gè)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
  }
}

現(xiàn)在為這些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},
      ]
    }));
  });
});

現(xiàn)在我們可以開發(fā)reducer函數(shù),實(shí)際操作三個(gè)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;
}

清除完成,添加和刪除條目

代碼在這里

三個(gè)剩下的action是:

  1. CLEAR_COMPLETED,在TodoTools組件中觸發(fā),從列表中清除完成的條目
  2. ADD_ITEM,在TodoHeader中觸發(fā),根據(jù)用戶的的輸入文本來添加條目
  3. DELETE_ITEM,相似TodoItem中調(diào)用,刪除一個(gè)條目

我們現(xiàn)在使用的工作流是:添加action creators,單元測試reducer和代碼邏輯,最終通過props傳遞回調(diào)函數(shù):
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現(xiàn)在完成了.

包裝起來

這我們的測試驅(qū)動(dòng)的React,Redux&Immutable 技術(shù)棧

如果你想了解更多內(nèi)容,有更多的事情等著你去挖掘
例如:

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容