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





5.00/5 (7投票s)
React 中处理副作用的不同方法的比较
引言
React 是一个用于构建用户界面的 JavaScript 库。通常情况下,使用 React 意味着同时使用 Redux。Redux 是另一个用于管理全局状态的 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日:初始版本