React 是许多开发人员构建动态客户端应用程序的首选框架。这些应用程序的动态特性来自于客户端的灵活性和扩展的功能和特性列表,这使得开发人员能够构建成熟的应用程序,并在几秒钟内加载到浏览器上,这是一个以前没有的壮举。在静态网络时代这是可能的(或者非常麻烦)。
随着可能性的扩展,管理状态的概念出现了,随着客户端应用程序复杂性的增加,如果处理不当并考虑到可扩展性,保持本地状态的需求本身就会成为瓶颈。
许多框架都解决了这个问题,采用不同的方法并专注于不同的子问题集,这就是为什么对所选框架的生态系统有高度的了解以评估每个应用程序的需求并采用正确的方法非常重要指标。本文将简要概述常见的状态管理问题,并尝试介绍不同的方法(useState、Context API)作为对此的回应。虽然本文将介绍多种解决方案,但它只会关注较小规模的挑战,我们将在接下来的文章中介绍更高级的主题。
身份验证工作流程
整篇文章中展示的代码可以在此处找到。
可以在此处访问实时预览链接。
考虑我们为 React 应用程序实现身份验证过程的情况。
如上面的 GIF 所示,我们希望允许用户使用凭据登录或注册我们的应用程序。如果提供了有效的凭据,用户将登录,应用程序将自动导航到主页,用户可以继续使用该应用程序。
同样,如果用户注销,则登录后主页资源将受到保护,登录页面将是用户可以访问的唯一页面。
从实现的角度考虑这个工作流程,我们将有一个名为 App 的主要组件,App 组件将用户重定向到两个页面之一:主页或登录,用户的当前状态(登录、注销)将决定哪个页面用户重定向到的页面,用户当前状态的更改(例如从登录更改为注销)应触发即时重定向到相应页面。
如上图所示,我们希望 App 组件考虑当前状态,并根据当前状态仅渲染两个页面之一(主页或登录)。
如果用户为空,则意味着我们没有任何经过身份验证的用户,因此我们自动导航到登录页面并使用条件渲染保护主页。如果用户存在,我们做相反的事情。
现在我们已经对应该实现的内容有了深入的了解,让我们探索一些选项,但首先让我们设置我们的 React 项目,
项目存储库包含一个示例后端应用程序,我们将使用它来实现前端(我们不会深入讨论它,因为它不是这里的主要焦点,但代码故意保持简单,因此您不会遇到困难)
我们首先创建以下页面和组件:
-
主页
-
登录页面
-
编辑用户页面
-
导航栏组件
-
用户下拉组件
具有多个页面的 React 应用程序需要正确的导航,为此我们可以使用 React-router-dom 创建全局浏览器路由器上下文并注册不同的 React 路由。
yarn add react-router-dom
我们知道许多读者更喜欢跟随教程,因此这里有一个入门模板可以帮助您快速上手。此入门分支使用 DaisyUI 作为预定义的 TailwindCSS JSX 组件。它包括所有组件、页面和已设置的路由器。如果您正在考虑遵循本教程,按照简单的步骤自行构建整个身份验证流程,请首先分叉存储库。分叉存储库后,克隆它并从 start-herebranch: 开始
git clone git@github.com:<your-username>/fullstack-resourcify.git
一旦拉动 start-here 分支:
-
使用您喜欢的代码编辑器打开项目
-
将目录更改为 frontend/
-
安装依赖项:yarn
-
启动开发服务器:yarn dev·
预览应该是这样的:
导航栏(右上角)中呈现的名称是主应用程序组件中定义的状态变量。相同的变量被传递到导航栏和主页。上面使用的简单形式实际上更新了 EditPage 组件中的“name”状态变量。
下面介绍的两种方法将详细介绍实现过程:
第一种方法:useState
useState()
useState 是最常用的 React hook 之一,它允许您在 React 功能组件中创建和改变状态。 useState 组件的实现非常简单并且易于使用:要创建一个新状态,您需要使用状态的初始值调用 useState,useState 挂钩将返回一个包含两个变量的数组:第一个是状态您可以使用它来引用状态的变量,第二个变量是您用来更改状态值的函数:非常简单。
我们来看看它的实际效果怎么样?导航栏(右上角)中呈现的名称是主应用程序组件中定义的状态变量。相同的变量被传递到导航栏和主页。上面使用的简单形式实际上更新了 EditPage 组件中的“name”状态变量。底线是这样的: useState 是一个核心钩子,它接受初始状态作为参数,并返回两个保存两个值的变量,包含初始状态的状态变量以及同一状态变量的 setter 函数。
让我们把它分解一下,看看它最初是如何实现的。
- 创建“name”状态变量:
./src/App.jsx
import { useState } from "react";
function App() {
const testValue = "CLA";
//using the useState hook to create a state variable out of an initial value passed as an argument
const [name, setName] = useState(testValue);
console.log(`Rendering: ${name}`);
return (...)};
道具
props 是 React 组件的基本构建块之一,从概念上讲,如果你将 React 功能组件视为 Javascript 函数,那么 props 只不过是函数参数,将 props 和 useState hook 结合起来可以为你提供一个坚实的框架用于管理简单 React 应用程序的状态。
React props 作为属性传递给自定义组件。当接受作为参数时,作为 props 传递的属性可以从 props 对象中解构,类似于:
传递道具
<Routes>
<Route path="/" element={<Home name={name} />} /> // Passing name as a prop to
Home Component
<Route
path="/user"
element={<EditUser name={name} setName={setName} />} // passing both name and setItem function as props to EditUser component
/>
<Route path="/login" element={<Login />} />
</Routes>
与普通函数参数类似,道具可以在函数组件内部被接受和使用。这是因为“name”作为 props 传递给 Home 组件,我们可以在同一组件中渲染它。在下面的示例中,我们使用解构语法接受传递的 prop,以从 props 对象中提取 name 属性。
接受道具
./src/pages/Home.jsx
... ...
export default function Home({name}) { //Destructuring the name property from the props object, another approach would be: Home(props.name)
console.log("Rendering: Home");
return (
<div className="flex flex-col bg-white m-auto p-auto">
<h1 className="flex py-5 lg:px-20 md:px-10 mx-5 font-bold text-2xl text-gray-800">
Welcome {name}
</h1>
... ...
专业提示
打开浏览器的控制台,并注意当状态更改时所有使用“name”属性的组件如何重新渲染。当操作状态变量时,React 将存储下一个状态,使用新值再次渲染组件,并更新 UI。
useState 的缺点
道具-钻孔
道具钻取是一个术语,指的是组件的层次结构,其中一组组件需要父组件提供的某些道具,缺乏经验的开发人员通常使用的常见解决方法是将这些道具传递到整个组件链,此问题方法是,任何这些 props 的更改都会触发整个组件链重新渲染,由于这些不必要的渲染(不需要这些 props 的组件链中间的组件),有效地减慢了整个应用程序的速度充当传递道具的媒介。
示例:
-
使用 useState() 挂钩在主应用程序组件中定义状态变量
-
名称属性已传递给导航栏组件
-
导航栏中接受相同的道具,并再次作为道具传递给 UserDropdown 组件
-
UserDropdown 是接受该 prop 的最后一个子元素。
./src/App.jsx
... ...
function App() {
const [name, setName] = useState(test);
console.log("Rendering: App");
return (
<BrowserRouter>
<div className="h-screen">
<Navbar name={name} />
<main className="px-4">
... ...
./src/components/Navbar.jsx
import React from "react";
import Logo from "../assets/cla.svg";
import { BiSearchAlt } from "react-icons/bi";
import { Link } from "react-router-dom";
import UserDropdown from "./UserDropdown";
export default function Navbar({ name }) {
console.log("Rendering: Navbar");
return (
<>
<div className="navbar bg-base-100 drop-shadow-sm">
<div className="flex-1">
<Link
to="/"
className="btn btn-ghost normal-case text-md md:text-xl px-2 gap-1"
>
<img src={Logo} className="h-6" alt="" />
Resources
</Link>
</div>
<UserDropdown name={name} />
</div>
</>
);
}
想象一下,维护一个不同层组件都传递和呈现相同状态的 React 应用程序是多么困难。
复杂性和代码质量不断提高
使用 useState 和 props 作为 React 应用程序中状态管理的唯一手段,代码库的复杂性会迅速增加,必须管理数十或数百个状态变量,这些变量可以彼此重复,分散在不同的文件中,并且组件可能非常令人畏惧,对给定状态变量的任何更改都需要仔细考虑组件之间的依赖关系,以避免在已经很慢的应用程序中出现任何潜在的额外重新渲染。
第二种方法:Context API
Context API 是 React 试图解决仅使用 props 和 useState 进行状态管理的缺点的尝试,特别是,Context API 是对前面提到的需要将 props 传递到整个组件树的问题的答案。通过使用上下文,您可以为您认为全局的数据定义一个状态,并从组件树中的任何点访问其状态:不再需要钻取属性。
需要指出的是,context API 最初的构想是为了解决全局数据共享的问题,对于需要共享的其他类型的数据,例如 UI 主题、身份验证信息(我们的用例、语言等)等数据在多个组件中,但不一定对所有应用程序都是全局的,上下文可能不是最佳选择,具体取决于用例,您可能会考虑其他技术,例如组件组合 函数,将返回值分配给接下来将导出的新变量 Auth:
./src/contexts/Auth.jsx
import { createContext } from "react";
export const Auth = createContext();
提供身份验证上下文
现在我们需要公开我们之前创建的“Auth”上下文变量,为了实现这一点,我们使用上下文提供程序,上下文提供程序是一个具有“value”属性的组件,我们可以使用此属性来共享一个值 - 名称– 在整个组件树中,通过使用提供程序组件包装组件树,我们可以从组件树内的任何位置访问该值,而无需将该 prop 单独传递给每个子组件。
在我们的身份验证示例中,我们需要一个变量来保存用户对象,以及某种 setter 来操作该状态变量,这是 useState 的完美用例。使用 Context 时,您需要确保定义要提供的数据,并将该数据(在我们的示例中为 user)传递给嵌套在其中的所有组件树,因此在提供程序组件中定义一个新的状态变量 user 并最后,我们将 user 和 setUser 传递到一个数组中,作为提供者将公开给组件树的值:
./src/contexts/Auth.jsx
import { createContext, useState } from "react";
export const Auth = createContext();
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
return <Auth.Provider value={[user, setUser]}>{children}</Auth.Provider>;
};
我们可以做的另一件事是将我们的“name”状态变量从主应用程序组件移动到 Auth 上下文,并将其公开给嵌套组件:
import { createContext, useState } from "react";
export const Auth = createContext();
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [name, setName] = useState("CLA");
return <Auth.Provider value={[name, setName]}>{children}</Auth.Provider>;
};
现在剩下的就是将我们的应用程序嵌套到我们刚刚导出的同一个 AuthProvider 组件中。
./src/main.jsx:
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { AuthProvider } from "./contexts/Auth";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<AuthProvider>
<App />
</AuthProvider>
);
因为我们在 Auth.Provider 内渲染 Children 属性,所以嵌套在 AuthProvider 组件内的所有元素现在都可以使用我们传递给 Auth.Provider 的 value 属性。这可能看起来令人困惑,但是一旦您尝试过它,请尝试提供和使用全局上下文状态。毕竟,只有在尝试了 Context API 后,它才对我有意义。
使用身份验证上下文
最后一步很简单,我们使用上下文钩子“useContext”来访问“Auth”的上下文提供者提供的值,在我们的例子中是包含 user 和 setUser 的数组,在下面的代码中,我们能够使用 useContext 来使用导航栏中的身份验证上下文。这是可能的,因为 Navbar 嵌套在 App 组件内,并且由于 AuthProvider 包装了 App 组件,因此只能使用 useContext 钩子使用 value 属性。 React 提供了另一个很棒的开箱即用的工具来管理可以全局访问并且可以由任何消费者组件操作的任何数据。
./src/components/Navbar.jsx
export default function Navbar() {
const [name, setName] = useContext(Auth);
console.log(name)
return (...)};
请注意,我们在 Navbar() 功能组件中不再接受任何 props。我们使用 useContext(Auth) 来使用 Auth 上下文,同时获取 name 和 setName。这也意味着我们不再需要将 prop 传递给导航栏:
./src/App.jsx
// ... //
return (
<BrowserRouter>
<div className="h-screen">
<Navbar/> // no need to pass prop anymore
// ... //
更新身份验证上下文
我们还可以使用提供的 setName 函数来操作“name”状态变量:
./src/pages/EditUser.jsx
export default function EditUser() { // no need to accept props anymore
const [name, setName] = useContext(Auth); // grabbing the name and setName variables from Auth context
console.log("Rendering: EditUser");
引入 useReducer() 钩子
在前面的示例中,我们使用上下文 API 来管理和共享组件树中的状态,您可能已经注意到我们仍然使用 useState 作为逻辑的基础,这基本上没问题,因为我们的状态仍然是一个简单的对象此时,但如果我们要扩展身份验证流程的功能,我们肯定需要存储的不仅仅是当前登录用户的电子邮件,这就是我们回到之前涉及的限制的地方使用 useState 进行复杂状态,幸运的是,React 通过提供 useState 的替代方法来管理复杂状态解决了这个问题:使用 useReducer。
useReducer 可以被认为是 useState 的通用版本,它有两个参数:reducer 函数和初始状态。
对于初始状态,没有什么值得注意的,神奇的事情发生在reducer函数内部:它检查发生的操作的类型,并且根据该操作,reducer将确定要应用于状态的更新并返回其新值。
查看下面的代码,reducer 函数有两种可能的操作类型:
-
“登录”:在这种情况下,用户状态将使用操作有效负载内提供的新用户凭据进行更新。
-
“LOGOUT”:在这种情况下,用户状态将从本地存储中删除并设置回空。
需要注意的是,操作对象包含一个类型字段(用于确定要应用的逻辑)和一个可选的有效负载字段(用于提供应用该逻辑所需的数据)。
最后,useReducer 钩子返回当前状态和一个调度函数,我们用它来将操作传递给减速器。
对于其余逻辑,它与前面的示例相同:
./src/contexts/Auth.jsx
import { createContext, useEffect, useReducer, useState } from "react";
export const Auth = createContext();
import { createContext, useReducer } from "react";
export const Auth = createContext();
const reducer = (state, action) => {
switch (action.type) {
case "LOGIN":
return { user: action.payload };
case "LOGOUT":
localStorage.removeItem("user");
return { user: null };
default:
break;
}
};
export const AuthProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, {
user: null,
});
return (
<Auth.Provider value={{ ...state, dispatch }}>{children}</Auth.Provider>
);
};
调度操作而不是使用 setState setter 函数 – 例如:setName –
正如我们刚才提到的,我们使用调度函数将操作传递给减速器,在下面的代码中,我们启动登录操作并提供用户电子邮件作为有效负载,现在用户状态将得到更新,并且此更改将触发重新渲染订阅用户状态的所有组件。需要指出的是,只有当状态发生实际变化时才会触发重新渲染,如果减速器返回相同的先前状态,则不会重新渲染。
export default function Login() {
const { dispatch } = useContext(Auth);
const handleLogin = async (e) => {
// Updating the global Auth context
dispatch({ type: "LOGIN", payload: {email: email.current.value} });
};
return (...)};
用户登录
专业提示
请注意成功登录后我们收到的用户对象现在如何存储在 localStorage 中。
用于登录的自定义挂钩
现在我们已经很好地掌握了 useReducer,我们可以进一步将登录和注销逻辑封装到它们自己单独的自定义钩子中,通过对登录钩子的单个调用,我们可以处理对登录路由的 API 调用,检索新用户凭据并将其存储在本地存储中,调度 LOGIN 调用来更新用户状态,同时处理错误处理:
./src/hooks/useLogin.jsx
import { useContext, useState } from "react";
import { Auth } from "../contexts/Auth";
export const useLogin = () => {
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const { dispatch } = useContext(Auth);
const login = async (email, password) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/users/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const json = await response.json();
if (json.name === "Error") {
setError(json.message);
setIsLoading(false);
}
if (!response.ok) {
setIsLoading(false);
setError(json);
}
if (response.ok) {
// Save the user and token in the localstorage
localStorage.setItem("user", JSON.stringify(json));
// Updating the global Auth context
dispatch({ type: "LOGIN", payload: json });
setIsLoading(false);
}
} catch (error) {
console.log(error);
}
};
return { error, isLoading, login };
};
注意:对于读者中更高级的 React 用户,您可能想知道为什么我们不使用 useReducer 的延迟初始化功能来检索用户凭据,useReducer 接受第三个称为 init 函数的可选参数,该函数用于以防万一在获得状态的初始值之前,我们需要应用一些逻辑,我们没有选择这样做的原因是一个简单的关注点分离问题,这种方式的代码更容易理解,因此更容易维护。
登录页面
以下是使用 useLogin() 挂钩提取登录函数并使用用户提交的凭据调用登录函数后登录页面顶部的样子:
// ... ... //
export default function Login() {
const { login, isLoading, error } = useLogin();
console.log("Rendering: Login");
const email = createRef(null);
const password = createRef(null);
const handleLogin = async (e) => {
await login(email.current.value, password.current.value);
};
return (...)
// ... ... //
当用户提交表单时,我们还会禁用提交功能:
<button
onClick={handleLogin}
disabled={isLoading}
className="btn btn-square w-full bg-gray-100 text-gray-600 hover:bg-gray-300 border-none"
>
{isLoading && "A moment please!"}
{!isLoading && "Login"}
</button>
并呈现我们从后端收到的任何身份验证错误:
{
error && <span className="text-red-500 p-2">{error.message}</span>;
}
维护用户状态
您可能想知道为什么我们需要将用户对象存储在 localStorage 中,简单地说,我们希望只要令牌未过期就保持用户登录状态。使用 localStorage 是存储 JSON 位的好方法,就像我们的示例一样。请注意,如果登录后刷新页面,状态会被清除。这可以轻松解决,使用 useEffect 挂钩来检查 localStorage 中是否有存储的用户对象,如果有,我们会自动登录用户:
// ... ... //
export const AuthProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, {
user: null,
});
useEffect(() => {
const user = JSON.parse(localStorage.getItem("user"));
if (user) {
return dispatch({ type: "LOGIN", payload: user });
}
}, []);
return (
<Auth.Provider value={{ ...state, dispatch }}>{children}</Auth.Provider>
);
};
注销的自定义挂钩
同样的情况也适用于 Logout 钩子,这里我们调度一个 LOGOUT 操作以从状态和本地存储中删除当前用户凭据:
./src/hooks/useLogout.jsx
import { useContext } from "react";
import { Auth } from "../contexts/Auth";
export const useLogout = () => {
const { dispatch } = useContext(Auth);
const logout = () => {
// delete user from the localstorage
localStorage.removeItem("user");
// Wipe out the Auth context (user:null) / dipatch 'LOGOUT'
dispatch({ type: "LOGOUT" });
};
return { logout };
};
用户注销
要注销用户,让我们向 UserDropdown.jsx 中的注销按钮添加一个单击事件,并进行相应的处理:
./src/components/UserDropdown.jsx
// Extracting the logout function from useLogout() and handling the click event listener //
export default function UserDropdown() {
const { user } = useContext(Auth);
const { logout } = useLogout();
console.log("Rendering: UserDropdown");
const handleLogout = () => {
logout();
};
// ... ... //
// Adding a click event listener to logout button //
<li>
<button onClick={handleLogout}>Logout</button>
</li>
// ... ... //
保护 React 路由
实现我们的应用程序的最后一步是利用全局用户状态来控制用户导航,快速提醒我们应该实现什么行为:最初用户会看到登录页面,从那时起用户只能访问主页成功登录后,同样,用户注销时将被重定向到登录页面。
我们在react-router-dom库的帮助下通过定义2个路由来实现这一点:“/”和“/login”,我们使用全局身份验证状态控制在每个路由上渲染哪个组件, auth 计算为 null 表示未经身份验证的用户,反之亦然:
./src/App.jsx
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import "./App.css";
import Navbar from "./components/Navbar";
import Home from "./pages/Home";
import Login from "./pages/Login";
import { useContext } from "react";
import { Auth } from "./contexts/Auth";
function App() {
const { user } = useContext(Auth);
return (
<BrowserRouter>
<div className="h-screen">
<Navbar />
<main className="px-4">
<Routes>
<Route
path="/"
element={user ? <Home /> : <Navigate to="/login" />}
/>
<Route
path="/login"
element={!user ? <Login /> : <Navigate to="/" />}
/>
</Routes>
</main>
</div>
</BrowserRouter>
);
}
export default App;
回顾图
## 包起来
在本文中,我们尝试解决为身份验证工作流程实施状态管理的简单但非常常见的用例,介绍不同的方法、每种方法背后的基本原理及其权衡。客户端框架中的状态管理,尤其是 React 中的状态管理是前端社区中讨论最多的主题之一,因为它可以决定或破坏应用程序的性能和可扩展性。试图解决状态管理问题的不同技术、模式、库和工具的数量是巨大的,我们的目标是让您对实践有一个扎实的理解,以便您可以在自己的应用程序中实现它,以获得更高级的效果。更复杂的应用程序中状态管理的技术和模式,请查看我们的下一篇文章,其中我们将介绍 React 中可扩展状态管理的 redux。
## 即将推出
ContextAPI 与 Redux 工具包
在 Redux 工具包出现之前,Redux 已经在社区中得到了多年的采用,事实上,RTK 是作为入门模板引入的,用于在新应用程序中引导 redux 状态管理(其最初名称为“redux-starter-kit”,于 2019 年 10 月),尽管今天,Redux 维护者和社区之间达成了普遍共识,即 Redux 工具包是使用 redux 的有效方式。RTX 抽象了很多 Redux 逻辑,这使得它更容易使用,而且更简洁,并鼓励开发人员遵循最佳实践,两者之间的一个主要区别是 Redux 的构建是不拘一格的,提供最少的 API,并期望开发人员通过编写自己的常见任务库和处理代码结构来完成大部分繁重的工作,这导致了开发时间缓慢和代码混乱,Redux 工具包被添加为额外的抽象层,防止开发人员陷入其常见陷阱,请参阅官方文档以获取更多见解及其维护者的推理[此处](https:// /redux.js.org/introduction/why-rtk-is-redux-today)。