05-使用Redux进行状态管理

什么是Redux

Redux 是 JavaScript 状态容器,提供可预测化的状态管理。Redux将应用程序所有的状态 State 都保存在 Store 中,因此每个应用程序也只能有一个 Store。State 不能直接改变,必须通过 Reducer 来触发。Reducer 是一个无副作用的纯函数,形式为 (state, action) => state,意思通过传入变化前状态及动作,返回执行该动作后的变化后状态。Action 即描述了如何改变 State。

整体架构及流程图

1.在 View 层,用户通过点击等动作触发事件。
2.将事件转化为对应的 Action 传递给处理的 Middleware。
3.Middleware处理对应的副作用后,再调用对应的 Reducer ,根据原始 State 和 传递过来的 Action 合成新的 State,此时可以触发异步调用。
4.将合成后的 State 返回给 View。

Redux目录构成

创建如下目录结构来实现Redux状态管理

src
└─ src/
   └─ actions                         定义所有的Action
   └─ components                      展示组件
   └─ containers                      容器组件
   └─ services                       管理api
   └─ reducers                        定义所有的Reducer
   └─ store                           管理Store
   └─ types                           管理ts定义的类型

我们预计会使用到以下的包:

  • redux Redux 状态管理核心包
  • react-redux Redux 与 React 的绑定库
  • redux-actions 以FSA标准实现 Action
  • redux-thunk 实现异步 Action 的中间件

运行安装命令

yarn add redux react-redux redux-actions redux-thunk

及类型定义

yarn add -D @types/react-redux @types/redux-actions

接着我们来实现一个简单的列表显示功能

Store设计

{
    ...
    novel: {
        isLoading: false,
        data: [
            {
                id: 1,
                title: 'Learning Python',
                author: 'wang3',
                comment: 'aaaaaaaaaa'
            },
            {
                id: 2,
                title: 'Learning Java',
                author: 'wang4',
                comment: 'bbbbbbbbbb'
            },
        ]
    }

}
  • isLoading 表示请求状态,true 请求中,false 请求完成。
  • data是一个列表,表示一个域数据,是我们主要的业务数据。
  • 实际状态树中可能包含路由,浏览器历史等非业务状态。

Action设计

我们采用标准的FSA来实现 Action。FSA全称为Flux Standard Action,是一个实现 Action 的标准化规范。简单来讲,一个 Action 必须只能是一个简单的
JavaScript 对象,它必须拥有一个字符串常量属性 type 来标识动作类型,它可以拥有 error,payload,meta属性,这3个属性可以是任意类型,除此之外的任何属性都不符合FSA的规范。

  • type 标识动作类型的字符串常量。
  • payload 任何不是 type或 status 等属性的值,都应该属于 payload,如果 error 等于 true的话,那 payload 就应该为一个 error 对象。
  • error 如果 Action 出错,那 error 可以设置为 true,如果 error 被设置成 true 以外的任意值,那该 Action 就不能被认为出错。
  • meta 它包括了不属于 payload 的其他任意属性。

理解了FSA的概念,我们选择 redux-actions 来辅助我们实现 Action,这有助于我们在项目中维持统一的规范。

一次异步请求API的过程都需要dispatch至少3种
Action,首先 constants.ts 中增加 Action 定义:

// 定义ACTION类型 
export const ACTION_TYPES = {
    // 发起请求
    FETCH_NOVELS: 'FETCH_NOVELS',
    // 请求成功
    FETCH_NOVELS_OK: 'FETCH_NOVELS_OK',
    // 请求失败
    FETCH_NOVELS_NG: 'FETCH_NOVELS_NG',
}

新增actions/nobel.ts

import { ACTION_TYPES } from '../constants'
import { createActions } from "redux-actions";

// 普通 Action
const { fetchNovels, fetchNovelsOK, fetchNovelsNG } = createActions(
    ACTION_TYPES.FETCH_NOVELS,
    ACTION_TYPES.FETCH_NOVELS_OK,
    ACTION_TYPES.FETCH_NOVELS_NG
)

