1.初始化项目
bash
//搭建项目
npm create vite@latest react-jike-mobile -- --template react-ts
bash
//安装依赖
npm i
bash
//运行
npm run dev
清理项目目录结构
安装ant design mobile
ant design mobile是ant design家族里专门针对于移动端的组件库
bash
npm install --save antd-mobile
测试组件
bash
import { Button } from 'antd-mobile'
function App() {
return (
<>
<Button>click me </Button>
</>
)
}
export default App
2.初始化路由
react的路由初始化,采用react-router-dom进行配置
bash
npm i react-router-dom
3. 配置基础路由
bash
//List页面
const List = () => {
return <div>this is List</div>
}
export default List
bash
//detail页面
const Detail = () => {
return <div>this is Detail</div>
}
export default Detail
bash
//router文件下index.tsx
import { createBrowserRouter } from 'react-router-dom'
import List from '../pages/List'
import Detail from '../pages/Detail'
const router = createBrowserRouter([
{
path: '/',
element: <List />,
},
{
path: '/detail',
element: <Detail />,
},
])
export default router
bash
//main.txt
import ReactDOM from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import router from './router/index.tsx'
ReactDOM.createRoot(document.getElementById('root')!).render(
<RouterProvider router={router} />
)
4. 配置路径别名
场景:项目中各个模块之间的互相导入导出,可以通过@别名路径做路径简化,经过配置@相当于src目录,比如:
步骤:
1.让vite做路径解析(真实的路径转换)
2.让vscode做智能路径提示(开发者体验)
1️⃣修改vite配置
javascript
//修改vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
2️⃣安装node类型包
bash
npm i @types/node -D
3️⃣修改tsconfig.json文件
json
{
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
},
}
5. 安装axios
1.安装axios到项目
2.在utils中封装http模块,主要包括接口基地址、超时时间、拦截器
3.在utils中做统一导出
bash
//安装axios
npm i axios
javascript
// 封装axios在utils下http.ts里
import axios from 'axios'
const httpInstance = axios.create({
baseURL: 'http://geek.itheima.net/v1_0',
timeout: 5000,
})
// 拦截器
httpInstance.interceptors.request.use(
(config) => {
return config
},
(error) => {
return Promise.reject(error)
}
)
httpInstance.interceptors.response.use(
(response) => {
return response
},
(error) => {
return Promise.reject(error)
}
)
export { httpInstance }
javascript
//utils下index.ts文件
// 模块中转导出文件
import { httpInstance } from './http'
export { httpInstance as http }
6.封装API模块---axios和ts的配合使用
场景:axios提供了request泛型方法,方便我们传入类型参数推导出接口返回值的类型
说明:泛型参数type的类型决定了res.data的类型
步骤:
1️⃣根据接口文档创建一个通用的泛型接口类型(多个接口返回值的结构是相似的)
2️⃣根据接口文档创建特有的接口类型(每个接口有自己特殊的数据格式)
3️⃣组合1和2的类型,得到最终传给request泛型的参数类型
javascript
//apis文件下shared.ts
// 1. 定义泛型
export type ResType<T> = {
message: string
data: T
}
javascript
//apis文件下list.ts
import { http } from '@/utils'
//引入泛型
import type { ResType } from './shared'
// 2. 定义具体的接口类型
export type ChannelItem = {
id: number
name: string
}
type ChannelRes = {
channels: ChannelItem[]
}
// 请求频道列表
export function fetchChannelAPI() {
return http.request<ResType<ChannelRes>>({
url: '/channels',
})
}
页面使用
javascript
import { fetchListAPI } from '@/apis/list'
fetchChannelAPI().then((res) => {
console.log(res.data.data.channels)
})
7.home模块
Home模块---Tabs区域实现
实现步骤:
1️⃣使用ant-mobile组件库中的tabs组件进行页面结构的创建
2️⃣使用真实接口数据进行渲染
3️⃣有优化的点进行优化处理
Home模块---Tabs自定义hook函数优化
针对上面代码封装hook函数进行代码优化
场景:当前状态数据的各种操作逻辑和组件渲染是写在一起的,可以采用自定义hook封装的方式让逻辑和渲染相分离
实现步骤:
1️⃣把和tabs相关的响应式数据状态以及操作数据的方法放到hook函数中
2️⃣组件中调用hook函数,消费其返回的状态和方法
javascript
//home文件下useTabs.ts
import { useEffect, useState } from 'react'
import { ChannelItem, fetchChannelAPI } from '@/apis/list'
function useTabs() {
const [channels, setChannels] = useState<ChannelItem[]>([])
useEffect(() => {
const getChannels = async () => {
try {
const res = await fetchChannelAPI()
setChannels(res.data.data.channels)
} catch (error) {
throw new Error('fetch channel error')
}
}
getChannels()
}, [])
return {
channels,
}
}
export { useTabs }
javascript
//home文件下 index.tsx
import './style.css'
import { Tabs } from 'antd-mobile'
import { useTabs } from './useTabs'
const Home = () => {
const { channels } = useTabs()
return (
<div>
<div className="tabContainer">
{/* tab区域 */}
<Tabs defaultActiveKey={'0'}>
{channels.map((item) => (
<Tabs.Tab title={item.name} key={item.id}>
</Tabs.Tab>
))}
</Tabs>
</div>
</div>
)
}
export default Home
Home模块---List组件实现
实现步骤:
1️⃣搭建基础结构,并获取基础数据
2️⃣为组件设计channelld参数,点击tab时传入不同的参数
3️⃣实现上来加载功能
javascript
// home/homeList/index.tsx
import { Image, List } from 'antd-mobile'
// mock数据
// import { users } from './users'
import { useEffect, useState } from 'react'
import { ListRes, fetchListAPI } from '@/apis/list'
type Props = {
channelId: string
}
const HomeList = (props: Props) => {
const { channelId } = props
// 获取列表数据
const [listRes, setListRes] = useState<ListRes>({
results: [],
pre_timestamp: '' + new Date().getTime(),
})
useEffect(() => {
const getList = async () => {
try {
const res = await fetchListAPI({
channel_id: channelId,
timestamp: '' + new Date().getTime(),
})
setListRes({
results: res.data.data.results,
pre_timestamp: res.data.data.pre_timestamp,
})
} catch (error) {
throw new Error('fetch list error')
}
}
getList()
}, [channelId])
return (
<>
<List>
{listRes.results.map((item) => (
<List.Item
onClick={() => goToDetail(item.art_id)}
key={item.art_id}
prefix={
<Image
src={item.cover.images?.[0]}
style={{ borderRadius: 20 }}
fit="cover"
width={40}
height={40}
/>
}
description={item.pubdate}>
{item.title}
</List.Item>
))}
</List>
</>
)
}
export default HomeList
javascript
// home/index.tsx
import './style.css'
import { Tabs } from 'antd-mobile'
import { useTabs } from './useTabs'
import HomeList from './HomeList'
const Home = () => {
const { channels } = useTabs()
return (
<div>
<div className="tabContainer">
{/* tab区域 */}
<Tabs defaultActiveKey={'0'}>
{channels.map((item) => (
<Tabs.Tab title={item.name} key={item.id}>
{/* list组件 */}
{/* 别忘嘞加上类名 严格控制滚动盒子 */}
<div className="listContainer">
<HomeList channelId={'' + item.id} />
</div>
</Tabs.Tab>
))}
</Tabs>
</div>
</div>
)
}
export default Home
javascript
// apis/list.ts
import { http } from '@/utils'
import type { ResType } from './shared'
// 2. 定义具体的接口类型
// 请求文章列表
type ListItem = {
art_id: string
title: string
aut_id: string
comm_count: number
pubdate: string
aut_name: string
is_top: number
cover: {
type: number
images: string[]
}
}
export type ListRes = {
results: ListItem[]
pre_timestamp: string
}
type ReqParams = {
channel_id: string
timestamp: string
}
export function fetchListAPI(params: ReqParams) {
return http.request<ResType<ListRes>>({
url: '/articles',
params,
})
}
Home模块---List列表无限滚动实现
交互要求:List列表在滑动到底部时,自动加载下一页列表数据
实现思路:
1️⃣滑动到底部触发加载下一页动作
javascript
<InfiniteScroll>
2️⃣加载下一页数据
pre_timestamp 接口参数
3️⃣把老数据和新数据做拼接处理
[...oldList,...newList]
4️⃣停止监听边界值
hasMore
javascript
// home/homeList/index.tsx
import { Image, List, InfiniteScroll } from 'antd-mobile'
// mock数据
// import { users } from './users'
import { useEffect, useState } from 'react'
import { ListRes, fetchListAPI } from '@/apis/list'
import { useNavigate } from 'react-router-dom'
type Props = {
channelId: string
}
const HomeList = (props: Props) => {
const { channelId } = props
// 获取列表数据
const [listRes, setListRes] = useState<ListRes>({
results: [],
pre_timestamp: '' + new Date().getTime(),
})
useEffect(() => {
const getList = async () => {
try {
const res = await fetchListAPI({
channel_id: channelId,
timestamp: '' + new Date().getTime(),
})
setListRes({
results: res.data.data.results,
pre_timestamp: res.data.data.pre_timestamp,
})
} catch (error) {
throw new Error('fetch list error')
}
}
getList()
}, [channelId])
// 开关 标记当前是否还有新数据
// 上拉加载触发的必要条件:1. hasMore = true 2. 小于threshold
const [hasMore, setHasMore] = useState(true)
// 加载下一页的函数
const loadMore = async () => {
// 编写加载下一页的核心逻辑
console.log('上拉加载触发了')
try {
const res = await fetchListAPI({
channel_id: channelId,
timestamp: listRes.pre_timestamp,
})
// 拼接新数据 + 存取下一次请求的时间戳
setListRes({
results: [...listRes.results, ...res.data.data.results],
pre_timestamp: res.data.data.pre_timestamp,
})
// 停止监听
if (res.data.data.results.length === 0) {
setHasMore(false)
}
} catch (error) {
throw new Error('fetch list error')
}
// setHasMore(false)
}
return (
<>
<List>
{listRes.results.map((item) => (
<List.Item
key={item.art_id}
prefix={
<Image
src={item.cover.images?.[0]}
style={{ borderRadius: 20 }}
fit="cover"
width={40}
height={40}
/>
}
description={item.pubdate}>
{item.title}
</List.Item>
))}
</List>
<InfiniteScroll loadMore={loadMore} hasMore={hasMore} threshold={10} />
</>
)
}
export default HomeList
8.详情模块-路由跳转&数据渲染
需求:点击列表中的某一项跳转到详情路由并显示当前文章
1️⃣通过路由跳转方法进行挑战,并传递参数
2️⃣在详情路由下获取参数,并请求数据
3️⃣渲染数据到页面中
javascript
// home/homeList/index.tsx
import { Image, List, InfiniteScroll } from 'antd-mobile'
// mock数据
// import { users } from './users'
import { useEffect, useState } from 'react'
import { ListRes, fetchListAPI } from '@/apis/list'
import { useNavigate } from 'react-router-dom'
type Props = {
channelId: string
}
const HomeList = (props: Props) => {
const { channelId } = props
// 获取列表数据
const [listRes, setListRes] = useState<ListRes>({
results: [],
pre_timestamp: '' + new Date().getTime(),
})
useEffect(() => {
const getList = async () => {
try {
const res = await fetchListAPI({
channel_id: channelId,
timestamp: '' + new Date().getTime(),
})
setListRes({
results: res.data.data.results,
pre_timestamp: res.data.data.pre_timestamp,
})
} catch (error) {
throw new Error('fetch list error')
}
}
getList()
}, [channelId])
// 开关 标记当前是否还有新数据
// 上拉加载触发的必要条件:1. hasMore = true 2. 小于threshold
const [hasMore, setHasMore] = useState(true)
// 加载下一页的函数
const loadMore = async () => {
// 编写加载下一页的核心逻辑
console.log('上拉加载触发了')
try {
const res = await fetchListAPI({
channel_id: channelId,
timestamp: listRes.pre_timestamp,
})
// 拼接新数据 + 存取下一次请求的时间戳
setListRes({
results: [...listRes.results, ...res.data.data.results],
pre_timestamp: res.data.data.pre_timestamp,
})
// 停止监听
if (res.data.data.results.length === 0) {
setHasMore(false)
}
} catch (error) {
throw new Error('fetch list error')
}
// setHasMore(false)
}
const navigate = useNavigate()
const goToDetail = (id: string) => {
// 路由跳转
navigate(`/detail?id=${id}`)
}
return (
<>
<List>
{listRes.results.map((item) => (
<List.Item
onClick={() => goToDetail(item.art_id)}
key={item.art_id}
prefix={
<Image
src={item.cover.images?.[0]}
style={{ borderRadius: 20 }}
fit="cover"
width={40}
height={40}
/>
}
description={item.pubdate}>
{item.title}
</List.Item>
))}
</List>
<InfiniteScroll loadMore={loadMore} hasMore={hasMore} threshold={10} />
</>
)
}
export default HomeList
javascript
// apis/detail.ts
import { type ResType } from './shared'
import { http } from '@/utils'
/**
* 响应数据
*/
export type DetailDataType = {
/**
* 文章id
*/
art_id: string
/**
* 文章-是否被点赞,-1无态度, 0未点赞, 1点赞, 是当前登录用户对此文章的态度
*/
attitude: number
/**
* 文章作者id
*/
aut_id: string
/**
* 文章作者名
*/
aut_name: string
/**
* 文章作者头像,无头像, 默认为null
*/
aut_photo: string
/**
* 文章_评论总数
*/
comm_count: number
/**
* 文章内容
*/
content: string
/**
* 文章-是否被收藏,true(已收藏)false(未收藏)是登录的用户对此文章的收藏状态
*/
is_collected: boolean
/**
* 文章作者-是否被关注,true(关注)false(未关注), 说的是当前登录用户对这个文章作者的关注状态
*/
is_followed: boolean
/**
* 文章_点赞总数
*/
like_count: number
/**
* 文章发布时间
*/
pubdate: string
/**
* 文章_阅读总数
*/
read_count: number
/**
* 文章标题
*/
title: string
}
export function fetchDetailAPI(id: string) {
return http.request<ResType<DetailDataType>>({
url: `/articles/${id}`,
})
}
javascript
// /detail/index.tsx
import { DetailDataType, fetchDetailAPI } from '@/apis/detail'
import { NavBar } from 'antd-mobile'
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
const Detail = () => {
const [detail, setDetail] = useState<DetailDataType | null>(null)
// 获取路由参数
const [params] = useSearchParams()
const id = params.get('id')
useEffect(() => {
const getDetail = async () => {
try {
const res = await fetchDetailAPI(id!)
setDetail(res.data.data)
} catch (error) {
throw new Error('fetch detail error')
}
}
getDetail()
}, [id])
const navigate = useNavigate()
const back = () => {
navigate(-1)
}
// 数据返回之前 loading渲染占位
if (!detail) {
return <div>this is loading...</div>
}
// 数据返回之后 正式渲染的内容
return (
<div>
<NavBar onBack={back}>{detail?.title}</NavBar>
<div
dangerouslySetInnerHTML={{
__html: detail?.content,
}}></div>
</div>
)
}
export default Detail