webpack5从零搭建完整的react18+ts项目模板(二)

299 阅读8分钟

欢迎关注我的其他文章
webpack5从零搭建完整的react18+ts项目模板

上篇文章,我们做了项目的一些初始化工作,本篇将加入一些团队实际开发过程中必不可少的基本的配置例如react-routerreduxaxios封装等;

redux配置

安装依赖

redux也是我们实际项目开发中常用的配置项,react中的props需要安装reduxreact-reduxredux-thunkredux-persistredux-promise@types/redux-promise@reduxjs/toolkit等依赖,依赖安装命令如下:

npm i redux react-redux redux-thunk redux-persist redux-promise @types/redux-promise @reduxjs/toolkit -D
or
yarn add redux react-redux redux-thunk redux-persist redux-promise @types/redux-promise @reduxjs/toolkit -D

定义redux配置文件

依赖安装完成之后,我们在src目录下创建一个redux目录用来创建redux相关配置,目录结构如下:

├─ redux 
│ ├─ interface # ts 接口声明 
│ │ ├─ index.ts # 声明文件
│ ├─ moudles # 模块文件目录
│ │ ├─ home.ts # redux 模块
│ ├─ index.ts # 入口文件 

interface/index.ts文件主要是用来放置redux目录下文件模块使用到的ts接口声明;例如下面的内容:

// interface/index.ts

// HomeState
export interface HomeState {
 userInfo: any;
}

moudles目录主要用来放置redux的模块文件,例如:

// modules/home.ts

import { HomeState } from "@/redux/interface";
import { createSlice, PayloadAction } from "@reduxjs/toolkit";

const homeState: HomeState = {
 userInfo: null,
};

const homeSlice = createSlice({
 name: "home",
 initialState: homeState,
 reducers: {
   // 设置用户信息
   setUserInfo: (state: HomeState, { payload }: PayloadAction<object>) => {
     state.userInfo = payload;
   },
 },
});

export const { setUserInfo } = homeSlice.actions;

export default homeSlice.reducer;

index.ts则是redux配置目录的入口文件,内容如下:

import { configureStore, combineReducers } from "@reduxjs/toolkit";
import { persistStore, persistReducer } from "redux-persist";
import { TypedUseSelectorHook, useDispatch as useReduxDispatch, useSelector as useReduxSelector } from "react-redux";
import storage from "redux-persist/lib/storage";
import reduxThunk from "redux-thunk";
import reduxPromise from "redux-promise";

import home from "./modules/home";

// create reducer
const reducer = combineReducers({
  home
});

// redux persist
const persistConfig = {
	key: "redux-state",
	storage: storage
};
const persistReducerConfig = persistReducer(persistConfig, reducer);

// redux middleWares
const middleWares = [reduxThunk, reduxPromise];

// store
export const store = configureStore({
	reducer: persistReducerConfig,
	middleware: middleWares,
	devTools: true
});

// create persist store
export const persistor = persistStore(store);

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useSelector: TypedUseSelectorHook<RootState> = useReduxSelector;
export const useDispatch = () => useReduxDispatch<AppDispatch>();

使用redux如下:

import React from "react";
import { useSelector, useDispatch, RootState } from "@/redux/index";
import { setUserInfo } from "@/redux/modules/home";

const Home = () => {
  console.log("NODE_ENV", process.env.NODE_ENV);
  console.log("BASE_ENV", process.env.BASE_ENV);
  const dispatch = useDispatch();
  const { userInfo } = useSelector((state: RootState) => state.home);
  const but = () => {
    console.log("执行");
    dispatch(setUserInfo({ age: 1, name: "Home", email: "2095034789@qq.com" }));
  };
  return (
    <div>
      <div>Home组件</div>
      <button onClick={but}>赋值</button>
      <div>name:{userInfo.name}</div>
      <div>age:{userInfo.age}</div>
      <div>email:{userInfo.email}</div>
    </div>
  );
};

export default Home;

至此redux的配置以及持久化配置就都已经基本完成了,但是这时候你在页面里面使用useSelector你会发现页面报错,这是因为我们要想在页面中使用redux还需要对src/index.ts进行改造:

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { Provider } from "react-redux";
import { store, persistor } from "@/redux";
import { PersistGate } from "redux-persist/integration/react";
const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);
root.render(
  <Provider store={store}>
    <PersistGate persistor={persistor}>
        <App />
    </PersistGate>
  </Provider>
);

这时候你再去使用useSelector方法,你会发现不会再报错,可以正常使用了;

路由配置

