正如许多开发人员所知,状态管理是您在构建健壮的应用程序时必须处理的众多问题之一。它会很快变成一场噩梦,尤其是在客户端。
Redux 强制执行单向数据流,这使得理解事件如何改变应用程序状态变得容易。伟大的!但是如何处理副作用,例如网络请求,最常见的副作用?
让我们探索 Redux 提供的一些用于获取和更新数据的解决方案,以及如何设置自定义中间件解决方案来满足您的特定需求和您可能遇到的任何副作用。
在本文中:
什么是 Redux?
从服务器获取数据的简单示例
获取和更新数据的各种解决方案
使用 React 状态钩子
使用redux-thunk和redux-promise
使用redux-saga和redux-observable
自定义中间件是完美的解决方案吗?
设置中间件
忽略不相关的操作类型
从动作负载中提取重要变量
处理任何 HTTP 方法
处理全局变量
处理加载状态
发出实际的网络请求、处理错误并调用回调
自定义中间件在行动
什么是 Redux?
Redux 是一个状态容器和很好的工具,它解决了 UI 框架的主要问题之一:状态管理。使用 Redux,应用程序状态可以通过称为actions.
Redux 状态管理的可预测质量是,如果actions重放,我们每次都会到达正确的数据状态。
有许多库可以扩展 Redux 在我们应用程序的状态管理方面的功能。但是我们如何判断哪些适合我们的项目呢?
事实是,这些解决方案中的每一个都是根据不同的方法、用例和心智模型构建的,因此它们都有其优点和缺点。在本博客中,我不会讨论所有可能的方法,但让我们通过一个简单的应用程序来看看一些最常见的模式。
为了探索客户端应用程序状态管理的不同选项,我们将使用一个简单的React 应用程序。
从服务器获取数据的简单示例
让我们在我们简单的 React 应用程序中使用一个虚假的 Medium 帖子作为示例。
看看下面的应用程序屏幕截图。你不同意这很简单吗?它只包含一堆文本和左侧的中号拍手图标。您可以获取此应用程序的 GitHub 存储库以进行后续操作。
请注意,中号拍手是可点击的。以下是我构建 Medium clap克隆的方法,以防您感兴趣。
即使对于这个简单的应用程序,您也必须从服务器获取数据。显示所需视图所需的 JSON 有效负载可能如下所示:
{
"numberOfRecommends": 1900,
"title": "My First Fake Medium Post",
"subtitle": "and why it makes no intelligible sense",
"paragraphs": [
{
"text": "This is supposed to be an intelligible post about something intelligible."
},
{
"text": "Uh, sorry there’s nothing here."
},
{
"text": "It’s just a fake post."
},
{
"text": "Love it?"
},
{
"text": "I bet you do!"
}
]
}该应用程序的结构确实很简单,有两个主要组件:Article和Clap.
在components/Article.js中,article 组件是一个无状态的函数组件,它接收title, subtitle, 和paragraphsprops。渲染的组件如下所示:
const Article = ({ title, subtitle, paragraphs }) => {
return (
<StyledArticle>
<h1>{title}</h1>
<h4>{subtitle}</h4>
{paragraphs.map(paragraph => <p>{paragraph.text}</p>)}
</StyledArticle>
);
};这里,是一个通过CSS-in-JS 解决方案StyledArticle设置样式的常规div元素。styled-components
您是否熟悉任何 CSS-in-JS 解决方案都没关系。StyledArticle可以用div通过良好的 ol' CSS 样式替换。
让我们结束它,而不是开始争论。
Medium clap 组件在components/Clap.js. 代码稍微复杂一些,超出了本文的范围。但是,您可以阅读我是如何构建 Medium clap 的——阅读时间为 5 分钟。
有了Clap和Article组件,该App组件就组合了两个组件,如下所示containers/App.js:
class App extends Component {
state = {};
render() {
return (
<StyledApp>
<aside>
<Clap />
</aside>
<main>
<Article />
</main>
</StyledApp>
);
}
}同样,您可以StyledApp用常规替换div并通过 CSS 设置样式。
超过 20 万开发人员使用 LogRocket 来创造更好的数字体验了解更多 →
现在,到本文的重点。
获取和更新数据的各种解决方案
让我们看看您可以选择在 Redux 应用程序中获取和更新数据的一些不同方式,并考虑它们的优缺点。
使用 React 状态钩子
React 提供 Hooks,它充当对 React 功能的速记访问,例如state. 在本节中,我们将专门研究React State Hook。
使用 Hooks,我们可以构建一个组件,该组件可以访问状态等特性,而无需编写类来扩展React.Component和引用this.state
要使用 State Hook,我们useState从 React 库中导入,如下所示:
import {useState} from 'react';简而言之,useState提供了一种创建状态数据对象和更新它的函数的简写方式。
useState接受一个值——任何值类型,例如integer, string, boolean,object等——并返回一个包含两项的数组。第一项是变量,它保存值。第二个是更新变量的函数。
让我们看一个使用我们的Clap组件编写为函数而不是类的小示例:
// src/components/Clap.js
import {useState} from 'react'
function generateRandomNumber(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
function Clap() {
const [isClicked, setIsClicked] = useState(false)
const [count, setCount] = useState(0)
const [countTotal, setCountTotal] = useState(generateRandomNumber(500, 1000))
const handleClick = () => {
// set is clicked to true - this makes our button green
setIsClicked(true)
setCount(count + 1)
setCountTotal(countTotal + 1)
}
return (
<div>
<button id="clap" className="clap" onClick={handleClick}>
<span>
{/*<!-- SVG Created by Luis Durazo from the Noun Project -->*/}
<svg
id="clap--icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="-549 338 100.1 125"
className={`${isClicked && "checked"}`}
>
<path d="M-471.2 366.8c1.2 1.1 1.9 2.6 2.3 4.1.4-.3.8-.5 1.2-.7 1-1.9.7-4.3-1-5.9-2-1.9-5.2-1.9-7.2.1l-.2.2c1.8.1 3.6.9 4.9 2.2zm-28.8 14c.4.9.7 1.9.8 3.1l16.5-16.9c.6-.6 1.4-1.1 2.1-1.5 1-1.9.7-4.4-.9-6-2-1.9-5.2-1.9-7.2.1l-15.5 15.9c2.3 2.2 3.1 3 4.2 5.3zm-38.9 39.7c-.1-8.9 3.2-17.2 9.4-23.6l18.6-19c.7-2 .5-4.1-.1-5.3-.8-1.8-1.3-2.3-3.6-4.5l-20.9 21.4c-10.6 10.8-11.2 27.6-2.3 39.3-.6-2.6-1-5.4-1.1-8.3z" />
<path d="M-527.2 399.1l20.9-21.4c2.2 2.2 2.7 2.6 3.5 4.5.8 1.8 1 5.4-1.6 8l-11.8 12.2c-.5.5-.4 1.2 0 1.7.5.5 1.2.5 1.7 0l34-35c1.9-2 5.2-2.1 7.2-.1 2 1.9 2 5.2.1 7.2l-24.7 25.3c-.5.5-.4 1.2 0 1.7.5.5 1.2.5 1.7 0l28.5-29.3c2-2 5.2-2 7.1-.1 2 1.9 2 5.1.1 7.1l-28.5 29.3c-.5.5-.4 1.2 0 1.7.5.5 1.2.4 1.7 0l24.7-25.3c1.9-2 5.1-2.1 7.1-.1 2 1.9 2 5.2.1 7.2l-24.7 25.3c-.5.5-.4 1.2 0 1.7.5.5 1.2.5 1.7 0l14.6-15c2-2 5.2-2 7.2-.1 2 2 2.1 5.2.1 7.2l-27.6 28.4c-11.6 11.9-30.6 12.2-42.5.6-12-11.7-12.2-30.8-.6-42.7m18.1-48.4l-.7 4.9-2.2-4.4m7.6.9l-3.7 3.4 1.2-4.8m5.5 4.7l-4.8 1.6 3.1-3.9" />
</svg>
</span>
<span id="clap--count" className="clap--count">
+{count}
</span>
<span id="clap--count-total" className="clap--count-total">
{countTotal}
</span>
</button>
</div>
);
}正如我们在上面的示例中看到的那样,我们使用 State Hook 初始化了所有数据 - 和 - isClicked。此外,在我们的活动中,我们使用适当的函数更新了我们的状态数据。countcountTotalhandleClick
现在让我们介绍另一个名为useEffect. 这个 Hook 可以和useStateHook 一起使用来加载从请求中获取的状态数据。这是一个例子:
import {useState, useEffect} from 'react'
import axios from 'axios'
function Example() {
const [post, updatePost] = useState({title: ''})
useEffect(() => {
axios.get("https://api.myjson.com/bins/19dtxc")
.then(({ data }) => {
updatePost(data)
})
})
return (
<div>
<p>{post.title}</p>
...
</div>
)
}当我们的数据是独立的并受单个组件影响时,使用 React Hooks 进行 API 调用可以正常工作。当我们有多个组件共享的数据时,我们不能再依赖 Hooks。
来自 LogRocket 的更多精彩文章:
不要错过来自 LogRocket 的精选时事通讯The Replay
了解LogRocket 的 Galileo 如何消除噪音以主动解决应用程序中的问题
使用 React 的 useEffect优化应用程序的性能
在多个 Node 版本之间切换
了解如何使用 AnimXYZ 为您的 React 应用程序制作动画
探索 Tauri,一个用于构建二进制文件的新框架
比较NestJS 与 Express.js
现在让我们探索一些 Redux 库以进行更复杂的数据更改。最受欢迎的选项可以说是redux-thunk和redux-saga。
准备好?
使用redux-thunk和redux-promise
要记住的重要一点是,每个第三方库都有其学习曲线和潜在的可扩展性问题。但在 Redux 中用于管理副作用的所有社区库中,那些工作类似redux-thunk并且redux-promise最容易上手的库。
前提很简单。Athunk是一段执行一些延迟工作的代码,或者简单地说,是用于稍后更新状态的逻辑。
对于redux-thunk,您编写了一个不“创建”对象但返回函数的动作创建器。这个函数是从 Redux传递过来的getStateand函数。dispatch
让我们看看虚假的 Medium 应用程序如何利用该redux-thunk库。
首先,安装redux-thunk库:
yarn add redux-thunk
为了使库按预期工作,它必须作为中间件应用。在store/index.js:
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
const store = createStore(rootReducer, applyMiddleware(thunk));上面代码块中的第一行从 Redux导入了createStoreand函数。applyMiddleware第二行导入thunkfrom redux-thunk。第三行创建store, 但带有应用的中间件。
现在,我们准备发出实际的网络请求。
我们将使用 Axios 库来发出网络请求,但您可以随意将其替换为您选择的另一个 HTTP 客户端。
使用 .发起网络请求实际上非常简单redux-thunk。您创建了一个返回如下函数的动作创建者:
export function fetchArticleDetails() {
return function(dispatch) {
return axios.get("https://api.myjson.com/bins/19dtxc")
.then(({ data }) => {
dispatch(setArticleDetails(data));
});
};
}安装App组件后,您将分派此操作创建者:
componentDidMount() {
this.props.fetchArticleDetails();
}就是这样。请务必检查完整的代码差异,因为我在这里只突出显示关键行。这样,文章详细信息已被获取并显示在应用程序中,如下所示:
这种方法到底有什么问题?
如果您正在构建一个非常小的应用程序,redux-thunk则可以解决问题,并且它可能是最容易相处的。
为了改进我们的使用redux-thunk,我们可以为实现我们选择的客户端的 HTTP 请求创建一个类或对象。这样,交换库并创建一个实例以减少基本 URL 和标头的重复以及处理基本网络错误将很容易。
一个好主意是让我们的动作创建者尽可能无状态。使它们成为简单的功能使它们更易于调试和测试。
使用redux-saga和redux-observable
这些 Redux 库比redux-thunkor稍微复杂一些redux-promise。
redux-saga并且redux-observable肯定可以更好地扩展,但它们需要学习曲线。像 RxJS和Redux Saga这样的概念必须学习,并且取决于团队中的工程师有多少经验,这可能是一个挑战。
所以,如果redux-thunk和redux-promise对你的项目来说太简单了,redux-saga并且redux-observable会引入一层你想从你的团队中抽象出来的复杂性,你会转向哪里?
自定义中间件!
大多数解决方案都喜欢redux-thunk, redux-promise,并且redux-saga无论如何都在后台使用中间件。为什么你不能创造你的?
你刚才说,“为什么要重新发明轮子?”
自定义中间件是完美的解决方案吗?
虽然重新发明轮子最初听起来像是一件坏事,但给它一个机会。
无论如何,许多公司已经构建了定制解决方案以满足他们的需求。事实上,很多开源项目就是这样开始的。
那么,您对这个定制解决方案有何期望?
集中式解决方案;即,在一个模块中
可以处理各种 HTTP 方法,包括GET、POST、DELETE和PUT
可以处理设置自定义标题
支持自定义错误处理;例如,发送到某个外部日志服务,或用于处理授权错误
允许onSuccess和onFailure回调
支持处理加载状态的标签
同样,根据您的具体需求,您可能有一个更大的列表。
现在,让我带您了解一个不错的起点——您可以根据自己的特定用例进行调整。
Redux API 中间件总是这样开始的:
const apiMiddleware = ({dispatch}) => next => action => {
next (action)
}您可以在 Github 上找到自定义 Redux API 中间件的完整代码。一开始可能看起来很多,但我会很快解释每一行。
干得好:
import axios from "axios";
import { API } from "../actions/types";
import { accessDenied, apiError, apiStart, apiEnd } from "../actions/api";
const apiMiddleware = ({ dispatch }) => next => action => {
next(action);
if (action.type !== API) return;
const {
url,
method,
data,
accessToken,
onSuccess,
onFailure,
label,
headers
} = action.payload;
const dataOrParams = ["GET", "DELETE"].includes(method) ? "params" : "data";
// axios default configs
axios.defaults.baseURL = process.env.REACT_APP_BASE_URL || "";
axios.defaults.headers.common["Content-Type"]="application/json";
axios.defaults.headers.common["Authorization"] = `Bearer${token}`;
if (label) {
dispatch(apiStart(label));
}
axios
.request({
url,
method,
headers,
[dataOrParams]: data
})
.then(({ data }) => {
dispatch(onSuccess(data));
})
.catch(error => {
dispatch(apiError(error));
dispatch(onFailure(error));
if (error.response && error.response.status === 403) {
dispatch(accessDenied(window.location.pathname));
}
})
.finally(() => {
if (label) {
dispatch(apiEnd(label));
}
});
};
export default apiMiddleware;只需 100 行代码(同样,您可以从 GitHub 获取),您就拥有了一个定制的 Redux API 中间件解决方案,其流程易于推理。
我答应解释每一行,所以首先,这里是中间件如何工作的概述:
首先,你做了一些重要的导入,你很快就会看到它们的用法。
设置中间件
这是 Redux API 中间件所需的典型设置:
const apiMiddleware = ({ dispatch }) => next => action => {}到这里,第一步就完成了。简单的!
忽略不相关的操作类型
我们的下一步是消除不相关的操作类型。请看下面的代码:
if (action.type !== API) return;
上述条件对于防止除 type 之外的任何操作API触发网络请求非常重要。
从动作负载中提取重要变量
为了发出成功的请求,需要从操作负载中提取一些重要的变量:
const {
url,
method,
data,
onSuccess,
onFailure,
label,
} = action.payload;以下是每个变量所代表或所指的内容:
url:endpoint被击中
method:请求的HTTP方法
data``GET在orDELETE请求的情况下要发送到服务器或查询参数的任何数据
onSuccess和onFailure: 代表您希望在请求成功或失败时分派的任何动作创建者
label: 请求的字符串表示
您很快就会在一个实际示例中看到这些使用。
处理任何 HTTP 方法
const dataOrParams = ["GET", "DELETE"].includes(method) ? "params" : "data";
因为这个解决方案使用axios- 而且我认为大多数HTTP客户端无论如何都是这样工作的 -GET和DELETE方法使用params,而其他方法可能需要将一些发送data到服务器。
因此,该变量dataOrParams将保存任何相关值——params或者data——取决于请求的方法。
如果你有一些在网络上开发的经验,这应该不会奇怪。
处理全局变量
// axios default configs
axios.defaults.baseURL = process.env.REACT_APP_BASE_URL || "";
axios.defaults.headers.common["Content-Type"]="application/json";
axios.defaults.headers.common["Authorization"] = `Bearer${token}`;大多数体面的应用程序将具有一些授权层、abaseUrl和一些默认标头。从技术上讲,每个 API 客户端很可能对每个请求都有一些默认值。
这是通过在 Axios 对象上设置一些属性来完成的。我相信您选择的任何客户都可以这样做。
处理加载状态
if (label) {
dispatch(apiStart(label));
}标签只是一个字符串,用于标识某个网络请求操作。就像一个动作的类型。 如果label存在,中间件将分派一个apiStart动作创建者。
下面是apiStart动作创建者的样子:
export const apiStart = label => ({
type: API_START,
payload: label
});动作类型是API_START。
现在,在您的 reducer 中,您可以处理此操作类型以了解请求何时开始。我将很快展示一个示例。
此外,在成功或失败的网络请求时,API_END也将分派一个动作。这非常适合处理加载状态,因为您确切地知道请求何时开始和结束。
同样,我将很快展示一个示例。
发出实际的网络请求、处理错误并调用回调
axios
.request({
url: `${BASE_URL}${url}`,
method,
headers,
[dataOrParams]: data
})
.then(({ data }) => {
dispatch(onSuccess(data));
})
.catch(error => {
dispatch(apiError(error));
dispatch(onFailure(error));
if (error.response && error.response.status === 403) {
dispatch(accessDenied(window.location.pathname));
}
})
.finally(() => { if (label) { dispatch(apiEnd(label)); } });上面的代码并不像看起来那么复杂。
axios.request负责发出网络请求,并传入一个对象配置。这些是您之前从操作有效负载中提取的变量。
成功请求后,如then块中所示,分派一个apiEnd动作创建者。
如下所示:
export const apiEnd = label => ({
type: API_END,
payload: label
});在你的 reducer 中,你可以监听这个并在请求结束时终止任何加载状态。完成后,调度onSuccess回调。
网络请求成功后,onSuccess回调会返回您希望调度的任何操作。在成功的网络请求之后,几乎总是会分派一个动作,例如,将获取的数据保存到 Redux 存储中。
如果发生错误,如catch块中所示,也会触发apiEnd动作创建者,调度apiError带有失败错误的动作创建者:
export const apiError = error => ({
type: API_ERROR,
error
});您可能有另一个中间件来侦听此操作类型并确保错误命中您的外部日志服务。
您也可以发送onFailure回调,以防万一您需要向用户显示一些视觉反馈。这也适用于 toast 通知。
最后,我展示了一个处理身份验证错误的示例:
if (error.response && error.response.status === 403) {
dispatch(accessDenied(window.location.pathname));
}在这个例子中,我调度了一个accessDenied动作创建者,它获取用户所在的位置。然后我可以accessDenied在另一个中间件中处理这个动作。
您真的不必在另一个中间件中处理这些。它们可以在同一个代码块中完成。但是,为了仔细抽象,将这些关注点分开可能对您的项目更有意义。
就是这样!
自定义中间件在行动
我现在将重构伪造的 Medium 应用程序以使用这个自定义中间件。唯一要做的更改是包含此中间件,然后编辑fetchArticleDetails操作以返回一个普通对象。
包括中间件:
import apiMiddleware from "../middleware/api"; const store = createStore(rootReducer, applyMiddleware(apiMiddleware));
编辑fetchArticleDetails动作:
export function fetchArticleDetails() {
return {
type: API,
payload: {
url: "https://api.myjson.com/bins/19dtxc",
method: "GET",
data: null,
onSuccess: setArticleDetails,
onFailure: () => {
console.log("Error occured loading articles");
},
label: FETCH_ARTICLE_DETAILS
}
};
}
function setArticleDetails(data) {
return {
type: SET_ARTICLE_DETAILS,
payload: data
};
}请注意来自的有效负载如何fetchArticleDetails包含中间件所需的所有信息。
但是有一个小问题:一旦你超越了一个动作创建者,安卓清理君高级版App,告别手机卡顿一键扫描清理垃圾,完美爆破版无限使用!每次都编写有效负载对象变得很痛苦。当某些值是null或具有某些默认值时,这尤其令人沮丧。
为方便起见,您可以将动作对象的创建抽象为一个名为的新动作创建者apiAction:
function apiAction({
url = "",
method = "GET",
data = null,
onSuccess = () => {},
onFailure = () => {},
label = ""
}) {
return {
type: API,
payload: {
url,
method,
data,
onSuccess,
onFailure,
label
}
};
}使用 ES6 默认参数,请注意apiAction已经设置了一些合理的默认值。 现在,fetchArticleDetails您可以这样做:
function fetchArticleDetails() {
return apiAction({
url: "https://api.myjson.com/bins/19dtxc",
onSuccess: setArticleDetails,
onFailure:() => {console.log("Error occured loading articles")},
label: FETCH_ARTICLE_DETAILS
});
}使用一些 ES6 甚至可以更简单:
const fetchArticleDetails = () => apiAction({
url: "https://api.myjson.com/bins/19dtxc",
onSuccess: setArticleDetails,
onFailure: () => {console.log("Error occured loading articles")},
label: FETCH_ARTICLE_DETAILS
});简单得多——结果是一样的,一个工作的应用程序!
为了了解标签如何用于加载状态,我将继续处理 reducer 中的API_START和API_END动作类型。
case API_START:
if (action.payload === FETCH_ARTICLE_DETAILS) {
return {
...state,
isLoadingData: true
};
}
case API_END:
if (action.payload === FETCH_ARTICLE_DETAILS) {
return {
...state,
isLoadingData: false
};
}现在,我isLoadingData根据两种操作类型在状态对象中设置一个标志,API_START并且API_END. 基于此,我可以在App组件内设置加载状态。
结果如下:
那行得通!
请记住,我在这里分享的自定义中间件只是作为您应用程序的良好起点。评估以确保这适合您的确切情况。根据您的具体用例,您可能需要进行一些调整。
对于它的价值,我将其作为相当大的项目的起点,而不后悔这个决定。
结论
我绝对鼓励您在提交之前尝试在 Redux 应用程序中发出网络请求的各种可用选项。可悲的是,在为增长的应用程序选择策略后,重构变得困难。
归根结底,是您的团队、您的应用程序、您的时间,最终,您可以为自己做出选择。