一、
# 使用npx创建项目 npx create-react-app react-jike # 进入到项 cd react-jike # 启动项目 npm start


-src -apis 项目接口函数 -assets 项目资源文件,比如,图片等 -components 通用组件 -pages 页面组件 -store 集中状态管理 -utils 工具,比如,token、axios 的封装等 -App.js 根组件 -index.css 全局样式 -index.js 项目入口
下面来清理文件:
src/index.js
import React from 'react' import ReactDOM from 'react-dom/client' import './index.scss' import './App.js' ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <App/> </React.StrictMode> )
src/App.js
const App = () => { return <div>this is app</div> } export default App
删除App.css、App.test.js、logo.svg、reportWebVitals.js、setupTests.js文件
二、

实现步骤
-
安装解析 sass 的包:
npm i sass -D -
创建全局样式文件:
index.scss(将index.css改名为index.scss,并修改index.js中引入为import './index.scss';)
下面的嵌套语法在原生css中是不支持的,只在scss文件里面才支持,
body { margin: 0; div { color: blue; } }
效果如下:

Ant Design 是什么
Ant Design 是由蚂蚁金服出品的社区使用最广的React PC端组件库,内置了常用的现成组件,可以帮助我们快速开发PC管理后台项目
访问 Ant Design 官网卡顿,通常是因为官方站点 ant.design 的服务器在海外,国内网络直连时容易受网络波动影响
最直接的解决方法是改用国内镜像站。这些镜像由国内服务托管,访问速度会快很多。

实现步骤
-
安装 antd 组件库:
npm i antd -
导入 Button 组件
-
在 App.js 页面渲染 Button 组件进行测试
import { Button } from 'antd'
function App() {
return (
<div>
this is app
<Button type='primary'>test</Button>
</div>
);
}
export default App;
效果如下:

四、
实现步骤
-
安装路由包
npm i react-router-dom -
准备
Layout和Login俩个基础组件 -
配置路由(在router/index.js文件中引入组件进行路由配置,导出router实例)
-
在入口文件中渲染 <RouterProvider/>,传入router实例
const Layout = () => { return <div>this is layout</div> } export default Layout
pages/Login/index.js
const Login = () => { return <div>this is login</div> } export default Login
router/index.js
import { createBrowserRouter } from 'react-router-dom'
import Login from '../pages/Login'
import Layout from '../pages/Layout'
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
},
{
path: '/login',
element: <Login />,
},
])
export default router
index.js
import React from 'react' import ReactDOM from 'react-dom/client' import './index.scss' import router from './router' import { RouterProvider } from 'react-router-dom' ReactDOM.createRoot(document.getElementById('root')).render( <RouterProvider router={router} /> )
效果如下:

项目背景:在业务开发过程中文件夹的嵌套层级可能会比较深,通过传统的路径选择会比较麻烦也容易出错,设置路径别名可以简化这个过程
什么是@别名路径?
通过@替代src路径,方便开发过程中的路径查找访问

上面是常规路径访问,如果文件的嵌套层次很深的话,用这种方式很容易出错,而且写起来很麻烦
下面是加了@别名之后的写法,相当于始终从src目录往下找,不容易报错,而且找起来也很方便
如何配置?
1. 针对路径转换,修改webpack别名路径配置 craco
2. 针对联想提示,修改VSCode配置 jsconfig.json
npm i @craco/craco -D
(2)、在根目录下增加 craco.config.js 配置文件
const path = require('path') module.exports = { // webpack 配置 webpack: { // 配置别名 alias: { // 约定:使用 @ 表示 src 文件所在路径 '@': path.resolve(__dirname, 'src') } } }
(3)、修改package.json中的 scripts 命令
修改之前
"scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" },
修改之后
"scripts": { "start": "craco start", "build": "craco build", "test": "craco test", "eject": "react-scripts eject" }
(4)、测试是否生效
import { createBrowserRouter } from 'react-router-dom'
import Login from '@/pages/Login'
import Layout from '@/pages/Layout'
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
},
{
path: '/login',
element: <Login />,
},
])
export default router
实现步骤
-
在项目根目录创建
jsconfig.json配置文件 -
在配置文件中添加以下配置
{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }
六、登录模块