路由是前端开发中不可或缺的一个重要概念,它允许我们根据 URL 的不同来显示不同的内容和 UI。在 react 开发中,我们通常会使用第三方库来实现路由功能。最常用的就是 react-router

安装依赖

在使用 react-router 之前,我们需要先安装它。依赖安装命令如下:

npm i react-router-dom -D
or
yarn add react-router-dom -D

因为我们的项目有使用typescript所以我们还需要安装@types/react-router-dom声明文件,使typescript能识别到react-router-dom,依赖安装如下:

npm i @types/react-router-dom -D
or
yarn add @types/react-router-doom -D

定义路由配置文件

为了方便维护我们的路由信息,我们在实际项目开发中一般是定义一个路由配置文件,单独维护路由信息,在项目的src目录下创建一个router目录,在该目录下再定义一个路由配置文件index.tsx,内容如下:

// router/index.tsx
import React, { lazy, Suspense } from "react";
import { Route, Routes, Navigate } from "react-router-dom";

const Home = lazy(() => import("../pages/Home"));
const Class = lazy(() => import("../components/Class"));
const LazyDemo = lazy(() => import("../components/LazyDemo"));

type RouteObject = {
 path: string;
 element?: React.ReactElement | React.FC;
 permissions?: Array<string>;
 children?: Array<RouteObject>;
};

// 全局路径
const globalRoutes: RouteObject[] = [
 {
   path: "/", // 路径
   element: <Home />,
   permissions: ["add"], // 权限
 },
];

// 主路由->后续接口中动态获取
const mainRoutes: RouteObject[] = [
 {
   path: "/class",
   permissions: ["add", "edit"], // 权限
   children: [
     {
       path: "",
       element: <Class />
     },
     {
       path: "lazy",
       element: <LazyDemo />,
     },
   ],
 },
];

// 路由错误重定向
const NotFound = () => {
 return <div>你所访问的页面不存在!</div>;
};

let routes: RouteObject[] = globalRoutes.concat(mainRoutes);

// 路由路径处理函数
const transformRoutes = (routeList: RouteObject[]) => {
 return (
   <>
     {routeList.map((route: any) => {
       return route.children && route.children.length ? (
           <Route key={route.path} path={route.path} element={route.element}>
             {transformRoutes(route.children)}
           </Route>
       ) : (
         <Route
           key={route.path}
           path={route.path}
           element={route.element}
         ></Route>
       );
     })}
   </>
 );
};

console.log("transformRoutes", transformRoutes(routes));

const RoutersConfig = () => {
 return (
   <Suspense fallback={<div>loading...</div>}>
     <Routes>{transformRoutes(routes)}</Routes>
   </Suspense>
 );
};

export default RoutersConfig;

全局注册路由

import React from "react";
import { BrowserRouter } from "react-router-dom";
import RoutersConfig from "./router/router";

function App() {
 return (
   <BrowserRouter>
     <RoutersConfig />
   </BrowserRouter>
 );
}
export default App;

至此react-router-dom路由的基本配置就搭建完了

axios 封装

axios也是我们项目中常用的配置项来着,我们通常都是将axios请求统一封装,然后调用的,该节主要介绍我在项目中是如何封装axios,大家可以借鉴一下根据你们项目的实际情况去调整;

依赖安装

npm i axios -D
or
yarn add axios -D

定义axios配置文件

src目录下创建一个api目录,目录下创建interfaceconfighelpermodules 等目录,在api目录下创建一个request.ts文件,内容如下:

import NProgress from "@/config/nprogress";
import axios, {
  AxiosInstance,
  AxiosError,
  AxiosRequestConfig,
  AxiosResponse,
} from "axios";

import {
  showFullScreenLoading,
  tryHideFullScreenLoading,
} from "@/config/serviceLoading";

import { ResultData } from "./interface/index";
import { AxiosCanceler } from "./helper/axiosCancel";
import { ResultEnum } from "@/enums/httpEnum";
import { checkStatus } from "./helper/checkStatus";
import { API_URL } from "@/config/env";
import { store } from "@/redux";
import { setToken } from "@/redux/modules/home";
import { Toast } from "antd-mobile";

console.log("API_URL", API_URL);

const axiosCanceler = new AxiosCanceler();

const config = {
  // 默认地址请求地址,可在 .env 开头文件中修改
  baseURL: API_URL,
  // 设置超时时间(10s)
  timeout: 10000,
  // 跨域时候允许携带凭证
  withCredentials: true,
};

