在 React 中管理状态:Redux 还是非 Redux?

2025-06-11

在 React 中管理状态:Redux 还是非 Redux?

注意:这篇文章最初发布在marmelab.com上。

在 Marmelab,我们非常喜欢使用Redux来管理 React 应用的状态。它的出现彻底改变了我们编写应用程序的方式:不变性、函数式编程、使用 Redux-Saga 生成器管理异步 API 调用……以至于我们有时倾向于将 Redux “事实上” 集成到项目启动栈中。

但这是个好主意吗?我不确定……

示例:使用 React 管理聚会

让我们以一个简单的聚会管理应用程序为例。它应该能够显示:

  • 提案清单,
  • 一份希望讨论的清单,
  • 聚会成员名单。

数据来自 REST API。登录名/密码可同时保护应用程序和 API。

该应用程序使用Create React App引导并使用以下方式升级:

该项目如下所示:

编辑 simple-application-with-redux

该应用程序体现了典型的 Redux 架构。它以<App />挂载 Redux Store ( <Provider store={store}>) 和 Router ( <ConnectedRouter history={history}>) 的组件开始:

// in App.js
...
 export const App = ({ store, history }) => (
    <Provider store={store}>
        <ConnectedRouter history={history}>
            <Container>
                <Header />
                <Switch>
                    <Route exact path="/" component={Home} />
                    <Route path="/talks" component={Talks} />
                    <Route path="/wishes" component={Wishes} />
                    <Route path="/members" component={Members} />
                    <Route path="/login" component={Authentication} />
                    <Route component={NoMatch} />
                </Switch>
            </Container>
        </ConnectedRouter>
    </Provider>
);
Enter fullscreen mode Exit fullscreen mode

Redux 用户应该会对我选择的文件结构感到满意。我将与某个功能相关的所有代码分组到了一个目录中。以下talks页面示例:

├── talks
│   ├── actions.js
│   ├── reducer.js
│   ├── sagas.js
│   └── Talks.js
Enter fullscreen mode Exit fullscreen mode

页面<Talks>组件是一个简单的“连接组件”:

 // in talks/Talks.js
export const Talks = ({ isLoading, talks }) => (
    <div>
        <h1>Talks</h1>
        {isLoading && <Spinner />}
        {talks && talks.map(talk => <h2 key={talk.id}>{talk.title}</h2>)}
    </div>
);

const mapStateToProps = ({  talks }) => ({
    isLoading: talks.isLoading,
    talks: talks.data,
});

// passing {} as the second's connect argument prevents it to pass dispatch as prop
const mapDispatchToProps = {};

export default connect(mapStateToProps, mapDispatchToProps)(Talks);
Enter fullscreen mode Exit fullscreen mode

会谈的数据不是通过获取的componentWillMount,而是通过监听路线变化的 saga 获取的:

// in talks/sagas.js
import { put, select, takeLatest } from 'redux-saga/effects';
import { LOCATION_CHANGE } from 'react-router-redux';

import { loadTalks } from './actions';

const hasData = ({ talks }) => !!talks.data;

export function* handleTalksLoading() {
    if (yield select(hasData)) {
        return;
    }

    yield put(loadTalks());
}

export const sagas = function*() {
    yield takeLatest(
        action =>
            action.type === LOCATION_CHANGE &&
            action.payload.pathname === '/talks',
        handleTalksLoading,
    );
};
Enter fullscreen mode Exit fullscreen mode

当路线发生变化并对应于会谈部分(action.type === LOCATION_CHANGE && action.payload.pathname === '/talks')时,我的应用程序将使用以下功能触发操作loadTalks

// in talks/actions.js
export const LOAD_TALKS = 'LOAD_TALKS';

export const loadTalks = payload => ({
    type: 'LOAD_TALKS',
    payload,
    meta: {
        request: {
            url: '/talks',
        },
    },
});
Enter fullscreen mode Exit fullscreen mode

