Getting Started with React, Redux and Immutable: a Test-Driven Tutorial (Part 2)
翻譯版本,原文請見
](https://ww3.sinaimg.cn/large/006tNc79ly1fds4960hm8j30xc0b4weu.jpg)
這是第二部分的內(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,它將給文本劃線,但是這里還沒有方法邀請用戶來完成它:
- state tree通過
props定義了UI和action回調(diào)函數(shù). - 用戶的actions,例如點(diǎn)擊,被發(fā)送到action creator,action被它范式化.
- redux action被傳遞到reducer實(shí)現(xiàn)實(shí)際的app邏輯
- reducer更新state tree,dispatch state到store.
- UI根據(jù)store里的新state tree來更新UI

設(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組件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,你應(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);
// ...

重新加載app,點(diǎn)擊Redux圖標(biāo),有了.
有三個(gè)不同的監(jiān)視器可以使用:Diff監(jiān)視器,日志監(jiān)視器,Slider監(jiān)視器.
使用Action Creators配置我們的actions
切換item的不同狀態(tài).
下一步是允許用戶在active和completed之前切換狀態(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和后繼的更新.
改變目前的過濾器
現(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是:
-
CLEAR_COMPLETED,在TodoTools組件中觸發(fā),從列表中清除完成的條目 -
ADD_ITEM,在TodoHeader中觸發(fā),根據(jù)用戶的的輸入文本來添加條目 -
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)容,有更多的事情等著你去挖掘
例如:
- React Redux router創(chuàng)建完全的單頁面應(yīng)用
- 是由Redux在后臺(tái)同構(gòu)Redux,看這1教程,2教程
- Gambie,簡單的包裝器簡化到API的連接
- 系列視頻,作者是Dan Abramov(Redux的創(chuàng)建者)
- Redux 網(wǎng)站上更多的內(nèi)容!