1.初始化项目
//搭建项目
npm create vite@latest react-jike-mobile -- --template react-ts
//安装依赖
npm i
//运行
npm run dev
清理项目目录结构
安装ant design mobile
ant design mobile是ant design家族里专门针对于移动端的组件库
npm install --save antd-mobile
测试组件
import { Button } from 'antd-mobile'function App() {return (<><Button>click me </Button></>)
}export default App
2.初始化路由
react的路由初始化,采用react-router-dom进行配置
npm i react-router-dom
3. 配置基础路由
//List页面
const List = () => {return <div>this is List</div>
}export default List
//detail页面
const Detail = () => {return <div>this is Detail</div>
}export default Detail
//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
//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配置
//修改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类型包
npm i @types/node -D
3️⃣修改tsconfig.json文件
{"baseUrl": ".","paths": {"@/*": ["src/*"]},
}
5. 安装axios
1.安装axios到项目
2.在utils中封装http模块,主要包括接口基地址、超时时间、拦截器
3.在utils中做统一导出
//安装axios
npm i axios
// 封装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 }
//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泛型的参数类型
//apis文件下shared.ts
// 1. 定义泛型
export type ResType<T> = {message: stringdata: T
}
//apis文件下list.ts
import { http } from '@/utils'
//引入泛型
import type { ResType } from './shared'// 2. 定义具体的接口类型
export type ChannelItem = {id: numbername: string
}type ChannelRes = {channels: ChannelItem[]
}// 请求频道列表export function fetchChannelAPI() {return http.request<ResType<ChannelRes>>({url: '/channels',})
}
页面使用
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函数,消费其返回的状态和方法
//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 }
//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️⃣实现上来加载功能
// 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.ItemonClick={() => goToDetail(item.art_id)}key={item.art_id}prefix={<Imagesrc={item.cover.images?.[0]}style={{ borderRadius: 20 }}fit="cover"width={40}height={40}/>}description={item.pubdate}>{item.title}</List.Item>))}</List></>)
}export default HomeList
// 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
// apis/list.ts
import { http } from '@/utils'import type { ResType } from './shared'// 2. 定义具体的接口类型
// 请求文章列表type ListItem = {art_id: stringtitle: stringaut_id: stringcomm_count: numberpubdate: stringaut_name: stringis_top: numbercover: {type: numberimages: string[]}
}export type ListRes = {results: ListItem[]pre_timestamp: string
}type ReqParams = {channel_id: stringtimestamp: string
}export function fetchListAPI(params: ReqParams) {return http.request<ResType<ListRes>>({url: '/articles',params,})
}
Home模块—List列表无限滚动实现
交互要求:List列表在滑动到底部时,自动加载下一页列表数据
实现思路:
1️⃣滑动到底部触发加载下一页动作
<InfiniteScroll>
2️⃣加载下一页数据
pre_timestamp 接口参数
3️⃣把老数据和新数据做拼接处理
[…oldList,…newList]
4️⃣停止监听边界值
hasMore
// 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. 小于thresholdconst [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.Itemkey={item.art_id}prefix={<Imagesrc={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️⃣渲染数据到页面中
// 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. 小于thresholdconst [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.ItemonClick={() => goToDetail(item.art_id)}key={item.art_id}prefix={<Imagesrc={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
// 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}`,})
}
// /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><divdangerouslySetInnerHTML={{__html: detail?.content,}}></div></div>)
}export default Detail