此操作包含获取其元数据中的对话数据的 url ,将被通用的 fetch saga 拦截action => !!action.meta && action.meta.request

// in /services/fetch/fetchSagas.js
import { call, put, takeEvery, select } from 'redux-saga/effects';

import { appFetch as fetch } from './fetch';

export const fetchError = (type, error) => ({
    type: `${type}_ERROR`,
    payload: error,
    meta: {
        disconnect: error.code === 401,
    },
});

export const fetchSuccess = (type, response) => ({
    type: `${type}_SUCCESS`,
    payload: response,
});

export function* executeFetchSaga({ type, meta: { request } }) {
    const token = yield select(state => state.authentication.token);
    const { error, response } = yield call(fetch, request, token);
    if (error) {
        yield put(fetchError(type, error));
        return;
    }

    yield put(fetchSuccess(type, response));
}

export const sagas = function*() {
    yield takeEvery(
        action => !!action.meta && action.meta.request,
        executeFetchSaga,
    );
};

Enter fullscreen mode Exit fullscreen mode

一旦获取成功,saga 会触发最终操作,表明数据恢复成功( )。此操作由talkreducercreateAction('${type}_SUCCESS')(response)使用

// in talks/reducers.js
export const reducer = (state = defaultState, action) => {
    switch (action.type) {
        case LOAD_TALKS:
            return {
                ...state,
                loading: true,
            };
        case LOAD_TALKS_ERROR:
            return {
                ...state,
                loading: false,
                error: action.payload,
            };
        case LOAD_TALKS_SUCCESS:
            return {
                ...state,
                loading: false,
                data: action.payload,
            };
        case LOGOUT:
            return defaultState;
        default:
            return state;
    }
};
Enter fullscreen mode Exit fullscreen mode

效果很好。这真是太巧妙了,甚至可以说是优雅!使用 action 的meta 属性,可以在应用内共享通用行为(例如数据获取、错误处理和注销)。

它很聪明,但很复杂

当你发现这个应用时,找到它的用法并不容易,有些行为简直太神奇了。总结一下,应用使用连接到路由器的 redux-saga 获取数据,路由器发送一个获取操作,该操作会被另一个通用 saga 拦截,如果成功,该 saga 会发出另一个操作,而这个操作会被页面的 reducer 拦截,该 reducer 已经发出了链中的第一个操作……

有些人可能会说这是对 redux 的滥用,但这主要是在这个堆栈上完成的几个项目的结果,具有重写动作和 reducer 的经验。

除了这种复杂性之外,还有大量的管道,即每个功能(动作、减速器和其他传奇)重复的许多文件。

让我们分析一下示例应用程序的三个页面:主页和登录页:

 ❯ cloc services/cra_webapp/src
      32 text files.
      32 unique files.
       0 files ignored.

