思考
最近我也一直在研究redux到底是干什么的,经过零零散散的学习之后,我有这么几个不算成熟的认识分享给大家:
http://www.tuicool.com/articles/u2QFzub
- redux和react没有依赖关系,redux出现是提出了一种规范和模型,可以这么来描述它:程序某处产生一个action(理解为动作,行为,事件),通过dispatch(分发,路由的感觉)中间派发,交由处理者reducer(其实就是处理action)进行最终的计算,结果存储到store(就是一个对象,存的是程序的状态state)里。
- redux在全局只维护一个store存储对象,程序分很多组件,那么各个组件的state状态都存储到这1个store里,同时每个组件的reducer处理器也聚合到一起挂接到store上。
- react把组件的展示,数据的异步获取,state的变化全部实现在component里,随着代码膨胀愈加复杂,而redux有利于我们将数据的处理和展示进行进一步分层,优化项目结构。
- redux将所有组件的state存储在1个store中,很自然的有利于跨组件的state共享,我个人认为这是非常重要的一个特性。如果没有Redux,我们的react跨组件共享信息,难免通过深层次的callback传递与notify思路去实现,是很麻烦的事情。
学习方法
其实我按照自己的计划学习实践到第11篇博客,已经感受到纯react代码混合实现异步数据和组件特效会导致代码一团糟。如果redux能够不费多大功夫帮我把『数据和计算』与『组件展现』分离开来,我还是愿意花点时间来掌握的。
学习redux,首先是基础知识:至少知道redux的理念,基础API,同时对redux如何结合react,甚至结合react-router有一个朦朦胧胧的认识,我认为就足够了,这方面应该看一下 这篇教程 。
不过,我看完这个教程也挺迷糊的,并不清楚怎么应用到我的项目里来,所以我接着看了 这篇博客 ,它主要是介绍react和redux是如何结合的,更加贴合实践。其中connect是我们最需要了解的API,它极大的简化了react和redux结合的背后问题,使得我们可以很自然的在react框架下套用redux的理念。
当然看完之后,大概扫一下 这个github里的例子 会加深一点理解,这个例子里用到了redux-simple-router这个库,它是为react-router封装的redux库,因为我的项目也使用react-router(我相信绝大多人都要用),因此后续实践也会涉及到这个库的使用。
打开它最新的 官方地址 ,阅读一下介绍会发现它是redux-simple-router的前身,现在叫做react-router-redux,并且里面的例子似曾相识。 我们知道react-redux是为react封装了redux便于使用,而react-router-redux是封装了react-router相关的redux逻辑,因此我们最终会同时使用react,react-redux,react-router-redux,它们分别只解决自己关心的问题,需要组合使用才能完成项目。
下载源码
重构项目
安装依赖
- npm install redux –save
- 安装redux不必多说
- npm install redux-thunk –save
- 这是一个redux的中间件,能够支持我们dispatch一个function而不是action对象,后面会看到具体啥意思,没那么复杂
- npm install react-redux –save
- 基于react封装的redux,从而我们可以很方便的为组件注入action和props,其实就是屏蔽redux原生API的复杂性
- npm install react-router-redux –save
- 为react-router封装了一下相关的redux逻辑,因为react-router是最外层的组件(<Router>还记得吗),我们的组件都套在里面。因此如果如果我们的组件要用redux,那Router组件不实现redux相关逻辑我们又怎么用呢,所以顺理成章。
创建store
redux本来创建store也只需要传入reducer函数就足够了,之后做的事情无非是定义一下action对象,通过dispatch方法交给reducer进行处理,所以store的创建仅需要reducer函数。
而比较简单的是,正因为react-router本身需要为store提供reducer函数,因此我自己的组件MsgListPage,MsgDetailPage不需要立马进行改造。我先把react-router的reducer注入到store上,把这个全局的store对象建立出来,代码应该可以正常运行。
importReactfrom "react";
importReactDOMfrom "react-dom";
import {Router, Route, IndexRoute, hashHistory} from "react-router";
// 引入redux,react-redux,react-router-redux,它们各有各的职责
import {createStore, combineReducers, applyMiddleware} from 'redux'
import {Provider} from 'react-redux'
import {syncHistoryWithStore, routerReducer} from 'react-router-redux';
importthunkfrom 'redux-thunk';
// 默认的App根路由,作为组件容器
importContainerfrom "../component/Container";
// 各种小组件在这里引入
importMsgListPagefrom "../component/MsgListPage/MsgListPage";
importMsgDetailPagefrom "../component/MsgDetailPage/MsgDetailPage";
importMsgCreatePagefrom "../component/MsgCreatePage/MsgCreatePage";
// 引入reducer
importMsgDetailPageReduerfrom "../component/MsgDetailPage/reducer";
// 聚集所有reducer
// 注:这里的key就是全局store的1级key,用于划分不同reducer的state集合,避免互相污染
const reducer = combineReducers({
MsgDetailPageReduer: MsgDetailPageReduer,
routing: routerReducer, // react-router所需要的reducer
}, );
// 创建redux的store
const store = createStore(
reducer, // 全部的reducer
applyMiddleware( // 安装若干中间件
thunk,
),
);
// 增强react-router的history能力,其实就是把history相关信息也存储到store中
// 在<Router>中取代原有的hashHistory
const history = syncHistoryWithStore(hashHistory, store)
ReactDOM.render(
(
<Providerstore={store}>
<Routerhistory={history}>
<Routepath="/" component={Container}>
<IndexRoutecomponent={MsgListPage} />
<Routepath="msg-list-page" component={MsgListPage}/>
<Routepath="msg-detail-page/:msgId" component={MsgDetailPage}/>
<Routepath="msg-create-page" component={MsgCreatePage}/>
</Route>
</Router>
</Provider>
),
document.getElementById('reactRoot')
);
这是Router.es6修改后的代码,做了几个修改点:
- 引入redux,使用了combineReducers实现reducer聚合,createStore实现store创建,applyMiddleware引入中间件。
- 引入react-redux,使用了它为react封装的外层容器<Provider>。
- 引入react-router-redux,使用了它为react-router提供的routerReducer以及用于增强react-router的history能力的syncHistoryWithStore,用于取代原生的hashHistory。
- 引入了redux-thunk中间件,因为我有异步dispatch action的需求。
经过简单的修改,现在我们已经为redux铺垫好了基础环境,并且对我们现有的程序不会造成任何影响。这样,接下来我就可以从最简单的组件MsgDetailPage入手,为其实现action和reducer,同时把组件需要的state和action以props的形式注入到组件内,最终把reducer添加到store中。
MsgDetailPage详情页
按道理说,只要涉及到setState调用的相关逻辑链,都应该用redux重构。
最终的效果应该是component仅访问props即可完成UI渲染,而所有业务逻辑和状态管理都应该挪到action和reducer中处理。
为了方便翻阅,先贴出修改前、后的MsgDetailPage.es6的完整代码:
修改前的代码
importReactfrom "react";
import {Link} from "react-router";
import $ from "jquery";
importstylefrom "./MsgDetailPage.css";
importToolBarfrom "./ToolBar/ToolBar";
importLoadingLayerfrom "../LoadingLayer/LoadingLayer";
exportdefault class MsgDetailPageextends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
msgId: this.props.params.msgId,
contentHeight: 0,
isLoading: true,
outerStyle: {height: 0},
};
}
fetchDetail() {
letmsgId = this.state.msgId;
$.ajax({
type: 'GET',
url: '/msg-detail',
data: {'msgId': msgId},
dataType: 'json',
success: (response) => {
this.setState({
msgTitle: response.data.title,
msgContent: response.data.content,
isLoading: false, // 首屏加载完成, 标记loading结束
});
console.log(`msg-detail?msgId=${msgId} 请求成功, msgContent=${this.state.msgContent}`);
},
error: () => {
console.log(`msg-detail?msgId=${msgId} 请求异常`);
}
});
}
componentDidMount() {
// 调整loading界面的样式
letToolBar = $(this.refs.ToolBar.refs.ToolBarContainter);
this.setState({outerStyle: {height: window.innerHeight - ToolBar.height()}});
// 发起数据加载(setTimeout模拟延迟)
setTimeout(() => {
this.fetchDetail();
}, 500);
}
componentDidUpdate() {
// 加载完成
if (!this.state.isLoading) {
lettitle = $(this.refs.MsgTitle);
letcontainer = $(this.refs.MsgContainer);
letToolBar = $(this.refs.ToolBar.refs.ToolBarContainter);
// 上半部分总高度
letheight = title.height() + parseInt(title.css('padding-top')) +
parseInt(title.css('padding-bottom')) +
parseInt(container.css("padding-top")) +
parseInt(container.css("padding-bottom")) +
parseInt(ToolBar.height());
// 窗口高度-上半部分总高度作为文章的最小高度
if (this.state.contentHeight != window.innerHeight - height) { // 如果一样则不要setState避免递归渲染
this.setState({
contentHeight: window.innerHeight - height,
});
}
}
}
renderLoading() {
letouterStyle = {
height: window.innerHeight,
};
return (
<div>
<ToolBarref="ToolBar"/>
<LoadingLayerouterStyle={this.state.outerStyle}/>
</div>
);
}
renderPage() {
// refs属性会捕获对应的原生的Dom节点,会在componentDidUpdate中访问Dom来动态计算高度
return (
<div>
<ToolBarref="ToolBar"/>
<h1id={style.MsgTitle} ref="MsgTitle">{this.state.msgTitle}</h1>
<divid={style.MsgContainer} ref="MsgContainer" style={{minHeight: this.state.contentHeight}}>
<p id={style.MsgContent}>{this.state.msgContent}</p>
</div>
</div>
);
}
render() {
if (this.state.isLoading) {
return this.renderLoading();
} else {
return this.renderPage();
}
}
}
MsgDetailPage.contextTypes = {
router: () => { React.PropTypes.object.isRequired }
};
修改后的代码
importReactfrom "react";
import {Link} from "react-router";
import $ from "jquery";
importstylefrom "./MsgDetailPage.css";
importToolBarfrom "./ToolBar/ToolBar";
importLoadingLayerfrom "../LoadingLayer/LoadingLayer";
// redux相关
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import * as actionsfrom "./action";
class MsgDetailPageextends React.Component {
constructor(props, context) {
super(props, context);
}
componentDidMount() {
// 调整Loading界面高度
// action: MSG_DETAIL_PAGE_ADJUST_LOADING_HEIGHT
letToolBar = $(this.refs.ToolBar.refs.ToolBarContainter);
this.props.adjustLoadingHeight(window.innerHeight - ToolBar.height());
// 发起数据加载(setTimeout模拟延迟)
// action: MSG_DETAIL_PAGE_FETCH_DETAIL
this.props.fetchDetail(this.props.msgId);
}
componentDidUpdate() {
// 加载完成
if (!this.props.isLoading) {
lettitle = $(this.refs.MsgTitle);
letcontainer = $(this.refs.MsgContainer);
letToolBar = $(this.refs.ToolBar.refs.ToolBarContainter);
// 上半部分总高度
letheight = title.height() + parseInt(title.css('padding-top')) +
parseInt(title.css('padding-bottom')) +
parseInt(container.css("padding-top")) +
parseInt(container.css("padding-bottom")) +
parseInt(ToolBar.height());
// 窗口高度-上半部分总高度作为文章的最小高度
if (this.props.contentHeight != window.innerHeight - height) { // 如果一样则不要setState避免递归渲染
// 调整文章部分最小高度
// action: MSG_DETAIL_PAGE_ADJUST_CONTENT_HEIGHT
this.props.adjustContentHeight(window.innerHeight - height);
}
}
}
renderLoading() {
return (
<div>
<ToolBarref="ToolBar"/>
<LoadingLayerouterStyle={this.props.outerStyle}/>
</div>
);
}
renderPage() {
// refs属性会捕获对应的原生的Dom节点,会在componentDidUpdate中访问Dom来动态计算高度
return (
<div>
<ToolBarref="ToolBar"/>
<h1id={style.MsgTitle} ref="MsgTitle">{this.props.msgTitle}</h1>
<divid={style.MsgContainer} ref="MsgContainer" style={{minHeight: this.props.contentHeight}}>
<p id={style.MsgContent}>{this.props.msgContent}</p>
</div>
</div>
);
}
render() {
if (this.props.isLoading) {
return this.renderLoading();
} else {
return this.renderPage();
}
}
}
MsgDetailPage.contextTypes = {
router: () => { React.PropTypes.object.isRequired }
};
// 将redux store里的state映射到本组件的Props上
// 注:这里传来的state是全局store,从而可以共享所有全局状态的访问!
function mapStateToProps(state, ownProps) {
console.log(state);
return {
msgId: ownProps.params.msgId, // 访问react-router的参数是可以的
contentHeight: state.MsgDetailPageReduer.contentHeight,
isLoading: state.MsgDetailPageReduer.isLoading,
outerStyle: state.MsgDetailPageReduer.outerStyle,
msgTitle: state.MsgDetailPageReduer.msgTitle,
msgContent: state.MsgDetailPageReduer.msgContent,
};
}
// 将实现的若干action方法映射到本组件的Props上,后续用来实现逻辑,触发redux事件流
function mapDispatchToProps(dispatch) {
return bindActionCreators(actions, dispatch);
}
//通过react-redux提供的connect方法将我们需要的state中的数据和actions中的方法绑定到props上
exportdefault connect(mapStateToProps, mapDispatchToProps)(MsgDetailPage);
为组件注入state与action
从代码对比看出,这里使用export default connect….取代了原先的export default class MsgDetailPage。
- mapStateToPorps函数用于为component对象注入props属性,所谓『注入』其实就是返回一个映射关系:其中key是props里的名字,value是全局store中某个字段的值。
- mapDispatchToProps里调用了bindActionCreators,顾名思义是将我们实现的若干action生成方法注入到component的props里。
- connect方法将上述2个注入函数关联到组件MsgDetaiPage,最终导出一个经过修饰包装的组件。
- ownProps用于访问react-router提供给我们的一些数据,例如:获取url query参数和router捕获的params。
- 关注一下代码里的注释,我们完全有能力将redux store中任何属性注入到组件中来访问,这就提供了非常方便的跨组件数据共享的目的。
// 将redux store里的state映射到本组件的Props上
// 注:这里传来的state是全局store,从而可以共享所有全局状态的访问!
function mapStateToProps(state, ownProps) {
console.log(state);
return {
msgId: ownProps.params.msgId, // 访问react-router的参数是可以的
contentHeight: state.MsgDetailPageReduer.contentHeight,
isLoading: state.MsgDetailPageReduer.isLoading,
outerStyle: state.MsgDetailPageReduer.outerStyle,
msgTitle: state.MsgDetailPageReduer.msgTitle,
msgContent: state.MsgDetailPageReduer.msgContent,
};
}
// 将实现的若干action方法映射到本组件的Props上,后续用来实现逻辑,触发redux事件流
function mapDispatchToProps(dispatch) {
return bindActionCreators(actions, dispatch);
}
//通过react-redux提供的connect方法将我们需要的state中的数据和actions中的方法绑定到props上
exportdefault connect(mapStateToProps, mapDispatchToProps)(MsgDetailPage);
编写action
我将涉及state修改的业务逻辑全部抽取成独立的『action生成方法』,这里有3个:
- adjustLoadingHeight(height):用于调整Loading界面的高度
- fetchDetail(msgId):用于ajax获取文章内容
- adjustContentHeight(height):用于调整文章内容的最小高度
说明:common/consts里则仅仅定义了一些全局的常量,用于唯一标识action的type。
action完整代码如下:
import * as constsfrom "../../common/consts";
import $ from "jquery";
/**
* 调整loading界面的高度
*/
exportfunction adjustLoadingHeight(height) {
// 隐式的dispatch:
// 直接返回action对象,这是同步dispatch的最简单套路,
// 框架会立即交给reducer,立即生效到props
return {
type: consts.MSG_DETAIL_PAGE_ADJUST_LOADING_HEIGHT,
height: height
};
}
/**
* 请求文章内容
*/
exportfunction fetchDetail(msgId) {
// 显式的diapatch
// 基于react-thunk实现,支持返回function从而获得dispatch上下文,异步的发送action
return (dispatch) => {
setTimeout(() => {
$.ajax({
type: 'GET',
url: '/msg-detail',
data: {'msgId': msgId},
dataType: 'json',
success: (response) => {
dispatch({
type: consts.MSG_DETAIL_PAGE_FETCH_DETAIL,
title: response.data.title,
content: response.data.content,
});
console.log(`msg-detail?msgId=${msgId} 请求成功, msgContent=${response.data.content}`);
},
error: () => {
console.log(`msg-detail?msgId=${msgId} 请求异常`);
}
});
}, 1000);
}
}
/**
* 调整文章最小高度
* @param height
* @returns {{type, contentHeight: *}}
*/
exportfunction adjustContentHeight(height) {
return {
type: consts.MSG_DETAIL_PAGE_ADJUST_CONTENT_HEIGHT,
contentHeight: height,
};
}
其中adjustLoadingHeight和adjustContentHeight是『同步action』,也就是通过return返回一个action对象的形式,由框架立即dispatch这个action给reducer进行处理与生效。
而fetchDetail则不同,它基于之前的redux-thunk中间件,从而可以返回一个function,这个函数应该接受至少一个dispatch函数。框架会调用我们返回的函数,我们在函数中发起异步ajax刷新,并在ajax回调里通过dispatch函数将action发送出去,这就是『异步action』了。
编写reducer
每个组件的reducer都应该与其实现的action对应,这样才能完整的实现redux的流程。
因此,reducer中也有对应的3个action的处理函数实现,它们接收现有的redux state和action对象,经过处理返回新的redux state,框架将帮我们存储到redux store中全局存储。
import * as constsfrom "../../common/consts";
// 组件初始化状态,其实就是把component的constructor的挪到这里就完事了
const initState = {
contentHeight: 0,
isLoading: true,
outerStyle: {height: 0},
msgTitle: '',
msgContent: '',
};
function MSG_DETAIL_PAGE_ADJUST_LOADING_HEIGHT_reducer(state, action) {
return Object.assign({}, state, {
outerStyle: {height: action.height}
});
}
function MSG_DETAIL_PAGE_FETCH_DETAIL_reducer(state, action) {
return Object.assign({}, state, {
msgTitle: action.title,
msgContent: action.content,
isLoading: false, // 首屏加载完成, 标记loading结束
});
}
function MSG_DETAIL_PAGE_ADJUST_CONTENT_HEIGHT_reducer(state, action) {
return Object.assign({}, state, {
contentHeight: action.contentHeight
});
}
// Reducer函数
// 1, 在redux初始化,路由切换等时机,都会被唤醒,从而有机会返回初始化state,
// 这将领先于componnent从而可以props传递
// 2, 这里redux框架传来的是state对应Reducer的子集合
exportdefault function MsgDetailPageReduer(state = initState, action) {
switch (action.type) {
// 调整Loading界面高度
case consts.MSG_DETAIL_PAGE_ADJUST_LOADING_HEIGHT:
return MSG_DETAIL_PAGE_ADJUST_LOADING_HEIGHT_reducer(state, action);
case consts.MSG_DETAIL_PAGE_FETCH_DETAIL:
return MSG_DETAIL_PAGE_FETCH_DETAIL_reducer(state, action);
case consts.MSG_DETAIL_PAGE_ADJUST_CONTENT_HEIGHT:
return MSG_DETAIL_PAGE_ADJUST_CONTENT_HEIGHT_reducer(state, action);
// 有2类action.type会进入default
// 1) 你不关心的action,属于其他组件
// 2)系统的action,例如router切换了location,redux初始化了等等
default:
console.log(action);
return state; // 返回当前默认state或者当前state
}
}
我们只为MsgDetailPage导出唯一的reducer函数入口,它判断action.type后分发到具体的3个处理函数,需要注意的几个点如下:
- MsgDetalPageReduer函数的initState参数应该定义成组件初始化的state,也就是此前通过mapSteteToProps()函数定义的那些state。这个做法和在组件的constructor里定义this.state类似,只不过现在是这些state被存储到了redux store中。
- 在组件对象分配前,这个reducer函数会被框架调用,通过日志可以看到收到了这些action:Object {type: “@@redux/INIT”}、Object {type: “@@redux/INIT”}、 Object {type: “@@router/LOCATION_CHANGE”, payload: Object},从而让我们有机会返回组件的初始化state,这个过程也是在default分支中生效的。
- 每个组件的reducer函数需要注册到redux的store中,可以回头看Router.es6中createStore的实现:
// 引入reducer importMsgDetailPageReduerfrom "../component/MsgDetailPage/reducer"; // 聚集所有reducer // 注:这里的key就是全局store的1级key,用于划分不同reducer的state集合,避免互相污染 const reducer = combineReducers({ MsgDetailPageReduer: MsgDetailPageReduer, routing: routerReducer, // react-router所需要的reducer }, );
因此,某一个组件的action被dispatch到store时,是有可能通知到其他组件的reducer中,因此default分支也有这方面的用途(不做任何动作,返回当前state),同时也意味着各个组件定义的action.type不能重复。 - redux的state不能直接原地修改,需要拷贝一个副本进行修改,这里使用的Object.assign非常适合这个用途。
定义action.type常量
为了确保action.type全局唯一,所以在common/consts.es6中定义所有action的type。
exportconst MSG_DETAIL_PAGE_ADJUST_LOADING_HEIGHT = "MSG_DETAIL_PAGE_ADJUST_LOADING_HEIGHT";
exportconst MSG_DETAIL_PAGE_FETCH_DETAIL = "MSG_DETAIL_PAGE_ADJUST_FETCH_DETAIL";
exportconst MSG_DETAIL_PAGE_ADJUST_CONTENT_HEIGHT = "MSG_DETAIL_PAGE_ADJUST_CONTENT_HEIGHT";
回头看看组件的变化
我们回到开始的组件完整代码,对比发现代码中已经没有state关键字了,所有的状态都从this.props(从redux注入的全局state)中获取,所有的处理方法(用redux注入的action)也都是从this.props获取并调用的。
可见,通过redux我们原先的组件变得非常简单,仅仅是从this.props获取一下属性,渲染出组件的样子就可以了。而异步网络请求这样的操作都挪到了action中,对状态的变更都挪到了reducer当中,差别仅仅是现在的状态是redux store全局状态,而组件通过注入props的方法取代了直接访问this.state,仅此而已。
通过本篇博客,对redux的实践有了基本的掌握。而我也遇到了新的问题:既然redux store全局保存组件的状态,那么当我重复访问相同的MsgDetailPage组件,不同的文章时,默认就会把此前文章在redux store中保存的状态注入进来,这个问题我会在接下来最后一篇博客中想办法解决。