发布于 2026-01-06 5 阅读
0

使用自定义 Hook 和 Context API 在 React 中实现受保护路由

使用自定义 Hook 和 Context API 在 React 中实现受保护路由

TLDR

自定义受保护路由组件 + 自定义 Hook + React Context API = 受保护路由 ❤️

GitHub 代码库:https://github.com/edmondso006/react-protected-routes

我们经常需要根据用户是否登录来限制其可见内容。例如,对于未登录用户,隐藏不包含任何数据的个人资料页面比显示该页面能提供更好的用户体验。虽然限制用户权限的大部分逻辑应该在服务器端实现,但我们仍然需要一种方法来隐藏前端页面。本教程假设您已经实现了相应的服务器端代码。

前端身份验证梗

在 React 中,如何将已认证的页面/资源隐藏在受保护的路由之后?

受保护的路线来救援!

受保护的路由或私有路由是指只有经过授权(登录、拥有适当的帐户权限等)的用户才能访问的路由。

使用路由配置 React

我们将使用react-router-dom它来创建路由,这些路由会渲染不同的“页面”(React 创建的是单页应用,所以每个页面实际上只是一个渲染的组件)。请确保在您的项目中安装它。

npm i react-router-dom 
Enter fullscreen mode Exit fullscreen mode

在本教程中,我们将使用 3 个不同的页面:

Home - Public Page (Do not have to be authenticated to view it)
Profile - Protected Page (Have to be authenticated to view it)
About - Public Page (Do not have to be authenticated to view it)
Enter fullscreen mode Exit fullscreen mode

我们需要将该BrowserRouter组件添加到应用程序的主入口文件中。

// index.tsx or index.js
import reportWebVitals from "./reportWebVitals";
import { BrowserRouter } from "react-router-dom";

ReactDOM.render(
   <React.StrictMode>
      <BrowserRouter>
            <App />
      </BrowserRouter>
   </React.StrictMode>,
   document.getElementById("root")
);
Enter fullscreen mode Exit fullscreen mode

我们再创建一个Navbar组件,以便能够跳转到其他页面:

import React from "react";
import { Link } from "react-router-dom";

function Navbar() {
   return (
      <div>
         <Link to={"/"}>Home (Public)</Link>
         <Link to={"/about"}> About (Public) </Link>
         <Link to={"/profile"}>Profile (Protected)</Link>
      </div>
   );
}

export default Navbar; 

Enter fullscreen mode Exit fullscreen mode

之后,我们需要在App.tsx文件中设置路由。

// App.tsx or App.js
import React from "react";
import "./App.css";
import { Switch, Route } from "react-router-dom";

import Navbar from "./components/Navbar";
import Home from "./Pages/Home";
import Profile from "./Pages/Profile";
import About from "./Pages/About";

