React+ts+vite脚手架搭建(一)【前期准备+路由篇】

1,167 阅读7分钟

前言

不少人在入职之后,便直接投身到已有项目的需求开发工作当中。此时,项目的架子早已搭建完毕,因而无需再去考虑其搭建问题。然而,这也导致我们对项目的一些基础架构知之甚少。要知道,搭建项目架子可是一名前端开发者必须掌握的技能。接下来,我会对搭建架子的流程进行逐步拆解,以此来帮助大家回忆并熟悉一个项目的起始阶段

一个项目的最基础的功能包括:路由、接口、状态管理、mock、规范、登录

image.png

本文将从配置路由开始......

具体步骤

选择一个基础架子

我这里是直接通过vite的命令下一个react+ts的模板作为我们的基础架子,以此基础上进行改造:

下载模板命令:

yarn create vite reactTs --template react-ts

回到对应目录:

cd reactTs

下载项目依赖:

yarn

启动项目:

yarn dev

修改目录结构

下载完模板后,我们还需要对现有的目录结构进行修改,以符合我们的需求;

因为清晰的文件目录结构将会极大的提高后续的开发效率以及开发舒适度;

我将目录结构分为:静态资源区、组件区、常量区、高阶函数区、状态区、页面区、路由区、接口区、样式区、自定义方法区

image.png

单个页面组件的文件夹也可以以此为基础搭建,用来设置自己的相关配置,我这里以home组件为例:

image.png

下载路由依赖

当以上准备就绪后,我们就可以开始正式配置路由;

我们先将路由依赖下载下来:

yarn add react-router-dom

使用HashRouter来管理路由

这里我的项目使用哈希路由,你也可以采用合适的别的路由;

main.tsx文件夹下,用HashRouter组件将App组件包裹起来:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter } from "react-router-dom";
import './style/index.css'
import App from './App'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <HashRouter> 
      <App />
    </HashRouter>
  </StrictMode>,
)

自定义路由配置

将整个组件包裹起来以后,我们可以开始自定义路由的配置了

我这里增加一些自定义的配置,比如是否是菜单项,是否需要路由守卫,还有使用lazy进行懒加载优化

我们在上面文件目录的router下建立一个index.ts文件,用来进行配置:

image.png
import { ComponentType, lazy } from "react";
import { RouteObject } from "react-router-dom";

// 使用交叉类型扩展 RouteObject
export type IRoute = RouteObject & {
    /**
     * 是否是菜单项
     * @default true
     */
    isMenu?: boolean;
    /**
     * 生成菜单的姓名
     */
    name: string;
    component: ComponentType;
    /**
     * 是否触发守卫逻辑
     * @default true
     */
    needGuard?: boolean;
    children?: IRoute[];
};

interface IRouteConfig {
    routes: IRoute[];
}

const routeConfig: IRouteConfig = {
    routes: [
        {
            name: 'home',
            path: "/",
            component: lazy(() => import("../page/home")),
        },
        {
            name: 'other',
            path: "/other",
            component: lazy(() => import("../page/other")),
        },

    ]
}

export default routeConfig;

将自定义配置转成react-router-dom所需要的配置

上述的自定义配置中添加了一些自定义的属性配置,需要再进行一次转换,才能真正使用;

所以我们在自定义配置的同级目录下再建一个文件createRouteConfig.tsx用来处理我们的自定义属性

import React, { Suspense } from "react";
import { RouteObject } from "react-router-dom";
import routeConfig, { IRoute } from ".";

/**
 * 将简单路由配置转化为 react-router-dom 所需要的配置
 * @param routeList
 * @returns
 */
function createRouteConfig(routeList: IRoute[]): RouteObject[] {
  return routeList.map((item) => {
    const Com = item.component;

    return {
      path: item.path,
      element: (
        <Suspense fallback={<p>Loading...</p>}>
          <Com />
        </Suspense>
      ),
      /**
       * 支持覆盖原属性
       */
      ...item,
      children: item.children && createRouteConfig(item.children),
    };
  });
}

export const router = createRouteConfig(routeConfig.routes);

使用router配置

我们在app.tsx文件中使用处理好的配置

