Getting Started with React, Redux and Immutable: a Test-Driven Tutorial (Part 2)
翻譯版本,原文請見
](https://ww3.sinaimg.cn/large/006tNc79ly1fds4960hm8j30xc0b4weu.jpg)
這是第二部分的內容.
在第一部分,我們羅列了app的UI,開發和單元測試的基礎.
我們看到了app的state通過React的props
向下傳遞到單個的組件,用戶的actions聲明為回調函數,因此app的邏輯和UI分離開來了.
Redux的工作流介紹
在這一點上,我們的UI是沒有交互操作的:盡管我們已經測試了如果一個item如果被設定為completed
,它將給文本劃線,但是這里還沒有方法邀請用戶來完成它:
- state tree通過
props
定義了UI和action回調函數. - 用戶的actions,例如點擊,被發送到action creator,action被它范式化.
- redux action被傳遞到reducer實現實際的app邏輯
- reducer更新state tree,dispatch state到store.
- UI根據store里的新state tree來更新UI

設定初始化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組件TodoApp
的props
.
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);
// ...

重新加載app,點擊Redux圖標,有了.
有三個不同的監視器可以使用:Diff監視器,日志監視器,Slider監視器.
使用Action Creators配置我們的actions
切換item的不同狀態.
下一步是允許用戶在active
和completed
之前切換狀態:
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_FILTER
action,改變當前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是:
-
CLEAR_COMPLETED
,在TodoTools
組件中觸發,從列表中清除完成的條目 -
ADD_ITEM
,在TodoHeader
中觸發,根據用戶的的輸入文本來添加條目 -
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 技術棧
如果你想了解更多內容,有更多的事情等著你去挖掘
例如: