65.9K
CodeProject 正在变化。 阅读更多。
Home

在 React 中加载数据:redux-thunk、redux-saga、suspense、hooks

2019 年 1 月 31 日

MIT

7分钟阅读

viewsIcon

23359

React 中处理副作用的不同方法的比较

引言

React 是一个用于构建用户界面的 JavaScript 库。通常情况下,使用 React 意味着同时使用 ReduxRedux 是另一个用于管理全局状态的 JavaScript 库。遗憾的是,即使同时使用这两个库,也没有一种明确的方法来处理对 API(后端)的异步调用或任何其他副作用。

在本文中,我将尝试比较解决此问题的不同方法。让我们先定义一下问题。

组件 X 是网站(或移动、桌面应用程序,这也是可能的)众多组件之一。X 查询并显示从 API 加载的一些数据。X 可以是一个页面,也可以只是页面的一部分。重要的是,X 是一个独立的组件,应该与系统的其余部分松散耦合(尽可能地)。在数据检索时,X 应该显示加载指示器,如果调用失败,则应显示错误信息。

本文假设您已经具备创建 React/Redux 应用程序的一些经验。

本文将展示解决这个问题的 4 种方法,并比较每种方法的优缺点本文并非关于如何使用 thunk、saga、suspense 或 hooks 的详细手册

这些示例的代码可在 GitHub 上找到。

初始设置

模拟服务器

为了测试,我们将使用 json-server。这是一个很棒的项目,可以非常快速地构建伪 REST API。对于我们的示例,它看起来像这样

const jsonServer = require('json-server');
const server = jsonServer.create();
const router = jsonServer.router('db.json');
const middleware = jsonServer.defaults();

server.use((req, res, next) => {
   setTimeout(() => next(), 2000);
});
server.use(middleware);
server.use(router);
server.listen(4000, () => {
   console.log(`JSON Server is running...`);
});

db.json 文件包含 json 格式的测试数据。

{
 "users": [
   {
     "id": 1,
     "firstName": "John",
     "lastName": "Doe",
     "active": true,
     "posts": 10,
     "messages": 50
   },
   ...
   {
     "id": 8,
     "firstName": "Clay",
     "lastName": "Chung",
     "active": true,
     "posts": 8,
     "messages": 5
   }
 ]
}

启动服务器后,调用 http://localhost:4000/users 会返回用户列表,并模拟大约 2 秒的延迟。

项目和 API 调用

现在我们准备开始编码了。我假设你已经使用 create-react-app 创建了一个 React 项目,并且已经配置好 Redux,随时可以使用。

如果您对此有任何困难,可以查阅这篇这篇文章

接下来是创建一个调用 API 的函数 (api.js)

const API_BASE_ADDRESS = 'https://:4000';

export default class Api {
   static getUsers() {
       const uri = API_BASE_ADDRESS + "/users";

       return fetch(uri, {
           method: 'GET'
       });
   }
}

Redux-thunk

Redux-thunk 是一个推荐的中间件,用于处理基本的 Redux 副作用逻辑,例如像请求 API 这样的简单异步逻辑。Redux-thunk 本身做的事情不多。它只有 14!!! 。它只是增加了一些“语法糖”而已。

下面的流程图有助于理解我们要做什么。

每当一个 action 被执行,reducer 就会相应地改变 state。组件将 state 映射到 props,并在 render() 方法中使用这些 props 来决定用户应该看到什么:加载指示器、数据或错误消息。

为了实现这一点,我们需要做 5 件事。

1. 安装 thunk

npm install redux-thunk

2. 在配置 Store 时添加 thunk 中间件 (configureStore.js)

import { applyMiddleware, compose, createStore } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './appReducers';

export function configureStore(initialState) {
 const middleware = [thunk];

 const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
 const store = createStore(rootReducer, initialState, 
                           composeEnhancers(applyMiddleware(...middleware)));

 return store;
}

在第 12-13 行,我们还配置了 redux devtools。稍后,它将有助于展示此解决方案存在的一个问题。