import { router } from "./router/createRouteConfig";
import { useRoutes } from "react-router-dom";
import './style/index.css';

const App = function () {
  const elements = useRoutes(router);
  return elements;
};

export default App;

路由监听

在一个项目中,我们通常是需要在路由跳转的时候执行一些特殊的操作,比如:权限控制、数据获取与加载控制、导航控制、埋点等等......

我们这里专门封装一个方法

index.ts的同级目录下添加一个routerListener.ts文件

image.png

文件内容如下:

import type { Location } from "@remix-run/router";
import { NavigateFunction } from "react-router-dom";

/**
 * 全局路由监听
 * * notice: 暂时仅支持同步逻辑,异步逻辑会闪屏,如果想实现异步的话建议封装高阶组件
 * @param next 满足某些条件时支持跳转到指定页面
 * @param to 跳转目标页路由信息
 * @param from 发起跳转页路由信息
 */
const routerListener = (next: NavigateFunction, to: Location, from?: Location) => {
    console.log('from', from, 'to', to);

    // 逻辑处理
    // 权限控制
    // 权限控制
    // 数据获取与加载控制
    // 导航控制埋点
    // ......

}

export default routerListener;

然后我们将这个方法暴露出去,那么该方法的执行时机在哪呢?

路由监听的执行时机

我这里将其放在了app.tsx

但是在此之前,我们再封装一层hook,在这个自定义的hook里调用routerListener方法:

目录结构如下:

image.png

useLocationChhange.tsx的内容如下:

import { useEffect, useRef } from "react"
import { useLocation } from "react-router-dom"
import type { Location } from "@remix-run/router";

export const usePrevious = (value: Location) => {
    const ref = useRef<Location>()
    useEffect(() => { ref.current = value }, [value])

    return ref.current
}


const useLocationChange = (action: (location: Location, prevLocation?: Location) => void) => {
    const location = useLocation()
    const prevLocation = usePrevious(location)
    const currentHashRef = useRef<string>();

    useEffect(() => {
        const hashPath = window.location.hash.slice(0, window.location.hash.indexOf('?'))
        if (currentHashRef.current === hashPath) return;
        currentHashRef.current = hashPath;
        action(location, prevLocation)
    }, [location, prevLocation, action]);
}

export default useLocationChange;

最后的调用时机在app.tsx文件里:

import { router } from "./router/createRouteConfig";
import { useNavigate, useRoutes } from "react-router-dom";
import useLocationChange from "./router/useLocationChange";
import routerListener from "./router/routerListener";
import './style/index.css';

const App = function () {
  const elements = useRoutes(router);
  const navigate = useNavigate();
  useLocationChange((to, from) => {
    routerListener(navigate, to, from);
  });
  return elements;
};

export default App;

路由守卫

还记得我们前面的自定义的路由配置needGuard吗?

我们对其进行处理,再添加一个文件Guard.tsx:

import React, { useEffect, useState } from "react";

/**
 * 守卫逻辑
 * @returns 
 */
const guardAction = () => {
  // 1s后再显示组件
  return new Promise((resolve, rejected) => {
    resolve("");
    console.log('after resolve')
    /* setTimeout(() => {
      resolve("");
    }, 1000); */
  });
};

interface IGuardCom {
  children: JSX.Element;
}

/**
 * 针对路由配置中needGuard字段为true或者undefined的组件封装守卫逻辑
 * 可自行调整
 * @param props
 * @returns
 */
const GuardCom = (props: IGuardCom) => {
  const [visible, setVisible] = useState(false);
  useEffect(() => {
    guardAction().then(() => {
      console.log('resolve')
    }).finally(() => {
      setVisible(true);
    });
  }, []);

  return visible ? props.children : <></>;
};

export default GuardCom;

然后在createRouteConfig.tsx中进行处理:

import React, { Suspense } from "react";
import { RouteObject } from "react-router-dom";
import routeConfig, { IRoute } from ".";
import Guard from "./Guard";

/**
 * 将简单路由配置转化为 react-router-dom 所需要的配置
 * @param routeList
 * @returns
 */
