Back to blog
May 20, 2024
4 min read

梳理当前项目的路由系统(三),代码梳理

梳理一个完整项目的整体路由系统是相对复杂的,我们将深入代码,进行演示

路由架构的主入口

import { lazy } from 'react';
import { Navigate, RouteObject, RouterProvider, createHashRouter } from 'react-router-dom';

import DashboardLayout from '@/layouts/dashboard';
import AuthGuard from '@/router/components/auth-guard';
import { usePermissionRoutes } from '@/router/hooks';
import { ErrorRoutes } from '@/router/routes/error-routes';

import { AppRouteObject } from '#/router';

const { VITE_APP_HOMEPAGE: HOMEPAGE } = import.meta.env;
const LoginRoute: AppRouteObject = {
  path: '/login',
  Component: lazy(() => import('@/pages/sys/login/Login')),
};
const PAGE_NOT_FOUND_ROUTE: AppRouteObject = {
  path: '*',
  element: <Navigate to="/404" replace />,
};

export default function Router() {
  const permissionRoutes = usePermissionRoutes();
  const asyncRoutes: AppRouteObject = {
    path: '/',
    element: (
      <AuthGuard>
        <DashboardLayout />
      </AuthGuard>
    ),
    children: [{ index: true, element: <Navigate to={HOMEPAGE} replace /> }, ...permissionRoutes],
  };

  const routes = [LoginRoute, asyncRoutes, ErrorRoutes, PAGE_NOT_FOUND_ROUTE];

  const router = createHashRouter(routes as unknown as RouteObject[]);

  return <RouterProvider router={router} />;
}

在当前文件中,我们第一眼就会发现一个典型的路由器定义和设置的代码:RouterProvidercreateHashRouterroutes参数明显是负责配置路由规则、指定路径对应组件的,那么分别来看一下路径集合中的各个函数和对应分别是什么

LoginRoute

// LoginRoute
const LoginRoute: AppRouteObject = {
  path: '/login',
  Component: lazy(() => import('@/pages/sys/login/Login'))
}
  1. 当路由命中 /login 时,因为 Component 属性使用了 React.lazy 进行懒加载,React 会在后台开始加载 Login 组件。
  2. 一旦组件被加载完毕,它将自动显示在应用中。这是因为 React Router 在匹配到路由后,会将对应的 Component 渲染到 Outlet 组件中(或者直接渲染到路由定义的位置)。

element 元素、内容

用于直接渲染 React 元素( element?: React.ReactNode | null ) 特别适用于:在定义路由的时候,直接传递 JSX 元素 是一个具体的 React 元素,必须是 <Component /> 的形式 直接渲染内容,适合简单路由配置

Component 组件

用于指定一个 React 组件类型(组件函数或类组件)(Component?: React.ComponentType | null),而不是一个 React 元素实例 特别适用于:更加灵活、用于动态加载组件、SSR或代码分割的场景 是一个React组件类型,指向组件本身,而不是实例化后的元素 支持懒加载、SSR等复杂用例,可以结合 React.lazySuspense 实现代码分割,适用于需要动态渲染不同组件的场景

因为当前,路由挑战的是登录页,因此就使用最简单的 Component 即可。lazy、Suspense、element 这三个的结合场景更适合在同一个大模块下去基于用户体验考虑在添加,在当前跳转到登录页的情况,因为登录页是属于独立页面,所以不需要 占位符 的存在

// asyncRoutes
const permissionRoutes = usePermissionRoutes();
const asyncRoutes: AppRouteObject = {
 path: '/',
 element: (
   <AuthGuard>
     <DashboardLayout />
   </AuthGuard>
 ),
 children: [{ index: true, element: <Navigate to={HOMEPAGE} replace /> }, ...permissionRoutes],
};

这代码一看也同样是一个路由配置逻辑,而且是一个嵌套路由,命中 / 时,

// ErrorRoutes
// PAGE_NOT_FOUND_ROUTE

AuthGuard组件的作用? 这个组件在路由中是中间件的形式,如何去理解?

主要作用:用户认证检查和错误边界处理

