ts+hooks 实现的小型项目笔记
项目依赖
- gitee.com/zhufengpeix…
- npm i react react-dom react-router-dom react-transition-group react-swipe qs antd @types/react @types/react-dom @types/react-router-dom @types/react-transition-group @types/react-swipe @types/qs -S
- npm i webpack webpack-cli webpack-dev-server html-webpack-plugin -D
- npm i typescript ts-loader source-map-loader style-loader css-loader less-loader less url-loader file-loader -D
- npm i redux react-redux @types/react-redux redux-thunk redux-logger @types/redux-logger redux-promise @types/redux-promise -S
- npm i connected-react-router -S
配置文件
- tsc --init 创建 ts 配置文件
- webpack.config.js
- path.join join 连接,只是机械的相连接,不解析真实路径
- path.resolve 要解析真实路径 永远是一个相对于盘符根目录的绝对路径
- index.html 增加 refreshRem
rem
;(function () {
function refreshRem() {
document.documentElement.style.fontSize =
(document.documentElement.clientWidth / 750) * 100 + 'px'
}
refreshRem()
window.addEventListener('resize', refreshRem, false)
})()
ts 支持@
- import history from '@/history';
- tsconfig 增加配置
"baseUrl": ".",
"paths": {
"@/*" : [
"src/*",
]
}
rootReducer 类型的定义
方法 1: 根据定义的 recuder 对象推出 rootreducer 的类型 (有点像反向推)
const reducers = {
home,
mine,
profile,
router: connectRouter(history),
}
export type IRootState = {
[key in keyof typeof reducers]: ReturnType<typeof reducers[key]>
}
方法 2:正向一步一步定义
interface IRootState {
home: HomeState
mine: MineState
profile: ProfileState
router: RouterState
}
const reducers: ReducersMapObject<IRootState, AnyAction> = {
home,
mine,
profile,
router: connectRouter(history),
}
const rootReducer: Reducer<IRootState, AnyAction> =
combineReducers<IRootState>(reducers)
删除 node_modules
- npm install rimraf -g
- rimraf node_modules
withRouter(Tabs)
- 用 withRouter 包裹是因为 底部导航 Tabs 不是路由渲染的组件
- 高阶组件中的 withRouter, 作用是将一个组件包裹进 Route 里面, 然后 react-router 的三个对象 history, location, match 就会被放进这个组件的 props 属性中.
怎么在代码中使用图片
- 默认情况下不能导入一个图片,ts 里默认只能导入 ts、js 代码
- 为了支持图片导入,需要在 src 目录下建立一个文件叫做 typings/images.d.ts
具体解释
- 如果在 js 中引入本地静态图片时使用'import img from "./img/logo.png"'这种写法是没有问题的
- 但是在 ts 中是无法识别非代码资源的,所以会报错 TS2307:cannot find module '.png'
- 因此需要主动去声明这个 module
- 新建一个 ts 声明文件如:images.d.ts 这种类型的文件,所以不需要手动去加载他们
- 当然.d.ts 文件也不能随便放置在项目中 这类文件和 ts 文件一样需要被 typescript 编译,所以一样只能放在 tsconfig.json 中 include 属性所配置的文件夹下
declare module '*.svg'
declare module '*.png'
declare module '*.jpg'
declare module '*.jpeg'
declare module '*.gif'
declare module '*.bmp'
css
- 可以在 less 文件中声明变量然后进行使用: @BG_COLOR: #2A2A2A;
- antd 里图标样式的更改:
span.anticon.anticon-bars
路由组件的属性类型定义
import React, { PropsWithChildren } from 'react'
import { RouteComponentProps } from 'react-router-dom'
type Props = PropsWithChildren<RouteComponentProps<Params>>
type StateProps = ReturnType<typeof mapStateToProps>
type DispatchProps = typeof actions
type Prop = PropsWithChildren<RouteComponentProps> & StateProps & DispatchProps
ts 类型
React.CSSProperties //CSS 的行内属性
如果定义了一个 interface,想获取到里面包含的所有的 key,作为参数的类型,可以使用 keyof
React.MouseEvent react 合成事件的类型
HTMLUListElement 原生的 dom 类型 就是 ul 元素的类型
ul dom 事件对象类型: event: React.MouseEvent<HTMLUListElement>
const target: HTMLUListElement = event.target as HTMLUListElement;//获取事件源对象
interface TransitionStyles {
entering: React.CSSProperties,
entered: React.CSSProperties,
exiting: React.CSSProperties,
exited: React.CSSProperties,
}
keyof TransitionStyles => entering | entered | exiting | exited
动画库里的stage参数的类型就是keyof TransitionStyles
动画库
- import { Transition } from 'react-transition-group';//react 官方动画库
const duration = 1000;
const defaultStyle = {
transition: `opacity ${duration}ms ease-in-out`,
opacity: 0
}
interface TransitionStyles {
entering: React.CSSProperties,
entered: React.CSSProperties,
exiting: React.CSSProperties,
exited: React.CSSProperties,
}
const transitionStyles: TransitionStyles = {
entering: { opacity: 1 },
entered: { opacity: 1 },
exiting: { opacity: 0 },
exited: { opacity: 0 },
}
<Transition
in={isMenuVisible}
timeout={duration}
>
style={
{
...defaultStyle,
...transitionStyles[stage]
}
}
组件
Home
HomeHeader
Profile 个人中心
NavBar 公共组件 头部导航
import { withRouter, RouteComponentProps } from 'react-router-dom'
type Props = PropsWithChildren<RouteComponentProps>
export default withRouter(NavBar)
身份验证逻辑
- 在 Profile/index.tsx 这个路由组件中 在组件初始化的时候调接口验证身份 validate
- redux-promise 可以让我们派发 promise redux-thunk 让我们可以派发函数
const actions = {
validate(): AnyAction {
return {
type: types.VALIDATE,
payload: validate(),
}
},
}
useEffect(() => {
props.validate().catch((error: AxiosError) => message.error(error.message))
}, [])
注册逻辑
- action 为 function 时会经过 redux-thunk 的处理,如果里面有异步逻辑,可以包裹自执行函数来实现 async await 的书写
register(values:RegisterPayload){
return function(dispatch:Function,getState:Function){
(async function(){
let result:RegisterResult = await register<RegisterResult>(values);
if(result.success){
dispatch(push('/login'));
}else{
message.error(result.message);
}
})();
}
},
axios 请求封装
import axios from 'axios'
axios.defaults.baseURL = 'http://localhost:8899'
axios.defaults.headers.post['Content-Type'] = 'application/json;charset=utf8'
axios.interceptors.request.use(
(config) => {
let token = sessionStorage.getItem('token')
config.headers = config.headers || {}
config.headers['Authorization'] = `Bearer ${token}`
return config
},
(error) => {
return Promise.reject(error)
}
)
axios.interceptors.response.use(
(response) => response.data,
(error) => Promise.reject(error)
)
export default axios
首页轮播图的实现
- let homeContainerRef = useRef(null) homeContainer 是中间部分所有内容的容器,包含轮播图、课程列表
- 在 Home 路由组件中加入 div, ref=homeContainerRef
- 在 div 容器里面包裹轮播图 HomeSliders 组件
- HomeSliders 组件传入的属性:sliders 数组,获取 sliders 的方法
- 不论是轮播图还是课程列表,都是在组件里面通过在 useeffect 里面判断有没有数据,如果没有,就调用获取数据的方法来加载数据
React.useEffect(() => {
if (props.lessons.list.length === 0) {
props.getLessons()
}
}, [])
加载课程列表 实现上拉刷新
- LessonList 课程列表组件也是放在 homeContainer 这个 dom 的里面
- 在 redux-home 中存储 lessons 相关的状态
- LessonList 组件传入的属性: lessons 数组,获取 lessons 的方法
- 逻辑还需要用到 LessonList 的 dom 元素
export interface Lessons {
loading: boolean;
list: Lesson[];
hasMore: boolean;
offset: number;
limit: number;
}
export interface HomeState {
title: string;
currentCategory: string;
sliders: Slider[];
lessons: Lessons;
}
const initialState: HomeState = {
title: '首页',
currentCategory: 'all',
sliders: [],
lessons: {
loading: false,
list: [],
hasMore: true,
offset: 0,
limit: 5,
},
}
loadMore 方法
- 给 homeContainer 绑定这个方法
- 当滚动到底部的时候加载下一页
export function loadMore(element: HTMLDivElement, callback: Function) {
function _loadMore() {
let clientHeight = element.clientHeight
let scrollTop = element.scrollTop
let scrollHeight = element.scrollHeight
if (clientHeight + scrollTop + 10 >= scrollHeight) {
callback()
}
}
element.addEventListener('scroll', debounce(_loadMore, 100))
}
export function debounce(callback: Function, wait: number) {
var timeout: any
return function () {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(callback, wait)
}
}
React.useEffect(() => {
loadMore(homeContainerRef.current, props.getLessons)
}, [])
getLessons(){
return function(dispatch:Function,getState:Function){
(async function(){
let {currentCategory,lessons:{hasMore,offset,limit,loading}}=getState().home;
if(hasMore && !loading){
dispatch({type:types.SET_LESSONS_LOADING,payload:true});
let result = await getLessons(currentCategory,offset,limit);
dispatch({type:types.SET_LESSONS,payload:result.data});
}
})();
}
},
点击课程列表中的条目跳转到详情页
- 在课程列表组件中通过 Link 进行跳转,在跳转的时候可以在 to 参数中传入数据
- to 可以传入对象:pathname 表示跳转路径,state 表示跳转携带的数据
<Link
to={{pathname:`/detail/${lesson.id}`,state:lesson}}
>
- 在 Detail 组件中,可以拿到 state 里面传过来的数据
import {StaticContext} from 'react-router';
interface Params{
id:string
}
type RouteProps = RouteComponentProps<Params,StaticContext,Lesson>;
let [lesson,setLesson]= useState<Lesson>({} as Lesson);
useEffect(()=>{
(async function(){
let lesson:Lesson = props.location.state;
if(!lesson){
let id = props.match.params.id;
let result:LessonResult = await getLesson<LessonResult>(id);
if(result.success){
lesson = result.data;
}
}
setLesson(lesson);
})();
},[]);
下拉刷新
refreshLessons(){
return function(dispatch:Function,getState:Function){
(async function(){
let {currentCategory,lessons:{limit,loading}}=getState().home;
if(!loading){
dispatch({type:types.SET_LESSONS_LOADING,payload:true});
let result = await getLessons(currentCategory,0,limit);
dispatch({type:types.REFRESH_LESSONS,payload:result.data});
}
})();
}
}
为了实现下拉刷新,homeContainer div 需要绝对定位,固定高度
.home-container {
position: fixed;
top: 100px;
left: 0;
width: 100%;
overflow-y: auto;
height: calc(100vh - 222px);
background-color: #fff;
}
在 touchstart 事件发生时才去绑定 touchmove 和 touchend 事件
export function downRefresh(element: HTMLDivElement, callback: Function) {
let startY: number
let distance: number
let originalTop = element.offsetTop
let startTop: number
let $timer: any
element.addEventListener('touchstart', (event: TouchEvent) => {
if (element.scrollTop === 0) {
startTop = element.offsetTop
startY = event.touches[0].pageY
let touchMove = throttle(_touchMove, 30)
element.addEventListener('touchmove', touchMove)
element.addEventListener('touchend', touchEnd)
}
function _touchMove(event: TouchEvent) {
let pageY = event.touches[0].pageY
if (pageY > startY) {
distance = pageY - startY
element.style.top = startTop + distance + 'px'
} else {
element.removeEventListener('touchmove', touchMove)
element.removeEventListener('touchend', touchEnd)
}
}
function touchEnd(event: TouchEvent) {
element.removeEventListener('touchmove', touchMove)
element.removeEventListener('touchend', touchEnd)
if (distance > 50) {
callback()
}
function _back() {
let currentTop = element.offsetTop
if (currentTop - originalTop >= 1) {
element.style.top = currentTop - 1 + 'px'
requestAnimationFrame(_back)
} else {
element.style.top = originalTop + 'px'
}
}
requestAnimationFrame(_back)
}
})
}
export function throttle(func: Function, delay: number) {
let prev = Date.now()
return function () {
let context = this
let args = arguments
let now = Date.now()
if (now - prev >= delay) {
func.apply(context, args)
prev = now
}
}
}
实现虚拟列表
- 如果页面里的 dom 元素太多的话,会卡顿,可通过只渲染可视区域内的 dom 元素来进行优化
- 需要对课程列表 LessonList 实现虚拟列表
- 具体实现举例
- 渲染容器内的内容,容器外的内容不渲染
- 假设容器 width=100,height=100,每条数据高度 itemHeight=10
- 容器的作用是根据卷曲的高度 scrollTop 来计算当前在容器中显示的数据条目对应的是哪几条
- 在所有数据外面会包裹一个 div 作为内容容器
- 内容容器相对定位,里面的每个元素是相对于内容容器绝对定位
- 内容容器要设置 height 值,为数据条目乘以每一条数据的高度
const homeContainerRef = React.useRef(null);
const lessonListRef = React.useRef(null);
<div className="home-container" ref={homeContainerRef}>
<HomeSliders sliders={props.sliders} getSliders={props.getSliders}/>
<LessonList
lessons = {props.lessons}
getLessons = {props.getLessons}
homeContainerRef={homeContainerRef}
ref={lessonListRef}
/>
</div>
function LessonList(props, PropsWithChildren<Props>, forwardedRef:any){
}
React.forwardRef(LessonList);
- 在 Home 组件中,给 homeContainer dom 绑定滚动事件,当滚动事件触发时重新渲染 LessonList 组件
React.useEffect(() => {
loadMore(homeContainerRef.current, props.getLessons)
downRefresh(homeContainerRef.current, props.refreshLessons)
lessonListRef.current()
homeContainerRef.current.addEventListener(
'scroll',
throttle(lessonListRef.current, 30)
)
}, [])
function LessonList(props: PropsWithChildren<Props>, forwardedRef: any) {
let [, forceUpdate] = React.useReducer((x) => x + 1, 0)
lessonListRef.current = forceUpdate
}
.home-container{
position: fixed;
top:100px;
left:0;
width:100%;
overflow-y: auto;
height: calc(100vh - 222px);
background-color: #FFF;
}
.lesson-list{
h2{
line-height: 100px;
span{
margin: 0 10px;
}
}
.ant-card.ant-card-bordered.ant-card-hoverable{
height:650px;
overflow: hidden;
}
}
<div style={{position:'relative',width:'100%',height:`${props.lessons.list.length*itemSize}px`}}>
{
visibleList.map()
}
</div>
let remUnit:number = parseFloat(document.documentElement.style.fontSize);
let itemSize = (650/75)*remUnit;
let screenHeight=window.innerHeight -(222/75)*remUnit;
if(homeContainer){
const scrollTop = homeContainer.scrollTop;
start = Math.floor(scrollTop /itemSize);
end = start + Math.floor(screenHeight/itemSize);
start -=2,end +=2;
start = start<0?0:start;
end = end > props.lessons.list.length?props.lessons.list.length:end;
}
let visibleList = props.lessons.list.map((item:Lesson,index:number)=>({...item,index})).slice(start,end);
let cardStyle:React.CSSProperties = {position:'absolute',top:0,left:0,width:'100%',height:itemSize};
let bottomTop = props.lessons.list.length*itemSize;
return (
<section className="lesson-list">
<h2><MenuOutlined/>全部课程</h2>
<Skeleton
loading={props.lessons.list.length === 0 && props.lessons.loading}
active
paragraph={{rows:8}}
>
<div style={{position:'relative',width:'100%',height:`${props.lessons.list.length*itemSize}px`}}>
{
visibleList.map((lesson:VisibleLesson,index:number)=>(
<Link
key={lesson.id}
to={{pathname:`/detail/${lesson.id}`,state:lesson}}
style={{...cardStyle,top:`${itemSize*lesson.index}px`}}
>
<Card hoverable={true} style={{width:'100%'}}
cover={<img src={lesson.poster}/>}
>
<Card.Meta title={lesson.title} description={`价格:¥${lesson.price}元`}/>
</Card>
</Link>
))
}
{
props.lessons.hasMore?(
<Button style={{textAlign:'center',top:`${bottomTop}px`}} onClick={props.getLessons} loading={props.lessons.loading} type="primary" block>{props.lessons.loading?'':'加载更多'}</Button>
):(<Alert style={{textAlign:'center',top:`${bottomTop}px`}} message='我是有底线的' type='warning'/>)
}
</div>
</Skeleton>
</section>
)
购物车的实现 (redux-immer ,redux-persist)
redux-immer 在更新 state 的时候就不用先解构老的 state,再赋值
case actionTypes.GET_SLIDERS:
return {...state, sliders: action.payload.data}
case actionTypes.GET_SLIDERS:
state.sliders = action.payload.data;
break;
import { combindeReducers } from 'redux-immer'
import produce from 'immer'
let rootReducer = combineReducers(produce, reducers)
redux-persist 实现刷新之后数据还在 redux 数据持久化
- 持久化是说当改变 redux 仓库的时候,会把数据保存一份,可以保存在 localStorage、cookie、接口等,可以指定位置
- npm install redux-persist
import { store, persistor } from './store'
import { PersistGate } from 'redux-persist/integration/react'
ReactDOM.render(
<Provider store={store}>
<PersistGate loading={<Spin />} persistor={persistor}></PersistGate>
</Provider>
)
import { persistStore, persistReducer } from 'redux-persist'
import storage from 'redux-persist/lib/storage'
const persistConfig = {
key: 'root',
storage,
}
const persistedReducer = persistReducer(persistConfig, rootReducer)
const store = applyMiddleware(
routerMiddleware(history),
thunk,
promise,
logger
)(createStore)(persistedReducer)
const persistor = persistStore(store)
export { store, persistor }
const addCartItem = (lesson: Lesson) => {
let video:HTMLVideoElement = document.querySelector('#lesson-video');
let cart:HTMLSpanElement = document.querySelector('.anticon.anticon-shopping-cart');
let cloneVideo:HTMLVideoElement = video.cloneNode(true) as HTMLVideoElement;
let videoWidth = video.offsetWidth;
let videoHeight = video.offsetHeight;
let cartWidth = cart.offsetWidth;
let cartHeight = cart.offsetHeight;
let videoLeft = video.getBoundingClientRect().left;
let videoTop = video.getBoundingClientRect().top;
let cartRight = cart.getBoundingClientRect().right;
let cartBottom = cart.getBoundingClientRect().bottom;
cloneVideo.style.cssText = `
z-index:1000;
opacity:0.8;
position:fixed;
width:${videoWidth}px;
height:${videoHeight}px;
top:${videoTop}px;
left:${videoLeft}px;
transition: all 2s ease-in-out
`;
document.body.appendChild(cloneVideo);
setTimeout(() => {
cloneVideo.style.left = (cartRight - (cartWidth/2)) + 'px';
cloneVideo.style.top = (cartBottm - (cartHright/2)) + 'px';
cloneVideo.style.width = '0px';
cloneVideo.style.height = '0px';
cloneVideo.style.opacity = '0.1';
})
}