function App() {

   return (
      <div className="App">
                 <Navbar />

         <Switch>
            <Route path="/" exact component={Home} />
            <Route path="/about" exact component={About} />
            <Route path="/profile" exact component={Profile} />
         </Switch>
      </div>
   );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

现在运行我们的应用,可以看到导航功能正常!现在我们只需要知道用户是否已通过身份验证。

使用 React Context API 创建自定义身份验证钩子

为了跟踪用户是否已通过身份验证,我们可以结合 React 上下文 API 创建一个自定义钩子。这样,无论用户在应用程序的哪个位置,我们都能知道他们是否已通过身份验证。

让我们创建一个名为 `.txt` 的新文件useAuth.tsx,并添加以下代码:

// /src/hooks/useAuth.tsx
import React, { useState, createContext, useContext, useEffect } from "react";

// Create the context 
const AuthContext = createContext(null);

export const AuthProvider = ({ children }) => {

     // Using the useState hook to keep track of the value authed (if a 
   // user is logged in)
   const [authed, setAuthed] = useState<boolean>(false);

   const login = async (): Promise<void> => {
      const result = await fakeAsyncLogin();

      if (result) {
         console.log("user has logged in");

         setAuthed(true);
      }
   };

   const logout = async (): Promise<void> => {
      const result = await fakeAsyncLogout();

      if (result) {
         console.log("The User has logged out");
         setAuthed(false);
      }
   };

   /// Mock Async Login API call.
   // TODO: Replace with your actual login API Call code
   const fakeAsyncLogin = async (): Promise<string> => {
      return new Promise((resolve, reject) => {
         setTimeout(() => {
            resolve("Logged In");
         }, 300);
      });
   };

   // Mock Async Logout API call.
   // TODO: Replace with your actual logout API Call code
   const fakeAsyncLogout = async (): Promise<string> => {
      return new Promise((resolve, reject) => {
         setTimeout(() => {
            resolve("The user has successfully logged on the server");
         }, 300);
      });
   };

   return (
            // Using the provider so that ANY component in our application can 
            // use the values that we are sending.
      <AuthContext.Provider value={{ authed, setAuthed, login, logout }}>
         {children}
      </AuthContext.Provider>
   );
};

// Finally creating the custom hook 
export const useAuth = () => useContext(AuthContext);
Enter fullscreen mode Exit fullscreen mode

现在我们需要确保将这个新AuthProvider组件添加到根入口点文件中,就像之前添加其他BrowserRoute组件一样。这样,树中的所有子组件才能看到我们之前指定的值。

// index.tsx or index.js
import { BrowserRouter } from "react-router-dom";

import { AuthProvider } from "./hooks/useAuth";

ReactDOM.render(
   <React.StrictMode>
      <BrowserRouter>
         <AuthProvider>
            <App />
         </AuthProvider>
      </BrowserRouter>
   </React.StrictMode>,
   document.getElementById("root")
);
Enter fullscreen mode Exit fullscreen mode

让我们来试用一下这个新的钩子。我创建了一个非常基础LoginLogout组件,如下所示:

// Login.tsx
import React from "react";
import { useAuth } from "../hooks/useAuth";

function Login() {
   // Destructing our hook to get the `login` function 
   const { login } = useAuth();

   return (
      <div>
         <button onClick={login}>Login</button>
      </div>
   );
}

export default Login;
Enter fullscreen mode Exit fullscreen mode
// Logout.tsx
import React from "react";
import { useAuth } from "../hooks/useAuth";

function Logout() {
   // Destructing our hook to get the `logout` function 
   const { logout } = useAuth();

   return <button onClick={logout}>Logout</button>;
}

export default Logout;
Enter fullscreen mode Exit fullscreen mode

当我们点击该Login按钮时,我们会发起一个模拟登录 API 调用,并将登录按钮的状态设置authed为 true;而注销按钮的状态则相反。是不是很酷?

登录注销实现

现在我们需要创建一个受保护的路由组件来使用我们新设计的钩子。

创建受保护的路由组件

遗憾的是,react-router-dom它没有提供现成的<ProtectedRoute>组件。但这并不会阻止我们自己创建一个。这个组件主要会检查钩子函数authed中的值useAuth。如果用户已通过身份验证,则渲染受保护的页面;如果用户未通过身份验证,则重定向回公共页面。

// ProtectedRoute.tsx 
import React from "react";
import { Route, Redirect } from "react-router-dom";
import { useAuth } from "./../hooks/useAuth";

// We are taking in the component that should be rendered if the user is authed
// We are also passing the rest of the props to the <Route /> component such as
// exact & the path
const ProtectedRoute = ({ component: Component, ...rest }) => {
     // Getting the value from our cool custom hook
   const { authed } = useAuth();

   return (
      <Route
         {...rest}
         render={(props) => {
                        // If the user is authed render the component
            if (authed) {
               return <Component {...rest} {...props} />;
            } else {
                             // If they are not then we need to redirect to a public page
               return (
                  <Redirect
                     to={{
                        pathname: "/",
                        state: {
                           from: props.location,
                        },
                     }}
                  />
               );
            }
         }}
      />
   );
};

export default ProtectedRoute;
Enter fullscreen mode Exit fullscreen mode

现在我们可以使用此受保护路由,并替换受保护页面的常规路由组件!

// App.tsx
import Login from "./components/Login";
import Logout from "./components/Logout";
import Navbar from "./components/Navbar";
import Home from "./Pages/Home";
import Profile from "./Pages/Profile";
import ProtectedRoute from "./components/ProtectedRoute";
import { useAuth } from "./hooks/useAuth";
import About from "./Pages/About";

function App() {
   const { authed } = useAuth();

   return (
      <div className="App">
         <Navbar />
         {authed ? <Logout /> : <Login />}

         <div style={{ margin: "20px" }}>
            <span>Auth Status: {authed ? "Logged In" : "Not Logged In"}</span>
         </div>

         <Switch>
            <Route path="/" exact component={Home} />
            <Route path="/about" exact component={About} />
            <ProtectedRoute path="/profile" exact component={Profile} />
         </Switch>
      </div>
   );
}
Enter fullscreen mode Exit fullscreen mode

刷新错误 GIF

如上图所示,目前运行正常。但是存在一个bug。当用户在受保护的路由上刷新页面时,会被重定向回原/页面。我们该如何解决这个问题?

刷新错误 - 持久化身份验证状态

这个 bug 出现的原因是,authed当用户刷新页面时,我们丢失了一个值。由于这个值falseuseAuth钩子函数中被设置为默认值,所以会触发重定向逻辑,将用户重定向回/页面。我们可以通过几种方式来解决这个问题。

曲奇饼

如果您的服务器在身份验证后向客户端发送 cookie,您可以使用该 cookie 来验证用户是否已登录。但是,如果您http only在 cookie 中使用了其他选项,则无法实现此目的,因为代码将无法与 cookie 交互。不过不用担心,还有其他两种方法可以实现此目的。

会话存储

我们可以将一个值保存到会话存储中,以便在页面刷新后保持该值不变。但是,精通技术的用户可以进入开发者工具并修改该值。根据您的实现方式,这可能会带来问题。以下是在useAuth钩子函数中实现此功能的方法。

//useAuth.tsx
...
export const AuthProvider = ({ children }) => {
   // Get the value from session sotrage. 
   const sessionStorageValue = JSON.parse(sessionStorage.getItem("loggedIn"));
     // Use this value as the defalt value for the state 
   const [authed, setAuthed] = useState<boolean>(sessionStorageValue);

     const login = async (): Promise<void> => {
      const result = await fakeAsyncLogin();

      if (result) {
         console.log("user has logged in");

         setAuthed(true);
         sessionStorage.setItem("loggedIn", "true");
      }
   };

   const logout = async (): Promise<void> => {
      const result = await fakeAsyncLogout();

      if (result) {
         console.log("The User has logged out");
         setAuthed(false);
         sessionStorage.setItem("loggedIn", "false");
      }
   };
  ... 
Enter fullscreen mode Exit fullscreen mode

会话存储实现 GIF

身份验证端点检查

如果会话存储不适用于您的实现,您可以向服务器的身份验证端点发出 API 调用,以验证当前用户是否已登录。这是最安全的解决方案,但代价是需要额外发出一次 API 调用。以下是实现此解决方案的方法。

// useAuth.tsx
...
export const AuthProvider = ({ children }) => {
   const [authed, setAuthed] = useState<boolean>(false);
     // Store new value to indicate the call has not finished. Default to true
   const [loading, setLoading] = useState<boolean>(true);

   // Runs once when the component first mounts
   useEffect(() => {
         fakeAsyncLoginCheck().then((activeUser) => {
            if (activeUser) {
               console.log("fake async login check called");
               setAuthed(true);
               setLoading(false);
            } else {
                             setAuthed(false);
               setLoading(false);
            }
         });
      }
   }, []);

     // Mock call to an authentication endpoint 
   const fakeAsyncLogin = async (): Promise<string> => {
      return new Promise((resolve, reject) => {
         setTimeout(() => {
            resolve("Logged In");
         }, 300);
      });
   };

return (
      // Expose the new `loading` value so we can consume it in `App.tsx`
      <AuthContext.Provider
         value={{ authed, setAuthed, login, logout, loading }}
      >
         {children}
      </AuthContext.Provider>
   );
...
Enter fullscreen mode Exit fullscreen mode

我们还需要修改文件App.tsx。我们需要使用新值,并且仅当该值为 false 时才渲染路由。这样可以解决用户因值尚未更新而loading被重定向回首页的问题。由于我们在加载完成后才渲染组件,因此可以确保该值准确无误。authed<ProtectedRoute>authed

// App.tsx
function App() {
   const { authed, loading } = useAuth();

   return (
      <div className="App">
         <Navbar />
         {authed ? <Logout /> : <Login />}

         {loading ? (
            <div> Loading... </div>
         ) : (
            <>
               <div style={{ margin: "20px" }}>
                  <span>
                     Auth Status: {authed ? "Logged In" : "Not Logged In"}
                  </span>
               </div>

               <Switch>
                  <Route path="/" exact component={Home} />
                  <Route path="/about" exact component={About} />
                  <ProtectedRoute path="/profile" exact component={Profile} />
               </Switch>
            </>
         )}
      </div>
   );
}
Enter fullscreen mode Exit fullscreen mode

身份验证检查实现

参考

React Router DOM - https://reactrouter.com/web/guides/quick-start

React 自定义 Hooks - https://reactjs.org/docs/hooks-custom.html

React Context API - https://reactjs.org/docs/context.html

就这些了,各位。

如有任何问题或疑问,欢迎随时在推特上联系我@jeff_codes。感谢阅读!

GitHub 代码库:https://github.com/edmondso006/react-protected-routes

本文最初发表于:https://www.jeffedmondson.dev/blog/react-protected-routes/。前往该博客查看更多类似文章。

文章来源:https://dev.to/jeff_codes/protected-routes-in-react-with-custom-hook-context-api-363p