[React-章节11 终结篇] 做一个留言板项目 之 重构至redux

silverlion 发布于1年前 阅读28477次
0 条评论

 

思考

最近我也一直在研究redux到底是干什么的,经过零零散散的学习之后,我有这么几个不算成熟的认识分享给大家:

  1. redux和react没有依赖关系,redux出现是提出了一种规范和模型,可以这么来描述它:程序某处产生一个action(理解为动作,行为,事件),通过dispatch(分发,路由的感觉)中间派发,交由处理者reducer(其实就是处理action)进行最终的计算,结果存储到store(就是一个对象,存的是程序的状态state)里。

  2. redux在全局只维护一个store存储对象,程序分很多组件,那么各个组件的state状态都存储到这1个store里,同时每个组件的reducer处理器也聚合到一起挂接到store上。

  3. react把组件的展示,数据的异步获取,state的变化全部实现在component里,随着代码膨胀愈加复杂,而redux有利于我们将数据的处理和展示进行进一步分层,优化项目结构。

  4. 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,它们分别只解决自己关心的问题,需要组合使用才能完成项目。

下载源码

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中保存的状态注入进来,这个问题我会在接下来最后一篇博客中想办法解决。

需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。