3. 创建 Actions (redux-thunk/actions.js)

import Api from "../api"

export const LOAD_USERS_LOADING = 'REDUX_THUNK_LOAD_USERS_LOADING';
export const LOAD_USERS_SUCCESS = 'REDUX_THUNK_LOAD_USERS_SUCCESS';
export const LOAD_USERS_ERROR = 'REDUX_THUNK_LOAD_USERS_ERROR';

export const loadUsers = () => dispatch => {
   dispatch({ type: LOAD_USERS_LOADING });

   Api.getUsers()
       .then(response => response.json())
       .then(
           data => dispatch({ type: LOAD_USERS_SUCCESS, data }),
           error => dispatch
            ({ type: LOAD_USERS_ERROR, error: error.message || 'Unexpected Error!!!' })
       )
};

通常也建议将 action creators 分开(这会增加一些额外的编码),但对于这个简单的例子,我认为“即时”创建 action 是可以接受的。

4. 创建 reducer (redux-thunk/reducer.js)

import {LOAD_USERS_ERROR, LOAD_USERS_LOADING, LOAD_USERS_SUCCESS} from "./actions";

const initialState = {
   data: [],
   loading: false,
   error: ''
};

export default function reduxThunkReducer(state = initialState, action) {
   switch (action.type) {
       case LOAD_USERS_LOADING: {
           return {
               ...state,
               loading: true,
               error:''
           };
       }
       case LOAD_USERS_SUCCESS: {
           return {
               ...state,
               data: action.data,
               loading: false
           }
       }
       case LOAD_USERS_ERROR: {
           return {
               ...state,
               loading: false,
               error: action.error
           };
       }
       default: {
           return state;
       }
   }
}

5. 创建连接到 redux 的组件 (redux-thunk/UsersWithReduxThunk.js)

import * as React from 'react';
import { connect } from 'react-redux';
import {loadUsers} from "./actions";

class UsersWithReduxThunk extends React.Component {
   componentDidMount() {
       this.props.loadUsers();
   };

   render() {
       if (this.props.loading) {
           return <div>Loading</div>
       }

       if (this.props.error) {
           return <div style={{ color: 'red' }}>ERROR: {this.props.error}</div>
       }

       return (
           <table>
               <thead>
                   <tr>
                       <th>First Name</th>
                       <th>Last Name</th>
                       <th>Active?</th>
                       <th>Posts</th>
                       <th>Messages</th>
                   </tr>
               </thead>
               <tbody>
               {this.props.data.map(u =>
                   <tr key={u.id}>
                       <td>{u.firstName}</td>
                       <td>{u.lastName}</td>
                       <td>{u.active ? 'Yes' : 'No'}</td>
                       <td>{u.posts}</td>
                       <td>{u.messages}</td>
                   </tr>
               )}
               </tbody>
           </table>
       );
   }
}

const mapStateToProps = state => ({
   data: state.reduxThunk.data,
   loading: state.reduxThunk.loading,
   error: state.reduxThunk.error,
});

const mapDispatchToProps = {
   loadUsers
};

export default connect(
   mapStateToProps,
   mapDispatchToProps
)(UsersWithReduxThunk);

我试着让组件尽可能简单。我知道它看起来很糟糕。:)

加载指示器

Data

Error(错误)

3 个文件,109 行代码 (13(actions) + 36(reducer) + 60(component))。

优点

  • React/Redux 应用程序的“推荐”方法
  • 没有额外的依赖项。几乎没有,thunk 非常小 :)
  • 无需学习新知识

缺点

  • 大量代码分散在不同地方
  • 导航到另一个页面后,旧数据仍保留在全局状态中(见下图)。这些数据是过时的无用信息,会消耗内存。
  • 在复杂场景下(例如一个 action 中有多个条件调用),代码可读性不高

Redux-saga

Redux-saga 是一个 redux 中间件库,旨在以一种简单易读的方式处理副作用。它利用了 ES6 的 Generators,这使得可以编写看起来同步的异步代码。此外,该解决方案易于测试。

