🍞吐司问卷:网络请求与问卷基础实现
Date: February 10, 2025
Log
技术要点:
- HTTP协议
- XMLHttpRequest、fetch、axios
- mock.js、postman
- Webpack devServer 代理、craco.js 扩展 webpack
- Restful API
开发要点:
- 搭建 mock 服务
注:前端项目并不推荐直接使用 mock.js。因为它不支持 fetch,且上线时需要剔除模拟接口。
因此,建议构建一个简易服务端,通过 Koa 搭建一个接口路由用于测试接口。
- Ajax 封装、useRequest 使用
- 分页、LoadMore
Mock 数据
前端 Mock 模拟 Ajax
要点:
- 前端引入 mock.js 测试 api
安装:
npm i mockjs
npm i --save-dev @types/mockjs // 使用ts需要额外安装
注意点:
- mock.js 只能劫持 XMLHttpRequest,不能劫持 fetch
- 要在生产环境(上线时)注释掉,否则线上请求也被劫持
Case:
**效果:**会执行两次 mock(原因见下)
定义的mock
import Mock from 'mockjs'
Mock.mock('/api/test', 'get', () => {
return {
error: 0,
data: {
name: 'test',
age: 18,
},
}
})
页面中引用
import React, { FC, useEffect } from 'react'
import styles from './Home.module.scss'
import { useNavigate } from 'react-router-dom'
import { Typography, Button } from 'antd'
import { MANAGE_INDEX_PATHNAME } from '../router'
import axios from 'axios'
import '../_mock/index'
const { Title, Paragraph } = Typography
const Home: FC = () => {
const nav = useNavigate()
useEffect(() => {
axios.get('/api/test').then(res => console.log(res))
}, [])
return (
<div className={styles.container}>
<div className={styles.info}>
<Title>问卷调查 | 在线投票</Title>
<Paragraph>
已累计创建问卷 100 份,发布问卷 90 份,收到答卷 980 份
</Paragraph>
<div>
<Button type="primary" onClick={() => nav(MANAGE_INDEX_PATHNAME)}>
创建问卷
</Button>
</div>
</div>
</div>
)
}
export default Home
思考:
为什么
useEffect
会执行两次?
React 18 中,useEffect
默认会在开发模式下执行两次,这是为了帮助开发者发现副作用的潜在问题。
参考:
https://github.com/nuysoft/Mock/wiki/Getting-Started
服务端 nodejs 实现 mock.js
服务端 mock 实现
目标:
- 服务端实现mock
要点:
- mock.js 用于劫持网络请求,并实现丰富的 Random 能力
- mock.js 部署于 nodejs 服务端,并实现 Random 功能
安装:
npm init -y
npm i mockjs
npm i koa koa-router
npm i nodemon # 用于监听node修改, 不用重启项目
功能实现:
思路:服务端采用 Koa 构建路由处理需要 Mock 的 api
mock文件夹用于存放 Mock 的api,其中 index 做所有 Mock api 的整合。
目录:
.
├── index.js
├── mock
│ ├── index.js
│ ├── question.js
│ └── test.js
├── package-lock.json
├── package.json
└── projectTree.md
2 directories, 7 files
index.js
注意:这里设计 getRes()
可以刻意延迟 1s,模拟 loading 效果
const Koa = require('koa')
const Router = require('koa-router')
const mockList = require('./mock/index')
const app = new Koa()
const router = new Router()
// 模拟网络延迟函数
async function getRes(fn) {
return new Promise(resolve => {
setTimeout(() => {
const res = fn()
resolve(res)
}, 1000)
})
}
mockList.forEach(item => {
const {url, method, response} = item
router[method](url, async (ctx, next) => {
const res = await getRes(response)
ctx.body = res
}
)
})
app.use(router.routes())
app.listen(3002)
mock/index.js
const test = require('./test')
const question = require('./question')
const mockList = [
...test,
...question
]
module.exports = mockList
question.js
const Mock = require('mockjs')
const Random = Mock.Random
module.exports = [
{
url: '/api/question/:id',
method: 'get',
response() {
return {
error: 0,
data: {
id: Random.id(),
title: Random.ctitle(),
content: Random.cparagraph()
}
}
}
},
{
url: '/api/question',
method: 'post',
response() {
return {
error: 0,
data: {
id: Random.id()
}
}
}
}
]
**测试:**采用 postman 进行测试 post 请求
跨域问题处理
**目标:**处理跨域问题
刚刚我们搞定了服务端的 Mock,并处理了前端页面。现在遇到跨域问题:
问题:
问题原因:
http://localhost:3001/home 访问 http://localhost:3002/api/test 会产生 CORS 即跨域问题
Home.tsx
const nav = useNavigate()
useEffect(() => {
try {
const fetchData = async () => {
try {
const response = await axios.get('http://localhost:3001/api/test')
console.log(response.data) // 输出返回的响应数据
} catch (error) {
console.error('请求失败', error)
}
}
fetchData()
} catch (error) {
console.error('请求失败', error)
}
}, [])
解决方案:
采用 Craco 来处理 React 中的跨域问题,本质上讲是通过拓展 React 的 CRA 工具配置来处理跨域
具体步骤:
- 前端配置Craco: 配置见参考文档
- 通过 Craco 构建 api 代理
**结果:**成功处理
参考文档:
- https://github.com/dilanx/craco
API 设计
Restful API
概念:
RESTful API 是一种基于 REST(Representational State Transfer)架构风格的 Web 服务设计方法。
特点:
资源导向:
- 将系统中的所有内容视为资源,每个资源有唯一的 URI(统一资源标识符)。例如,
/users
表示用户资源。
无状态性(Statelessness):
- 每个请求都是独立的,不依赖于之前的请求。服务器不保留客户端状态信息。
表现层状态转移(Representation of Resources):
- 通过 JSON、XML 等格式在客户端和服务器之间传递资源的表现形式,而不是资源本身。
统一接口:
- 定义一致的方式进行操作,使得不同的客户端可以以统一的接口与服务器交互。
自描述消息:
- 请求和响应中包含所有必要的信息,例如 HTTP 状态码、头信息等,以帮助客户端理解操作结果。
可缓存性:
- 设计 API 使得响应可以被缓存,从而提高性能。
使用标准 HTTP 方法:
- 使用 HTTP 动词来操作资源:
- GET:获取资源。
- POST:创建资源。
- PUT:更新资源。
- DELETE:删除资源。
总结:
RESTful API 简洁灵活,适用于构建现代Web服务,因其遵循标准化的设计原则,使得开发和集成变得简单直观。
用户和问卷API设计
以下是设计的 API 表格,涵盖了用户功能和问卷功能:
功能 | 方法 | 路径 | 请求体 | 响应 |
---|---|---|---|---|
获取用户信息 | GET | /api/user/info | 无 | { errno: 0, data: {...} } 或 { errno: 10001, msg: 'xxx' } |
注册 | POST | /api/user/register | { username, password, nickname } | { errno: 0 } |
登录 | POST | /api/user/login | { username, password } | { errno: 0, data: { token } } — JWT 使用 token |
创建问卷 | POST | /api/question | 无 | { errno: 0, data: { id } } |
获取单个问卷 | GET | /api/question/:id | 无 | { errno: 0, data: { id, title ... } } |
获取问卷列表 | GET | /api/question | 无 | { errno: 0, data: { list: [ ... ], total } } |
更新问卷信息 | PATCH | /api/question/:id | { title, isStar ... } | { errno: 0 } |
批量彻底删除问卷 | DELETE | /api/question | { ids: [ ... ] } | { errno: 0 } |
复制问卷 | POST | /api/question/duplicate/:id | 无 | { errno: 0, data: { id } } |
说明:
- GET 请求 通常用于获取资源,不需要请求体。
- POST 请求 用于创建资源或进行某些操作,可能需要请求体包含必要的数据。
- PATCH 请求 用于部分更新资源,需要请求体提供更新的字段和值。
- DELETE 请求 用于删除资源,这里是“假删除”,通过更新
isDeleted
属性实现。
问卷功能实现
目标:
- 配置 axios 基础功能
- 开发问卷功能,期间使用 useRequest
- 分页和 LoadMore
接口案例测试
**目标:**构建接口文件并测试
要点:
- 设计 axios instance 实例
- 设计 getQuestionList 接口
- 测试 getQuestionList 接口
文件树:
├── src
│ ├── services
│ │ ├── ajax.ts
│ │ └── question.ts
ajax.ts
import axios from 'axios'
import { message } from 'antd'
const instance = axios.create({
timeout: 10000,
})
instance.interceptors.response.use(res => {
const resData = (res.data || {}) as ResType
console.log('resData', resData)
const { errno, data, msg } = resData
if (errno !== 0) {
if (msg) {
message.error(msg)
}
throw new Error(msg || '未知错误')
}
return data as any
})
export default instance
export type ResType = {
errno: number
data?: ResDataType
msg?: string
}
// key表示字段名,any表示字段值的类型
export type ResDataType = {
[key: string]: any
}
question.tsx
import axios, { ResDataType } from './ajax'
export const getQuestionList = async (id: string): Promise<ResDataType> => {
const url = `/api/question/${id}`
const data = (await axios.get(url)) as ResDataType
return data
}
接口测试:
import React, { FC, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { getQuestionList } from '../../../services/question'
const Edit: FC = () => {
const { id = '' } = useParams()
useEffect(() => {
async function fetchData() {
const res = await getQuestionList(id)
console.log('res', res)
}
fetchData()
}, [])
return (
<div>
<h1>Edit {id}</h1>
</div>
)
}
export default Edit
设置 loading 状态优化体验
前言:之前我们在设计接口的时候,故意设计延迟函数用于模拟
// 模拟网络延迟函数
async function getRes(fn) {
return new Promise(resolve => {
setTimeout(() => {
const res = fn()
resolve(res)
}, 1000)
})
}
**问题:**现在我们设计完成新增问卷函数 createQuestionService()
。实际测试时,点击创建页面到新页面时,会发生1s的延迟。在这期间,我们仍然可以频繁点击创建问卷,如下所示:
**解决方案:**设置 disable
属性,当点击时禁用问卷创建即可
Case:
import { createQuestionService } from '../services/question'
const ManageLayout: FC = () => {
const nav = useNavigate()
const { pathname } = useLocation()
const [loading, setLoading] = useState(false)
async function handleCreateClick() {
setLoading(true)
const data = await createQuestionService()
const { id } = data
if (id) {
nav(`/question/edit/${id}`)
message.success('创建成功')
}
setLoading(false)
}
return (
<>
<div className={styles.container}>
<div className={styles.left}>
<Flex gap="small" wrap>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
disabled={loading}
onClick={handleCreateClick}
>
新建问卷
</Button>
</Flex>
</div>
</>
)
}
export default ManageLayout
自定义Hook抽离公共逻辑
**思路:**抽离原有的获取编辑页面的数据为 hook,方便编辑页面进行复用。
不用 Hook 之前:/edit/index
import React, { FC, useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { getQuestionListService } from '../../../services/question'
const Edit: FC = () => {
const { id = '' } = useParams()
const [loading, setLoading] = useState(true)
const [questionData, setQuestionData] = useState({})
useEffect(() => {
async function fetchData() {
const res = await getQuestionListService(id)
setQuestionData(res)
setLoading(false)
}
fetchData()
}, [])
return (
<div>
<h1>Edit {id}</h1>
{loading ? (
<div>Loading...</div>
) : (
<div>
<p>{JSON.stringify(questionData)}</p>
</div>
)}
</div>
)
}
export default Edit
Hook设计:
hooks/useLoadQuestionData
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { getQuestionListService } from '../services/question'
function useLoadQuestionData() {
const { id = '' } = useParams()
const [loading, setLoading] = useState(true)
const [questionData, setQuestionData] = useState({})
useEffect(() => {
async function fetchData() {
const res = await getQuestionListService(id)
setQuestionData(res)
setLoading(false)
}
fetchData()
}, [])
return { loading, questionData }
}
export default useLoadQuestionData
优化之后:/edit/index
import React, { FC } from 'react'
import { useParams } from 'react-router-dom'
import useLoadQuestionData from '../../../hooks/useLoadQuestionData'
const Edit: FC = () => {
const { id = '' } = useParams()
const { loading, questionData } = useLoadQuestionData()
return (
<div>
<h1>Edit {id}</h1>
{loading ? (
<div>Loading...</div>
) : (
<div>
<p>{JSON.stringify(questionData)}</p>
</div>
)}
</div>
)
}
export default Edit
useRequest重构Ajax请求
**思路:**采用 ahooks 中的 useRequest
钩子重构之前的 Ajax 请求
useRequest
:
默认请求:默认情况下,useRequest
第一个参数是一个异步函数,在组件初始化时,会自动执行该异步函数。同时自动管理该异步函数的 loading
, data
, error
等状态。
const { data, error, loading } = useRequest(service);
**Case:**重构 useLoadQuestionData
原本:
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { getQuestionListService } from '../services/question'
function useLoadQuestionData() {
const { id = '' } = useParams()
const [loading, setLoading] = useState(true)
const [questionData, setQuestionData] = useState({})
useEffect(() => {
async function fetchData() {
const res = await getQuestionListService(id)
setQuestionData(res)
setLoading(false)
}
fetchData()
}, [])
return { loading, questionData }
}
export default useLoadQuestionData
重构之后:
import { useParams } from 'react-router-dom'
import { getQuestionListService } from '../services/question'
import { useRequest } from 'ahooks'
function useLoadQuestionData() {
const { id = '' } = useParams()
async function load() {
const data = await getQuestionListService(id)
return data
}
const { data, loading } = useRequest(load)
return { data, loading }
}
export default useLoadQuestionData
参考:
https://ahooks.js.org/zh-CN/hooks/use-request/index#index-default
分页功能实现
要点:
- 从 URL 参数中获取 page 和 pageSize, 并同步到 Pagination 组件中
- 当 page 或 pageSize 变化时, 更新 URL 参数
- AntD 中 Pagination 的 current、pageSize、total、onChange 等属性和方法
ListPage.tsx
import React, { FC } from 'react'
import { Pagination, PaginationProps } from 'antd'
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom'
import {
LIST_PAGE_SIZE,
LIST_PAGE_PARAM_KEY,
LIST_PAGE_SIZE_PARAM_KEY,
} from '../constant'
type ListPageProps = {
total: number
}
const ListPage: FC<ListPageProps> = (props: ListPageProps) => {
const { total } = props
const [current, setCurrent] = React.useState(1)
const [pageSize, setPageSize] = React.useState(LIST_PAGE_SIZE)
// 从 URL 参数中获取 page 和 pageSize, 并同步到 Pagination 组件中
const [searchParams] = useSearchParams()
const nav = useNavigate()
const { pathname } = useLocation()
const handleChange: PaginationProps['onChange'] = pageNumber => {
searchParams.set(LIST_PAGE_PARAM_KEY, pageNumber.toString())
searchParams.set(LIST_PAGE_SIZE_PARAM_KEY, pageSize.toString())
nav({
pathname,
search: searchParams.toString(),
})
}
React.useEffect(() => {
const page = parseInt(searchParams.get(LIST_PAGE_PARAM_KEY) || '') || 1
const pageSize =
parseInt(searchParams.get(LIST_PAGE_SIZE_PARAM_KEY) || '') ||
LIST_PAGE_SIZE
setCurrent(page)
setPageSize(pageSize)
}, [searchParams])
return (
<Pagination
current={current}
pageSize={pageSize}
total={total}
onChange={handleChange}
/>
)
}
export default ListPage
问卷中进行使用:
Star.tsx
...
const { Title } = Typography
const Star: FC = () => {
useTitle('星标问卷')
const { data = {}, loading } = useLoadQuestionListData({ isStar: true })
const { list = [], total = 0 } = data
return (
<>
...
{!loading && list.length > 0 && (
<div className={styles.footer}>
<ListPage total={total} />
</div>
)}
</>
)
}
export default Star
LoadMore 功能实现
要点:
- 防抖功能实现
思路:
当页面的 ele 的 bottom 距离顶部一段距离时,自动加载页面
问卷标星、复制、删除功能
**目标:**实现问卷标星功能
**效果:**点击星标更新
思路:
- 标星接口更新实现:采用 useRequest 实现
- 页面标星状态更新:采用 useRequest 的回调函数实现
Code:
const [isStarState, setIsStarState] = useState(isStar)
// 标星接口更新实现
const { loading: changeStarLoading, run: changeStar } = useRequest(
async () => {
await updateQuestionService(_id, { isStar: !isStarState })
},
{
// 页面标星状态更新
manual: true,
onSuccess: () => {
setIsStarState(!isStarState)
message.success('已更新')
},
}
)
**目标:**实现问卷复制功能
思路:
- 实现复制功能的接口请求
- 实现复制功能的回调实现,实现导航到编辑页面
细节:
- 防止重复点击:
loading: duplicateLoading
绑定到 Button 上,当我们点击复制后,在接口数据返回前,按钮无法再次点击。
Code:
const { loading: duplicateLoading, run: duplicate } = useRequest(
async () => {
const data = await duplicateQuestionService(_id)
return data
},
{
manual: true,
onSuccess: (res: any) => {
message.success('复制成功')
nav(`/question/edit/${res.id}`)
},
}
)
----
<Popconfirm
title="确认复制吗?"
okText="确认"
cancelText="取消"
onConfirm={duplicate}
>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
disabled={duplicateLoading}
>
复制
</Button>
</Popconfirm>
**目标:**实现问卷删除功能
**需求:**实现删除功能,问卷点击删除是假删除,删除后,问卷会进入回收站
思路:
- 实现删除功能的接口请求与回调
- 实现当删除后,页面会不再渲染此卡片
const [isDeleted, setIsDeleted] = useState(false)
const { loading: deleteLoading, run: deleteQuestion } = useRequest(
async () => await updateQuestionService(_id, { isDeleted: true }),
{
manual: true,
onSuccess: () => {
message.success('删除成功')
},
}
)
function del() {
confirm({
title: '删除问卷',
icon: <ExclamationCircleOutlined />,
onOk() {
deleteQuestion()
setIsDeleted(true)
},
})
}
// 实现当删除后,页面会不再渲染此卡片
if (isDeleted) return null // 已经删除的问卷,不要再渲染卡片了
return (
<div className={styles.container}>
<div className={styles.title}>
<div className={styles.left}>
...
---
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={del}
disabled={deleteLoading}
>
删除
</Button>
问卷恢复与删除
要点:
for await (const id of selectionIds)
可以遍历请求useRequeset
中的debounceWait
可以实现恢复防抖
useRequeset
中的refresh()
可以实现:使用上一次的参数,重新发起请求。
理解:refresh()
触发数据的重新加载,它确保在执行恢复和删除操作后,页面上的数据能够及时更新,避免了显示过时的信息。
Code:
const {
data = {},
loading,
refresh,
} = useLoadQuestionListData({ isDeleted: true })
...
// 恢复
const { run: recover } = useRequest(
async () => {
for await (const id of selectionIds) {
await updateQuestionService(id, { isDeleted: false })
}
},
{
manual: true,
debounceWait: 500,
onSuccess: () => {
message.success('恢复成功')
refresh()
setSelectionIds([])
},
}
)
// 删除
const { run: deleteQuestion } = useRequest(
async () => await deleteQuestionService(selectionIds),
{
manual: true,
onSuccess: () => {
message.success('删除成功')
refresh()
setSelectionIds([])
},
}
)