React 与 TypeScript 极客园移动端

发布于:2025-05-23 ⋅ 阅读:(23) ⋅ 点赞:(0)

一、基础环境创建

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


网站公告

今日签到

点亮在社区的每一天
去签到