从高层次来看,这个解决方案的工作方式与 thunk 相同。thunk 示例中的流程图仍然适用。

为了实现这一点,我们需要做 6 件事。

1. 安装 saga

npm install redux-saga

2. 添加 saga 中间件并添加所有 Sagas (configureStore.js)

import { applyMiddleware, compose, createStore } from 'redux';
import createSagaMiddleware from 'redux-saga';
import rootReducer from './appReducers';
import usersSaga from "../redux-saga/sagas";

const sagaMiddleware = createSagaMiddleware();

export function configureStore(initialState) {
 const middleware = [sagaMiddleware];

 const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
 const store = createStore
     (rootReducer, initialState, composeEnhancers(applyMiddleware(...middleware)));

 sagaMiddleware.run(usersSaga);

 return store;
}

第 4 行的 Sagas 将在第 4 步中添加。

3. 创建 Action (redux-saga/actions.js)

export const LOAD_USERS_LOADING = 'REDUX_SAGA_LOAD_USERS_LOADING';
export const LOAD_USERS_SUCCESS = 'REDUX_SAGA_LOAD_USERS_SUCCESS';
export const LOAD_USERS_ERROR = 'REDUX_SAGA_LOAD_USERS_ERROR';

export const loadUsers = () => dispatch => {
   dispatch({ type: LOAD_USERS_LOADING });
};

4. 创建 Sagas (redux-saga/sagas.js)

import { put, takeEvery, takeLatest } from 'redux-saga/effects'
import {loadUsersSuccess, LOAD_USERS_ERROR, LOAD_USERS_LOADING, LOAD_USERS_SUCCESS} 
        from "./actions";
import Api from '../api'

async function fetchAsync(func) {
   const response = await func();

   if (response.ok) {
       return await response.json();
   }

   throw new Error("Unexpected error!!!");
}

function* fetchUser() {
   try {
       const users = yield fetchAsync(Api.getUsers);

       yield put({type: LOAD_USERS_SUCCESS, data: users});
   } catch (e) {
       yield put({type: LOAD_USERS_ERROR, error: e.message});
   }
}

export function* usersSaga() {
   // Allows concurrent fetches of users
   yield takeEvery(LOAD_USERS_LOADING, fetchUser);

   // Does not allow concurrent fetches of users
   // yield takeLatest(LOAD_USERS_LOADING, fetchUser);
}

export default usersSaga;

Saga 的学习曲线相当陡峭,所以如果你从未使用过,也从未读过关于这个框架的任何资料,可能会很难理解这里发生了什么。简而言之,在 userSaga 函数中,我们配置 saga 来监听 LOAD_USERS_LOADING action 并触发 fetchUsers 函数。fetchUsers 函数调用 API。如果调用成功,则分发 LOAD_USER_SUCCESS action,否则分发 LOAD_USER_ERROR action。

5. 创建 reducer (redux-saga/reducer.js)

import {LOAD_USERS_ERROR, LOAD_USERS_LOADING, LOAD_USERS_SUCCESS} from "./actions";

const initialState = {
   data: [],
   loading: false,
   error: ''
};

export default function reduxSagaReducer(state = initialState, action) {
   switch (action.type) {
       case LOAD_USERS_LOADING: {
           return {
               ...state,
               loading: true,
               error:''
           };
       }
       case LOAD_USERS_SUCCESS: {
           return {
               ...state,
               data: action.data,
               loading: false
           }
       }
       case LOAD_USERS_ERROR: {
           return {
               ...state,
               loading: false,
               error: action.error
           };
       }
       default: {
           return state;
       }
   }
}

Reducer 与 thunk 示例中的完全相同。

6. 创建连接到 redux 的组件 (redux-saga/UsersWithReduxSaga.js)

import * as React from 'react';
import {connect} from 'react-redux';
import {loadUsers} from "./actions";

class UsersWithReduxSaga extends React.Component {
   componentDidMount() {
       this.props.loadUsers();
   };