import { useCallback, useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

import PageError from '@/pages/sys/error/PageError';
import { useUserToken } from '@/store/userStore';

import { useRouter } from '../hooks';

type Props = {
  children: React.ReactNode;
};
export default function AuthGuard({ children }: Props) {
  const router = useRouter();
  const { accessToken } = useUserToken();

  const check = useCallback(() => {
    if (!accessToken) {
      router.replace('/login');
    }
  }, [router, accessToken]);

  useEffect(() => {
    check();
  }, [check]);

  return <ErrorBoundary FallbackComponent={PageError}>{children}</ErrorBoundary>;
}

用户认证检查: 确保用户在访问受保护的路由时已经登录。如果用户未登录,立即重定向到登录页面。这是一种常见的认证保护模式

  1. useUserToken Hook 用于获取用户的访问令牌(accessToken)。
  2. check 函数使用 useCallback 包装,检查 accessToken 是否存在。 如果 accessToken 不存在,用户被重定向到登录页面 /login。
  3. useEffect 在组件挂载时调用 check 函数,确保每次组件挂载时都进行认证检查。

错误边界处理: 为了提高应用的健壮性和用户体验,添加错误边界是一种最佳实践。尽管子组件发生错误的概率可能很低,但这种防护措施能确保即使发生错误,应用也能优雅地处理并显示友好的错误页面,而不是让整个应用崩溃或显示空白页面。

  1. 使用 react-error-boundary 提供的 ErrorBoundary 组件,包裹传入的子组件(children)。
  2. 如果子组件中发生错误,ErrorBoundary 将捕获错误并渲染备用 UI 组件 PageError。

children

children 是一个特殊的 props 属性,表示嵌套在组件内部的子节点。React 组件可以通过 props.children 访问这些嵌套的子元素,并进行渲染或其他操作。

import React from 'react';

function Wrapper({ children }) {
  // 这里的children,对应着:
  //  <p>This is a child element</p>
  //  <button>Click me</button>
  return <div className="wrapper">{children}</div>;
}

export default Wrapper;
import React from 'react';
import Wrapper from './Wrapper';

function App() {
  return (
    <Wrapper>
      <p>This is a child element</p>
      <button>Click me</button>
    </Wrapper>
  );
}

export default App;

react-error-boundary是什么?

react-error-boundary 是一个用于处理 React 应用中错误边界的第三方库。它旨在提供更方便和灵活的方式来捕获并处理 React 组件树中的错误。 在函数式组件中,其主要用于以下场景

  • 渲染错误:当组件在渲染过程中抛出错误时,例如 JSX 中的表达式计算出错。
  • 副作用错误:通过 useEffect、useLayoutEffect 等 Hook 执行副作用时抛出的错误。

<ErrorBoundary FallbackComponent={PageError}>{children}</ErrorBoundary>;

  • ErrorBoundary 用于包裹可能抛出错误的子组件,在错误发生时显示 FallbackComponent。
  • PageError 用于在捕获到错误时显示备用 UI,包括错误信息等等

当前代码待优化的点

  1. router.replace('/login'); 这行代码 router 是一个自定义的Hook,但就当前的逻辑上看,这块只需要一个简单的跳转即可,将抽象的代码提取出来直接展示更合理 navigate('/login', { replace: true }); 因为 只要是对React相对熟悉的开发者,都对 navigate 很熟悉,而且router自定义hook的replace方法中并没有封装很复杂的逻辑在内部,它只是最简单的函数包裹 replace: (href: string) => navigate(href, { replace: true })

  2. :基于分离职责的考虑,为了让代码更清晰和符合整体概念,建议将路由守卫和错误边界的逻辑分开处理

    1. 路由守卫的职责:用户认证、权限控制、其他路由相关的逻辑操作
    2. 错误边界的职责(ErrorBoundary):捕获渲染错误、显示备用UI
    3. 这两个不是一个概念,将这块逻辑换到最顶层独立处理

第二个问题,触及了一个重要的前端架构设计问题,该如何设计和组织应用中的错误处理机制。 是已进行集中的错误处理,还是进行分散错误处理。从实际项目中看,分散处理错误更合理,它们可以根据错误类型在各自的上下文中处理,减少代码的耦合度,增强维护性和可读性。 例如:

  1. React 组件的渲染错误:使用错误边界(Error Boundary)在最顶层处理。
  2. 路由相关的错误:在路由守卫中处理。
  3. 接口请求的错误:在接口请求的模块中处理。

如何去理解 <AuthGuard></AuthGuard> 的使用

我们可以将 AuthGuard 类比为一个 JavaScript 函数,它接受另一个函数(dashboardLayout)作为参数

function authGuard(dashboardLayout) {
  return function() {
    // 用户认证检查
    if (!isUserAuthenticated()) {
      // 跳转到登录页 ...
      return;
    }

    // 错误处理
    try {
      dashboardLayout();
    } catch (error) {
      // 错误处理 ...
    }
  };
}

function dashboardLayout() {
  console.log("Rendering Dashboard Layout");
}

// 使用 authGuard 包裹 dashboardLayout
const protectedDashboardLayout = authGuard(dashboardLayout);

// 调用受保护的函数
protectedDashboardLayout();

protectedDashboardLayout 是一个经过 authGuard 包装的函数,它在执行 dashboardLayout 之前进行用户认证检查和错误处理。更高层的封装就是装饰器 也就是说,在调用 dashboardLayout 之前要经过一些前置验证比如:检查用户是否已登录。如果未登录,重定向到登录页面。

在 React 中, AuthGuard 的作用类似于上面的 authGuard 函数,但通过 JSX 实现。 AuthGuard组件执行的时候,处理用户未登录情况 和 错误处理,这两步都通过之后,通过React提供的 children 功能,将AuthGuard内部嵌套的组件都传递到 AuthGuard内部。这功能柜在概念上和函数嵌套一样。方便大家做类比了解

asyncRoutes规则

  const asyncRoutes: AppRouteObject = {
    path: '/',
    element: (
      <AuthGuard>
        <DashboardLayout />
      </AuthGuard>
    ),
    children: [{ index: true, element: <Navigate to={HOMEPAGE} replace /> }],
  };

规则解析如下:

  1. 当路径为 / 时,该路由规则会被命中。
  2. 在路径匹配时,渲染 element 中定义的组件。这包含了 AuthGuard 和 DashboardLayout。
    1. AuthGuard 检查用户认证和处理错误。
    2. 如果通过了 AuthGuard 的检查,会渲染 DashboardLayout 组件。
  3. 嵌套路由
    1. children 属性: 定义了嵌套路由。定义了一个默认选项,当命中根路径时,默认跳转到 HOMEPAGE
    2. index: true: 指定这是一个索引路由,当父路径命中但没有指定子路径时,这个索引路由会被匹配。
    3. <Navigate to={HOMEPAGE} replace />: 使用 Navigate 组件自动重定向到 HOMEPAGE,并替换当前的浏览历史记录。

套路由和Outlet组件

嵌套路由和Outlet组件:当父路由匹配时,element 中的组件会被渲染,Outlet 作为子路由组件的占位符,等待子路由的匹配并渲染相应的子路由组件。

什么是索引路由?( index: true )

索引路由(index: true)是在定义嵌套路由时,用于指定当父路径命中但没有指定具体子路径时,应该渲染的默认组件。 索引路由存在的目的是什么?:是为了让层次结构更加清晰和易于管理。虽然理论上你可以将默认组件直接添加到父路由的 element 中,但使用索引路由可以更好地组织路由结构,使默认组件和其他子路由组件在结构上更加明确和一致。

  1. 清晰的层次结构:使用索引路由可以清晰地表明哪些组件是默认显示的,哪些是根据路径变化显示的子组件。这样可以更直观地看到路由层次结构。
  2. 一致的处理方式:将所有子路由(包括默认子路由)放在 children 数组中,保持了路由配置的一致性和可读性。无论是默认组件还是其他子组件,都在同一个层次结构中定义,避免了混乱。
  3. 灵活性:索引路由允许你更灵活地定义和管理默认组件。如果你需要更改默认组件的行为或路径,只需要修改索引路由的配置,而不需要修改父路由的 element。

element 成为这些子路由的公共组件部分

element 和公共组件部分

  1. element: 在路由配置中,element 属性定义了匹配该路径时要渲染的组件。这通常是一个布局组件,包含了页面的头部、导航、页脚等公共部分。
  2. 公共组件部分: 这些公共部分对于该路径下的所有子路由都是共享的,只有在子路由部分需要变化时,才通过 Outlet 来渲染不同的子路由组件。

Outlet 的占位符作用

  1. Outlet: Outlet 是一个特殊的组件,用于渲染当前路径匹配的子路由组件。当路由匹配到某个路径时,Outlet 会作为占位符,将匹配的子路由组件注入到 Outlet 的位置。