本文是《React管理平台》第七节
通过本文我们将学会环境变量的配置,以及axios封装
.env 环境变量
首先我们在项目根目录中创建三个文件,分别对应开发环境(.env.development
)、生产环境(.env.production
)和预发布环境(.env.stag
)。
NODE_ENV
:该变量用于设置当前的环境模式,例如开发模式(development)或生产模式(production)。VITE_BASE_API
:服务器接口地址,根据不同的环境设置不同的url。VITE_UPLOAD_API
:文件上传API接口地址,与上面类似,也是根据不同的环境设置不同的url。VITE_MOCK
:Mock的开关,用来决定是否启用模拟数据。如果设置为true,那么你的应用将使用在本地设定的Mock数据,而不会调用真实的API,便于开发和测试。VITE_MOCK_API
:如果mock开关打开,那么该值将被用作mock服务器的地址。
.env.development
文件:
# 设置NODE_ENV环境模式
NODE_ENV=development
# 接口API地址
VITE_BASE_API = http://127.0.0.1:3000/api
# 上传API
VITE_UPLOAD_API =
# mock 开关
VITE_MOCK = true
# MOCK API
VITE_MOCK_API = http://localhost:3000
.env.production
文件:
# 设置NODE_ENV环境模式
NODE_ENV=production
# 接口API地址
VITE_BASE_API = /api
# 上传API
VITE_UPLOAD_API =
# mock 开关
VITE_MOCK = true
# MOCK API
VITE_MOCK_API = http://localhost:3000
.env.production
文件:
# 设置NODE_ENV环境模式
NODE_ENV=production
# 接口API地址
VITE_BASE_API = /api
# 上传API
VITE_UPLOAD_API =
# mock 开关
VITE_MOCK = true
# MOCK API
VITE_MOCK_API = http://localhost:3000
.env.stag文件:
# 设置NODE_ENV环境模式
NODE_ENV=production
# 接口API地址
VITE_BASE_API = /api
# 上传API
VITE_UPLOAD_API =
# mock 开关
VITE_MOCK = true
# MOCK API
VITE_MOCK_API = http://localhost:3000
开发环境变量、生产环境变量不过多介绍,也不用做额外的配置,这里强调下预发布环境变量,它是我们开发完代码打包为预发布版本放在服务器上的,需要额外配置,在package.json文件的script
部分,增加:
"scripts": {
// ...
"build:stg": "tsc && vite build --mode stag",
// ...
},
现在我们运行打包命令pnpm build:stg
即可打包项目并读取的是.env.stag文件中的配置
配置
我们去src
目录中创建config
文件夹,这个文件夹我们打算放置所有从代码中抽离出来的公共配置,一来防止我们去源码中寻找对应的代码消耗多余时间;二来是多处用到的内容抽离出来统一管理和维护。
在config目录中我们创建3个文件:index.ts
、net.config.ts
、app.config.ts
,这三个文件使用匿名方式(export default)导出。
net.config.ts
文件是与axios相关的配置,包括API接口地址,请求超时时间,请求失败的错误信息,预期的成功状态代码,以及服务器返回的数据字段名。在该文件中我们使用import.meta.env.VITE_MOCK
读取环境变量的字段用于判断是否开启mock并赋值相应的接口地址。app.config.ts
文件主要存放应用级别的配置。这包括应用的标题,公开访问(无需登录即可访问)的路由白名单等,在今后的章节中会用到。index.ts
文件主要是集合了以上两个配置文件的内容,它将这两个配置文件的导出内容解构合并,并将合并后的结果作为默认导出。这样的话,在其他模块需要使用这些配置时,只需要从此文件中引入即可,方便管理和使用。
net.config.ts文件:
export default {
// API接口地址
baseURL: import.meta.env.VITE_MOCK === 'true' ? import.meta.env.VITE_MOCK_API : import.meta.env.VITE_BASE_API,
// 网络请求的超时时间
requestTimeout: 10000,
// 请求超时时的错误信息
timeoutErrorMessage: '请求超时,请稍后重试',
// 请求成功时服务端返回的状态码
successCode: [200, 0],
// 服务端返回的状态字段名
statusName: 'code',
// 服务端返回的消息的字段名
messageName: 'message'
}
app.config.ts文件:
export default {
// 应用的标题
title: 'React Admin',
// 应用的路由白名单,白名单内的路由可以在未登录的情况下访问
routeWhiteList: ['/login', '/register', '/callback', '/404', '/403']
}
index.ts文件:
import newConfig from './net.config.ts'
import appConfig from './app.config.ts'
export default {
...appConfig,
...newConfig
}
至此我们的配置准备工作已完成,接下来我们增加loading动画。
loading动画
loading动画有两种,分别是进入应用的loading动画与请求发起时的loading动画,我们在项目根目录的public目录中创建css/loading.css文件,这两种动画的css统一放在该文件中:
#root .loader-container {
display: flex;
justify-content: center;
align-items: center;
height: 90vh;
min-height: 90vh;
}
#root .loader-container > h1 {
font-size: 28px;
font-weight: bolder;
margin-left: 10px;
}
#root .loader {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
#root .loader div {
position: absolute;
border: 4px solid #1890ff;
opacity: 1;
border-radius: 50%;
animation: loader 1s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
#root .loader div:nth-child(2) {
animation-delay: -0.5s;
}
#loading {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 20px;
}
#loading .loading {
animation: rotate linear 1.5s infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes loader {
0% {
top: 36px;
left: 36px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 72px;
height: 72px;
opacity: 0;
}
}
并在根目录的index.html
文件中导入loading.css
文件
<link rel="stylesheet" href="css/loading.css">
之后,我们先来看进入应用的loading动画
启动应用的loading
在index.html
文件的id=”root”子元素中增加:
<div id="root">
<div class="loader-container">
<div class="loader">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<h1>React Admin</h1>
</div>
</div>
在浏览器中的效果:
网络请求的loading
在index.html
文件的id=”root”兄弟节点中增加:
<div id="loading" style="display: none">
<svg
t="1682858040467"
class="icon loading"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="7810"
width="64"
height="64"
>
<path
d="M511.882596 287.998081h-0.361244a31.998984 31.998984 0 0 1-31.659415-31.977309v-0.361244c0-0.104761 0.115598-11.722364 0.115598-63.658399V96.000564a31.998984 31.998984 0 1 1 64.001581 0V192.001129c0 52.586273-0.111986 63.88237-0.119211 64.337537a32.002596 32.002596 0 0 1-31.977309 31.659415zM511.998194 959.99842a31.998984 31.998984 0 0 1-31.998984-31.998984v-96.379871c0-51.610915-0.111986-63.174332-0.115598-63.286318s0-0.242033 0-0.361243a31.998984 31.998984 0 0 1 63.997968-0.314283c0 0.455167 0.11921 11.711527 0.11921 64.034093v96.307622a31.998984 31.998984 0 0 1-32.002596 31.998984zM330.899406 363.021212a31.897836 31.897836 0 0 1-22.866739-9.612699c-0.075861-0.075861-8.207461-8.370021-44.931515-45.094076L195.198137 240.429485a31.998984 31.998984 0 0 1 45.256635-45.253022L308.336112 263.057803c37.182834 37.182834 45.090463 45.253022 45.41197 45.578141A31.998984 31.998984 0 0 1 330.899406 363.021212zM806.137421 838.11473a31.901448 31.901448 0 0 1-22.628318-9.374279L715.624151 760.859111c-36.724054-36.724054-45.018214-44.859267-45.097687-44.93874a31.998984 31.998984 0 0 1 44.77618-45.729864c0.32512 0.317895 8.395308 8.229136 45.578142 45.411969l67.88134 67.88134a31.998984 31.998984 0 0 1-22.624705 54.630914zM224.000113 838.11473a31.901448 31.901448 0 0 0 22.628317-9.374279l67.88134-67.88134c36.724054-36.724054 45.021826-44.859267 45.097688-44.93874a31.998984 31.998984 0 0 0-44.776181-45.729864c-0.32512 0.317895-8.395308 8.229136-45.578142 45.411969l-67.88134 67.884953a31.998984 31.998984 0 0 0 22.628318 54.627301zM255.948523 544.058589h-0.361244c-0.104761 0-11.722364-0.115598-63.658399-0.115598H95.942765a31.998984 31.998984 0 1 1 0-64.00158h95.996952c52.586273 0 63.88237 0.111986 64.337538 0.11921a31.998984 31.998984 0 0 1 31.659414 31.97731v0.361244a32.002596 32.002596 0 0 1-31.988146 31.659414zM767.939492 544.058589a32.002596 32.002596 0 0 1-31.995372-31.666639v-0.361244a31.998984 31.998984 0 0 1 31.659415-31.970085c0.455167 0 11.754876-0.11921 64.34115-0.11921h96.000564a31.998984 31.998984 0 0 1 0 64.00158H831.944685c-51.936034 0-63.553638 0.111986-63.665624 0.115598h-0.335957zM692.999446 363.0176a31.998984 31.998984 0 0 1-22.863126-54.381656c0.317895-0.32512 8.229136-8.395308 45.41197-45.578141l67.88134-67.884953A31.998984 31.998984 0 1 1 828.693489 240.429485l-67.892177 67.88134c-31.020013 31.023625-41.644196 41.759794-44.241539 44.393262l-0.697201 0.722488a31.908673 31.908673 0 0 1-22.863126 9.591025z"
fill="#1677ff"
p-id="7811"
></path>
</svg>
<p>Loading....</p>
</div>
网络请求的loading元素默认不显示,即display="none"
,之后我们会在网络请求发起时通过id="loading"
获取节点并去掉display="none"
,
控制网络请求的loading显示/隐藏
我们在src/utils
目录中新建loading.ts
文件,该文件的作用是调用showLoading
函数显示网络请求loading动画,调用hideLoading
函数隐藏网络请求动画,其中count
用来修复在一个页面中多次发起网络请求时的闪烁问题,而定时器用来修复网络请求速度快时loading一闪而过的问题:
let count = 0
let loadingTimeoutId: NodeJS.Timeout | number
export const showLoading = () => {
clearTimeout(loadingTimeoutId)
if (count === 0) {
const loading = document.getElementById('loading')
loading?.style.setProperty('display', 'flex')
}
count++
}
export const hideLoading = () => {
count--
if (count === 0) {
// Set a delay before actually hiding the loading screen (500ms in this case)
loadingTimeoutId = setTimeout(() => {
const loading = document.getElementById('loading')
loading?.style.setProperty('display', 'none')
}, 500)
}
}
封装axios
在封装axios之前,我们需要在src/types
目录中创建api.ts
文件,今后我们API接口的ts类型声明将会放在该文件中进行管理和维护:
// api.ts
export interface Result<T = any> {
code: number
data: T
msg: string
}
我们在constants目录中创建httpStatusCodes.ts
文件,用来描述状态码对应的中文描述,以便在后端未返回错误的消息信息时匹配状态码对应的中文描述,该文件的内容是:
export const CODE_MESSAGE: { [key: number]: string } = {
200: '服务器成功返回请求的数据',
201: '新建或修改数据成功',
202: '一个请求已经进入后台排队(异步任务)',
204: '删除数据成功',
400: '发出的请求有错误,服务器没有进行新建或修改数据的操作',
401: '用户没有权限(令牌、用户名、密码错误)',
403: '用户得到授权,但是访问是被禁止的',
404: '发出的请求针对的是不存在的记录,服务器没有进行操作',
406: '请求的格式不可得',
410: '请求的资源被永久删除,且不会再得到的',
422: '当创建一个对象时,发生一个验证错误',
500: '服务器发生错误,请检查服务器',
502: '网关错误',
503: '服务不可用,服务器暂时过载或维护',
504: '网关超时'
}
至于刷新令牌的接口,我们临时写一个不存在的接口,在实际开发中再与后端对接:
// api/refreshToken.ts文件
import request from '@/utils/request'
export function refreshToken() {
return request.get<{ token: string }>('/refreshToken')
}
接下来回到本文的主题,封装axios。在src/utils
目录中新建request.ts
文件
import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { showLoading, hideLoading } from '@/utils/loading.ts'
import { Result } from '@/types/api'
import { message } from './AntdGlobal'
import storage from './localStorage'
import config from '@/config/net.config.ts'
import router from '@/router'
import { CODE_MESSAGE } from '@/constants/httpStatusCodes.ts'
import { refreshToken } from '@/api/refreshToken.ts'
declare module 'axios' {
interface AxiosRequestConfig {
isShowLoading?: boolean
isShowError?: boolean
}
}
interface IConfig {
isShowLoading?: boolean
isShowError?: boolean
}
const { baseURL, messageName, requestTimeout, statusName, successCode, timeoutErrorMessage } = config
let refreshToking = false
const requests: (() => void)[] = []
/**
* 请求拦截器配置
* @param config
*/
const requestConfig = (config: InternalAxiosRequestConfig) => {
if (config.isShowLoading) showLoading()
const token = storage.get('token')
// 规范写法
if (token) config.headers!.Authorization = 'Bearer ' + token
// 非规范写法
// if (token) config.headers['token'] = token
return config
}
/**
* 刷新令牌
* @param config
*/
const tryRefreshToken = async (config: InternalAxiosRequestConfig) => {
if (!refreshToking) {
refreshToking = true
try {
const {
data: { token }
} = await refreshToken()
if (token) {
storage.set('token', token)
requests.forEach(req => req())
requests.length = 0
return instance(requestConfig(config))
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('刷新令牌错误:', error)
router.navigate('/login', { replace: true }).then(() => {})
} finally {
refreshToking = false
}
} else {
return new Promise(resolve => {
requests.push(() => {
resolve(instance(requestConfig(config)))
})
})
}
}
const responseData = async ({ config, data, status, statusText }: AxiosResponse) => {
hideLoading()
if (config.responseType === 'blob') return data
let code: number = data && data[statusName] ? data[statusName] : status
if (successCode.includes(data[statusName])) code = 200
switch (code) {
case 200:
return data
case 401:
router.navigate('/login?redirect=' + encodeURIComponent(location.href), { replace: true }).then(() => {
storage.remove('token')
message.error(data.msg)
})
break
case 402:
// 刷新令牌
return await tryRefreshToken(config)
}
if (config.isShowError === true) {
const errorMessage =
data && data[messageName] ? data[messageName] : CODE_MESSAGE[code] ? CODE_MESSAGE[code] : statusText
message.error(errorMessage)
}
return Promise.reject(data)
}
// 创建 axios 实例
const instance = axios.create({
baseURL: baseURL,
timeout: requestTimeout,
timeoutErrorMessage: timeoutErrorMessage,
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
})
// 请求拦截器
instance.interceptors.request.use(requestConfig, (error: AxiosError) => Promise.reject(error))
// 响应拦截器
instance.interceptors.response.use(responseData, (error: AxiosError) => {
hideLoading()
message.error(error.message)
return Promise.reject(error.message)
})
export default {
get<T>(
url: string,
params?: object,
options: IConfig = { isShowLoading: true, isShowError: true }
): Promise<Result<T>> {
return instance.get(url, { params, ...options })
},
post<T>(url: string, params?: object, options: IConfig = { isShowLoading: true, isShowError: true }): Promise<T> {
return instance.post(url, params, options)
}
}
配置Axios实例
创建一个Axios实例且只用这个实例发送所有的HTTP请求。
// 创建 axios 实例
const instance = axios.create({
baseURL: baseURL,
timeout: requestTimeout,
timeoutErrorMessage: timeoutErrorMessage,
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
})
这段代码创建了一个Axios实例并配置了默认的基础URL、请求超时时间和错误信息,以及默认的请求头。
请求拦截器
请求拦截器允许在发送请求之前对请求数据做处理,比如统一添加Token。
// 请求拦截器
instance.interceptors.request.use(requestConfig, (error: AxiosError) => Promise.reject(error))
这里添加了一个请求拦截器,如果开启了显示Loading的选项,则显示Loading动画。如果本地存储中有token,会将其添加到请求头中。
响应拦截器
响应拦截器能在后端返回数据后对数据进行处理。
// 响应拦截器
instance.interceptors.response.use(responseData, (error: AxiosError) => {
hideLoading()
message.error(error.message)
return Promise.reject(error.message)
})
我们配置的响应拦截器会在请求完成后关闭Loading动画,并进行错误处理。如果是正常响应,会根据返回的状态码确认是否是成功请求,如果遇到401错误码,可能是Token失效,就会引导用户重新登录,如果是其他错误,显示错误信息。
封装请求方法
为了简化代码并提高可读性,我们可以封装常用的请求方法。
export default {
get<T>(
url: string,
params?: object,
options: IConfig = { isShowLoading: true, isShowError: true }
): Promise<Result<T>> {
return instance.get(url, { params, ...options })
},
...
}
以上封装后的get
请求,在发起请求时将显示Loading动画并在遇到错误时显示错误信息。
刷新Token逻辑
在执行需要验证的请求时,如果检测到Token过期,可尝试自动刷新Token并重发请求。
// 刷新令牌
const tryRefreshToken = async (config: InternalAxiosRequestConfig) => {
...
}
这段代码处理了Token刷新的逻辑。如果Token过期,应用程序将尝试刷新Token,并重新发起之前的请求。
测试
我们在src/api
目录中创建test.ts
文件:
import request from '@/utils/request'
interface Posts {
author: string
id: number
title: string
}
export function getPosts(params?: { id: number; title: string; author: string }) {
return request.get<Posts[]>('/posts', params)
}
在pages/Welcome/Welcome.tsx
文件中测试发起请求:
import { useEffect } from 'react'
import { getPosts } from '@/api/test.ts'
export const Welcome = () => {
useEffect(() => {
const fetchPosts = async () => {
const response = await getPosts()
console.log(response)
}
// Immediately invoke the async function
fetchPosts()
}, [])
return <div></div>
}
我们首先启动之前章节中准备的mock服务
再启动我们的应用:
因为我们在main.tsx
文件中开启了严格模式,所以会看到有两次请求,这是正常的。至此axios封装完毕!
原文链接:https://juejin.cn/post/7349087888049618979 作者:辰火流光