   render() {
       if (this.props.loading) {
           return <div>Loading</div>
       }

       if (this.props.error) {
           return <div style={{color: 'red'}}>ERROR: {this.props.error}</div>
       }

       return (
           <table>
               <thead>
                   <tr>
                       <th>First Name</th>
                       <th>Last Name</th>
                       <th>Active?</th>
                       <th>Posts</th>
                       <th>Messages</th>
                   </tr>
               </thead>
               <tbody>
                   {this.props.data.map(u =>
                       <tr key={u.id}>
                           <td>{u.firstName}</td>
                           <td>{u.lastName}</td>
                           <td>{u.active ? 'Yes' : 'No'}</td>
                           <td>{u.posts}</td>
                           <td>{u.messages}</td>
                       </tr>
                   )}
               </tbody>
           </table>
       );
   }
}

const mapStateToProps = state => ({
   data: state.reduxSaga.data,
   loading: state.reduxSaga.loading,
   error: state.reduxSaga.error,
});

const mapDispatchToProps = {
   loadUsers
};

export default connect(
   mapStateToProps,
   mapDispatchToProps
)(UsersWithReduxSaga);

组件也与 thunk 示例中的几乎相同。

4 个文件,136 行代码 (7(actions) + 36(reducer) + sagas(33) + 60(component))。

优点

  • 更易读的代码 (async/await)
  • 适合处理复杂场景(一个 action 中有多个条件调用、一个 action 可以有多个监听器、取消 action 等)
  • 易于进行单元测试

缺点

  • 大量代码分散在不同地方
  • 导航到另一个页面后,旧数据仍保留在全局状态中。这些数据是过时的无用信息,会消耗内存。
  • 额外的依赖项
  • 需要学习很多概念

Suspense

Suspense 是 React 16.6.0 中的一个新特性。它允许延迟渲染组件的一部分,直到某个条件满足(例如,从 API 加载数据完成)。

为了实现这一点,我们需要做 4 件事(情况正在好转 :))。

1. 创建缓存 (suspense/cache.js)

对于 cache,我们将使用 simple-cache-provider,这是一个用于 react 应用程序的基本 cache 提供程序。

import {createCache} from 'simple-cache-provider';

export let cache;

function initCache() {
 cache = createCache(initCache);
}

initCache();

2. 创建错误边界 (suspense/ErrorBoundary.js)

这是一个错误边界(Error Boundary),用于捕获 Suspense 抛出的错误。

import React from 'react';

export class ErrorBoundary extends React.Component {
 state = {};

 componentDidCatch(error) {
   this.setState({ error: error.message || "Unexpected error" });
 }

 render() {
   if (this.state.error) {
     return <div style={{ color: 'red' }}>ERROR: {this.state.error || 'Unexpected Error'}</div>;
   }

   return this.props.children;
 }
}

export default ErrorBoundary;

3. 创建用户表格 (suspense/UsersTable.js)

对于这个例子,我们需要创建一个额外的组件来加载和显示数据。在这里,我们正在创建一个从 API 获取数据的 resource。

import * as React from 'react';
import {createResource} from "simple-cache-provider";
import {cache} from "./cache";
import Api from "../api";

let UsersResource = createResource(async () => {
   const response = await Api.getUsers();
   const json = await response.json();

   return json;
});

class UsersTable extends React.Component {
   render() {
       let users = UsersResource.read(cache);

       return (
           <table>
               <thead>
               <tr>
                   <th>First Name</th>
                   <th>Last Name</th>
                   <th>Active?</th>
                   <th>Posts</th>
                   <th>Messages</th>
               </tr>
               </thead>
               <tbody>
               {users.map(u =>
                   <tr key={u.id}>
                       <td>{u.firstName}</td>
                       <td>{u.lastName}</td>
                       <td>{u.active ? 'Yes' : 'No'}</td>
                       <td>{u.posts}</td>
                       <td>{u.messages}</td>
                   </tr>
               )}
               </tbody>
           </table>
       );
   }
}

export default UsersTable;

4. 创建组件 (suspense/UsersWithSuspense.js)