function createRouteConfig(routeList: IRoute[]): RouteObject[] {
  return routeList.map((item) => {
    const Com = item.component;
    item.needGuard = item.needGuard === undefined || item.needGuard === true;

    return {
      path: item.path,
      element: item.needGuard ? (
        <Guard>
          <Suspense fallback={<p>Loading...</p>}>
            <Com />
          </Suspense>
        </Guard>
      ) : (
        <Suspense fallback={<p>Loading...</p>}>
          <Com />
        </Suspense>
      ),
      /**
       * 支持覆盖原属性
       */
      ...item,
      children: item.children && createRouteConfig(item.children),
    };
  });
}

export const router = createRouteConfig(routeConfig.routes);

兜底路由

当进入到一个错误找不到的路由时,我们可以通过一个兜底路由,来引导用户回到正确的路由

我们先建立一个notFound页面:

image.png

这里我们给了一个简易的显示404,可以根据自己的需求去继续丰富,内容如下:

import React from 'react';
const NotFound = () => {
    return (
        <div>
            <h1>404</h1>
        </div>
    )
}

export default NotFound;

然后我们在路由配置里引用该组件:

image.png

import { ComponentType, lazy } from "react";
import { RouteObject } from "react-router-dom";
import NotFound from "../page/notFound/index";
// 使用交叉类型扩展 RouteObject
export type IRoute = RouteObject & {
    /**
     * 是否是菜单项
     * @default true
     */
    isMenu?: boolean;
    /**
     * 生成菜单的姓名
     */
    name: string;
    component: ComponentType;
    /**
     * 是否触发守卫逻辑
     * @default true
     */
    needGuard?: boolean;
    children?: IRoute[];
};

interface IRouteConfig {
    routes: IRoute[];
}

const routeConfig: IRouteConfig = {
    routes: [
        {
            name: 'home',
            path:  "/",
            component: lazy(() => import("../page/home")),
        },
        {
            name: 'other',
            path: "/other",
            component: lazy(() => import("../page/other")),
        },
        {
            name: '404',
            path: "/*",
            component: NotFound,
            isMenu: false
        }

    ]
}

export default routeConfig;

错误路由组件

我们建立一个组件errorRouterCom

import React from "react";
/**
 * 路由异常组件,路由中异常时显示
 * @returns 
 */
const ErrorRouterCom = () => {
    return <div>
        404
        <button onClick={() => {
            window.location.href = "/";
        }}> 点击回到首页</button>
    </div>;
};

export default ErrorRouterCom;

createRouteConfig进行修改:

import React, { Suspense } from "react";
import { RouteObject } from "react-router-dom";
import routeConfig, { IRoute } from ".";
import Guard from "./Guard";
import ErrorRouterCom from "./errorRouterCom";

/**
 * 将简单路由配置转化为 react-router-dom 所需要的配置
 * @param routeList
 * @returns
 */
function createRouteConfig(routeList: IRoute[]): RouteObject[] {
  return routeList.map((item) => {
    const Com = item.component;
    item.needGuard = item.needGuard === undefined || item.needGuard === true;

    return {
      path: item.path,
      element: item.needGuard ? (
        <Guard>
          <Suspense fallback={<p>Loading...</p>}>
            <Com />
          </Suspense>
        </Guard>
      ) : (
        <Suspense fallback={<p>Loading...</p>}>
          <Com />
        </Suspense>
      ),
      errorElement: ErrorRouterCom,
      /**
       * 支持覆盖原属性
       */
      ...item,
      children: item.children && createRouteConfig(item.children),
    };
  });
}

export const router = createRouteConfig(routeConfig.routes);

好了,以上就是一个完整的路由配置

踩坑

在路由配置中的我也遇到了一些坑,分享给大家:

文件命名ts和tsx搞清楚

image.png

文件名结尾的文件格式要搞清楚,我在完成createRouteConfig的时候,一开始使用ts,怎么配都不对,找了好久......

react-router-dom的版本

image.png

我下的 react-router-dom的版本是

image.png

在该版本中RouteObject的类型是type,不可继承:

image.png

但是在一些老项目中的老版本中:

image.png

RouteObject的类型是interface: image.png

总结

本文介绍了搭建脚手架的第一步,路由的配置,接下来将继续补充,敬请期待。