🍞吐司問卷:網絡請求與問卷基礎實現
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 } = Typographyconst 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.md2 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} = itemrouter[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.Randommodule.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 ResTypeconsole.log('resData', resData)const { errno, data, msg } = resDataif (errno !== 0) {if (msg) {message.error(msg)}throw new Error(msg || '未知錯誤')}return data as any
})export default instanceexport type ResType = {errno: numberdata?: ResDataTypemsg?: 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 ResDataTypereturn 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 } = dataif (id) {nav(`/question/edit/${id}`)message.success('創建成功')}setLoading(false)}return (<><div className={styles.container}><div className={styles.left}><Flex gap="small" wrap><Buttontype="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 } = propsconst [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) || '') || 1const pageSize =parseInt(searchParams.get(LIST_PAGE_SIZE_PARAM_KEY) || '') ||LIST_PAGE_SIZEsetCurrent(page)setPageSize(pageSize)}, [searchParams])return (<Paginationcurrent={current}pageSize={pageSize}total={total}onChange={handleChange}/>)
}export default ListPage
問卷中進行使用:
Star.tsx
...
const { Title } = Typographyconst Star: FC = () => {useTitle('星標問卷')const { data = {}, loading } = useLoadQuestionListData({ isStar: true })const { list = [], total = 0 } = datareturn (<>...{!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}`)},}
)----<Popconfirmtitle="確認復制嗎?"okText="確認"cancelText="取消"onConfirm={duplicate}
><Buttontype="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}>...---<Buttontype="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([])},}
)