// 异步 Action
export const searchNovels = (url: string) => (
    (dispatch: any) => {
        dispatch(fetchNovels()); // {type: 'FETCH_NOVELS'}
        callApi(url).then(
            json => dispatch(fetchNovelsOK(json)), // {type: 'FETCH_NOVELS_OK', payload: json}
            error => dispatch(fetchNovelsNG(error)) // {type: 'FETCH_NOVELS_NG', payload: error, error: true}
        )
    }
);

services/api.ts使用fetch api添加通用api调用

const callApi = (url: string) => {
    return fetch(url)
        .then(response =>
            response.json().then(json => {
                if (!response.ok) {
                    return Promise.reject(json)
                }

                return json
            })
        )
}

Reducer设计

新建reducers/novel.ts

import { ACTION_TYPES } from "../constants";
import { Novel } from "src/types/novel";
import { handleActions, Action } from "redux-actions";

// 定义管理状态树的结构类型
type NovelStateType = {
    isLoading: boolean;
    data: Array<Novel>;
}

// 初始化状态
export const initialState: NovelStateType = {
    isLoading: false,
    data: [],
};

// 根据不同的Action生成Reducer
const novel = handleActions({
    [ACTION_TYPES.FETCH_NOVELS]: (state: NovelStateType) => {
        return { ...state, isLoading: true };
    },
    [ACTION_TYPES.FETCH_NOVELS_OK]: (state: NovelStateType, action: Action<Array<Novel>>) => {
        return {
            ...state,
            data: action.payload,
            loadingFlag: false,
        };
    },
    [ACTION_TYPES.FETCH_NOVELS_NG]: (state: NovelStateType, action: Action<any>) => {
        return { ...state, loadingFlag: false };
    },
}, initialState);

export default novel;

Reducer 也和 Action 一样,不包括复杂的处理,这里使用了 redux-actions包的 handleActions方法,省略了case 判断的写法,看起来更加优雅。由此可知,每个 Action 都会对应创建 Reducer,而Reducer 可以更新所管理的状态树的一部分或者全部,这都是根据业务需要来定。

创建 Store

新建store/configureStore.ts

import { createStore, applyMiddleware, combineReducers } from 'redux'
import thunk from 'redux-thunk'
import { createBrowserHistory } from 'history'
import { connectRouter, routerMiddleware, RouterState } from 'connected-react-router'
import { NovelStateType, novel } from 'src/reducers/novel';

// 浏览器history对象
export const history = createBrowserHistory();
// 定义应用程序状态树的结构类型
export type AppStateType = {
    // 路由状态
    router: RouterState;
    novel: NovelStateType;
}

// 将各种reducer合并为一个根reducer
const rootReducer = combineReducers({
    novel,
    // 将路由与浏览器历史关联
    router: connectRouter(history),
})

// 创建store
export const store = createStore(
    // 跟reducer
    rootReducer,
    // 应用中间件
    applyMiddleware(
        thunk,
        routerMiddleware(history)
    )
)
  • 通过 connected-react-router 来完成 redux的绑定路由
  • 通过 combineReducers 工具函数合并reducer。
  • 通过 applyMiddleware 来增强 store,比如增加异步处理及路由管理能力。

applyMiddleware 完成了Action,Reducer不承担的一些额外的副作用,简单理解applyMiddleware其实就是链式的装饰器模式,通过一步步加入对应的 applyMiddleware 来增强store.dispatch。比如状态变化前后的日志功能等。

可以参照以下格式来自定义 applyMiddleware,next 参数是store的dispatch方法,可以看到本质上就是在保证原有调用的基础上,增加了别的功能。

/**
 * 记录所有被发起的 action 以及产生的新的 state。
 */
const logger = store => next => action => {
  console.group(action.type)
  console.info('dispatching', action)
  let result = next(action)
  console.log('next state', store.getState())
  console.groupEnd(action.type)
  return result
}

实际使用时

// 创建store
export const store = createStore(
    // 跟reducer
    rootReducer,
    // 应用中间件
    applyMiddleware(
        thunk,
        routerMiddleware(history),
        logger
    )
)

有许多现成的中间件可以使用,这里推荐一个记录状态变化日志的中间件 redux-logger。

