React项目使用Redux
⒈创建React项目
⒉React集成React-Router
⒊React集成Redux
Redux是React中的数据状态管理库,通常来讲,它的数据流模型如图所示:
我们先将目光放到UI层。通过UI层触发Action,Action会进入Reducer层中去更新Store中的State(应用状态),最后因为State和UI进行了绑定,UI便会自动更新。
React Redux应用和普通React应用的区别在于,React将应用状态存储在了React组件内部,而React Redux应用则将应用状态存储在了Store中进行统一管理。
路由状态也是应用状态的一种,所以我们可以试验,先把路由状态存入Store中,来看一下TypeScript如何使用的,先把我们的路由和Redux进行集成。
因为Redux的库中自己带有类型定义文件,所以不需要@types/redux。
yarn add redux react-redux react-router-redux
接下来创建以下文件
src/store/history.js(type环境为history.ts)
import {createBrowserHistory} from 'history'; const history = createBrowserHistory(); export default history;
src/store/index.js(type环境为index.ts)
import {routerMiddleware, routerReducer} from 'react-router-redux'; import {applyMiddleware, combineReducers, createStore} from 'redux'; import history from './history'; const middleware = routerMiddleware(history); const store = createStore( combineReducers({ router: routerReducer, }), applyMiddleware(middleware), ) export default store;
最后再绑定Store到Router组件上:
src/Router.js(type环境为Router.ts)
import {routerMiddleware, routerReducer, ConnectedRouter} from 'react-router-redux'; import React from 'react'; import {Provider} from 'react-redux'; import {Route,Router} from 'react-router'; import App from './App'; import Edit from './Edit'; import store from './store'; import history from './store/history'; export default () => ( <Provider store={store}> <ConnectedRouter history={history}> <> <Route exact path="/" component={App}/> <Route path="/edit" component={Edit}/> </> </ConnectedRouter> </Provider> )
刷新页面后,你会发现没有任何变化
但如果我们再稍微修改一下,你可能就会看到一些不一样的地方了:
yarn add redux-devtools-extension
src/store/index.js(type环境为index.ts)
import {routerMiddleware, routerReducer} from 'react-router-redux'; import {applyMiddleware, combineReducers, createStore} from 'redux'; import {composeWithDevTools} from 'redux-devtools-extension'; import history from './history'; const middleware = routerMiddleware(history); const store = createStore( combineReducers({ router: routerReducer, }), process.env.NODE_ENV === 'development'? composeWithDevTools(applyMiddleware(middleware)) : applyMiddleware(middleware), ) export default store;
然后,在Chrome中安装Redux DevTools,并打开它后再刷新一次页面,你就会看到路由信息已经完全同步进入Redux Store里了。
⒋组件
虽然我们把React项目跑起来了,但我们并没有正式的书写一个组件,我们来构思一个编辑提醒事项的组件,它应该有一个确认框和一条信息
src/Edit.js(type环境为Edit.tsx)
import React,{Component} from 'react'; class Edit extends Component{ render(){ return ( <div> <div> <input type="checkbox"/> <input type="text"/> </div> <div> <button>取消</button> <button>确定</button> </div> </div> ) } } export default Edit;
我们需要在用户点击“确定”的时候保存下当前的数据
可能有人会说,这很简单啊,直接加上id,然后用dom操作获取值。在React的世界中,这样做是不推荐的,我们应该尽量依靠React提供的API去解决,比如用onChange函数:
src/Edit.js(type环境为Edit.tsx)
import React,{ChangeEventHandler, Component} from 'react'; import { Interface } from 'readline'; interface IState{ isChecked: Boolean, content: string, } class Edit extends Component{ state: IState = { isChecked: false, content: '', } onCheckboxValueChange: ChangeEventHandler<HTMLInputElement> = e => { this.setState({ isChecked: e.target.checked, }) } onContentValueChange: ChangeEventHandler<HTMLInputElement> = e => { this.setState({ content: e.target.value; }) } onSave = () => { console.log(this.state); } render(){ return ( <div> <div> <input type="checkbox" checked={this.state.isChecked} onChange={this.onCheckboxValueChange}/> <input type="text" value={this.state.content} onChange={this.onContentValueChange}/> </div> <div> <button>取消</button> <button onClick={this.onSave}>确定</button> </div> </div> ) } } export default Edit;
这样就完成了一个可以工作的组件,初步保证了数据在内部的存储,也可以在onSave中扩展网络请求API。
但如果我文字写到一半,没保存,只是刷新一下页面,那所有的数据就没有了。接下来,我们可以看一下Redux全局统筹的魔力。
⒌Redux组件
一个Redux组件需要触发Action以及根据Action操作数据的Reducer,同时,我们还需要增加一些全局的类型定义。
首先,我们需要将redux-tools里面所看到的Redux Store的类型给定义出来:
src/typings/store.d.ts
declare interface IDraftState{ isChecked: boolean, content: string, } declare interface IStoreState{ route:{ location: Location } draft: IDraftState }
然后是Action
src/action/index.ts
export const EDIT_DRAFT_ACTION_TYPE = 'draft/edit'; export const editDraftAction = (payload: IDraftState) => ({ type: EDIT_DRAFT_ACTION_TYPE, payload, })
再然后是创建draft的Reducer:
src/reducer/draft.ts
import {editDraftAction,EDIT_DRAFT_ACTION_TYPE} from '../action'; const defaultState: IDraftState = { isChecked: false, content: '', } export default (state = defaultState,action: ReturnType<typeof editDraftAction>) => { switch(action.type){ case EDIT_DRAFT_ACTION_TYPE:{ return action.payload } default:{ return state } } }
这里需要把Reducer文件引入到Store中:
src/reducer/index.ts
import draft from './draft'; export default{ draft, }
src/store/index.ts
import {routerMiddleware, routerReducer} from 'react-router-redux'; import {applyMiddleware, combineReducers, createStore} from 'redux'; import {composeWithDevTools} from 'redux-devtools-extension'; import reducers from '../reducer'; import history from './history'; const middleware = routerMiddleware(history); const store = createStore( combineReducers({ ...reducers, router: routerReducer, }), process.env.NODE_ENV === 'development'? composeWithDevTools(applyMiddleware(middleware)) : applyMiddleware(middleware), ) export default store;
准备工作完成后,就可以将组件与Redux进行关联了
src/Edit.ts
import React,{ChangeEventHandler, Component} from 'react'; import {connect} from 'react-redux'; import { editDraftAction } from './action/index'; const mapStateToProps = (storeState: IStoreState) => ({ draft: storeState.draft, }) type IStateProps = ReturnType<typeof mapStateToProps> const mapDispatchToProps = { editDraftAction, } type IDispatchProps = typeof mapDispatchToProps; type IProps = IStateProps & IDispatchProps; class Edit extends Component<IProps>{ onCheckboxValueChange: ChangeEventHandler<HTMLInputElement> = e => { this.props.editDraftAction({ ...this.props.draft, isChecked:e.target.checked, }) } onContentValueChange: ChangeEventHandler<HTMLInputElement> = e => { this.props.editDraftAction({ ...this.props.draft, content:e.target.value, }) } onSave = () => { console.log(this.state); } render(){ return ( <div> <div> <input type="checkbox" checked={this.props.draft.isChecked} onChange={this.onCheckboxValueChange}/> <input type="text" value={this.props.draft.content} onChange={this.onContentValueChange}/> </div> <div> <button>取消</button> <button onClick={this.onSave}>确定</button> </div> </div> ) } } export default connect<IStateProps,IDispatchProps>(mapStateToProps,mapDispatchToProps)(Edit);
这个时候我在编辑框中输入文字或者修改CheckBox的状态,都会同步进入Store里面。
但是一刷新页面,数据还是没有。接下来我们来解决这个问题。
⒍Redux Persist
既然我们的全部数据已经存入了Store中,那么只需要为Store增加一个缓存层就完工了,因此介绍Redux Persist。
Redux Persist的架构如图所示,这也是一个Store内部的微观结构图
如果有一个Action进入的话,它会先穿过最底下的中间件,再穿过Reducer,最后改变State。
但在加入Redux Persist后,Redux Persist会对改变后的State进行一次存操作,默认是写入LocalStorge。当然这个存储位置是可以改变的。
另外在初始化Redux Store的时候,Redux Persist还会默认对LocalStorge进行一次读取操作,这样就能保证网页数据的持久性了。
现在,先看一下如何集成redux-persist吧:
yarn add redux-persist
src/store/index.ts
import {routerMiddleware, routerReducer} from 'react-router-redux'; import {applyMiddleware, combineReducers, createStore} from 'redux'; import {composeWithDevTools} from 'redux-devtools-extension'; import {persistReducer,persistStore,PersistConfig} from 'redux-persist'; import storage from 'redux-persist/es/storage'; import reducers from '../reducer'; import history from './history'; const middleware = routerMiddleware(history); const rootReducer = combineReducers({ ...reducers, router: routerReducer, }) const persistConfig: PersistConfig = { key: 'root', storage, whitelist: ['draft'], } const persistedReducer: typeof rootReducer = persistedReducer(PersistConfig,rootReducer); const store = createStore( persistedReducer, process.env.NODE_ENV === 'development'? composeWithDevTools(applyMiddleware(middleware)) : applyMiddleware(middleware), ) const persistor = persistStore(store); export{ store, persistor, }
src/Router.tsx
import {routerMiddleware, routerReducer, ConnectedRouter} from 'react-router-redux'; import React from 'react'; import {Provider} from 'react-redux'; import {Route,Router} from 'react-router'; import {PersistGate} from 'redux-persist/integration/react'; import App from './App'; import Edit from './Edit'; import store, { persistor } from './store'; import history from './store/history'; export default () => ( <Provider store={store}> <PersistGate loading={null} persistor={persistor}> <ConnectedRouter history={history}> <> <Route exact path="/" component={App}/> <Route path="/edit" component={Edit}/> </> </ConnectedRouter> </PersistGate> </Provider> )
在输入文字,再刷新,你就会发现数据能从缓存中读出来了。这样,我们就利用了Redux实现了数据持久化,接下来我们只需要扩展它的网络层即可。
⒎处理网络请求
接下来只需在Redux上做文章,就可以轻松兼容网络层了,由于组件只负责发出Action,所以后面的操作完全跟组件解耦。
组件在保存的时候发出Save的Action,然后将草稿清空:
src/action/index.ts
export const EDIT_DRAFT_ACTION_TYPE = 'draft/edit'; export const editDraftAction = (payload: IDraftState) => ({ type: EDIT_DRAFT_ACTION_TYPE, payload, }) export const SAVE_DRAFT_ACTION_TYPE = 'draft/save'; export const saveDraftAction = () => ({ type: SAVE_DRAFT_ACTION_TYPE, }) export const RESET_DRAFT_ACTION_TYPE = 'draft/reset'; export const resetDraftAction = () => ({ type:RESET_DRAFT_ACTION_TYPE })
src/reducer/draft.ts
import {editDraftAction,resetDraftAction,EDIT_DRAFT_ACTION_TYPE} from '../action'; import {RESET_DRAFT_ACTION_TYPE} from '../action/index'; const defaultState: IDraftState = { isChecked: false, content: '', } type actionType = ReturnType<typeof editDraftAction> | ReturnType<typeof resetDraftAction> export default (state = defaultState,action: actionType) => { switch(action.type){ case EDIT_DRAFT_ACTION_TYPE:{ return (action as ReturnType<typeof editDraftAction>).payload } case RESET_DRAFT_ACTION_TYPE:{ return defaultStatus } default:{ return state } } }
src/Edit.tsx
import React,{ChangeEventHandler, Component} from 'react'; import {connect} from 'react-redux'; import { editDraftAction,saveDraftAction } from './action/index'; const mapStateToProps = (storeState: IStoreState) => ({ draft: storeState.draft, }) type IStateProps = ReturnType<typeof mapStateToProps> const mapDispatchToProps = { editDraftAction, saveDraftAction, } type IDispatchProps = typeof mapDispatchToProps; type IProps = IStateProps & IDispatchProps; class Edit extends Component<IProps>{ onCheckboxValueChange: ChangeEventHandler<HTMLInputElement> = e => { this.props.editDraftAction({ ...this.props.draft, isChecked:e.target.checked, }) } onContentValueChange: ChangeEventHandler<HTMLInputElement> = e => { this.props.editDraftAction({ ...this.props.draft, content:e.target.value, }) } onSave = () => { this.props.saveDraftAction() } render(){ return ( <div> <div> <input type="checkbox" checked={this.props.draft.isChecked} onChange={this.onCheckboxValueChange}/> <input type="text" value={this.props.draft.content} onChange={this.onContentValueChange}/> </div> <div> <button>取消</button> <button onClick={this.onSave}>确定</button> </div> </div> ) } } export default connect<IStateProps,IDispatchProps>(mapStateToProps,mapDispatchToProps)(Edit);
网络请求的过程是异步的,我们需要引入一个库来处理异步Action,在这里我们选择了Redux Thunk来进行处理,如下图所示:
在Redux Thunk中可以获取整个Store的State,同时分发一个新的Action出去:
yarn add redux-thunk
src/store/index.ts
import {routerMiddleware, routerReducer} from 'react-router-redux'; import {applyMiddleware, combineReducers, createStore} from 'redux'; import {composeWithDevTools} from 'redux-devtools-extension'; import {persistReducer,persistStore,PersistConfig} from 'redux-persist'; import storage from 'redux-persist/es/storage'; import thunk from 'redux-thunk'; import reducers from '../reducer'; import history from './history'; const middleware = [thunk,routerMiddleware(history)]; const rootReducer = combineReducers({ ...reducers, router: routerReducer, }) const persistConfig: PersistConfig = { key: 'root', storage, whitelist: ['draft'], } const persistedReducer: typeof rootReducer = persistedReducer(PersistConfig,rootReducer); const store = createStore( persistedReducer, process.env.NODE_ENV === 'development'? composeWithDevTools(applyMiddleware(...middleware)) : applyMiddleware(...middleware), ) const persistor = persistStore(store); export{ store, persistor, }
由于当前域是localhost:3000,而API服务器是运行在localhost:3001,所以我们还需要配置一下代理:
package.json(部分)
"proxy": { "/work-items":{ "target": "http://localhost:3001" } },
准备工作都完成了,接下来就开始改造 saveDraftAction:
import {ThunkAction} from 'redux-thunk'; const headers = new Headers({ 'content-type':'application/json' }) export const saveDraftAction = (): ThunkAction<void,IStoreState,undefined> => { (dispatch,getState) => { const draft = getState().draft fetch('http://localhost:3000/work-items',{ headers, method:'post', body:JSON.stringify(draft) }).then(() => { dispatch(resetDraftAction()) }) } }
saveDraftAction作为一个异步Action,是不用写入Reducer里去改变State,在完成自己的工作后,再去触发别的Action就行了。
在这里,我们还希望保存成功后再回到首页,那么只需要调用react-router已经写好的push action就好了:
import {push} from 'react-router-redux'; import {ThunkAction} from 'redux-thunk'; const headers = new Headers({ 'content-type':'application/json' }) export const saveDraftAction = (): ThunkAction<void,IStoreState,undefined> => { (dispatch,getState) => { const draft = getState().draft fetch('http://localhost:3000/work-items',{ headers, method:'post', body:JSON.stringify(draft) }).then(() => { dispatch(push('/')) dispatch(resetDraftAction()) }) } }
这样,UI和业务就完全进行解耦了,仅仅靠Action维持联系。
⒏实现列表
既然可以创建提醒事项,那么接下来就可以正式渲染列表了。
8.1实现列表页
我们先来思考一下,完成列表页有哪些工作,我们需要获取数据,数据会存放到Store里去,然后组件连接Store取值,那么就先需要在store.d.ts中添加新的list的定义,然后写Action、Reducer,然后再是组件:
src/typings/store.d.ts
declare interface IDraftState{ isChecked: boolean, content: string, } declare type IList = IDraftState[] declare interface IStoreState{ route:{ location: Location } draft: IDraftState list:IList }
src/action/index.ts
export const fetchList = (): ThunkAction<void,IStoreState,undefined> => async(dispatch) => { const response = await fetch('http://localhost:3000/work-item',{headers}) const data = await response.json() dispatch(fetchListSuccess(data)) } export const FETCH_LIST_SUCCESS_TYPE = 'list/success' export const fetchListSuccess = (payload: IList) => ({ type: FETCH_LIST_SUCCESS_TYPE, payload, })
src/reducer/list.ts
import {fetchListSuccess,FETCH_LIST_SUCCESS_TYPE} from '../action/index'; const defaultState:IList = [] type actionType = ReturnType<typeof fetchListSuccess> export default (state = defaultStatus,action: actionType) => { switch(action.type){ case FETCH_LIST_SUCCESS_TYPE:{ return action.payload } default:{ return state } } }
最后再修改一下App.tsx的样式
src/App.tsx
import React,{Component} from 'react'; import './App.css'; import logo from './logo.svg'; import {fetchList} from './action/index'; import {connect} from 'react-redux'; const mapStateToProps = (storeState: IStoreState) => ({ list: storeState.list, }) type IStoreState = ReturnType<typeof mapStateToProps> const mapDispatchToProps = { fetchList, } type IDispatchProps = typeof mapDispatchToProps type IProps = IStateProps & IDispatchProps class App extends Component<IProps>{ componentDidMount(){ this.props.fetchList() } render(){ return ( <div> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <h1 className="App-title">Welcome to Check List</h1> </header> <ul> {this.props.list.map((item) => { <li>{item.isChecked ? '完成' : '未完成'} - {item.content}</li> })} </ul> </div> ) } } extends default connect<IStateProps,IDispatchProps>(mapStateToProps,mapDispatchToProps)(App)
这里有个新的问题,如果我想点击列表的某一项直接进行编辑呢?
8.2复用编辑组件
因为后端代码也是我们自己编写的,所以我们知道,创建一个数据的时候,它是没有主键ID的,而更新删除的时候是有主键ID的。所以我们可以通过是否有主键ID来区分路由,从两个不同的路由渲染同一个组件,然后再在内部做一些业务上的区分。
那么,根据主键ID的设定,我们需要先更新一下store.d.ts:
src//typings/store.d.ts
declare interface IDraftState{ id?: number, isChecked: boolean, content: string, } declare type IList = IDraftState[] declare interface IStoreState{ route:{ location: Location } draft: IDraftState list:IList }
然后更新路由
src/Router.tsx
import {routerMiddleware, routerReducer, ConnectedRouter} from 'react-router-redux'; import React from 'react'; import {Provider} from 'react-redux'; import {Route,Router} from 'react-router'; import {PersistGate} from 'redux-persist/integration/react'; import App from './App'; import Edit from './Edit'; import store, { persistor } from './store'; import history from './store/history'; export default () => ( <Provider store={store}> <PersistGate loading={null} persistor={persistor}> <ConnectedRouter history={history}> <> <Route exact path="/" component={App}/> <Route path="/edit/new" component={Edit}/> <Route path="/edit/:id" component={Edit}/> </> </ConnectedRouter> </PersistGate> </Provider> )
为单个item添加点击事件:
src/App.tsx
import React,{Component} from 'react'; import './App.css'; import logo from './logo.svg'; import {fetchList} from './action/index'; import {connect} from 'react-redux'; import {push} from 'react-router-redux'; const mapStateToProps = (storeState: IStoreState) => ({ list: storeState.list, }) type IStoreState = ReturnType<typeof mapStateToProps> const mapDispatchToProps = { fetchList, push, } type IDispatchProps = typeof mapDispatchToProps type IProps = IStateProps & IDispatchProps class App extends Component<IProps>{ componentDidMount(){ this.props.fetchList() } navigateToEditor = (id?: number) => () => this.props.push(`/edit/${id}`) render(){ return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <h1 className="App-title">Welcome to Check List</h1> </header> <ul> {this.props.list.map((item) => { <li onClick={this.navigateToEditor(item.id)}>{item.isChecked ? '完成' : '未完成'} - {item.content}</li> })} </ul> </div> ) } } extends default connect<IStateProps,IDispatchProps>(mapStateToProps,mapDispatchToProps)(App)
但跳转过去后,发现内容都是空的
那么我们能不能直接去读本地的存储呢?答案是可以,但不能完全只读本地存储,因为如果直接访问这个地址,就没有本地存储可读了。
所以最稳妥的方法是发一次API拉取一次数据。我们要考虑如何最省力地去设计,以便减少修改代码的工作
毫无疑问,凡是进入编辑页面,都是我们希望能保存的。所以这里的编辑也不例外,我们的draft需要改造成一个字典,那么,创建的内容可以放在一个特殊关键字里面。这样修改的量可以达到最小。
src/typings/store.d.ts
declare interface IDraftState{ id?: number, isChecked: boolean, content: string, } declare type IList = IDraftState[] declare interface IStoreState{ route:{ location: Location } draft: { [id:number] :IDraftState } list:IList }
src/action/index.ts
import {push} from 'react-router-redux'; import { ThunkAction } from "redux-thunk"; import {NEW_DRAFT_SYMBOL} from '../reducer/draft'; export const EDIT_DRAFT_ACTION_TYPE = 'draft/edit'; export const editDraftAction = (payload: IDraftState) => ({ type: EDIT_DRAFT_ACTION_TYPE, payload, }) const headers = new Headers({ 'content-type':'application/json' }) export const saveDraftAction = (id:number): ThunkAction<void,IStoreState,undefined> => { (dispatch,getState) => { const draft = getState().draft[id] if(id === NEW_DRAFT_SYMBOL){ fetch('http://localhost:3000/work-items',{ headers, method:'post', body:JSON.stringify(draft) }).then(() => { dispatch(push('/')) dispatch(resetDraftAction(id)) }) }else{ fetch(`http://localhost:3000/work-items/${id}`,{ headers, method:'put', body:JSON.stringify(draft) }).then(() => { dispatch(push('/')) dispatch(resetDraftAction(id)) }) } } } export const SAVE_DRAFT_ACTION_TYPE = 'draft/save'; export const saveDraftAction = () => ({ type: SAVE_DRAFT_ACTION_TYPE, }) export const RESET_DRAFT_ACTION_TYPE = 'draft/reset'; export const resetDraftAction = (id:number) => ({ type:RESET_DRAFT_ACTION_TYPE, payload:{ id, } }) export const fetchList = (): ThunkAction<void,IStoreState,undefined> => async(dispatch) => { const response = await fetch('http://localhost:3000/work-item',{headers}) const data = await response.json() dispatch(fetchListSuccess(data)) } export const FETCH_LIST_SUCCESS_TYPE = 'list/success' export const fetchListSuccess = (payload: IList) => ({ type: FETCH_LIST_SUCCESS_TYPE, payload, }) export const fetchItemById = (id:number): ThunkAction<void,IStoreState,undefined> => async(dispatch) => { const response = await fetch(`http://localhost:3000/work-item/${id}`,{headers}) const data =await response.json(); dispatch(editDraftAction(data)) }
src/reducer/draft.ts
import {editDraftAction,resetDraftAction,EDIT_DRAFT_ACTION_TYPE} from '../action'; import {RESET_DRAFT_ACTION_TYPE} from '../action/index'; const defaultState: IDraftState = { isChecked: false, content: '', } import {editDraftAction,resetDraftAction,EDIT_DRAFT_ACTION_TYPE} from '../action'; import {RESET_DRAFT_ACTION_TYPE} from '../action/index'; export const NEW_DRAFT_SYMBOL = -1 const defaultState: IDraftState = { id: NEW_DRAFT_SYMBOL, isChecked: false, content: '', } type actionType = ReturnType<typeof editDraftAction> | ReturnType<typeof resetDraftAction> export default (state = defaultState,action: actionType) => { switch(action.type){ case EDIT_DRAFT_ACTION_TYPE:{ return{ ...state, [action.payload.id]: action.payload } } case RESET_DRAFT_ACTION_TYPE:{ return { ...state, [action.payload.id]: defaultState, } } default:{ return state } } }
src/Edit.ts
import React,{ChangeEventHandler, Component} from 'react'; import {connect} from 'react-redux'; import {RouteComponentProps} from 'react-router'; import { editDraftAction, fetchItemById,saveDraftAction } from './action/index'; import {NEW_DRAFT_SYMBOL} from './reducer/draft'; const mapStateToProps = (storeState: IStoreState) => ({ draft: storeState.draft, }) type IStateProps = ReturnType<typeof mapStateToProps> const mapDispatchToProps = { editDraftAction, saveDraftAction, fetchItemById, } type IDispatchProps = typeof mapDispatchToProps; type IProps = IStateProps & IDispatchProps & RouteComponentProps<{id?:number}>; class Edit extends Component<IProps>{ get draft(){ return this.props.draft[this.props.match.params.id || NEW_DRAFT_SYMBOL] } componentDidMount(){ if(this.props.match.params.id){ this.props.fetchItemById(this.props.match.params.id) } } onCheckboxValueChange: ChangeEventHandler<HTMLInputElement> = e => { this.props.editDraftAction({ ...this.props.draft, isChecked:e.target.checked, }) } onContentValueChange: ChangeEventHandler<HTMLInputElement> = e => { this.props.editDraftAction({ ...this.props.draft, content:e.target.value, }) } onSave = () => { this.props.saveDraftAction(this.draft.id) } render(){ const draft = this.draft if(!draft){ return null } return ( <div> <div> <input type="checkbox" checked={draft.isChecked} onChange={this.onCheckboxValueChange}/> <input type="text" value={draft.content} onChange={this.onContentValueChange}/> </div> <div> <button>取消</button> <button onClick={this.onSave}>确定</button> </div> </div> ) } } export default connect<IStateProps,IDispatchProps>(mapStateToProps,mapDispatchToProps)(Edit);
这样,我们就能最大限度的复用了组件
⒐测试
在React的开发中,测试是必不可少的一环。
9.1配置Jest
先安装依赖
yarn add ts-jest @types/ts-jest sinon @types/sinon enzyme @types/enzyme enzyme-adapter-react-16 jest-enzyme jest-fetch-mock raf
在package.json中添加配置
"jest":{ "setupFiles":[ "<rootDir>/_mocks_/setupJest.js" ], "setupTestFrameworkScriptFile":"./node_modules/jest-enzyme/lib/index.js", "moduleNameMapper":{ "\\.(css|less)$":"<rootDir>/_mocks_/styleMock.js", "\\.(gif|ttf|eot|svg)$":"<rootDir>/_mocks_/fileMock.js" }, "unmockedModulePathPatterns":[ "react", "enzyme", "jest-enzyme" ], "transform":{ "^.+\\.tsx?$":"ts-jest" }, "testRegex":"(/_tests_/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", "moduleFileExtensions":[ "ts", "tsx", "js", "jsx", "json", "node" ] },
这样,配置就完成了
在根目录下新建这三个文件
fileMock.js
module.exports = 'test-file-stub';
styleMock.js
module.exports = {};
setupJest.js
//React also depends on requestAnimationFrame(even in test environments) //You can use the raf package to shim requestAnimationFrame require('raf/polyfill') //mock fetct global.fetch = require('jest-fetch-mock') const Adapter = require('enzyme-adapter-react-16') require('enzyme').configure({adapter:new Adapter()});
一切准备就绪后,就可以开始了
9.2组件的测试
以App.tsx为例进行测试,一般进行组件测试的话,不需要去测试已经连接了Store的组件,那没有意义,只需要测试组件本身即可,所以先将App组件进行export操作:
export class App extends Component<IProps>{
然后新建一个文件名为App.test.tsx,模拟组件渲染
import {shallow} from 'enzyme'; import React from 'react'; import {App} from './App'; describe('App Component Test Suits',() => { it('renders<App /> components with empty array' () => { const fetchList = jest.fn() const push = jest.fn() const wrapper = shallow(<App list={[]} fetchList={fetchList} push={push}/>) wrapper.rander() }) })
然后运行
yarn jest --watch
会根据文件的变化实时重跑测试
我们再给一个有数据的数据试一下
import {shallow} from 'enzyme'; import React from 'react'; import {App} from './App'; const isChecked = () => Math.random() >= 0.5 describe('App Component Test Suits',() => { it('renders<App /> components with empty array' () => { const fetchList = jest.fn() const push = jest.fn() const wrapper = shallow(<App list={[]} fetchList={fetchList} push={push}/>) wrapper.rander() }) it('renders <App /> components with array',() => { const fetchList = jest.fn() const push = jest.fn() const list = [ {id:Math.random(),content:Math.random.toString(),isChecked:isChecked()}, {id:Math.random(),content:Math.random.toString(),isChecked:isChecked()}, ] const wrapper = shallow(<App list={[]} fetchList={fetchList} push={push}/>) wrapper.rander() }) })
但仅仅渲染成功还不能满足我们的要求,我们希望列表渲染的文字也能符合要求,所以可以稍微再扩展一下:
wrapper.render() wrapper.find('li').forEach((element,index) => { const item = list[index] expect(element.text()).toBe(`${item.isChecked?'完成':'未完成'} - ${item.content}`) })
接下来需要测试一下点击事件:
it('li should be call by clicked', () => { const fetchList = jest.fn() const push = jest.fn() const list = [ {id:Math.random(),content:Math.random.toString(),isChecked:isChecked()}, {id:Math.random(),content:Math.random.toString(),isChecked:isChecked()}, ] const wrapper = shallow(<App list={[]} fetchList={fetchList} push={push}/>) wrapper.render() wrapper.find('li').first().simulate('click') expect(push.mock.calls.length).toBe(1) })
最后再测试一下生命周期
it('fetchList should be call on did mount', () => { const fetchList = jest.fn() const push = jest.fn() mount(<App list={[]} fetchList={fetchList} push={push}/>) expect(fetchList.mock.calls.length).toBe(1) })
由于异步请求都由redux-thunk接管了,所以组件的测试就显得非常容易了。
9.3Action的测试
同样,我们到Action的目录下新建文件action.test.ts
先测试一个普通的Action:
import {editDraftAction} from '.'; const isChecked = () => Math.random() >= 0.5 describe('Action Test Suits',() => { it('test editDraftAction',() => { const payload = {id:Math.random(),content:Math.random().toString(),isChecked:isChecked()} expect(editDraftAction(payload)).toEqual({payload,type:'draft/edit'}) }) })
一个普通的Action就是一个普通的函数,非常容易测试。
但是redux-thunk的异步Action就不容易测试了,需要我们引入一个假的Store来模拟Action在Atore里的情况。
yarn add redux-mock-store @types/redux-mock-store
import fetch from 'jest-fetch-mock'; import createMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import {editDraftAction} from '.'; import {fetchList,fetchListSuccess} from './index'; const isChecked = () => Math.random() >= 0.5 const middlewares = [thunk] const mockStore = createMockStore(middlewares) describe('Action Test Suits',() => { beforeEach(() => { fetch.resetMocks() }) it('test editDraftAction',() => { const payload = {id:Math.random(),content:Math.random().toString(),isChecked:isChecked()} expect(editDraftAction(payload)).toEqual({payload,type:'draft/edit'}) }) it('test fetchLisst',async () => { const response = [{id:Math.random(),content:Math.random().toString(),isChecked:isChecked()}] fetch.mockResponseOnce(JSON.stringify(response)) const store = mockStore({}) //tslint:disable-next-line:no-any await store.dispatch(fetchList() as any) expect(store.getActions()).toEqual([fetchListSuccess(response)]) }) })
整个测试相对复杂一些,需要考虑异步的次数,还有从mock的Store中进行Action操作。
9.4Reducer的操作
在list.ts旁新建list.test.ts文件
Reducer本身也是一个函数,所以测试方法与Action类似:
import {fetchListSuccess} from '../action'; import listReducer from './list'; type ActionType = ReturnType<typeof fetchListSuccess> describe('List Reducer Test Suits',() => { it('test reducer without any action', () => { expect(listReducer(undefined,{} as ActionType)).toEqual([]) }) })
这里演示了一个传递空Action进去之后的输出,可以仿照上面的方法测试其他的Action情况。由于把架构进行了合理拆分,才使得React的测试非常容易编写。
本文中,我们集成了路由,嵌入了Redux,为Redux的Store编写了声明文件,同时编写了从页面组件到Action,再到Reducer的全面测试。