本教程属于react入门教程,课程围绕如何搭建一个项目框架展开,会带你快速了解react
、redux
、redux-devtool
、react-router-dom
、axiox
这些常见技术的使用方式,教程最后会附上项目源码。
在搭建项目时,我们通常会使用cli工具来搭建,方便、快捷、高效,一行命令就能生成一个完整的脚手架项目,这里我们使用 vite
来创建一个 react+vite+typescript
项目;Vite官网地址
npm init vite
//或者
yarn create vite
cd react-vite-project
npm install
npm run dev
启动完成后,控制台打印如下:
打开:http://localhost:5173
按照不同的功能,一般会把项目按照下面的结构来划分:
项目目录:
├─node_modules //第三方依赖
├─public //静态资源(不参与打包)
└─src
├─assets //静态资源
├─components //组件
├─config //配置
├─http //请求方法封装
├─layout //页面布局
├─pages //页面
├─routes //路由
├─service //请求
├─store //状态管理
└─util //通用方法
└─App.css
└─App.tsx
└─index.css
└─main.tsx
└─vite-env.d.ts
├─.eslinttrc.cjs
├─.gitignore
├─index.html //项目页面总入口
├─package.json
├─tsconfig.json //ts配置文件
├─tsconfig.node.json
├─vite.config.ts //vite配置文件
npm i sass -D
assets/styles/index.scss
文件$red:red;
index.scss
文件//打开vite.config.ts,添加scss的预编译选项
export default defineConfig({
...
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "./assets/styles/index.scss";`
},
}
}
})
//1、修改App.css > App.scss
//2、添加scs语法设置字体颜色
h1{
color:$red;
}
//3、修改引入的文件名
//import './App.css'
修改为:
import './App.scss'
import React, { useState } from 'react';
function Example() {
// Declare a new state variable, which we'll call "count"
const [count, setCount] = useState(0);
return (
You clicked {count} times
);
}
class Example extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
}
render() {
return (
You clicked {this.state.count} times
);
}
}
官网说明:Using the State Hook
npm install react-router-dom -S
//npm i @types/react-router-dom //默认是带代码提示的(非必须)
main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { HashRouter as Router } from 'react-router-dom';
import './index.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
,
)
App.tsx
import { useState } from 'react'
import { HashRouter, Route, Routes, Link, useNavigate } from "react-router-dom";
import Login from "./pages/login";
import Home from "./pages/home";
import User from "./pages/user";
import './App.scss'
function App() {
const [count, setCount] = useState(0)
const navigate=useNavigate();
return (
{/* 指定跳转的组件,to 用来配置路由地址 */}
首页
用户
{/* 路由出口:路由对应的组件会在这里进行渲染 */}
{/* 指定路由路径和组件的对应关系:path 代表路径,element 代表对应的组件,它们成对出现 */}
}>
}>
}>
)
}
export default App
login.tsx + home.tsx + user.tsx
//login.tsx
function Login() {
return (
<div>login页面</div>
);
}
export default Login;
//home.tsx
function Home() {
return (
<div>home页面</div>
);
}
export default Home;
//user.tsx
function User() {
return (
<div>user页面</div>
);
}
export default User;
这时候,打开http://localhost:5173/
,如图:
main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import { BrowserRouter as Router } from 'react-router-dom';
import './index.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
,
)
App.tsx
import { useState } from 'react'
import { Route, Routes, Link, useNavigate } from 'react-router-dom';
import Login from "./pages/login";
import Home from "./pages/home";
import User from "./pages/user";
import './App.scss'
function App() {
const navigate = useNavigate();
return (
{/* 指定跳转的组件,to 用来配置路由地址 */}
首页
用户
{/* 路由出口:路由对应的组件会在这里进行渲染 */}
{/* 指定路由路径和组件的对应关系:path 代表路径,element 代表对应的组件,它们成对出现 */}
}>
}>
}>
)
}
export default App
这时候,打开http://localhost:5173/
,如图
App.tsx
import { useState } from 'react'
import { Route, Routes, Link, useNavigate } from 'react-router-dom';
import Login from "./pages/login";
import Home from "./pages/home";
import User from "./pages/user";
import './App.scss'
function App() {
const navigate = useNavigate();
return (
{/* 指定跳转的组件,to 用来配置路由地址 */}
首页
用户
{/* 路由出口:路由对应的组件会在这里进行渲染 */}
{/* 指定路由路径和组件的对应关系:path 代表路径,element 代表对应的组件,它们成对出现 */}
}>
}>
}>
)
}
export default App
home.tsx
import { Outlet } from "react-router-dom";
function Home() {
return (
home页面
);
}
export default Home;
在嵌套路由下的子级路由,如果我们要设置一个默认路由,那么只要在路由上添加index
属性。
}>
}>
}>
import { Navigate } from 'react-router-dom';
}>
在做权限验证的时候我们可以使用重定向,如果已经登录则进入主页,没有登录则进入登录页面。
上面的路由我们都是在App.tsx
中手动写,不太方便,实际项目中我们的路由会在配置文件中统一配置,这时候我们可以使用useRoutes
来实现路由配置;
App.tsx
import GetRoutes from "@/routes/index";
function App() {
return (
)
}
export default App
/routes/index.tsx
import { useRoutes, Navigate, RouteObject } from "react-router-dom";
import Layout from "@/layout/index";
import Login from "@/pages/login";
import Home from "@/pages/home";
import User from "@/pages/user";
export const router_item: Array
有些页面比较大,我们可以使用懒加载,来提升页面加载性能,避免页面卡顿;react官网提供了路由懒加载的完整实例:react路由懒加载。懒加载主要借助lazy
、suspense组件
来实现。
lazy
能够让你在组件第一次被渲染之前延迟加载组件的代码。
允许您显示临时组件(一般是一个loading状态),直到其子项完成加载。
实例演示:
import { lazy } from 'react';
const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));
}>
Preview
MarkdownPreview.js
是需要加载的组件,使用lazy()
生成一个LazyExoticComponent
对象,然后包裹在suspense
组件中,fallback={
中的Loading
是一个组件,显示加载过程中的页面。
知道了上述的使用方式,我们可以这样在项目中来使用懒加载。
lazyLoad()
,用于传入lazy()
生成的LazyExoticComponent
对象,返回一个suspense
组件lazyLoad(lazy(() => import("@/pages/home")))
具体代码如下:
/routes/index.tsx(请查看home、user组件的引入方式)
import { useRoutes, Navigate, RouteObject } from "react-router-dom";
import { lazy } from "react";
//页面
import Layout from "@/layout/index";
import Login from "@/pages/login";
import Error404 from "@/pages/error/404";
//公共
import lazyLoad from "./lazyLoad";
// 添加一个固定的延迟时间,以便你可以看到加载状态
function delayForDemo(promise: Promise) {
return new Promise(resolve => {
setTimeout(resolve, 2000);
}).then(() => promise);
}
export const router_item: Array
/routes/lazyLoad.tsx
import { LazyExoticComponent, Suspense } from "react";
import Spinner from "@/components/spinner";
/**
* 实现路由懒加载
* @param Comp 懒加载组件
* @returns
*/
function lazyLoad(Comp: LazyExoticComponent<() => JSX.Element>) {
return (
}>
);
}
export default lazyLoad;
/compoments/spinner.tsx
function Spinner() {
return (
<>
loading...
>
);
}
export default Spinner;
由于home加载留有2s的延迟,我们看下主页加载效果,能看到loading…加载状态,如图:
/layout/index.tsx
代码如下:
import "./index.scss";
import { Outlet } from "react-router-dom";
import Aside from "./aside"
function Layout() {
return (
header
);
}
export default Layout;
由于前面我们已经设置了路由,在生成菜单的时候可以使用路由的数据来直接生成,结构一致,不过需要补充key
、label
、hidden
属性;
生产菜单时我们需要考虑多层级菜单、刷新后自动展开上次选中的菜单;
npm i antd --save
路由数据如下:
/routes/index.tsx
import { useRoutes, Navigate, RouteObject } from "react-router-dom";
import Layout from "@/layout/index";
import Login from "@/pages/login";
import Home from "@/pages/home";
import User from "@/pages/user";
export const router_item: Array
aside.tsx
// react hook
import { useState } from "react";
import { router_item } from "@/routes/index";
import { Menu } from "antd";
import { useNavigate } from "react-router-dom";
function aside() {
const navigate = useNavigate();
//菜单
const [routes] = useState(router_item);
//打开和选中
const defaultOpenKeys = (localStorage.getItem("openKeys") || "")?.split(",");
const defaultSelectKeys = localStorage.getItem("selectKeys") || "";
const [selectKeys, setSelectKeys] = useState([defaultSelectKeys]);
const [openKeys, setOpenKeys] = useState(defaultOpenKeys);
//点击菜单
const menuHandler = (e: any) => {
let path = "/" + e.keyPath.reverse().join("/");
path = path.replace("//", "/");
navigate(path);
// 缓存打开和选中的keys
const selectKeys = e.key;
e.keyPath.pop();
const openKeys = e.keyPath.join(",");
setSelectKeys(selectKeys);
setOpenKeys(openKeys);
localStorage.setItem("selectKeys", selectKeys);
localStorage.setItem("openKeys", openKeys);
}
return (
<>
>
);
}
export default aside;
这是一个最简单的登录页,交互很简单,输入用户名和密码,验证通过,点击登录,登录成功则跳转到内部控制台页面,不成功则给出错误提示。
npm i @ant-design/icons --save
/pages/login/index.tsx
import "./index.scss";
import reactIcon from "@/assets/react.svg";
import { Button, Checkbox, Form, Input, message } from 'antd';
import { UserOutlined, LockOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
const styles = {
login: {
background: `linear-gradient(blue, pink)`,
width: "100vw",
height: "100vh",
}
};
interface loginData {
username: string;
password: string;
remember: string;
}
function Login() {
const navigate = useNavigate();
const [messageApi, contextHolder] = message.useMessage();
const onFinish = (values: loginData) => {
messageApi.open({
type: 'loading',
content: '正在登录..',
duration: 0,
});
setTimeout(() => {
messageApi.destroy();
//默认请求延时
if (values.username == "admin" && values.password == "123") {
messageApi.open({
type: 'success',
content: '登录成功!',
onClose() {
navigate("/layout/home");
}
});
return;
} else {
messageApi.open({
type: 'error',
content: '登录失败!',
});
}
}, 500);
};
const onFinishFailed = (errorInfo: any) => {
// console.log('Failed:', errorInfo);
};
return (
{contextHolder}
Ant Design后台管理系统
({
validator(_, value) {
if (value === "admin") {
return Promise.resolve();
}
return Promise.reject(new Error('用户名不存在!'));
},
}),
]}
>
} placeholder="用户名" autoComplete="off" />
} placeholder="密码" />
记住密码
{/* */}
);
}
export default Login;
pages/login/index.scss
.login_container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.title_big {
width: 400px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
// letter-spacing: 0.1rem;
margin-bottom: 1rem;
background: #fff;
border-radius: 5px;
position: relative;
color: #0052b6;
}
.login_panel {
width: 400px;
background-color: white;
padding: 15px;
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.2);
border-radius: 5px;
.title {
height: 40px;
line-height: 40px;
font-size: 1.5rem;
border-bottom: 1px solid #eee;
margin-bottom: 10px;
}
}
}
登录页面用到了ANTD
中的Form表单、表单自定义验证、消息提示、navigate跳转。
实际项目在进入某个页面中前,是需要做校验的:token校验、权限校验、登录状态校验等等。
在进入除login页面
以外的页面时,我们要对页面进行拦截,并进行token校验,判断token是否存在,存在则进入;不存在则跳转到登录页面进行登录;
/components/AutoRouter.tsx
//这个组件用于拦截判断token是否存在。
import { useLocation, Navigate } from "react-router-dom";
function AutoRouter(props: { children: JSX.Element }) {
const { pathname } = useLocation();
//校验
if (pathname.startsWith("/login")) {
return props.children;
}
const token = localStorage.getItem("token");
if (token) {
// 1、存在token,则进入主页
return props.children;
} else {
// 2、如果不存在token,则进入登录页
return
}
}
export default AutoRouter;
App.tsx
//AutoRouter作为父组件,包裹所有子组件
import GetRoutes from "@/routes/index";
import AutoRouter from "@/components/autoRouter";
function App() {
return (
)
}
export default App
/pages/login/index.tsx
//修改onFinish回调方法,加入token设置逻辑
const onFinish = (values: loginData) => {
...
setTimeout(() => {
//默认请求延时
if (values.username == "admin" && values.password == "123") {
localStorage.setItem("token","djalkdjadjlasj3123123"); //----登录成功后,设置token
...
return;
} else {
...
}
}, 500);
};
上面我们在进入login
页面时,在autoRoute页面
中针对login
路径不做权限校验,但是正常项目中大量的页面需要免校验,如果没有都要这么来写,使用不是很方便。可以将是否要检验,直接写在路由清单中。然后在页面跳转时,拦截目标页面对应的路由配置,查看是否声明需要做校验。
之前autoRoute.tsx中关于login页面免校验的代码:
function AutoRouter(props: { children: JSX.Element }) {
const { pathname } = useLocation();
//校验
if (pathname.startsWith("/login")) {
return props.children;//-----手动适配,不是很合理
}
...
}
我们可以改成下面这种形式:
//login路由配置noAuth
{
path: "/login",
key: "login",
label: "登录",
hidden: true,
element: ,
meta: {
noAuth: true //不需要检验
}
},
//autoRoute.tsx中的拦截逻辑
function AutoRouter(props: { children: JSX.Element }) {
const { pathname } = useLocation();
const token = localStorage.getItem("token");
//1、获取当前路径对应的路由配置
const route = matchRoute(pathname, router_item);
//2、如果noAuth为true,则直接跳过校验
if (route && route.meta && route.meta.noAuth) {
// 路由配置noAuth,则不需要校验
return props.children;
}
if (token) {
// 1、存在token,则进入主页
return props.children;
} else {
// 2、如果不存在token,则进入登录页
return
}
}
/util/util.ts
export interface MetaProp {
title: string;
key: string;
noAuth: boolean;
}
export interface RouteObject {
children?: RouteObject[];
element?: React.ReactNode;
path?: string;
meta?: Partial
}
/**
* 获取路径对应的路由配置,没有则返回null
* @param path 路由完整路径
* @param routes 路由数组
* @returns 路由配置项
*/
export function matchRoute(path: string, routes: RouteObject[] = []): any {
const pathArr = path.split("/");
pathArr.shift();
const curPath = pathArr.shift();
let result: any = null;
for (let i = 0; i < routes.length; i++) {
const item = routes[i];
if ([curPath, `/${curPath}`].includes(item.path)) {
if (!pathArr.length) {
return item;
}
if (item.children) {
const res = matchRoute(`/${pathArr.join("/")}`, item.children);
if (res) {
return res;
}
}
}
}
return result;
}
这样,我们如果要对某个页面做免校验设置,直接在/routes/index.tsx
对应的路由配置中加上meta.noAuth:true
就行,例如:404页面、忘记密码、登录页都可以加上。
类似于Vue
中的Vuex
,react中可以使用Redux
来做状态管理。
下面我们尝试创建一个redux
状态实例,然后在页面中获取或者更新状态值,页面上的值也会相应改变。
创建 /redux/index.ts
import { legacy_createStore as createStote } from "redux";
//定义数据
const initState = {
count: 0,
name: "IT飞牛"
};
// 关联action
function countReducer(state = initState, action: any) {
switch (action.type) {
case "ADD_COUNT":
return { ...state, count: state.count + action.number };
case "UPDATE_NAME":
return { ...state, name: action.name };
default:
return state;
}
}
const store = createStote(countReducer);
export default store;
修改 /pages/home.tsx
代码,点击按钮修改name值
import { Outlet } from "react-router-dom";
import store from "@/redux";
function Home() {
const updateCount = () => {
store.dispatch({ type: "UPDATE_NAME", name: "IT飞牛,前端行业的一个小学生!" });
}
return (
<>
>
);
}
export default Home;
修改 /layout/index.tsx
代码,监听store的变化,并同步修改name,显示到页面
import "./index.scss";
import { Outlet } from "react-router-dom";
import Aside from "./aside"
import store from "@/redux";
import { useState } from "react";
function Layout() {
const [name, setName] = useState(store.getState().name); //获取store.name作为默认值
store.subscribe(() => {
const store_data = store.getState();
setName(store_data.name); //更新store.name值
});
return (
header-{name} {/* store.name显示到页面 */}
);
}
export default Layout;
最终效果:
项目中的状态数据往往很多,可能每个模块都会有一批状态,这时候我们就需要对状态进行拆分管理,这里我们可以借助combineReducers
来实现。
combineReducers作用:
将值是不同 reducer 函数的对象转换为单个 reducer 函数。 它将调用每个子 reducer,并将它们的结果收集到一个状态对象中,其键对应于传递的 reducer 函数的键。
具体步骤:
countReducer
、nameReducer
两个reducercombineReducers
将上述两个reducer合并成mixReducer
mixReducer
创建一个store
实例store.dispatch()
store.getState().nameReducer[state值]
具体代码如下:
/redux/action.ts
//定义数据
const initState1 = {
count: 0,
};
export function countReducer(state = initState1, action: any) {
switch (action.type) {
case "ADD_COUNT":
return { ...state, count: state.count + action.number };
default:
return state;
}
}
const initState2 = {
name: "IT飞牛",
};
export function nameReducer(state = initState2, action: any) {
switch (action.type) {
case "UPDATE_NAME":
return { ...state, name: action.name };
default:
return state;
}
}
/redux/index.ts
import {
legacy_createStore as createStote,
combineReducers,
} from "redux";
import { countReducer, nameReducer } from "./action";
const reducer = combineReducers({ countReducer, nameReducer });//合并reducer
const store = createStote(reducer);
export default store;
/pages/home.tsx
代码不变,主要代码如下:
//更新state值
store.dispatch({
type: "UPDATE_NAME",
name: "IT飞牛,前端行业的一个小学生!",
});
/page/layout.tsx
中取值代码微调:
//老代码
const [name, setName] = useState(store.getState().name);
store.subscribe(() => {
const store_data = store.getState();
setName(store_data.name);
});
//新代码
const [name, setName] = useState(store.getState().nameReducer.name); //获取store.name作为默认值
store.subscribe(() => {
const store_data = store.getState();
setName(store_data.nameReducer.name); //更新store.name值
});
这时候,store
中的数据的结构就已经按照不同reducer
模块做了划分,具体如下:
redux-devtool
调试工具redux-devtool
是一个chrome浏览器插件,安装后可以直接在浏览器中查看redux
状态的变化。
安装插件
进入谷歌应用商店,搜索redux-devtool
进行安装
npm i redux-thunk redux-devtools-extension --save-dev
调整/redux/indx.tsx
代码,createStote
创建store时,使用thunk
中间件
import {
legacy_createStore as createStote,
applyMiddleware,
combineReducers,
} from "redux";
import thunk from "redux-thunk";
import { composeWithDevTools } from "redux-devtools-extension";
import { countReducer, nameReducer } from "./action";
const reducer = combineReducers({ countReducer, nameReducer });
const store = createStote(reducer, composeWithDevTools(applyMiddleware(thunk)));
export default store;
npm i axios --save
创建/util/request.ts
,代码如下:
import axios, { AxiosInstance } from "axios";
import { HttpError } from "./HttpError";
const isDev = process.env.NODE_ENV === "development";
const baseUrl = isDev ? "/api" : "/";
export const request: AxiosInstance = axios.create({
baseURL: baseUrl,
timeout: 30000,
headers: {
"Content-Type": "application/json;charset=utf-8",
},
});
request.interceptors.request.use(
(req) => {
req.headers.authorization =
"Bearer " + localStorage.getItem("ACCESS_TOKEN");
return req;
},
(err) => {
// err.message
throw err;
}
);
request.interceptors.response.use(
(res) => {
const { code } = res.data;
if (code === 401) {
// window.location.replace("/");
}
if (code != "200") {
// Message.error(res.data.message)
throw new HttpError(
res.data?.message || "网络错误!",
Number(res.data.status)
);
}
return res.data;
},
(err) => {
const { response } = err;
const { code } = response.data;
if (code === 401) {
// window.location.replace("/");
}
// err.message
throw err;
}
);
export const http = {
post(url: string, data?: any, config?: any) {
return request.post(url, data, config) as Promise; //使用范型,代码提示更简便
},
get(url: string, config?: any) {
return request.get(url, config) as Promise; //使用范型,代码提示更简便
},
};
class HttpError extends Error {
code: number;
constructor(message: string, code: number) {
super(message);
this.code = code;
}
}
创建api/homeApi.ts
,代码如下:
import { request, http } from "@/util/request";
//写法1
export function getInfo1() {
return http.post("/api/common/getInfo", {
url: "/api/common/getInfo",
method: "GET",
});
}
//写法2
export function getInfo2(): Promise {
return request.request({
url: "/api/common/getInfo",
method: "GET",
});
}
interface InfoRes {
token: string;
}
import { getInfo1, getInfo2 } from "@/api/homeApi";
const res1 = await getInfo1();
const res2 = await getInfo2();
在实际项目中,前端发起的请求会失败,失败的原因有两类:http状态码异常、业务异常,那么我们可以在request.ts
中对这两类失败做初步的统一处理。
例如针对http状态码异常,常见的4xx
、5xx
错误,我们统一拦截,直接给出类似**服务器/网络错误,请联系管理员!**这样的message错误消息提示。
如果http状态码正常,但是业务状态码code
异常,我们同样也可以做统一拦截,按照需求对业务异常做前置处理。
跨域的解决方法常见的有本地代理、CORS、服务器反向代理等等;
打开vite.config.ts
文件:
server: {
// 是否自动打开浏览器
open: true,
// 服务器主机名,如果允许外部访问,可设置为"0.0.0.0"
host: '0.0.0.0',
// 服务器端口号
port: 3000,
// 代理
proxy: {
'/v1/apigateway': {
target: `http://12.12.12.12/`,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/v1_redirect\/apigateway/, '')
},
}
}
本地代理的原理和服务器反向代理类似,就是在本地使用Express启动一个http服务,然后所有的本地请求都会经过本地的这个Express服务转发到实际api服务器上,这样就可以绕过浏览器的同源策略。
如上配置,我们在页面中发起一个请求http://localhost/v1/apigateway/getInfo
,那么经过本地代理处理后,实际转发到api服务器的地址是http://12.12.12.12/v1_redirect/apigateway/getInfo
。
vite更多跨域配置请查看:server.proxy
在安装完react-router-dom
之后,App.tsx
中引入react-router-dom
时,提示如下错误:
找不到模块“react-router-dom”。你的意思是要将 "moduleResolution" 选项设置为 "node",还是要将别名添加到 "paths" 选项中?ts(2792)
其实在react-router
中,是有自带的声明文件的,如图:
解决方案:
修改tsconfig.json
,将target
修改为es5
:
{
"compilerOptions": {
"target": "es5",
}
}
在使用编程式导航时,控制台报错如下:
caught Error: useNavigate() may be used only in the context of a component.
其实就是需要在使用useNavigate
时,确保事件出发的dom节点外层被Router
节点包裹。
修改main.tsx如下:
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import {BrowserRouter as Router} from 'react-router-dom';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<Router>//------添加
<App />
</Router>
</React.StrictMode>
);
有时候dom层级结构太复杂,我们需要清除某个不需要的dom节点,那么正常只要直接删除div
标签就可以;
那么jsx
语法中也可以使用空标签来实现<>>
,同样不会渲染这个div节点。
react-vite-project