import * as React from 'react';
import UsersTable from "./UsersTable";
import ErrorBoundary from "./ErrorBoundary";

class UsersWithSuspense extends React.Component {
   render() {
       return (
           <ErrorBoundary>
               <React.Suspense fallback={<div>Loading</div>}>
                   <UsersTable/>
               </React.Suspense>
           </ErrorBoundary>
       );
   }
}

export default UsersWithSuspense;

4 个文件,106 行代码 (9(cache) + 19(ErrorBoundary) + UsersTable(33) + 45(component))。

如果我们假设 ErrorBoundary 是一个可复用组件,则为 3 个文件,87 行代码 (9(cache) + UsersTable(33) + 45(component))。

优点

  • 不需要 redux。这种方法可以在没有 redux 的情况下使用。组件是完全独立的。
  • 没有额外的依赖项 (simple-cache-provider 是 React 的一部分)
  • 通过设置 dellayMs 属性延迟显示加载指示器
  • 代码行数比之前的示例少

缺点

  • 即使我们并不真的需要缓存,也需要 Cache
  • 需要学习一些新概念(这是 React 的一部分)。

钩子 (Hooks)

在撰写本文时,hooks 尚未正式发布,仅在“next”版本中可用。Hooks 无疑是即将推出的最具革命性的功能之一,它可能会在不久的将来极大地改变 React 的世界。关于 hooks 的更多细节可以在这里这里找到。

为了在我们的例子中实现它,我们只需要做一件!!!!!!!事。

1 创建并使用 Hooks (hooks/UsersWithHooks.js)

在这里,我们创建了 3 个 hooks(函数)来“钩入”React 的 state。

import React, {useState, useEffect} from 'react';
import Api from "../api";

function UsersWithHooks() {
   const [data, setData] = useState([]);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState('');

   useEffect(() => {
       async function fetchData() {
           try {
               const response = await Api.getUsers();
               const json = await response.json();
   
               setData(json);
           } catch (e) {
               setError(e.message || 'Unexpected error');
           }
 
           setLoading(false);
       }
        fetchData();
   }, []);

   if (loading) {
       return <div>Loading</div>
   }

   if (error) {
       return <div style={{color: 
       'red'}}>ERROR: {error}</div>
   }

   return (
       <table>
           <thead>
           <tr>
               <th>First Name</th>
               <th>Last Name</th>
               <th>Active?</th>
               <th>Posts</th>
               <th>Messages</th>
           </tr>
           </thead>
           <tbody>
           {data.map(u =>
               <tr key={u.id}>
                   <td>{u.firstName}</td>
                   <td>{u.lastName}</td>
                   <td>{u.active ? 'Yes' : 
                   'No'}</td>
                   <td>{u.posts}</td>
                   <td>{u.messages}</td>
               </tr>
           )}
           </tbody>
       </table>
   );
}

export default UsersWithHooks;

1 个文件,60 行代码!!!!

优点

  • 不需要 redux。这种方法可以在没有 redux 的情况下使用。组件是完全独立的。
  • 没有额外的依赖项
  • 代码量比其他解决方案少大约一半

缺点

  • 乍一看,代码看起来很奇怪,难以阅读和理解。需要一些时间来适应 hooks。
  • 需要学习一些新概念(这是 React 的一部分)
  • 尚未正式发布

结论

让我们先把各项指标整理成一个表格。

  文件 代码行数 依赖项 是否需要 Redux?
Thunk 3 109 0.001
Saga 4 136 1
Suspense 4/3 106/87 0
钩子 (Hooks) 1 60 0
  • Redux 仍然是管理全局状态的好选择(如果你有的话)
  • 每种方案都有其优缺点。哪种方法更好取决于项目:复杂性、用例、团队知识、项目何时上线等。
  • Saga 可以帮助处理复杂的用例
  • Suspense 和 Hooks 值得考虑(或至少学习),特别是对于新项目

就是这样了 — 祝你编码愉快!

历史

  • 2019年1月30日:初始版本
© . All rights reserved.