github.com/AlDanial/cloc v 1.74  T=0.06 s (581.6 files/s, 17722.1 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
JavaScript                      31            150              1            819
CSS                              1              0              0              5
-------------------------------------------------------------------------------
SUM:                            32            150              1            824
-------------------------------------------------------------------------------
Enter fullscreen mode Exit fullscreen mode

31个文件,819行代码,对于一个简单的应用程序来说,这已经够多了。这段代码可以稍微简化一下,但可能会降低其通用性。

现在确实是时候问自己一下,Redux 是否有必要?

Redux 是 JavaScript 应用程序的可预测状态容器。

但是,应用程序的不同部分是否会修改相同的数据,从而需要这些数据具有可预测的状态?不,我只需要显示来自 API 的数据。DOM 中是否存在可以修改数据的组件?不,用户交互非常有限。

所以我可能不需要 Redux。

不使用 Redux 获取数据

让我们尝试不使用 Redux 来获取数据,或者更确切地说,不使用Redux-Saga(因为 Redux 并不直接负责数据获取)。我可以在每个页面上实现所有这些获取逻辑。然而,这会引入非常重复的机制和大量重复的代码。所以我必须找到一种通用的方法来从 API 中获取数据,而不会引入太多的重复和复杂性。

渲染道具模式是解决此类问题的绝佳选择!

让我们创建一个DataProvider组件:

// in DataProvider.js
import React, { Component, Fragment } from 'react';
import { Redirect } from 'react-router';
import { appFetch } from './services/fetch';

export class DataProvider extends Component {
    static propTypes = {
        render: PropTypes.func.isRequired,
        url: PropTypes.string.isRequired,
    };

    state = {
        data: undefined,
        error: undefined,
    };

    fetchData = async props => {
        const token = window.sessionStorage.getItem('token');
        try {
            const data = await appFetch({ url }, token);
            this.setState({
                data: data.response,
                error: null,
            });
        } catch (error) {
            this.setState({
                error,
            });
        }
    };

    componentDidMount() {
        return this.fetchData(this.props);
    }

    render() {
        const { data, error } = this.state;
        const { location } = this.props;

        if (error) {
            return error.code >= 401 && error.code <= 403 ? (
                <Redirect to="/login" />
            ) : (
                <p>Erreur lors du chargement des données</p>
            );
        }


        return (
            <Fragment>
                {data ? (
                    <p>Aucune donnée disponible</p>
                ) : (
                    this.props.render({
                        data,
                    })
                )}
            </Fragment>
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

url此组件在 期间从 prop 获取数据。它管理错误和缺失数据。如果获取到数据,它会将渲染委托给通过prop ( )componentDidMount传递的函数renderthis.props.render({ data })

让我们在讨论页面上实现这个组件:

// in talks/Talks.js
import React from 'react';
import PropTypes from 'prop-types';

import { DataProvider } from '../DataProvider';

export const TalksView = ({ talks }) => (
    <div>
        <h1>Talks</h1>
        {talks && talks.map(talk => <h2 key={talk.id}>{talk.title}</h2>)}
    </div>
);

TalksView.propTypes = {
    talks: PropTypes.array,
};

export const Talks = () => (
    <DataProvider
        url="/talks"
        render={({ data }) => <TalksView talks={data} />}
    />
);

Enter fullscreen mode Exit fullscreen mode

我现在有两个组件:

  • TalksView组件仅显示数据,无论数据来自哪里,
  • 组件Talks,使用它DataProvider来获取数据并TalksView显示它render={({ data }) => <TalksView talks={data} />}

它简单、有效、易读!

有一个优秀的库实现了这种类型的 DataProvider:react-request:React 的声明式 HTTP 请求

我现在准备从应用程序中删除 Redux。

编辑 simple-application-without-redux

让我们重新开始我们的项目分析:

❯ cloc services/cra_webapp/src
      16 text files.
      16 unique files.
       0 files ignored.

github.com/AlDanial/cloc v 1.74  T=0.04 s (418.9 files/s, 13404.6 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
JavaScript                      15             64              1            442
CSS                              1              0              0              5
-------------------------------------------------------------------------------
SUM:                            16             64              1            447
-------------------------------------------------------------------------------
Enter fullscreen mode Exit fullscreen mode

所以我把代码从 819 行缩减到了442 行,几乎减少了一半。还不错!

用 React State 替换 Redux Store

目前,每个页面都使用 DataProvider 获取数据。但是,我的应用程序需要身份验证才能通过json-web-token获取用户信息。

在没有 Redux 存储的情况下,如何将这些用户信息传输到各个组件?嗯,通过使用更高级别组件的状态App.js( ),并将作为 prop 传递user给需要它的子组件(PrivateRoute.jsHeader.js)。

简而言之,让我们重新编写 React 代码吧!

// in App.js
import React, { Component } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

import { Authentication } from './authentication/Authentication';
import { Header } from './components/Header';
import { PrivateRoute } from './PrivateRoute';
import { Talks } from './talks/Talks';


export class App extends Component {
    state = {
        user: null,
    };

    decodeToken = token => {
        const user = decode(token);
        this.setState({ user });
    };

    componentWillMount() {
        const token = window.sessionStorage.getItem('token');

        if (token) {
            this.decodeToken(token);
        }
    }

    handleNewToken = token => {
        window.sessionStorage.setItem('token', token);
        this.decodeToken(token);
    };

    handleLogout = () => {
        window.sessionStorage.removeItem('token');
        this.setState({ user: null });
    };

    render() {
        const { user } = this.state;
        return (
            <Router>
                <div>
                    <Header user={user} onLogout={this.handleLogout} />
                    <Switch>
                        <PrivateRoute
                            path="/talks"
                            render={() => (
                                <Talks />
                            )}
                            user={user}
                        />
                        <Route
                            path="/login"
                            render={({ location }) => (
                                <Authentication
                                    location={location}
                                    onNewToken={this.handleNewToken}
                                />
                            )}
                        />
                    </Switch>
                </div>
            </Router>
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

注意:我知道:存储tokeninwindow.sessionStorage是一种不好的做法。但这允许我为了本示例快速设置身份验证。这与 Redux 的移除无关。

// in PrivateRoute.js
import React from 'react';
import PropTypes from 'prop-types';
import { Redirect, Route } from 'react-router';

/**
 * This Route will redirect the user to the login page if needed.
 */
export const PrivateRoute = ({ user, ...rest }) =>
    user ? (
        <Route {...rest} />
    ) : (
        <Redirect
            to={{
                pathname: '/login',
                state: { from: rest.location },
            }}
        />
    );

PrivateRoute.propTypes = {
    user: PropTypes.object,
};
Enter fullscreen mode Exit fullscreen mode
// in components/Header.js
import React from 'react';
import PropTypes from 'prop-types';

import { Navigation } from './Navigation';

export const Header = ({ user, onLogout }) => (
    <header>
        <h1>JavaScript Playground: meetups</h1>
        {user && <Navigation onLogout={onLogout} />}
    </header>
);

Header.propTypes = {
    user: PropTypes.object,
    onLogout: PropTypes.func.isRequired,
};

Enter fullscreen mode Exit fullscreen mode

我的应用程序相对简单,将其user作为道具传递给孩子们并不是什么问题。

假设我想让导航栏更漂亮,并添加一个显示用户名的真正的注销菜单。我必须将这个参数传递userNavigation组件。

<Navigation onLogout={onLogout} user={user}/>
Enter fullscreen mode Exit fullscreen mode

此外,如果<UserMenu>组件使用另一个组件来显示用户,我将不得不再次传输我的用户:

const UserMenu = ({ onLogout, user }) => {
    <div>
        <DisplayUser user={user} />
        <UserSubMenu onLogout={onLogout} />
    </div>
}
Enter fullscreen mode Exit fullscreen mode

user在显示之前已经经过了 4 个组件...

那么更复杂和/或更重的应用怎么办?这会变得非常痛苦。在这种情况下,有必要考虑是否使用 Redux!

然而,现在有一个简单的解决方案可以将数据从一个组件传输到 React 树中更深层的其他组件:React Context

使用 React Context 传递状态

React.createContext方法生成两个组件:

const {Provider, Consumer} = React.createContext(defaultValue);
Enter fullscreen mode Exit fullscreen mode
  • 负责Provider分发数据
  • Consumer能够读取提供者数据

让我们回到前三个组件。

// in App.js
import React, { Component } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import styled from 'styled-components';
import { decode } from 'jsonwebtoken';

...

const UserContext = React.createContext({
    user: null,
    onLogout: () => true,
});

export const UserConsumer = UserContext.Consumer;
const UserProvider = UserContext.Provider;

export class App extends Component {
    ...

    render() {
        const { user } = this.state;
        return (
            <UserProvider
                value={{
                    user,
                    onLogout: this.handleLogout,
                }}
            >
                <Router>
                    <Container>
                        <Header />
                        <Switch>
                            <PrivateRoute
                                exact
                                path="/"
                                render={({ location }) => (
                                    <Home location={location} />
                                )}
                            />
                        ...
Enter fullscreen mode Exit fullscreen mode
// in PrivateRoute.js
import React from 'react';
import PropTypes from 'prop-types';
import { Redirect, Route } from 'react-router';

import { UserConsumer } from './App';

const PrivateRouteWithoutContext = ({ user, ...rest }) =>
    user ? (
        <Route {...rest} />
    ) : (
        <Redirect
            to={{
                pathname: '/login',
                state: { from: rest.location },
            }}
        />
    );

PrivateRouteWithoutContext.propTypes = {
    user: PropTypes.object,
};

export const PrivateRoute = props => {
    return (
        <UserConsumer>
            {({ user }) => (
                <PrivateRouteWithoutContext user={user} {...props} />
            )}
        </UserConsumer>
    );
};

Enter fullscreen mode Exit fullscreen mode

请注意,Consumer使用渲染道具模式。

// in components/Header.js
import React from 'react';
import PropTypes from 'prop-types';

import { UserConsumer } from '../App';
import { Navigation } from './Navigation';

export const HeaderWithoutContext = ({ user, onLogout }) => (
    <header>
        <h1>JavaScript Playground: meetups</h1>
        {user && <Navigation onLogout={onLogout} />}
    </header>
);

HeaderWithoutContext.propTypes = {
    user: PropTypes.object,
    onLogout: PropTypes.func.isRequired,
};

export const Header = () => {
    return (
        <UserConsumer>
            {({ user, onLogout }) => (
                <HeaderWithoutContext user={user} onLogout={onLogout} />
            )}
        </UserConsumer>
    );
};
Enter fullscreen mode Exit fullscreen mode

React Context 是一种将数据从应用程序的 N 级组件直接传送到任何 Nx 级子组件的简单方法。

那么,Redux 还是 Not Redux?

当项目达到一定复杂度时,Redux 就会变得有趣。然而,预先判断代码的复杂程度通常不是一个好主意!我更喜欢保持简单,事后对自己说:“太好了!我要开始做一些复杂的事情了。” 这让我想起了几年前,我系统地使用 Symfony 来启动一个 PHP 项目,而 Silex 让启动过程更加舒适和快捷。

尽管如此,就像 Symfony 一样,使用 Redux 可以成为非常明智的选择。

在项目初期使用它只是一个不成熟的决定。

这真的不是什么新鲜事😄

你可能不需要 Redux。

— 丹·阿布拉莫夫 ( @dan_abramov ) 2016 年 9 月 19 日

此外,除了这些理论上的考虑之外,脱离 Redux 似乎也会带来有益的影响。

首先,我更专注于 React!通过编写本文中的第二个示例,我重新发现了仅用组件积木构建应用程序的乐趣:就像玩乐高积木一样。使用render prop允许在整个项目中复用代码,同时保持 React 组件嵌套的逻辑。这是一种强大的模式,不像HOC那样神奇。此外,它还能适配 Redux 的可能实现。react -admin 2.0就是一个例证,它通过 render prop将UI 部分应用程序逻辑分离。

最后,这似乎是 React 团队正在采取的方向。借助新的Context API,他们提供了无需采用 Redux 即可轻松设置全局共享存储的可能性。

鏂囩珷鏉ユ簮锛�https://dev.to/alexisjanvier/managing-state-in-react-redux-or-not-redux-4ebp
PREV
单个 HTML 元素星级评定组件 CultSoft #CodingHappiness #DeveloperLife #NoErrors #TechCommunity
NEXT
理解现代 Web 技术栈:Webpack - DevServer、React 和 Typescript