class RequestHttp {
  service: AxiosInstance;
  public constructor(config: AxiosRequestConfig) {
    // 实例化axios
    this.service = axios.create(config);

    /**
     * @description 请求拦截器
     * 客户端发送请求 -> [请求拦截器] -> 服务器
     * token校验(JWT) : 接受服务器返回的token,存储到redux/本地储存当中
     */
    this.service.interceptors.request.use(
      (config: AxiosRequestConfig): any => {
        NProgress.start();
        // * 将当前请求添加到 pending 中
        axiosCanceler.addPending(config);
        // * 如果当前请求不需要显示 loading,在api服务中通过指定的第三个参数: { headers: { noLoading: true } }来控制不显示loading,参见loginApi
        config.headers!.noLoading || showFullScreenLoading();
        const token: string = store.getState().home.token;
        return {
          ...config,
          headers: { ...config.headers, "x-access-token": token },
        };
      },
      (error: AxiosError) => {
        console.log("error", error);
        return Promise.reject(error);
      }
    );

    /**
     * @description 响应拦截器
     *  服务器换返回信息 -> [拦截统一处理] -> 客户端JS获取到信息
     */
    this.service.interceptors.response.use(
      (response: AxiosResponse): any => {
        const { data, config } = response;
        NProgress.done();
        // * 在请求结束后,移除本次请求(关闭loading)
        axiosCanceler.removePending(config);
        tryHideFullScreenLoading();
        // * 登录失效
        if (data.code == ResultEnum.OVERDUE) {
          store.dispatch(setToken(""));
          Toast.show({
            content: data.msg,
            maskClickable: false,
          });
          window.location.hash = "/login";
          return Promise.reject(data);
        }
        // * 全局错误信息拦截(防止下载文件得时候返回数据流,没有code,直接报错)
        if (data.code && data.code !== ResultEnum.SUCCESS) {
          Toast.show({
            content: data.msg,
            maskClickable: false,
          });
          return Promise.reject(data);
        }
        // * 成功请求(在页面上除非特殊情况,否则不用处理失败逻辑)
        return data;
      },
      async (error: AxiosError) => {
        const { response } = error;
        NProgress.done();
        tryHideFullScreenLoading();
        // 请求超时单独判断,请求超时没有 response
        if (error.message.indexOf("timeout") !== -1)
        Toast.show({
          content: "请求超时,请稍后再试",
          maskClickable: false,
        })
        // 根据响应的错误状态码,做不同的处理
        if (response) checkStatus(String(response.status));
        // 服务器结果都没有返回(可能服务器错误可能客户端断网) 断网处理:可以跳转到断网页面
        if (!window.navigator.onLine) window.location.hash = "/500";
        return Promise.reject(error);
      }
    );
  }
  	// * 常用请求方法封装
	get<T>(url: string, params?: object, _object = {}): Promise<ResultData<T>> {
		return this.service.get(url, { params, ..._object });
	}
	post<T>(url: string, params?: object, _object = {}): Promise<ResultData<T>> {
		return this.service.post(url, params, _object);
	}
	put<T>(url: string, params?: object, _object = {}): Promise<ResultData<T>> {
		return this.service.put(url, params, _object);
	}
	delete<T>(url: string, params?: any, _object = {}): Promise<ResultData<T>> {
		return this.service.delete(url, { params, ..._object });
	}
}

export default new RequestHttp(config)

config/serviceLoading.ts文件内容如下:

import React from "react";
import ReactDOM from "react-dom/client";
import Loading from "@/Demo/components/Loading";

let needLoadingRequestCount = 0;

// * 显示loading
export const showFullScreenLoading = () => {
	if (needLoadingRequestCount === 0) {
		let dom = document.createElement("div");
		dom.setAttribute("id", "loading");
		document.body.appendChild(dom);
		ReactDOM.createRoot(dom).render(<Loading />);
	}
	needLoadingRequestCount++;
};

// * 隐藏loading
export const tryHideFullScreenLoading = () => {
	if (needLoadingRequestCount <= 0) return;
	needLoadingRequestCount--;
	if (needLoadingRequestCount === 0) {
		document.body.removeChild(document.getElementById("loading") as HTMLElement);
	}
};

components/Loading/index.tsx文件内容如下:

import React from "react";
import { SpinLoading } from "antd-mobile";
import "./index.less";
const Loading = ({ color = "default" }: { color?: string }) => {
  return <SpinLoading color={color} className="request-loading"></SpinLoading>;
};

export default Loading;

interface/index.ts文件内容如下:

// * 请求响应参数(不包含data)
export interface Result {
	code: string;
	msg: string;
}

// * 请求响应参数(包含data)
export interface ResultData<T = any> extends Result {
	data?: T;
}

// * 登录
export namespace Login {
	export interface ReqLoginForm {
		username: string;
		password: string;
	}
	export interface ResLogin {
		access_token: string;
	}
	export interface ResAuthButtons {
		[propName: string]: any;
	}
}

helper/axiosCancel.ts文件内容如下:

import axios, { AxiosRequestConfig, Canceler } from "axios";
import { isFunction } from "@/utils/is/index";
import qs from "qs";

// * 声明一个 Map 用于存储每个请求的标识 和 取消函数
let pendingMap = new Map<string, Canceler>();

// * 序列化参数
export const getPendingUrl = (config: AxiosRequestConfig) =>
	[config.method, config.url, qs.stringify(config.data), qs.stringify(config.params)].join("&");

export class AxiosCanceler {
	/**
	 * @description: 添加请求
	 * @param {Object} config
	 */
	addPending(config: AxiosRequestConfig) {
		// * 在请求开始前,对之前的请求做检查取消操作
		this.removePending(config);
		const url = getPendingUrl(config);
		config.cancelToken =
			config.cancelToken ||
			new axios.CancelToken(cancel => {
				if (!pendingMap.has(url)) {
					// 如果 pending 中不存在当前请求,则添加进去
					pendingMap.set(url, cancel);
				}
			});
	}

	/**
	 * @description: 移除请求
	 * @param {Object} config
	 */
	removePending(config: AxiosRequestConfig) {
		const url = getPendingUrl(config);

		if (pendingMap.has(url)) {
			// 如果在 pending 中存在当前请求标识,需要取消当前请求,并且移除
			const cancel = pendingMap.get(url);
			cancel && cancel();
			pendingMap.delete(url);
		}
	}

	/**
	 * @description: 清空所有pending
	 */
	removeAllPending() {
		pendingMap.forEach(cancel => {
			cancel && isFunction(cancel) && cancel();
		});
		pendingMap.clear();
	}

	/**
	 * @description: 重置
	 */
	reset(): void {
		pendingMap = new Map<string, Canceler>();
	}
}

helper/checkStatus.ts文件内容如下:

import {Toast} from 'antd-mobile'
/**
 * @description 校验网络请求状态码
 * @param {string} status 状态码
 */
export const checkStatus = (status: string) => {
  switch(status) {
		case "400":
      Toast.show({
        content: "请求失败!请您稍后重试",
        maskClickable: false,
      })
			break;
		case "401":
      Toast.show({
        content: "登录失效!请您重新登录",
        maskClickable: false,
      })
			break;
		case "403":
      Toast.show({
        content: "当前账号无权限访问!",
        maskClickable: false,
      })
			break;
		case "404":
      Toast.show({
        content: "你所访问的资源不存在!",
        maskClickable: false,
      })
			break;
		case "405":
      Toast.show({
        content: "请求方式错误!请您稍后重试",
        maskClickable: false,
      })
			break;
		case "408":
      Toast.show({
        content: "请求超时!请您稍后重试",
        maskClickable: false,
      })
			break;
		case "500":
      Toast.show({
        content: "服务异常!",
        maskClickable: false,
      })
			break;
		case "502":
      Toast.show({
        content: "网关错误!",
        maskClickable: false,
      })
			break;
		case "503":
      Toast.show({
        content: "服务不可用!",
        maskClickable: false,
      })
			break;
		case "504":
      Toast.show({
        content: "网关超时!",
        maskClickable: false,
      })
			break;
		default:
      Toast.show({
        content: "请求失败!",
        maskClickable: false,
      })
  }
}

modules/login.ts文件内容如下:

import { Login } from "../interface/index";
// import qs from 'qs';
import http from "../request";

/**
 * @name 登录模块
 */
// * 用户登录接口
export const loginApi = (params: Login.ReqLoginForm) => {
  try {
    return http.get<Login.ResLogin>(`/rec`, params);
    // return http.post<Login.ResLogin>(`/login`, {}, { params }); // post 请求携带 query 参数  ==>  ?username=admin&password=123456
    // return http.post<Login.ResLogin>(`/login`, qs.stringify(params)); // post 请求携带 表单 参数  ==>  application/x-www-form-urlencoded
    // return http.post<Login.ResLogin>(`/login`, params, { headers: { noLoading: true } }); // 控制当前请求不显示 loading
  } catch (err) {}
};