实现步骤
-
在
Login/index.js中创建登录页面基本结构 -
在 Login 目录中创建 index.scss 文件,指定组件样式
-
将
logo.png和login.png拷贝到 assets 目录中
import './index.scss' import { Card, Form, Input, Button } from 'antd' import logo from '@/assets/logo.png' const Login = () => { return ( <div className="login"> <Card className="login-container"> <img className="login-logo" src={logo} alt="" /> {/* 登录表单 */} <Form> <Form.Item> <Input size="large" placeholder="请输入手机号" /> </Form.Item> <Form.Item> <Input size="large" placeholder="请输入验证码" /> </Form.Item> <Form.Item> <Button type="primary" htmlType="submit" size="large" block> 登录 </Button> </Form.Item> </Form> </Card> </div> ) } export default Login
pages/Login/index.scss
.login { width: 100%; height: 100%; position: absolute; left: 0; top: 0; background: center/cover url('~@/assets/login.png'); .login-logo { width: 200px; height: 60px; display: block; margin: 0 auto 20px; } .login-container { width: 440px; height: 360px; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); box-shadow: 0 0 50px rgb(0 0 0 / 10%); } .login-checkbox-label { color: #1890ff; } }
效果如下:

实现步骤
-
为 Form 组件添加
validateTrigger属性,指定校验触发时机的集合 -
为 Form.Item 组件添加 name 属性
-
为 Form.Item 组件添加
rules属性,用来添加表单校验规则对象
import './index.scss' import { Card, Form, Input, Button } from 'antd' import logo from '@/assets/logo.png' const Login = () => { return ( <div className="login"> <Card className="login-container"> <img className="login-logo" src={logo} alt="" /> {/* 登录表单 */} <Form validateTrigger={['onBlur']}> <Form.Item name="mobile" rules={[ { required: true, message: '请输入手机号' }, { pattern: /^1[3-9]\d{9}$/, message: '手机号码格式不对' } ]}> <Input size="large" placeholder="请输入手机号" /> </Form.Item> <Form.Item name="code" rules={[ { required: true, message: '请输入验证码' }, ]}> <Input size="large" placeholder="请输入验证码" maxLength={6} /> </Form.Item> <Form.Item> <Button type="primary" htmlType="submit" size="large" block> 登录 </Button> </Form.Item> </Form> </Card> </div> ) } export default Login
实现步骤
-
为 Form 组件添加
onFinish属性,该事件会在点击登录按钮时触发 -
创建 onFinish 函数,通过函数参数 values 拿到表单值
-
Form 组件添加
initialValues属性,来初始化表单值
import './index.scss' import { Card, Form, Input, Button } from 'antd' import logo from '@/assets/logo.png' const Login = () => { // 点击登录按钮时触发 参数values即是表单输入数据 const onFinish = formValue => { console.log(formValue) } return ( <div className="login"> <Card className="login-container"> <img className="login-logo" src={logo} alt="" /> {/* 登录表单 */} <Form validateTrigger={['onBlur']} onFinish={ onFinish } initialValues={{ mobile: '15342218161', code: '123456', }}> <Form.Item name="mobile" rules={[ { required: true, message: '请输入手机号' }, { pattern: /^1[3-9]\d{9}$/, message: '手机号码格式不对' } ]}> <Input size="large" placeholder="请输入手机号" /> </Form.Item> <Form.Item name="code" rules={[ { required: true, message: '请输入验证码' }, ]}> <Input size="large" placeholder="请输入验证码" maxLength={6} /> </Form.Item> <Form.Item> <Button type="primary" htmlType="submit" size="large" block> 登录 </Button> </Form.Item> </Form> </Card> </div> ) } export default Login
七、
业务背景: 前端需要和后端拉取接口数据,axios是使用最广的工具插件,针对于项目中的使用,我们需要做一些简单的封装
在整个项目中会发送很多网络请求,使用axios三方库做好统一封装,方便统一管理和复用
1. 几乎所有的接口都是一样的接口域名
2. 几乎所有的接口都需要设置一样的超时时间
3. 几乎所有的接口都需要做Token权限处理
....

实现步骤
(1)、安装 axios 到项目
npm i axios
(2)、创建 utils/request.js 文件
(3)、创建 axios 实例,配置 baseURL,请求拦截器,响应拦截器
import axios from 'axios' const request = axios.create({ baseURL: 'http://geek.itheima.net/v1_0', timeout: 5000 }) // 添加请求拦截器 request.interceptors.request.use((config)=> { return config }, (error)=> { return Promise.reject(error) }) // 添加响应拦截器 request.interceptors.response.use((response)=> { // 2xx 范围内的状态码都会触发该函数。 // 对响应数据做点什么 return response.data }, (error)=> { // 超出 2xx 范围的状态码都会触发该函数。 // 对响应错误做点什么 return Promise.reject(error) }) export { request }
(4)、在 utils/index.js 中,统一导出request
import { request } from './request'
export { request }
八、

1. Redux中编写获取Token的异步获取和同步修改
2. Login组件负责提交action并且把表单数据传递过来
npm i react-redux @reduxjs/toolkit
import { createSlice } from '@reduxjs/toolkit'
import { request } from '@/utils'
const userStore = createSlice({
name: 'user',
// 数据状态
initialState: {
token:''
},
// 同步修改方法
reducers: {
setToken (state, action) {
state.token = action.payload
},
}
})
// 解构出actionCreater
const { setToken } = userStore.actions
// 获取reducer函数
const userReducer = userStore.reducer
// 异步方法封装
const fetchLogin = (loginForm) => {
return async (dispatch) => {
const res = await request.post('/authorizations', loginForm)
dispatch(setToken(res.data.token))
}
}
export { fetchLogin }
export default userReducer
store/index.js
import { configureStore } from '@reduxjs/toolkit'
import userReducer from './modules/user'
export default configureStore({
reducer: {
// 注册子模块
user: userReducer
}
})
redux与react组件进行关联
import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.scss'; import router from './router' import { RouterProvider } from 'react-router-dom' import {Provider} from 'react-redux' import store from './store' const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <React.StrictMode> <Provider store={store}> <RouterProvider router={router} /> </Provider> </React.StrictMode> );
Access to XMLHttpRequest at 'http://geek.itheima.net/v1_0/authorizations' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request.
第 1 步:前端请求走相对路径
// 使用相对路径 /api,由 devServer 代理转发,避免浏览器跨域 // 真实地址:http://geek.itheima.net/v1_0/... baseURL: '/api/v1_0', timeout: 5000
调用处不再手写任何前缀:
const res = await request.post('/authorizations', loginForm)
第 2 步:配置代理文件
在 src/setupProxy.js(CRA 项目固定位置)声明转发规则:
const { createProxyMiddleware } = require('http-proxy-middleware') // 注意:该文件必须位于 src 目录下,CRA 才会自动加载 module.exports = function (app) { app.use( '/api', createProxyMiddleware({ target: 'http://geek.itheima.net', // 真实接口域名 changeOrigin: true, // 修改请求头 Host,保证代理生效 pathRewrite: { '^/api': '' } // 去掉 /api 前缀再转发 }) ) }
三个配置项含义:

完整链路验证
浏览器 POST http://localhost:3000/api/v1_0/authorizations ← 同源 ✅ ↓ 匹配 /api 前缀 pathRewrite: '^/api' → '' ⇒ /v1_0/authorizations ↓ changeOrigin 转发 http://geek.itheima.net/v1_0/authorizations ← 与真实地址一致 ✅
为什么必须用 /api 这种前缀
前端真实接口是 /v1_0/authorizations,而代理需要「只拦截接口请求,不拦截页面资源(如 /static/js/...)」。用一个自定义前缀 /api 做标识最稳妥:
前端:/api/v1_0/authorizations(多了层 /api 是故意的)代理:pathRewrite 把 /api 去掉 → 还原成真实路径
前缀叫什么无所谓(/api、/proxy、/dev 都行),只要是不会和真实接口路径冲突的一段即可。
在Login/index.js中触发异步action(fetchLogin),
-
跳转到首页
-
提示用户登录成功
import './index.scss' import { Card, Form, Input, Button } from 'antd' import logo from '@/assets/logo.png' import { fetchLogin } from '@/store/modules/user' import { useDispatch } from 'react-redux' import { useNavigate } from "react-router-dom" import { message } from 'antd' const Login = () => { // 点击登录按钮时触发 参数values即是表单输入数据 const dispatch = useDispatch() const navigate = useNavigate() const onFinish = async formValue => { console.log(formValue) await dispatch(fetchLogin(formValue)) navigate('/') message.success('登录成功') } return ( <div className="login"> <Card className="login-container"> <img className="login-logo" src={logo} alt="" /> {/* 登录表单 */} <Form validateTrigger={['onBlur']} onFinish={ onFinish } initialValues={{ mobile: '15342218161', code: '246810', }}> <Form.Item name="mobile" rules={[ { required: true, message: '请输入手机号' }, { pattern: /^1[3-9]\d{9}$/, message: '手机号码格式不对' } ]}> <Input size="large" placeholder="请输入手机号" /> </Form.Item> <Form.Item name="code" rules={[ { required: true, message: '请输入验证码' }, ]}> <Input size="large" placeholder="请输入验证码" maxLength={6} /> </Form.Item> <Form.Item> <Button type="primary" htmlType="submit" size="large" block> 登录 </Button> </Form.Item> </Form> </Card> </div> ) } export default Login
await 的作用是:
-
暂停当前函数的执行,等这个 Promise 完成(resolve 或 reject)
-
等请求真正结束后,才继续执行后面的
navigate('/')和message.success
如果去掉 await 会怎样?
dispatch(fetchLogin(formValue)) // 没有 await navigate('/') message.success('登录成功')
这三行会几乎同时执行。dispatch 只是发出了一个登录请求,请求还在路上,代码就已经跳到 navigate('/') 了。

发现从后台获取的token已经存入Redux中了。
我们刷新浏览器,发现token丢失,如下所示:

业务背景: Token数据具有一定的时效时间,通常在几个小时,有效时间内无需重新获取,而基于Redux的存储方式又是基于内存的,刷新就会丢失,为了保持持久化,我们需要单独做处理
现存问题
Redux存入Token之后如果刷新浏览器,Token会丢失(持久化就是防止刷新时丢失Token)
问题原因
Redux是基于浏览器内存的存储方式,刷新时状态恢复为初始值
initialState: { token:'' },
整个方案分成两个步骤,第一步是获取并存token,不仅要存入redux,还要存入localStorage。第二步:在初始化的过程中,先不要直接赋值一个空字符串,而是先去LocalStorage中查看有没有,如果有就用LocalStorage中的,如果没有就赋值空字符串,这样的话,刷新的时候先从LocalStorage中取,从而不会丢失了。

第一步:将token存入localStorage中
const userStore = createSlice({ name: 'user', // 数据状态 initialState: { token:'' }, // 同步修改方法 reducers: { setToken (state, action) { state.token = action.payload // localStorage中存一份 localStorage.setItem('token_key',action.payload) } } })
如下所示:

第二步:初始化时先从localStorage中取
initialState: { token:localStorage.getItem('token_key') || '' },
此时再刷新浏览器,则redux中的token仍然存在

封装Token的存取删方法
封装的原因?
对于Token的各类操作在项目多个模块中都有用到,为了共享复用可以封装成工具函数

// 封装存取方法 const TOKENKEY = 'token_key' function setToken (token) { return localStorage.setItem(TOKENKEY, token) } function getToken () { return localStorage.getItem(TOKENKEY) } function removeToken () { return localStorage.removeItem(TOKENKEY) } export { setToken, getToken, removeToken }
import { request } from './request'
import {getToken,setToken,removeToken} from './token'
export { request,getToken,setToken,removeToken }
import { createSlice } from '@reduxjs/toolkit'
import { request,getToken,setToken as _setToken } from '@/utils'
const userStore = createSlice({
name: 'user',
// 数据状态
initialState: {
token:getToken() || ''
},
// 同步修改方法
reducers: {
setToken (state, action) {
state.token = action.payload
// localStorage中存一份
// localStorage.setItem('token_key',action.payload)
_setToken(action.payload)
}
}
})
// 解构出actionCreater
const { setToken } = userStore.actions
// 获取reducer函数
const userReducer = userStore.reducer
// 异步方法封装
const fetchLogin = (loginForm) => {
return async (dispatch) => {
const res = await request.post('/authorizations', loginForm)
console.log(res)
dispatch(setToken(res.data.token))
}
}
export { fetchLogin }
export default userReducer
业务背景: Token作为用户的数据标识,在接口层面起到了接口权限控制的作用,也就是说后端有很多接口都需要通过查看当前请求头信息中是否含有token数据,来决定是否正常返回数据
为什么要做这件事儿?
Token作为用户的一个标识数据,后端很多接口都会以它作为接口权限判断的依据;请求拦截器注入Token之后,所有用到Axios实例的接口请求都自动携带了Token

utils/request.js
// 添加请求拦截器 request.interceptors.request.use(config => { // if not login add token const token = getToken() if (token) { config.headers.Authorization = `Bearer ${token}` } return config })
import {request} from '@/utils'
import { useEffect } from 'react'
const Layout = () => {
useEffect(()=>{ // useEffect组件渲染完毕之后从后台获取数据
request.get('/user/profile')
},[])
return <div>this is layout</div>
}
export default Layout
效果如下:


实现步骤
-
在 components 目录中,创建
AuthRoute/index.jsx文件 -
登录时,直接渲染相应页面组件
-
未登录时,重定向到登录页面
-
将需要鉴权的页面路由配置,替换为 AuthRoute 组件渲染
代码实现
components/AuthRoute/index.jsx
import { getToken } from '@/utils'
import { Navigate } from 'react-router-dom'
const AuthRoute = ({ children }) => {
const isToken = getToken()
if (isToken) {
return <>{children}</>
} else {
return <Navigate to="/login" replace />
}
}
export default AuthRoute
src/router/index.jsx
import { createBrowserRouter } from 'react-router-dom'
import Login from '@/pages/Login'
import Layout from '@/pages/Layout'
import AuthRoute from '@/components/AuthRoute'
const router = createBrowserRouter([
{
path: '/',
element: <AuthRoute><Layout /></AuthRoute>,
},
{
path: '/login',
element: <Login />,
},
])
export default router
测试有无token情况下的高阶组件
当删除localStorage中的token时,会跳转到登录页面


这样根据token的有无控制路由权限的跳转就完成了
九、

实现步骤
-
打开
antd/Layout布局组件文档,找到示例:顶部-侧边布局-通栏 -
拷贝示例代码到我们的 Layout 页面中
-
分析并调整页面布局
import { Layout, Menu, Popconfirm } from 'antd'
import {
HomeOutlined,
DiffOutlined,
EditOutlined,
LogoutOutlined,
} from '@ant-design/icons'
import './index.scss'
const { Header, Sider } = Layout
const items = [
{
label: '首页',
key: '1',
icon: <HomeOutlined />,
},
{
label: '文章管理',
key: '2',
icon: <DiffOutlined />,
},
{
label: '创建文章',
key: '3',
icon: <EditOutlined />,
},
]
const GeekLayout = () => {
return (
<Layout>
<Header className="header">
<div className="logo" />
<div className="user-info">
<span className="user-name">柴柴老师</span>
<span className="user-logout">
<Popconfirm title="是否确认退出?" okText="退出" cancelText="取消">
<LogoutOutlined /> 退出
</Popconfirm>
</span>
</div>
</Header>
<Layout>
<Sider width={200} className="site-layout-background">
<Menu
mode="inline"
theme="dark"
defaultSelectedKeys={['1']}
items={items}
style={{ height: '100%', borderRight: 0 }}></Menu>
</Sider>
<Layout className="layout-content" style={{ padding: 20 }}>
内容
</Layout>
</Layout>
</Layout>
)
}
export default GeekLayout
pages/Layout/index.scss
.ant-layout { height: 100%; } .header { padding: 0; } .logo { width: 200px; height: 60px; background: url('~@/assets/logo.png') no-repeat center / 160px auto; } .layout-content { overflow-y: auto; } .user-info { position: absolute; right: 0; top: 0; padding-right: 20px; color: #fff; .user-name { margin-right: 20px; } .user-logout { display: inline-block; cursor: pointer; } } .ant-layout-header { padding: 0 !important; }
效果如下:

样式重置:样式的全局初始化
npm install normalize.css
在入口文件index.js文件中引入
import 'normalize.css'
现在来让当前视图占满整个屏幕,在入口样式文件index.scss中添加下面的代码:
html, body { margin: 0; height: 100%; } #root { height: 100%; }
效果如下:


使用步骤
-
在 pages 目录中,分别创建:Home(数据概览)/Article(内容管理)/Publish(发布文章)页面文件夹
-
分别在三个文件夹中创建 index.jsx 并创建基础组件后导出
-
在
router/index.js中配置嵌套子路由,在Layout中配置二级路由出口 -
使用 Link 修改左侧菜单内容,与子路由规则匹配实现路由切换
const Home = () => { return <div>Home</div> } export default Home
pages/Article/index.js
const Article = () => { return <div>Article</div> } export default Article
pages/Publish/index.js
const Publish = () => { return <div>Publish</div> } export default Publish
router/index.js
import { createBrowserRouter } from 'react-router-dom'
import Login from '@/pages/Login'
import Layout from '@/pages/Layout'
import AuthRoute from '@/components/AuthRoute'
import Home from '@/pages/Home'
import Article from '@/pages/Article'
import Publish from '@/pages/Publish'
const router = createBrowserRouter([
{
path: '/',
element: <AuthRoute><Layout /></AuthRoute>,
children: [
{
index: true,
element: <Home />,
},
{
path: 'article',
element: <Article />,
},
{
path: 'publish',
element: <Publish />,
},
],
},
{
path: '/login',
element: <Login />,
},
])
export default router
Layout组件中配置二级路由出口
import { Layout, Menu, Popconfirm } from 'antd'
import { Outlet } from 'react-router-dom'
import {
HomeOutlined,
DiffOutlined,
EditOutlined,
LogoutOutlined,
} from '@ant-design/icons'
import './index.scss'
const { Header, Sider } = Layout
const items = [
{
label: '首页',
key: '1',
icon: <HomeOutlined />,
},
{
label: '文章管理',
key: '2',
icon: <DiffOutlined />,
},
{
label: '创建文章',
key: '3',
icon: <EditOutlined />,
},
]
const GeekLayout = () => {
return (
<Layout>
<Header className="header">
<div className="logo" />
<div className="user-info">
<span className="user-name">柴柴老师</span>
<span className="user-logout">
<Popconfirm title="是否确认退出?" okText="退出" cancelText="取消">
<LogoutOutlined /> 退出
</Popconfirm>
</span>
</div>
</Header>
<Layout>
<Sider width={200} className="site-layout-background">
<Menu
mode="inline"
theme="dark"
defaultSelectedKeys={['1']}
items={items}
style={{ height: '100%', borderRight: 0 }}></Menu>
</Sider>
<Layout className="layout-content" style={{ padding: 20 }}>
{/* 二级路由的出口 */}
<Outlet />
</Layout>
</Layout>
</Layout>
)
}
export default GeekLayout
效果如下:

实现效果:点击左侧菜单可以跳转到对应的目标路由
思路分析
1.左侧菜单要和路由形成一一对应的关系(知道点了谁)
2.点击时拿到路由路径调用路由方法跳转(跳转到对应的路由下面)

import { Layout, Menu, Popconfirm } from 'antd'
import { Outlet , useNavigate} from 'react-router-dom'
import {useState} from 'react'
import {
HomeOutlined,
DiffOutlined,
EditOutlined,
LogoutOutlined,
} from '@ant-design/icons'
import './index.scss'
const { Header, Sider } = Layout
const items = [
{
label: '首页',
key: '/',
icon: <HomeOutlined />,
},
{
label: '文章管理',
key: '/article',
icon: <DiffOutlined />,
},
{
label: '创建文章',
key: '/publish',
icon: <EditOutlined />,
},
]
const GeekLayout = () => {
const navigate = useNavigate()
const [selectedKey, setSelectedKey] = useState('1') // 默认选中首页
const menuClick = (route) => {
navigate(route.key)
setSelectedKey(route.key) // 点击时更新选中项
}
return (
<Layout>
<Header className="header">
<div className="logo" />
<div className="user-info">
<span className="user-name">柴柴老师</span>
<span className="user-logout">
<Popconfirm title="是否确认退出?" okText="退出" cancelText="取消">
<LogoutOutlined /> 退出
</Popconfirm>
</span>
</div>
</Header>
<Layout>
<Sider width={200} className="site-layout-background">
<Menu
mode="inline"
theme="dark"
selectedKeys={selectedKey}
items={items}
style={{ height: '100%', borderRight: 0 }}
onClick={menuClick}></Menu>
</Sider>
<Layout className="layout-content" style={{ padding: 20 }}>
{/* 二级路由的出口 */}
<Outlet />
</Layout>
</Layout>
</Layout>
)
}
export default GeekLayout

思路分析
1. 获取当前url上的路由路径
2. 找到菜单组件负责高亮的属性,绑定当前的路由路径
import { Layout, Menu, Popconfirm } from 'antd'
import { Outlet , useNavigate,useLocation} from 'react-router-dom'
import {useState} from 'react'
import {
HomeOutlined,
DiffOutlined,
EditOutlined,
LogoutOutlined,
} from '@ant-design/icons'
import './index.scss'
const { Header, Sider } = Layout
const items = [
{
label: '首页',
key: '/',
icon: <HomeOutlined />,
},
{
label: '文章管理',
key: '/article',
icon: <DiffOutlined />,
},
{
label: '创建文章',
key: '/publish',
icon: <EditOutlined />,
},
]
const GeekLayout = () => {
const navigate = useNavigate()
//const [selectedKey, setSelectedKey] = useState('1') // 默认选中首页
const menuClick = (route) => {
navigate(route.key)
//setSelectedKey(route.key) // 点击时更新选中项
}
// 反向高亮
// 1、获取当前路由路径
const location = useLocation()
console.log(location.pathname)
const selectedKey = location.pathname
return (
<Layout>
<Header className="header">
<div className="logo" />
<div className="user-info">
<span className="user-name">柴柴老师</span>
<span className="user-logout">
<Popconfirm title="是否确认退出?" okText="退出" cancelText="取消">
<LogoutOutlined /> 退出
</Popconfirm>
</span>
</div>
</Header>
<Layout>
<Sider width={200} className="site-layout-background">
<Menu
mode="inline"
theme="dark"
selectedKeys={selectedKey}
items={items}
style={{ height: '100%', borderRight: 0 }}
onClick={menuClick}></Menu>
</Sider>
<Layout className="layout-content" style={{ padding: 20 }}>
{/* 二级路由的出口 */}
<Outlet />
</Layout>
</Layout>
</Layout>
)
}
export default GeekLayout

实现步骤
(1)、在Redux的store中编写获取用户信息的相关逻辑
const fetchUserInfo = () => { return async (dispatch) => { const res = await http.get('/user/profile') dispatch(setUserInfo(res.data)) } }
(2)、在Layout组件中触发action的执行
(3)、在Layout组件使用使用store中的数据进行用户名的渲染
代码实现
store/userStore.js
import { createSlice } from '@reduxjs/toolkit'
import { request,getToken,setToken as _setToken } from '@/utils'
const userStore = createSlice({
name: 'user',
// 数据状态
initialState: {
token:getToken() || '',
userInfo: {}
},
// 同步修改方法
reducers: {
setToken (state, action) {
state.token = action.payload
// localStorage中存一份
_setToken(action.payload)
},
setUserInfo (state, action) {
state.userInfo = action.payload
}
}
})
// 解构出actionCreater
const { setToken, setUserInfo } = userStore.actions
// 获取reducer函数
const userReducer = userStore.reducer
// 异步方法封装
const fetchLogin = (loginForm) => {
return async (dispatch) => {
const res = await request.post('/authorizations', loginForm)
console.log(res)
dispatch(setToken(res.data.token))
}
}
const fetchUserInfo = () => {
return async (dispatch) => {
const res = await request.get('/user/profile')
console.log(res)
dispatch(setUserInfo(res.data))
}
}
export { fetchLogin, fetchUserInfo }
export default userReducer
pages/Layout/index.js
import { Layout, Menu, Popconfirm } from 'antd'
import { Outlet , useNavigate,useLocation} from 'react-router-dom'
import {useEffect} from 'react'
import {useSelector,useDispatch} from 'react-redux'
import {fetchUserInfo} from '@/store/modules/user'
import {
HomeOutlined,
DiffOutlined,
EditOutlined,
LogoutOutlined,
} from '@ant-design/icons'
import './index.scss'
const { Header, Sider } = Layout
const items = [
{
label: '首页',
key: '/',
icon: <HomeOutlined />,
},
{
label: '文章管理',
key: '/article',
icon: <DiffOutlined />,
},
{
label: '创建文章',
key: '/publish',
icon: <EditOutlined />,
},
]
const GeekLayout = () => {
const navigate = useNavigate()
//const [selectedKey, setSelectedKey] = useState('1') // 默认选中首页
const menuClick = (route) => {
navigate(route.key)
//setSelectedKey(route.key) // 点击时更新选中项
}
// 反向高亮
// 1、获取当前路由路径
const location = useLocation()
console.log(location.pathname)
const selectedKey = location.pathname
// React组件中修改store中的数据需要借助useDispatch
const dispatch = useDispatch()
useEffect(() => { // 渲染完毕后获取后台数据
dispatch(fetchUserInfo())
}, []) // 空数组表示执行一次
// useSelector的作用是把store中的数据映射到组件中
const name = useSelector(state => state.user.userInfo.name)
return (
<Layout>
<Header className="header">
<div className="logo" />
<div className="user-info">
<span className="user-name">{name}</span>
<span className="user-logout">
<Popconfirm title="是否确认退出?" okText="退出" cancelText="取消">
<LogoutOutlined /> 退出
</Popconfirm>
</span>
</div>
</Header>
<Layout>
<Sider width={200} className="site-layout-background">
<Menu
mode="inline"
theme="dark"
selectedKeys={selectedKey}
items={items}
style={{ height: '100%', borderRight: 0 }}
onClick={menuClick}></Menu>
</Sider>
<Layout className="layout-content" style={{ padding: 20 }}>
{/* 二级路由的出口 */}
<Outlet />
</Layout>
</Layout>
</Layout>
)
}
export default GeekLayout
实现步骤
(1)、为气泡确认框添加确认回调事件
<span className="user-logout"> <Popconfirm title="是否确认退出?" okText="退出" cancelText="取消" onConfirm={loginOut}> <LogoutOutlined /> 退出 </Popconfirm> </span>
(2)、在store/userStore.js 中新增退出登录的action函数,在其中删除token
import { createSlice } from '@reduxjs/toolkit'
import { request,getToken,setToken as _setToken,removeToken } from '@/utils'
const userStore = createSlice({
name: 'user',
// 数据状态
initialState: {
token:getToken() || '',
userInfo: {}
},
// 同步修改方法
reducers: {
setToken (state, action) {
state.token = action.payload
// localStorage中存一份
_setToken(action.payload)
},
setUserInfo (state, action) {
state.userInfo = action.payload
},
clearUserInfo (state) {
state.token = ''
state.userInfo = {}
removeToken() // 删除localStorage中的token
}
}
})
// 解构出actionCreater
const { setToken, setUserInfo, clearUserInfo } = userStore.actions
// 获取reducer函数
const userReducer = userStore.reducer
// 异步方法封装
const fetchLogin = (loginForm) => {
return async (dispatch) => {
const res = await request.post('/authorizations', loginForm)
console.log(res)
dispatch(setToken(res.data.token))
}
}
const fetchUserInfo = () => {
return async (dispatch) => {
const res = await request.get('/user/profile')
console.log(res)
dispatch(setUserInfo(res.data))
}
}
export { fetchLogin, fetchUserInfo, clearUserInfo }
export default userReducer
(3)、在回调事件中,调用userStore中的退出action
(4)、清除用户信息,返回登录页面
// 退出登录 const loginOut = () => { dispatch(clearUserInfo()) navigate('/login') }
业务背景:如果用户一段时间不做任何操作,到时之后应该清除所有过期用户信息跳回到登录
什么是Token失效?
为了用户的安全和隐私考虑,在用户长时间未在网站中做任何操作且规定的失效时间到达之后,当前的Token就会失效,一旦失效,不能再作为用户令牌标识请求隐私数据
通常在Token失效之后再去请求接口,后端会返回401状态码,前端可以监控这个状态做后续的操作
Token失效了前端做什么?

// 添加响应拦截器 request.interceptors.response.use((response)=> { // 2xx 范围内的状态码都会触发该函数。 // 对响应数据做点什么 return response.data }, (error)=> { // 超出 2xx 范围的状态码都会触发该函数。 // 对响应错误做点什么 console.dir(error) if (error.response.status === 401) { removeToken() router.navigate('/login') window.location.reload() } return Promise.reject(error) })

十、


点击“快速入门”

点击“基础柱状图”

图表类业务渲染,我们可以通过下面的顺序来实现
(2)、按照实际业务需求进行修改
注意:
1. 图表的初始化在useEffect中完成,在useEffect中初始化,保证dom节点是可用的
2. 挂载渲染的节点需要有宽高
安装echarts
npm i echarts
实现基础Demo
import * as echarts from 'echarts'; import {useState,useEffect} from 'react' const Home = () => { useEffect(() => { // 渲染完毕后获取后台数据 // 保证dom可用后再渲染 let chartDom = document.getElementById('main'); let myChart = echarts.init(chartDom); let option; option = { xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] }, yAxis: { type: 'value' }, series: [ { data: [120, 200, 150, 80, 70, 110, 130], type: 'bar' } ] }; option && myChart.setOption(option); }, []) // 空数组表示执行一次 return <div> <div id="main" style={{ width: 400, height: 400 }}></div> </div> } export default Home
效果:

上面的代码中,我们用的是原生的dom操作,如下所示:
let chartDom = document.getElementById('main');
我们可以用React中的方式进行dom操作
import * as echarts from 'echarts'; import {useState,useEffect,useRef} from 'react' const Home = () => { const chartRef = useRef(null) useEffect(() => { // 渲染完毕后获取后台数据 // 保证dom可用后再渲染 // let chartDom = document.getElementById('main'); const chartDom = chartRef.current let myChart = echarts.init(chartDom); let option; option = { xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] }, yAxis: { type: 'value' }, series: [ { data: [120, 200, 150, 80, 70, 110, 130], type: 'bar' } ] }; option && myChart.setOption(option); }, []) // 空数组表示执行一次 return <div> <div ref={chartRef} style={{ width: 500, height: 400 }}></div> </div> } export default Home
效果如下:

按照实际业务需求进行修改
import * as echarts from 'echarts'; import {useState,useEffect,useRef} from 'react' const Home = () => { const chartRef = useRef(null) useEffect(() => { // 渲染完毕后获取后台数据 // 保证dom可用后再渲染 // let chartDom = document.getElementById('main'); const chartDom = chartRef.current let myChart = echarts.init(chartDom); let option; option = { xAxis: { type: 'category', data: ['Vue', 'React', 'Angular'] }, yAxis: { type: 'value' }, series: [ { data: [10, 40, 70], type: 'bar' } ] }; option && myChart.setOption(option); }, []) // 空数组表示执行一次 return <div> <div ref={chartRef} style={{ width: 500, height: 400 }}></div> </div> } export default Home
为什么要做组件封装?
组件封装主要解决了复用的问题


在Home目录下新增components目录,这个组件只为Home来服务。在components目录下新增BarChart.js
BarChart.js
import * as echarts from 'echarts'; import {useState,useEffect,useRef} from 'react' const BarChart = () => { const chartRef = useRef(null) useEffect(() => { // 渲染完毕后获取后台数据 // 保证dom可用后再渲染 // let chartDom = document.getElementById('main'); const chartDom = chartRef.current let myChart = echarts.init(chartDom); let option; option = { title: { text: '三大框架满意度' }, xAxis: { type: 'category', data: ['Vue', 'React', 'Angular'] }, yAxis: { type: 'value' }, series: [ { data: [10, 40, 30], type: 'bar' } ] }; option && myChart.setOption(option); }, []) // 空数组表示执行一次 return <div> <div ref={chartRef} style={{ width: 500, height: 400 }}></div> </div> }; export default BarChart
Home/index.js
import BarChart from './components/BarChart' const Home = () => { return <div> <BarChart/> </div> } export default Home
效果:

抽象可变参数
把可变部分抽象成prop参数
BarChart.js
import * as echarts from 'echarts'; import {useState,useEffect,useRef} from 'react' const BarChart = ({title,xData, sData, style = { width: '400px', height: '300px' }}) => { const chartRef = useRef(null) useEffect(() => { // 渲染完毕后获取后台数据 // 保证dom可用后再渲染 // let chartDom = document.getElementById('main'); const chartDom = chartRef.current let myChart = echarts.init(chartDom); let option; option = { title: { text: title }, xAxis: { type: 'category', data: xData }, yAxis: { type: 'value' }, series: [ { data: sData, type: 'bar' } ] }; option && myChart.setOption(option); }, []) // 空数组表示执行一次 return <div> <div ref={chartRef} style={{ width: 500, height: 400 }}></div> </div> }; export default BarChart
Home/index.js
import BarChart from './components/BarChart' const Home = () => { return <div> <BarChart title={"三大框架满意度调查"} xData={['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']} sData={[120, 200, 150, 80, 70, 110, 130]}/> <BarChart title={"三大框架使用度调查"} xData={['Vue', 'React', 'Angular']} sData={[10, 40, 30]}/> </div> } export default Home
效果:

API模块封装
现存问题
当前的接口请求放到了功能实现的位置,没有在固定的模块内维护,后期查找维护困难
解决思路
把项目中的所有接口按照业务模块以函数的形式统一封装到apis模块中

在apis目录下新增user.js
import {request} from '@/utils'
export function loginApi(formData){
return request({
url: '/authorizations',
method: 'POST',
data: formData
})
}
export function getProfileApi(){
return request({
url: '/user/profile',
method: 'GET'
})
}
修改store/modules/user.js
import { createSlice } from '@reduxjs/toolkit'
import { loginApi,getProfileApi} from '@/apis/user'
import { request,getToken,setToken as _setToken,removeToken } from '@/utils'
const userStore = createSlice({
name: 'user',
// 数据状态
initialState: {
token:getToken() || '',
userInfo: {}
},
// 同步修改方法
reducers: {
setToken (state, action) {
state.token = action.payload
// localStorage中存一份
_setToken(action.payload)
},
setUserInfo (state, action) {
state.userInfo = action.payload
},
clearUserInfo (state) {
state.token = ''
state.userInfo = {}
removeToken() // 删除localStorage中的token
}
}
})
// 解构出actionCreater
const { setToken, setUserInfo, clearUserInfo } = userStore.actions
// 获取reducer函数
const userReducer = userStore.reducer
// 异步方法封装
const fetchLogin = (loginForm) => {
return async (dispatch) => {
const res = await loginApi(loginForm)
console.log(res)
dispatch(setToken(res.data.token))
}
}
const fetchUserInfo = () => {
return async (dispatch) => {
const res = await getProfileApi()
console.log(res)
dispatch(setUserInfo(res.data))
}
}
export { fetchLogin, fetchUserInfo, clearUserInfo }
export default userReducer
十一、发布文章模块
功能演示说明


1. 面包屑导航组件 Breadcrumb
2. 表单组件 Form
3. 输入框组件 Input
4. 下拉框组件 Select - Option
5. 按钮组件 Button
Publish/index.js
import { Card, Breadcrumb, Form, Button, Radio, Input, Upload, Space, Select } from 'antd' import { PlusOutlined } from '@ant-design/icons' import { Link } from 'react-router-dom' import './index.scss' const { Option } = Select const Publish = () => { return ( <div className="publish"> <Card title={ <Breadcrumb items={[ { title: <Link to={'/'}>首页</Link> }, { title: '发布文章' }, ]} /> } > <Form labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} initialValues={{ type: 1 }} > <Form.Item label="标题" name="title" rules={[{ required: true, message: '请输入文章标题' }]} > <Input placeholder="请输入文章标题" style={{ width: 400 }} /> </Form.Item> <Form.Item label="频道" name="channel_id" rules={[{ required: true, message: '请选择文章频道' }]} > <Select placeholder="请选择文章频道" style={{ width: 400 }}> <Option value={0}>推荐</Option> </Select> </Form.Item> <Form.Item label="内容" name="content" rules={[{ required: true, message: '请输入文章内容' }]} ></Form.Item> <Form.Item wrapperCol={{ offset: 4 }}> <Space> <Button size="large" type="primary" htmlType="submit"> 发布文章 </Button> </Space> </Form.Item> </Form> </Card> </div> ) } export default Publish
Publish/index.scss
.publish { position: relative; } .ant-upload-list { .ant-upload-list-picture-card-container, .ant-upload-select { width: 146px; height: 146px; } }
效果如下:

实现步骤
(1)、安装富文本编辑器
npm install react-quill-new
(2)、导入富文本编辑器组件以及样式文件
import ReactQuill from 'react-quill-new'; import 'react-quill-new/dist/quill.snow.css';
(3)、渲染富文本编辑器组件
<Form.Item label="内容" name="content" rules={[{ required: true, message: '请输入文章内容' }]} > <ReactQuill className="publish-quill" theme="snow" placeholder="请输入文章内容" /> </Form.Item>
(4)、调整富文本编辑器的样式
.publish-quill { .ql-editor { min-height: 300px; } }
效果如下:


实现步骤
(1)、使用useState初始化数据和修改数据的方法
(2)、在useEffect中调用接口并保存数据
(3)、使用数据渲染对应模版
代码实现
import { Card, Breadcrumb, Form, Button, Radio, Input, Upload, Space, Select } from 'antd' import { PlusOutlined } from '@ant-design/icons' import { Link } from 'react-router-dom' import './index.scss' import ReactQuill from 'react-quill-new'; import 'react-quill-new/dist/quill.snow.css'; import {useState,useEffect} from 'react' import {request} from '@/utils' const { Option } = Select const Publish = () => { // 频道列表 const [channels, setChannels] = useState([]) // 调用接口 useEffect(() => { async function fetchChannels() { const res = await request.get('/channels') setChannels(res.data.channels) } fetchChannels() }, []) return ( <div className="publish"> <Card title={ <Breadcrumb items={[ { title: <Link to={'/'}>首页</Link> }, { title: '发布文章' }, ]} /> } > <Form labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} initialValues={{ type: 1 }} > <Form.Item label="标题" name="title" rules={[{ required: true, message: '请输入文章标题' }]} > <Input placeholder="请输入文章标题" style={{ width: 400 }} /> </Form.Item> <Form.Item label="频道" name="channel_id" rules={[{ required: true, message: '请选择文章频道' }]} > <Select placeholder="请选择文章频道" style={{ width: 400 }}> {channels.map(item => ( <Option key={item.id} value={item.id}> {item.name} </Option> ))} </Select> </Form.Item> <Form.Item label="内容" name="content" rules={[{ required: true, message: '请输入文章内容' }]} > <ReactQuill className="publish-quill" theme="snow" placeholder="请输入文章内容" /> </Form.Item> <Form.Item wrapperCol={{ offset: 4 }}> <Space> <Button size="large" type="primary" htmlType="submit"> 发布文章 </Button> </Space> </Form.Item> </Form> </Card> </div> ) } export default Publish
import { Card, Breadcrumb, Form, Button, Radio, Input, Upload, Space, Select } from 'antd' import { PlusOutlined } from '@ant-design/icons' import { Link } from 'react-router-dom' import './index.scss' import ReactQuill from 'react-quill-new'; import 'react-quill-new/dist/quill.snow.css'; import {useState,useEffect} from 'react' import {request} from '@/utils' import { message } from 'antd' const { Option } = Select const Publish = () => { // 频道列表 const [channels, setChannels] = useState([]) // 调用接口 useEffect(() => { async function fetchChannels() { const res = await request.get('/channels') setChannels(res.data.channels) } fetchChannels() }, []) // 发布文章 const onFinish = async (formValue) => { const { channel_id, content, title } = formValue const params = { channel_id, content, title, type: 1, cover: { type: 1, images: [] } } await request.post('/mp/articles?draft=false', params) message.success('发布文章成功') } return ( <div className="publish"> <Card title={ <Breadcrumb items={[ { title: <Link to={'/'}>首页</Link> }, { title: '发布文章' }, ]} /> } > <Form labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} initialValues={{ type: 1 }} onFinish={ onFinish } > <Form.Item label="标题" name="title" rules={[{ required: true, message: '请输入文章标题' }]} > <Input placeholder="请输入文章标题" style={{ width: 400 }} /> </Form.Item> <Form.Item label="频道" name="channel_id" rules={[{ required: true, message: '请选择文章频道' }]} > <Select placeholder="请选择文章频道" style={{ width: 400 }}> {channels.map(item => ( <Option key={item.id} value={item.id}> {item.name} </Option> ))} </Select> </Form.Item> <Form.Item label="内容" name="content" rules={[{ required: true, message: '请输入文章内容' }]} > <ReactQuill className="publish-quill" theme="snow" placeholder="请输入文章内容" /> </Form.Item> <Form.Item wrapperCol={{ offset: 4 }}> <Space> <Button size="large" type="primary" htmlType="submit"> 发布文章 </Button> </Space> </Form.Item> </Form> </Card> </div> ) } export default Publish

<Form.Item label="封面"> <Form.Item name="type"> <Radio.Group> <Radio value={1}>单图</Radio> <Radio value={3}>三图</Radio> <Radio value={0}>无图</Radio> </Radio.Group> </Form.Item> <Upload listType="picture-card" showUploadList > <div style={{ marginTop: 8 }}> <PlusOutlined /> </div> </Upload> </Form.Item>
实现步骤
(1)、为 Upload 组件添加 action 属性,配置封面图片上传接口地址
(2)、为 Upload组件添加 name属性, 接口要求的字段名
(3)、为 Upload 添加 onChange 属性,在事件中拿到当前图片数据,并存储到React状态中
// 上传图片 const [imageList, setImageList] = useState([]) const onUploadChange = (info) => { setImageList(info.fileList) }
代码实现
import { Card, Breadcrumb, Form, Button, Radio, Input, Upload, Space, Select } from 'antd' import { PlusOutlined } from '@ant-design/icons' import { Link } from 'react-router-dom' import './index.scss' import ReactQuill from 'react-quill-new'; import 'react-quill-new/dist/quill.snow.css'; import {useState,useEffect} from 'react' import {request} from '@/utils' import { message } from 'antd' const { Option } = Select const Publish = () => { // 频道列表 const [channels, setChannels] = useState([]) // 调用接口 useEffect(() => { async function fetchChannels() { const res = await request.get('/channels') setChannels(res.data.channels) } fetchChannels() }, []) // 发布文章 const onFinish = async (formValue) => { const { channel_id, content, title } = formValue const params = { channel_id, content, title, type: 1, cover: { type: 1, images: [] } } await request.post('/mp/articles?draft=false', params) message.success('发布文章成功') } // 上传图片 const [imageList, setImageList] = useState([]) const onUploadChange = (info) => { setImageList(info.fileList) } return ( <div className="publish"> <Card title={ <Breadcrumb items={[ { title: <Link to={'/'}>首页</Link> }, { title: '发布文章' }, ]} /> } > <Form labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} initialValues={{ type: 1 }} onFinish={ onFinish } > <Form.Item label="标题" name="title" rules={[{ required: true, message: '请输入文章标题' }]} > <Input placeholder="请输入文章标题" style={{ width: 400 }} /> </Form.Item> <Form.Item label="频道" name="channel_id" rules={[{ required: true, message: '请选择文章频道' }]} > <Select placeholder="请选择文章频道" style={{ width: 400 }}> {channels.map(item => ( <Option key={item.id} value={item.id}> {item.name} </Option> ))} </Select> </Form.Item> <Form.Item label="封面"> <Form.Item name="type"> <Radio.Group> <Radio value={1}>单图</Radio> <Radio value={3}>三图</Radio> <Radio value={0}>无图</Radio> </Radio.Group> </Form.Item> <Upload name="image" listType="picture-card" showUploadList action={'http://geek.itheima.net/v1_0/upload'} onChange={onUploadChange} > <div style={{ marginTop: 8 }}> <PlusOutlined /> </div> </Upload> </Form.Item> <Form.Item label="内容" name="content" rules={[{ required: true, message: '请输入文章内容' }]} > <ReactQuill className="publish-quill" theme="snow" placeholder="请输入文章内容" /> </Form.Item> <Form.Item wrapperCol={{ offset: 4 }}> <Space> <Button size="large" type="primary" htmlType="submit"> 发布文章 </Button> </Space> </Form.Item> </Form> </Card> </div> ) } export default Publish

实现步骤
(1)、点击单选框时拿到当前的类型value
<Form.Item name="type"> <Radio.Group onChange={onTypeChange}> <Radio value={1}>单图</Radio> <Radio value={3}>三图</Radio> <Radio value={0}>无图</Radio> </Radio.Group> </Form.Item>
(2)、根据value控制上传组件的显示(大于零时才显示)
// 控制图片Type const [imageType, setImageType] = useState(0) const onTypeChange = (e) => { console.log(e) setImageType(e.target.value) } {imageType > 0 &&<Upload name="image" listType="picture-card" showUploadList action={'http://geek.itheima.net/v1_0/upload'} onChange={onUploadChange} > <div style={{ marginTop: 8 }}> <PlusOutlined /> </div> </Upload>}
实现步骤
(1)、通过 maxCount 属性限制图片的上传图片数量
{imageType > 0 &&<Upload
name="image"
listType="picture-card"
showUploadList
action={'http://geek.itheima.net/v1_0/upload'}
onChange={onUploadChange}
maxCount={imageType}
multiple={imageType > 1}
>
<div style={{ marginTop: 8 }}>
<PlusOutlined />
</div>
</Upload>}
multiple 是布尔值,为 true 时,文件选择框允许按住 Ctrl/Shift 一次选中多个文件;为 false(或不写)时,一次只能选一个。
业务描述 如果当前为三图模式,已经完成了上传,选择单图只显示一张,再切换到三图继续显示三张,该如何实现?
实现思路 在上传完毕之后通过ref存储所有图片,需要几张就显示几张,其实也就是把ref当仓库,用多少拿多少
实现步骤
(1)、通过useRef创建一个暂存仓库,在上传完毕图片的时候把图片列表存入
const cacheImageList = useRef([]) const [imageList, setImageList] = useState([]) const onUploadChange = (info) => { setImageList(info.fileList) cacheImageList.current = info.fileList }
这段代码是 React 中管理图片上传列表的典型写法,同时用到了 useState 和 useRef 来保存同一份数据。
为什么同一份数据要存两份?

核心原因:setState 是异步的,且有闭包陷阱。
场景 1:提交表单时想拿“最新的图片列表”
如果只用 state,在某个回调(比如点击“发布”)里直接读 imageList,可能读到的是旧值(闭包捕获的是那次渲染时的变量),尤其是在依赖数组没写全、或异步操作里。
而 cacheImageList.current 永远指向最新的 info.fileList,因为它不经过 state 的异步更新流程,读到的一定是当前值
const handleSubmit = () => { // 安全地拿到最新图片列表 const images = cacheImageList.current // 提交给后端... }
场景 2:避免闭包陷阱
useEffect(() => { // 这里如果直接读 imageList,依赖没写对就是旧值 // 读 cacheImageList.current 则始终是最新 console.log(cacheImageList.current) }, [])
(2)、如果是单图模式,就从仓库里取第一张图,以数组的形式存入fileList
(3)、如果是三图模式,就把仓库里所有的图片,以数组的形式存入fileList
const onTypeChange = (e) => { const type = e.target.value setImageType(type) if (type === 1) { // 单图,截取第一张展示 const imgList = cacheImageList.current[0] ? [cacheImageList.current[0]] : [] setImageList(imgList) } else if (type === 3) { // 三图,取所有图片展示 setImageList(cacheImageList.current) } }
-
setImageList(imgList)改了 state。 -
但 UI 要跟着变,就必须让 Upload 的
fileList绑定这个 state。 -
不传
fileList的话,Upload 只认自己内部的列表,setImageList改了也没用,界面不会变。
代码实现
import {useState,useEffect,useRef} from 'react'.... const Publish = () => {// 上传图片 const cacheImageList = useRef([]) // 创建一个 ref,初始值为空数组 []。 const [imageList, setImageList] = useState([]) const onUploadChange = (info) => { setImageList(info.fileList) // 更新 state → 触发渲染 → UI 更新 // ref.current 用来跨渲染保存数据,修改它不会触发组件重新渲染 cacheImageList.current = info.fileList // 同步更新 ref → 静默保存,不触发渲染 } // 控制图片Type const [imageType, setImageType] = useState(0) const onTypeChange = (e) => { const type = e.target.value setImageType(type) if (type === 1) { // 单图,截取第一张展示 const imgList = cacheImageList.current[0] ? [cacheImageList.current[0]] : [] setImageList(imgList) } else if (type === 3) { // 三图,取所有图片展示 setImageList(cacheImageList.current) } } return ( <div className="publish"> <Card title={ <Breadcrumb items={[ { title: <Link to={'/'}>首页</Link> }, { title: '发布文章' }, ]} /> } > <Form labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} initialValues={{ type: 1 }} onFinish={ onFinish } > <Form.Item label="标题" name="title" rules={[{ required: true, message: '请输入文章标题' }]} > <Input placeholder="请输入文章标题" style={{ width: 400 }} /> </Form.Item> <Form.Item label="频道" name="channel_id" rules={[{ required: true, message: '请选择文章频道' }]} > <Select placeholder="请选择文章频道" style={{ width: 400 }}> {channels.map(item => ( <Option key={item.id} value={item.id}> {item.name} </Option> ))} </Select> </Form.Item> <Form.Item label="封面"> <Form.Item name="type"> <Radio.Group onChange={onTypeChange}> <Radio value={1}>单图</Radio> <Radio value={3}>三图</Radio> <Radio value={0}>无图</Radio> </Radio.Group> </Form.Item> {imageType > 0 &&<Upload name="image" listType="picture-card" showUploadList action={'http://geek.itheima.net/v1_0/upload'} onChange={onUploadChange} maxCount={imageType} multiple={imageType > 1} fileList={imageList} > <div style={{ marginTop: 8 }}> <PlusOutlined /> </div> </Upload>} </Form.Item> <Form.Item label="内容" name="content" rules={[{ required: true, message: '请输入文章内容' }]} > <ReactQuill className="publish-quill" theme="snow" placeholder="请输入文章内容" /> </Form.Item> <Form.Item wrapperCol={{ offset: 4 }}> <Space> <Button size="large" type="primary" htmlType="submit"> 发布文章 </Button> </Space> </Form.Item> </Form> </Card> </div> ) } export default Publish
注意:需要给Upload组件添加fileList属性,达成受控的目的
这里传入了 imageList,意味着:Upload 显示什么文件,完全由 imageList 决定,组件自己不再私自维护列表。
fileList={imageList}
受控组件的标准闭环是:
<Upload fileList={imageList} // 1. 用 state 决定显示 onChange={onUploadChange} // 2. 用户操作后回调 /> const onUploadChange = (info) => { setImageList(info.fileList) // 3. 用最新列表更新 state cacheImageList.current = info.fileList }
流程:
-
用户选文件 / 删文件 → 触发
onChange。 -
onChange里把最新的info.fileList存进 state。 -
state 变化 → 组件重新渲染 →
fileList={imageList}传入新值 → UI 更新。
这就是单向数据流:onChange 改 state → state 驱动 fileList → fileList 驱动 UI。
上传图片解决跨域问题
<Form.Item label="封面"> <Form.Item name="type"> <Radio.Group onChange={onTypeChange}> <Radio value={1}>单图</Radio> <Radio value={3}>三图</Radio> <Radio value={0}>无图</Radio> </Radio.Group> </Form.Item> {imageType > 0 &&<Upload name="image" listType="picture-card" showUploadList action={'/api/v1_0/upload'} onChange={onUploadChange} maxCount={imageType} multiple={imageType > 1} fileList={imageList} > <div style={{ marginTop: 8 }}> <PlusOutlined /> </div> </Upload>} </Form.Item>
// 发布文章 const onFinish = async (formValue) => { if (imageType !== imageList.length) return message.warning('图片类型和数量不一致') const { channel_id, content, title } = formValue const params = { channel_id, content, title, type: imageType, cover: { type: imageType, images: imageList.map(item => item.response.data.url) } } await request.post('/mp/articles?draft=false', params) message.success('发布文章成功') }
十二、文章列表模块

(1)、如何让RangePicker日期范围选择框选择中文
(2)、Select组件配合Form.Item使用时,如何配置默认选中项
<Form initialValues={{ status: null }} >`
Article/index.js
import { Link } from 'react-router-dom'
import { Card, Breadcrumb, Form, Button, Radio, DatePicker, Select } from 'antd'
import locale from 'antd/es/date-picker/locale/zh_CN'
const { Option } = Select
const { RangePicker } = DatePicker
const Article = () => {
return (
<div>
<Card
title={
<Breadcrumb items={[
{ title: <Link to={'/'}>首页</Link> },
{ title: '文章列表' },
]} />
}
style={{ marginBottom: 20 }}
>
<Form initialValues={{ status: '' }}>
<Form.Item label="状态" name="status">
<Radio.Group>
<Radio value={''}>全部</Radio>
<Radio value={0}>草稿</Radio>
<Radio value={2}>审核通过</Radio>
</Radio.Group>
</Form.Item>
<Form.Item label="频道" name="channel_id">
<Select
placeholder="请选择文章频道"
defaultValue="lucy"
style={{ width: 120 }}
>
<Option value="jack">Jack</Option>
<Option value="lucy">Lucy</Option>
</Select>
</Form.Item>
<Form.Item label="日期" name="date">
{/* 传入locale属性 控制中文显示*/}
<RangePicker locale={locale}></RangePicker>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" style={{ marginLeft: 40 }}>
筛选
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default Article
效果:


代码实现
import { Link } from 'react-router-dom'
import { Card, Breadcrumb, Form, Button, Radio, DatePicker, Select } from 'antd'
import locale from 'antd/es/date-picker/locale/zh_CN'
// 导入资源
import { Table, Tag, Space } from 'antd'
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
import img404 from '@/assets/error.png'
const { Option } = Select
const { RangePicker } = DatePicker
const Article = () => {
// 准备列数据
const columns = [
{
title: '封面',
dataIndex: 'cover',
width: 120,
render: cover => {
return <img src={cover.images[0] || img404} width={80} height={60} alt="" />
}
},
{
title: '标题',
dataIndex: 'title',
width: 220
},
{
title: '状态',
dataIndex: 'status',
render: data => <Tag color="green">审核通过</Tag>
},
{
title: '发布时间',
dataIndex: 'pubdate'
},
{
title: '阅读数',
dataIndex: 'read_count'
},
{
title: '评论数',
dataIndex: 'comment_count'
},
{
title: '点赞数',
dataIndex: 'like_count'
},
{
title: '操作',
render: data => {
return (
<Space size="middle">
<Button type="primary" shape="circle" icon={<EditOutlined />} />
<Button
type="primary"
danger
shape="circle"
icon={<DeleteOutlined />}
/>
</Space>
)
}
}
]
// 准备表格body数据
const data = [
{
id: '8218',
comment_count: 0,
cover: {
images: [],
},
like_count: 0,
pubdate: '2019-03-11 09:00:00',
read_count: 2,
status: 2,
title: 'wkwebview离线化加载h5资源解决方案'
}
]
return (
<div>
<Card
title={
<Breadcrumb items={[
{ title: <Link to={'/'}>首页</Link> },
{ title: '文章列表' },
]} />
}
style={{ marginBottom: 20 }}
>
<Form initialValues={{ status: '' }}>
<Form.Item label="状态" name="status">
<Radio.Group>
<Radio value={''}>全部</Radio>
<Radio value={0}>草稿</Radio>
<Radio value={2}>审核通过</Radio>
</Radio.Group>
</Form.Item>
<Form.Item label="频道" name="channel_id">
<Select
placeholder="请选择文章频道"
defaultValue="lucy"
style={{ width: 120 }}
>
<Option value="jack">Jack</Option>
<Option value="lucy">Lucy</Option>
</Select>
</Form.Item>
<Form.Item label="日期" name="date">
{/* 传入locale属性 控制中文显示*/}
<RangePicker locale={locale}></RangePicker>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" style={{ marginLeft: 40 }}>
筛选
</Button>
</Form.Item>
</Form>
</Card>
{/* */}
<Card title={`根据筛选条件共查询到 count 条结果:`}>
<Table rowKey="id" columns={columns} dataSource={data} />
</Card>
</div>
)
}
export default Article
效果如下:

实现步骤
// 频道列表 const [channels, setChannels] = useState([]) // 调用接口 useEffect(() => { async function fetchChannels() { const res = await request.get('/channels') setChannels(res.data.channels) } fetchChannels() }, [])
(2)、将使用频道数据列表改写下拉框组件
<Form.Item label="频道" name="channel_id"> <Select placeholder="请选择文章频道" style={{ width: 120 }} > {channels.map(item => ( <Option key={item.id} value={item.id}> {item.name} </Option> ))} </Select> </Form.Item>
实现步骤
(1)、声明列表相关数据管理
// 文章列表数据管理 const [article, setArticleList] = useState({ list: [], count: 0 })
(2)、使用useState声明参数相关数据管理
const [params, setParams] = useState({ page: 1, per_page: 4, begin_pubdate: null, end_pubdate: null, status: null, channel_id: null })
(3)、调用接口获取数据
useEffect(() => { async function fetchArticleList () { const res = await request.get('/mp/articles', { params }) const { results, total_count } = res.data setArticleList({ list: results, count: total_count }) } fetchArticleList() }, [params])
(4)、使用接口数据渲染模板
{/* */}
<Card title={`根据筛选条件共查询到 ${article.count} 条结果:`}>
<Table rowKey="id" columns={columns} dataSource={article.list} />
</Card>
实现步骤
(1)、为表单添加onFinish属性监听表单提交事件,获取参数
(2)、根据接口字段格式要求格式化参数格式
(3)、修改params 参数并重新使用新参数重新请求数据
代码实现
import { Link } from 'react-router-dom'
import { Card, Breadcrumb, Form, Button, Radio, DatePicker, Select } from 'antd'
import locale from 'antd/es/date-picker/locale/zh_CN'
// 导入资源
import { Table, Tag, Space } from 'antd'
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
import img404 from '@/assets/error.png'
import {useState,useEffect} from 'react'
import {request} from '@/utils'
import {getArticleListApi} from '@/apis/article'
const { Option } = Select
const { RangePicker } = DatePicker
const Article = () => {
// 准备列数据
const columns = [
{
title: '封面',
dataIndex: 'cover',
width: 120,
render: cover => {
return <img src={cover.images[0] || img404} width={80} height={60} alt="" />
}
},
{
title: '标题',
dataIndex: 'title',
width: 220
},
{
title: '状态',
dataIndex: 'status',
render: data => <Tag color="green">审核通过</Tag>
},
{
title: '发布时间',
dataIndex: 'pubdate'
},
{
title: '阅读数',
dataIndex: 'read_count'
},
{
title: '评论数',
dataIndex: 'comment_count'
},
{
title: '点赞数',
dataIndex: 'like_count'
},
{
title: '操作',
render: data => {
return (
<Space size="middle">
<Button type="primary" shape="circle" icon={<EditOutlined />} />
<Button
type="primary"
danger
shape="circle"
icon={<DeleteOutlined />}
/>
</Space>
)
}
}
]
// 准备表格body数据
const data = [
{
id: '8218',
comment_count: 0,
cover: {
images: [],
},
like_count: 0,
pubdate: '2019-03-11 09:00:00',
read_count: 2,
status: 2,
title: 'wkwebview离线化加载h5资源解决方案'
}
]
// 频道列表
const [channels, setChannels] = useState([])
// 调用接口
useEffect(() => {
async function fetchChannels() {
const res = await request.get('/channels')
setChannels(res.data.channels)
}
fetchChannels()
}, [])
// 文章列表数据管理
// const [article, setArticleList] = useState({
// list: [],
// count: 0
// })
const [list, setList] = useState([])
const [count, setCount] = useState(0)
const [params, setParams] = useState({
page: 1,
per_page: 4,
begin_pubdate: null,
end_pubdate: null,
status: null,
channel_id: null
})
async function getList (reqData = {}) {
const res = await getArticleListApi(reqData)
// const { results, total_count } = res.data
// setArticleList({
// list: results,
// count: total_count
// })
console.log(res)
setList(res.data.results)
setCount(res.data.total_count)
}
useEffect(() => {
getList()
}, [])
const onFinish = async formValue => {
console.log(formValue)
// 1. 准备参数
const { channel_id, date, status } = formValue
const reqData = {
status,
channel_id,
begin_pubdate: date?.[0]?.format('YYYY-MM-DD') || undefined,
end_pubdate: date?.[1]?.format('YYYY-MM-DD') || undefined,
}
console.log(reqData)
// 2. 使用参数获取新的列表
getList(reqData)
}
return (
<div>
<Card
title={
<Breadcrumb items={[
{ title: <Link to={'/'}>首页</Link> },
{ title: '文章列表' },
]} />
}
style={{ marginBottom: 20 }}
>
<Form initialValues={{ status: '' }} onFinish={ onFinish }>
<Form.Item label="状态" name="status">
<Radio.Group>
<Radio value={''}>全部</Radio>
<Radio value={0}>草稿</Radio>
<Radio value={2}>审核通过</Radio>
</Radio.Group>
</Form.Item>
<Form.Item label="频道" name="channel_id">
<Select
placeholder="请选择文章频道"
style={{ width: 120 }}
>
{channels.map(item => (
<Option key={item.id} value={item.id}>
{item.name}
</Option>
))}
</Select>
</Form.Item>
<Form.Item label="日期" name="date">
{/* 传入locale属性 控制中文显示*/}
<RangePicker locale={locale}></RangePicker>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" style={{ marginLeft: 40 }}>
筛选
</Button>
</Form.Item>
</Form>
</Card>
{/* */}
<Card title={`根据筛选条件共查询到 ${count} 条结果:`}>
<Table rowKey="id" columns={columns} dataSource={list} />
</Card>
</div>
)
}
export default Article
实现步骤
(1)、为Table组件指定pagination属性来展示分页效果
{/* */}
<Card title={`根据筛选条件共查询到 ${count} 条结果:`}>
<Table rowKey="id" columns={columns} dataSource={list} pagination={{
current: params.page,
pageSize: params.per_page,
onChange: pageChange,
total: count
}} />
</Card>
(2)、在分页切换事件中获取到筛选表单中选中的数据
(3)、使用当前页数据修改params参数依赖引起接口重新调用获取最新数据
const pageChange = (page) => { // 拿到当前页参数 修改params 引起接口更新 setParams({ ...params, page }) }
实现步骤
(1)、给删除文章按钮绑定点击事件
(2)、弹出确认窗口,询问用户是否确定删除文章
先引入Popconfirm
import { Card, Breadcrumb, Form, Button, Radio, DatePicker, Select,Popconfirm } from 'antd'
使用Popconfirm
<Popconfirm title="确认删除该条文章吗?" onConfirm={() => delArticle(data)} okText="确认" cancelText="取消" > <Button type="primary" danger shape="circle" icon={<DeleteOutlined />} /> </Popconfirm>
(3)、拿到参数调用删除接口,更新列表
article.js
// 删除文章 export function delArticleApi(id) { return request({ url: `/mp/articles/${id}`, method: 'DELETE' }) }
article/index.js
async function delArticle (data) { await delArticleApi(data.id) // 更新列表 setParams({ page: 1, per_page: 2 }) }
<Button type="primary" shape="circle" icon={<EditOutlined />} onClick={() => navigate(`/publish?id=${data.id}`)} />
十三、编辑文章

(1)、调用接口获取后台数据
// 读写 URL 查询参数 const [searchParams] = useSearchParams() const articleId = searchParams.get('id') const [form] = Form.useForm() useEffect(() => { async function getArticle () { const res = await getArticleApi(articleId) const { cover, ...formValue } = res.data // 设置表单数据 form.setFieldsValue({ ...formValue, type: cover.type }) } if (articleId) { // 拉取数据回显 getArticle() } }, [articleId, form])
(2)、表单实例通过 form 属性绑到 <Form>
<Form form={form} labelCol={{ span: 4 }} wrapperCol={{ span: 16 }} initialValues={{ type: 0 }} // 默认无图 onFinish={ onFinish } >

// 读写 URL 查询参数 const [searchParams] = useSearchParams() const articleId = searchParams.get('id') const [form] = Form.useForm() useEffect(() => { async function getArticle () { const res = await getArticleApi(articleId) console.log(res) const { cover, ...formValue } = res.data // 设置表单数据 form.setFieldsValue({ ...formValue, type: cover.type }) // 2. 回填封面图片 setImageType(cover.type) // 封面类型 setImageList(cover.images.map(url => ({ url }))) // 封面list } if (articleId) { // 拉取数据回显 getArticle() } }, [articleId, form])
返回的数据如下:

<Breadcrumb items={[ { title: <Link to={'/'}>首页</Link> }, { title: `${articleId ? '编辑文章' : '发布文章'}` }, ]} />
const onFinish = async (formValue) => { console.log(imageList) if (imageType !== imageList.length) return message.warning('图片类型和数量不一致') const { channel_id, content, title } = formValue const formatUrl = (list) => { return list.map(item => { if (item.response) { return item.response.data.url } else { return item.url } }) } const params = { channel_id, content, title, type: imageType, cover: { type: imageType, images: formatUrl(imageList) } } // await request.post('/mp/articles?draft=false', params) if (articleId) { // 编辑 await request.put(`/mp/articles/${articleId}?draft=false`, params) } else { // 新增 await request.post('/mp/articles?draft=false', params) } // message.success('发布文章成功') message.success(`${articleId ? '编辑' : '发布'}文章成功`) }
十四、项目打包
命令
npm run build
(模拟服务器运行项目)
实现步骤
(1)、全局安装本地服务包 npm i -g serve 该包提供了serve命令,用来启动本地服务器
npm i -g serve
(2)、在项目根目录中执行命令 serve -s ./build 在build目录中开启服务器
serve -s ./build
(3)、在浏览器中访问:http://localhost:3000/ 预览项目
什么是路由懒加载?
路由懒加载是指路由的JS资源只有在被访问时才会动态获取,目的是为了优化项目首次打开的时间

使用步骤
把路由修改为由React提供的 lazy 函数进行动态导入
使用React内置的 Suspense组件 包裹路由中element选项对应的组件
代码实现
router/index.js
业务背景 通过分析打包体积,才能知道项目中的哪部分内容体积过大,方便知道哪些包如何来优化
怎么做到?source-map-explorer
使用步骤
(1)、安装分析打包体积的包:npm i source-map-explorer
(2)、在 package.json 中的 scripts 标签中,添加分析打包体积的命令
(3)、对项目打包:npm run build(如果已经打过包,可省略这一步)
(4)、运行分析命令:npm run analyze
(5)、通过浏览器打开的页面,分析图表中的包体积
核心代码:
"scripts": { "analyze": "source-map-explorer 'build/static/js/*.js'", }

什么是CDN?
CDN是一种内容分发网络服务,当用户请求网站内容时,由离用户最近的服务器将缓存的资源内容传递给用户
哪些资源可以放到CDN服务器?
体积较大的非业务JS文件, 比如react、react-dom
1. 体积较大,需要利用CDN文件在浏览器的缓存特性,加快加载时间
2. 非业务JS文件,不需要经常做变动,CDN不用频繁更新缓存
项目中怎么做?
1. 把需要做CDN缓存的文件排除在打包之外(react、react-dom)
2. 以CDN的方式重新引入资源(react、react-dom)
// 添加自定义对于webpack的配置 const path = require('path') const { whenProd, getPlugin, pluginByName } = require('@craco/craco') module.exports = { // webpack 配置 webpack: { // 配置别名 alias: { // 约定:使用 @ 表示 src 文件所在路径 '@': path.resolve(__dirname, 'src') }, // 配置webpack // 配置CDN configure: (webpackConfig) => { let cdn = { js:[] } whenProd(() => { // key: 不参与打包的包(由dependencies依赖项中的key决定) // value: cdn文件中 挂载于全局的变量名称 为了替换之前在开发环境下 webpackConfig.externals = { react: 'React', 'react-dom': 'ReactDOM' } // 配置现成的cdn资源地址 // 实际开发的时候 用公司自己花钱买的cdn服务器 cdn = { js: [ 'https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.production.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.production.min.js', ] } }) // 通过 htmlWebpackPlugin插件 在public/index.html注入cdn资源url const { isFound, match } = getPlugin( webpackConfig, pluginByName('HtmlWebpackPlugin') ) if (isFound) { // 找到了HtmlWebpackPlugin的插件 match.userOptions.files = cdn } return webpackConfig } } }
public/index.html
<body> <div id="root"></div> <!-- 加载第三发包的 CDN 链接 --> <% htmlWebpackPlugin.options.files.js.forEach(cdnURL => { %> <script src="<%= cdnURL %>"></script> <% }) %> </body>
浙公网安备 33010602011771号