一、基础环境创建
npm create vite@latest react-ts-pro -- --template react-ts
npm i
npm run dev
二、useState
1. 自动推导
通常 React 会根据传入 useState 的默认值来自动推导类型,不需要显式标注类型
const [value, toggle] = useState(false)
说明:
(1)value:类型为 boolean
(2)toggle: 参数类型为 boolean
// react + ts
// 根据初始值自动推断
// 场景:明确的初始值
import { useState } from 'react'
function App() {
const [value, toggle] = useState(false)
const [list, setList] = useState([1, 2, 3])
const changeValue = () => {
toggle(true)
}
const changeList = () => {
setList([4])
}
return <>this is app {list}</>
}
export default App
2. 泛型参数
useState 本身是一个泛型函数,可以传入具体的自定义类型
type User = {
name: string
age: number
}
const [user, setUser] = useState<User>()
说明:
(1)限制 useState 函数参数的初始值必须满足类型为:User | () => User
(2)限制 useState 函数的参数必须满足类型为:User | () => User | undefined
(3)user 状态数据具备 User 类型相关的类型提示
// react + ts
import { useState } from 'react'
type User = {
name: string
age: number
}
function App() {
// 1. 限制初始值的类型
// const [user, setUser] = useState<User>({
// name: 'jack',
// age: 18,
// })
// const [user, setUser] = useState<User>(() => {
// return {
// name: 'jack',
// age: 18,
// }
// })
const [user, setUser] = useState<User>({
name: 'jack',
age: 18,
})
const changeUser = () => {
setUser(() => ({
name: 'john',
age: 28,
}))
}
return <>this is app {user.name}</>
}
export default App
3. 初始值为 null
当我们不知道状态的初始值是什么,将 useState 的初始值为 null 是一个常见的做法,可以通过具体类型联合 null 来做显式注解
type User = {
name: string
age: number
}
const [user, setUser] = useState<User | null>(null)
说明:
(1)限制 useState 函数参数的初始值可以是 User | null
(2)限制 setUser 函数的参数类型可以是 User | null
// react + ts
import { useState } from 'react'
type User = {
name: string
age: number
}
function App() {
const [user, setUser] = useState<User | null>(null)
const changeUser = () => {
setUser(null)
setUser({
name: 'jack',
age: 18,
})
}
// 为了类型安全 可选链做类型守卫
// 只有user不为null(不为空值)的时候才进行点运算
return <>this is app {user?.age}</>
}
export default App
三、Props 与 TypeScript
1. 基础使用
为组件 prop 添加类型,本质是给函数的参数做类型注解,可以使用 type 对象类型或者 interface 接口来做注解
type Props = {
className: string
}
function Button(props: Props){
const { className } = props
return <button className={className}>click me</button>
}
说明:Button 组件只能传入名称为 className 的 prop 参数,类型为 string,且为必填
// props + ts
// type Props = {
// className: string
// }
interface Props {
className: string
title?: string
}
function Button(props: Props) {
const { className } = props
return <button className={className}>click me </button>
}
function App() {
return (
<>
<Button className="test" title="this is title" />
</>
)
}
export default App
2. 特殊的 children 属性
children 是一个比较特殊的 prop,支持多种不同类型数据的传入,需要通过一个内置的 ReactNode 类型来做注释
type Props = {
className: string
children: React.ReactNode
}
function Button(props: Props){
const { className, children } = props
return <button className={className}>{children}</button>
}
说明:注解之后,children 可以是多种类型,包括:React.ReactElement、string、number、React.ReactFragment、React.ReactPortal、boolean、null、undefined
// props + ts
type Props = {
className: string
children: React.ReactNode
}
function Button(props: Props) {
const { className, children } = props
return <button className={className}>{children} </button>
}
function App() {
return (
<>
<Button className="test">click me!</Button>
<Button className="test">
<span>this is span</span>
</Button>
</>
)
}
export default App
3. 为事件 prop 添加类型
组件经常执行类型为函数的 prop 实现子传父,这类 prop 重点在于函数参数类型的注解
说明:
(1)在组件内部调用时需要遵守类型的约束,参数传递需要满足要求
(2)绑定 prop 时如果绑定内联函数直接可以推断出参数类型,否则需要单独注解匹配的参数类型
// props + ts
type Props = {
onGetMsg?: (msg: string) => void
}
function Son(props: Props) {
const { onGetMsg } = props
const clickHandler = () => {
onGetMsg?.('this is msg')
}
return <button onClick={clickHandler}>sendMsg</button>
}
function App() {
const getMsgHandler = (msg: string) => {
console.log(msg)
}
return (
<>
<Son onGetMsg={(msg) => console.log(msg)} />
<Son onGetMsg={getMsgHandler} />
</>
)
}
export default App
四、useRef 与 TypeScript
1. 获取 dom
获取 dom 的场景,可以直接把要获取的 dom 元素的类型当成泛型参数传递给 useRef, 可以推导出 .current 属性的类型
function App(){
const domRef = useRef<HTMLInputElement>(null)
useEffect(() => {
domRef.current?.focus()
}, [])
return (
<>
<input ref={domRef}/>
</>
)
}
2. 引用稳定的存储器
把 useRef 当成引用稳定的存储器使用的场景可以通过泛型传入联合类型来做,比如定时器的场景:
function App(){
const timerRef = useRef<number | undefined>(undefined)
useEffect(() => {
timerRef.current = setInterval(() => {
console.log('1')
}, 1000)
return () => clearInterval(timerRef.current)
}, [])
return <>this is div</>
}
// useRef + ts
import { useEffect, useRef } from 'react'
// 1. 获取dom
// 2. 稳定引用的存储器(定时器管理)
function App() {
const domRef = useRef<HTMLInputElement>(null)
const timerId = useRef<number | undefined>(undefined)
useEffect(() => {
// 可选链 前面不为空值(null / undefined)执行点运算
// 类型守卫 防止出现空值点运算错误
domRef.current?.focus()
timerId.current = setInterval(() => {
console.log('123')
}, 1000)
return () => clearInterval(timerId.current)
}, [])
return (
<>
<input ref={domRef} />
</>
)
}
export default App
五、极客园移动端
1. 项目环境创建
基于 vite 创建开发环境
vite 是一个框架无关的前端工具链,可以快速的生成一个 React + TS 的开发环境,并且可以提供快速的开发体验
npm create vite@latest react-jike-mobile -- --template react-ts
说明:
(1)npm create vite@latest 固定写法 (使用最新版本 vite 初始化项目)
(2)react-ts-pro 项目名称 (可以自定义)
(3)-- --template react-ts 指定项目模版位 react + ts
2. 安装 Ant Design Mobile
Ant Design Mobile 是 Ant Design 家族里专门针对移动端的组件库
看文档!
3. 配置基础路由
初始化路由
React 的路由初始化,我们采用 react-router-dom 进行配置
4. 配置别名路径
场景:项目中各个模块之间的互相导入导出,可以通过 @ 别名路径做路径优化,经过配置 @ 相当于 src 目录,比如:
import Detail from '../pages/Detail'
import Detail from '@/pages/Detail'
步骤:
(1)让 vite 做路径解析 (真实的路径转换)
(2)让 vscode 做智能路径提示 (开发者体验)
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')
},
},
})
安装 node 类型包
npm i @types/node -D
tsconfig.json
"compilerOptions": {
...
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
},
}
5. 安装 axios
场景:axios 作为最流行的请求插件,同样是类型友好的,基于 axios 做一些基础的封装
(1)安装 axios 到项目
(2)在 utils 中封装 http 模块,主要包括 接口基地址、超时时间、拦截器
(3)在 utils 中做统一导出
npm i axios
utils/http.ts
// axios的封装处理
import axios from "axios"
const httpInstance = axios.create({
baseURL: 'http://geek.itheima.net',
timeout: 5000
})
// 添加请求拦截器
httpInstance.interceptors.request.use((config) => {
return config
}, (error) => {
return Promise.reject(error)
})
// 添加响应拦截器
httpInstance.interceptors.response.use((response) => {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么
return response.data
}, (error) => {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error)
})
export { httpInstance }
utils/index.js
// 统一中转工具模块函数
// import { httpInstance } from '@/utils'
import { httpInstance as http } from './request'
export {
http,
}
6. 封装 API 模块
场景:axios 提供了 request 泛型方法,方便我们传入类型参数推导出接口返回值的类型
axios.request<Type>(requestConfig).then(res=>{
// res.data 的类型为 Type
console.log(res.data)
})
说明:泛型参数 Type 的类型决定了 res.data 的类型
步骤:
(1)根据接口文档创建一个通用的泛型接口类型(多个接口返回值的结构是类似的)
(2)根据接口文档创建特有的接口数据类型(每个接口都有自己特殊的数据格式)
(3)组合1和2的类型,得到最终传给 request 泛型的参数类型
apis/list.ts
import { http } from "@/utils";
// 1. 定义泛型
type ResType<T> = {
message: string;
data: T;
};
// 2. 定义具体的接口类型
export type ChannelItem = {
id: number;
name: string;
};
type ChannelRes = {
channels: ChannelItem[];
};
// 请求频道列表
export function fetchChannelAPI() {
return http.request<ResType<ChannelRes>>({
url: "/channels",
});
}
六、极客园 - Home 模块开发
1. channels 基础数据渲染
实现步骤:
(1)使用 ant-mobile 组件库中的 Tabs 组件进行页面结构创建
(2)使用真实接口数据进行渲染
(3)有优化的点进行优化处理
pages/Home/index.js
import './style.css'
import { useEffect, useState } from 'react'
import { ChannelItem, fetchChannelAPI } from '@/apis/list'
import { Tabs } from 'antd-mobile'
const Home = () => {
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 (
<div>
<div className="tabContainer">
{/* tab区域 */}
<Tabs>
{channels.map((item) => (
<Tabs.Tab title={item.name} key={item.id}>
{/* list组件 */}
</Tabs.Tab>
))}
</Tabs>
</div>
</div>
)
}
export default Home
2. channels - hooks 优化
场景:当前状态数据的各种操作逻辑和组件渲染是写在一起的,可以采用自定义 hook 封装的方法让逻辑和渲染相分离
实现步骤:
(1)把和 Tabs 相关的响应式数据状态以及操作数据的方法放到 hook 函数中
(2)组件中调用 hook 函数,消费其返回的状态和方法
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 }
使用:
import { useTabs } from './useTabs'
const { channels } = useTabs()
3. List 组件实现
实现步骤:
(1)搭建基础结构,并获取基础数据
(2)为组件设计 channelId 参数, 点击 tab 时传入不同的参数
(3)实现上拉加载功能
apis/list.ts
// 请求文章列表
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,
});
}
pages/Home/index.js
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
pages/Home/HomeList
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])
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>
</>
)
}
export default HomeList
4. List 列表无限滚动实现
交互要求:List 列表在滑动到底部时,自动加载下一页列表数据
实现思路:
(1)滑动到底部触发加载下一页动作
<InfiniteScroll />
(2)加载下一页数据
pre_timestamp 接口参数
(3)把老数据和新数据做拼接处理
[...oldList, ...newList]
(4)停止监听边界值
hasMore
pages/Home/HomeList
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>
...
</List.Item>
))}
</List>
<InfiniteScroll loadMore={loadMore} hasMore={hasMore} threshold={10} />
</>
)
}
export default HomeList
七、极客园 - 详情模块
1. 路由跳转 & 数据渲染
需求:点击列表中的某一项跳转到详情路由并显示当前文章
(1)通过路由跳转方法进行跳转,并传递参数
(2)在详情路由下获取参数,并请求数据
(3)渲染数据到页面中
方法:
(1)根据接口文档封装接口 Type
(2)传递给 axios.request 泛型参数
pages/Home/HomeList
import { useNavigate } from 'react-router-dom'
type Props = {
channelId: string
}
const HomeList = (props: Props) => {
...
const navigate = useNavigate()
const goToDetail = (id: string) => {
// 路由跳转
navigate(`/detail?id=${id}`)
}
return (
<>
<List>
...
<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
apis/details.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}`,
})
}
pages/Detail/index.js
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