执行以下命令

yarn add redux-logger 
yarn add -D @types/redux-logger 

修改configureStore.ts

// 状态变化记录中间件
const loggerMiddleware = createLogger()

// 创建store
export const store = createStore(
    // 跟reducer
    rootReducer,
    // 应用中间件
    applyMiddleware(
        thunk,
        routerMiddleware(history),
        loggerMiddleware
    )
)

创建 React 组件

实现组件前需要简单了解下展示组件和容器组件的区别。

  • 展示组件:描述页面呈现,不与Redux直接作用,数据来源自props,修改数据调用props里的回调函数
  • 容器组件:描述如何运行(数据获取、状态更新),直接使用Redux,监听 Redux state,向 Redux 派发 actions

展示组件

首先创建components/NovelList.tsx的展示组件,根据传入的novels数组显示小说列表。

import * as React from 'react'
import { Novel } from '../types/novel';

export interface NovelListProps {
    novels: Novel[];
}

const NovelList: React.SFC<NovelListProps> = (props) => {
    return (<ul>
        {
            props.novels && props.novels.map((novel, index) => {
                return (
                    <li key={novel.id}>
                        <span>{index}</span>
                        <span>{novel.title}</span>
                        <span>{novel.author}</span>
                        <span>{novel.summary}</span>
                    </li>
                );
            })
        }
    </ul>);
}

export default NovelList;

这里有个需要注意的关于 key 属性,这个涉及到UI更新渲染问题,React 依靠 Diff 算法,会根据这个值来判断是否渲染UI,尽可能减少性能开销。用能够唯一标识数据的属性来做 key 是推荐的做法。

容器组件


import * as React from 'react'
import NovelList from '../components/NovelList';
import { useEffect } from "react";
import { AppStateType } from '../store/configureStore';
import { searchNovels } from '../actions/novel';
import { Novel } from '../types/novel';
import { connect } from 'react-redux';

export interface NovelContainerProps {
    isLoading: boolean;
    data: Array<Novel>;
    // 通过中间件自动注入
    dispatch: any;
}

const NovelContainer: React.SFC<NovelContainerProps> = (props) => {
    // componentDidMount,componentDidUpdate 和 componentWillUnmount 这三个函数的组合
    // 保持render的纯净,副作用操作都放到渲染之后执行
    useEffect(() => {
        let { dispatch } = props;
        dispatch(searchNovels('test'))
    }, [])

    // 加载中UI
    if (!props.isLoading) {
        return (<div>loading...</div>);
    }
    if (props.data && props.data.length == 0) {
        return <div>No Data...</div>
    }
    // 加载完成后UI
    return (<div>
        <NovelList novels={props.data}></NovelList>
    </div>);
}

// connect生成容器组件
export default connect(
    // 将state绑定到容器组件props上
    ({ novel }: AppStateType) => novel,
)(NovelContainer);

我们这里创建的是属于混合控件,它包含了UI定义及Redux操作。这里使用了React16.8 的新增特性Hook,功能类似于componentDidMount,componentDidUpdate 和 componentWillUnmount,只不过它每次重渲染时都会执行。

App.tsx更新

class App extends React.Component {
    render() {
        return (
            <Provider store={store}>
                <ConnectedRouter history={history}>
                    <Header />
                    <Route exact path={PATHS.TOP} component={Top}></Route>
                    <Suspense fallback={<div>Loading...</div>}>
                        <Route exact path={PATHS.ABOUT} component={About} ></Route>
                        <Route exact path={PATHS.NOVELS} component={NovelContainer} ></Route>
                    </Suspense>
                </ConnectedRouter>
            </Provider>
        );
    }
}

通过Provider标签将store注入到所有组件属性中,ConnectedRouter将浏览器历史及路由注入到组件属性中。

至此,基于 Redux 状态管理的 React 程序可以顺利运行了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,163评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,301评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,089评论 0 352
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,093评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,110评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,079评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,005评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,840评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,278评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,497评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,667评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,394评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,980评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,628评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,649评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,548评论 2 352

推荐阅读更多精彩内容