【山竹记账前端-react】6.自己封装一个 Hook 实现滑动功能
大纲链接 §
[toc]
1. BUG:errorElement造成无限循环 ⇧
误用 errorElement
- 当代码中有错误,比如未引入依赖造成页面报错时,目前的路由中配置了
errorElement: <RedirectToWelcome1 /> - 页面会先去
<WelcomLayout />,但path: 'welcome'未配置errorElement错误时的路由,会访问上一级,导致无限循环
解决方法
- 在
path: 'welcome'路由定义一个errorElement
显示
routes的类型提示
- 点击进入查看
export const router = createBrowserRouter(routes)中createBrowserRouter的参数类型 - 添加类型
export const routes: RouteObject[] = [...],之后就会有诸如errorElement的提示了
移动、重命名
@/pages/NotFoundPage.tsx
|
|
@/route/routes.tsx
|
|
- 不重定义到一个不能确保一定不报错的组件
@/route/welcomeRouter.tsx
|
|
2. BUG:解决TS报错,顺便重构 ⇧
const [extraStyle, setExtraStyle] = useState<Record<string, string | number>>({ position: 'relative' })如果
unocss相关样式属性有报错,可以将属性追加到src/shims.d.ts中1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21import { HTMLAttributes } from 'react' import type { AttributifyAttributes } from '@unocss/preset-attributify' declare module 'react' { interface HTMLAttributes<T> extends AriaAttributes, AttributifyAttributes, DOMAttributes<T> { // 允许传入 UnoCSS 属性 bold?: boolean text?: string bordered?: boolean bg?: string flex?: string | boolean block?: boolean relative?: boolean absolute?: boolean scopedId?: string w?: string h?: string // 或者直接使用通配符或特殊定义 [key: string]: unknown } }
重构前
@/layouts/WelcomeMainLayout.tsx
|
|
- 重构思路
- 将静态确定的结构提取出来
- 将动画的逻辑封装成单独
hook逻辑
重构拆分
@/modules/welcome/MangosteenHeader.tsx
|
|
重构拆分
@/modules/welcome/ToStartPage.tsx
|
|
重构拆分
@/modules/welcome/SwiperGuide.tsx
|
|
拆分后
@/layouts/WelcomeMainLayout.tsx
|
|
3. 使用体验:添加 ESLint 规则 ⇧
自主掌控
eslint我的代码我做主eslint.config.ts
- 代码规则划红线,鼠标浮动查看,点击链接进入相关规则页面
按照规则配置
rules即可1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20import antfu from '@antfu/eslint-config' export default antfu( { rules: { "curly": "off", "@typescript-eslint/brace-style": "off", "@typescript-eslint/no-unused-vars": [ "error", { "vars": "local", "args": "none", "ignoreRestSiblings": true, "caughtErrors": "none" } ], "@typescript-eslint/comma-dangle": "off" } }, )
4. 封装:自制 Hooks 之 useSwipe ⇧
使用 Hooks 实现:滑动手势,左滑切换欢迎页面
const {direction} = useSwipe(div)
新建
@/hooks/useSwipe.tsx
- 参数传入一个HTML元素
- 需要在组件挂载时监听这个元素
useEffect(() => {}, []) - 先搭一个框架
入参参数类型
element: HTMLElement | null存疑1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32import { useEffect, useState } from 'react' export const useSwipe = (element: HTMLElement | null) => { const [direction, setDirection] = useState<'' | 'left' | 'right'>('') const onTouchStart = (e: TouchEvent) => { window.console.log('onTouchStart', e) } const onTouchMove = (e: TouchEvent) => { window.console.log('onTouchMove', e) } const onTouchEnd = (e: TouchEvent) => { window.console.log('onTouchEnd', e) } useEffect(() => { if (!element) { return } element.addEventListener('touchstart', onTouchStart) element.addEventListener('touchmove', onTouchMove) element.addEventListener('touchend', onTouchEnd) return () => { element.removeEventListener('touchstart', onTouchStart) element.removeEventListener('touchmove', onTouchMove) element.removeEventListener('touchend', onTouchEnd) } }, []) return ({ direction, }) }
监听动画外层固定不动的元素
main:@/modules/welcome/SwiperGuide.tsx
- 引用元素
const mainRef = useRef<HTMLElement>(null),默认值不填会报类型错误 监听元素
const {direction} = useSwipe(mainRef.current)由于初始逻辑中
mainRef.current一直为 null,所以反证了useSwipe(element: HTMLElement | null)参数必须接受一个RefObject类型1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34import type { RefObject } from 'react' import { useEffect, useState } from 'react' export const useSwipe = (elementRef: RefObject<HTMLElement | null>, ) => { const [direction, setDirection] = useState<'' | 'left' | 'right'>('') const onTouchStart = (e: TouchEvent) => { window.console.log('onTouchStart', e) } const onTouchMove = (e: TouchEvent) => { window.console.log('onTouchMove', e) } const onTouchEnd = (e: TouchEvent) => { window.console.log('onTouchEnd', e) } useEffect(() => { if (!elementRef.current) { return } elementRef.current.addEventListener('touchstart', onTouchStart) elementRef.current.addEventListener('touchmove', onTouchMove) elementRef.current.addEventListener('touchend', onTouchEnd) return () => { if (!elementRef.current) { return } elementRef.current.removeEventListener('touchstart', onTouchStart) elementRef.current.removeEventListener('touchmove', onTouchMove) elementRef.current.removeEventListener('touchend', onTouchEnd) } }, []) return ({ direction, }) }
修改
@/hooks/useSwipe.tsx
|
|
用户手指第一次滑动操作方向 ⇧
由于需要记录的坐标不参与 UI 的展示,使用
useRef
const coordinateRef = useRef(-1)由于0具有特殊含义,所以使用-1作为初始值- 使用第一根手指
e.touches[0] - 根据开始和移动时的 x 坐标判断方向
- 方向决定动画的移动方向,参与 UI 的展示,所以
const [direction, setDirection] = useState<'' | 'left' | 'right'>('')
@/hooks/useSwipe.tsx
|
|
- 距离小于 3 像素忽略
监听返回的
direction:@/modules/welcome/SwiperGuide.tsx
- 判断方向,跳转下一页
- 使用
useEffect,依赖[direction, nav, pathname] 左滑跳转
if (direction === 'left') { nav(welcomeLinkMap[pathname]) }1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70import { useSwipe } from '@/hooks/useSwipe' import { NotFoundPage } from '@/pages/NotFoundPage' import { animated, useTransition } from '@react-spring/web' import { cn } from 'cn' import type { ReactNode } from 'react' import { useEffect, useRef, useState } from 'react' import { useLocation, useNavigate, useOutlet } from 'react-router-dom' const welcomeLinkMap: Record<string, string> = { '/welcome/1': '/welcome/2', '/welcome/2': '/welcome/3', '/welcome/3': '/welcome/4', '/welcome/4': '/welcome/xxx', } as const export const SwiperGuide: React.FC = () => { const { pathname } = useLocation() const mapRef = useRef<Record<string, ReactNode>>({}) // 获取当前的 outlet const currentOutlet = useOutlet() as React.ReactElement mapRef.current[pathname] = currentOutlet const [extraStyle, setExtraStyle] = useState<Record<string, string | number>>({ position: 'relative' }) const transitions = useTransition(pathname, { onStart: () => { setExtraStyle({ position: 'absolute' }) }, onRest: () => { setExtraStyle({ position: 'relative' }) }, from: { opacity: 0, transform: 'translate3D(100%, 0, 0)' }, enter: { opacity: 1, transform: 'translate3D(0%, 0, 0)' }, leave: { opacity: 0, transform: 'translate3D(-100%, 0, 0)' }, // 物理参数微调:让过渡更轻快 config: { tension: 280, friction: 30, duration: 300 }, }) const mainRef = useRef<HTMLElement>(null) const { direction } = useSwipe(mainRef) const nav = useNavigate() useEffect(() => { if (direction === 'left') { nav(welcomeLinkMap[pathname]) } }, [direction, nav, pathname]) window.console.log('SwiperGuide_______________________') window.console.log('✌️direction --->', direction) window.console.log('_______________________SwiperGuide') return ( <main ref={mainRef} className={cn( 'grow-1 shrink-1 mb-2em relative overflow-clip', )}> {transitions((style, pathname) => ( <animated.div key={pathname} style={{ ...style, ...extraStyle }} className={cn( 'flex h-100% w-100% p-16px', )}> <div className={cn( 'grow-1 flex justify-center items-center', 'bg-white rounded-8px', )}> {mapRef.current[pathname]} </div> </animated.div> ))} </main> ) }目前一滑动,页面就一下子跳转到最终页面了
需要节流,控制滑动动画,标记当前是否正正在动画中,判断阻止跳转下一页的执行
添加节流标志,防止一滑到底 ⇧
- 是否正正在动画中不参与 UI 的展示,仅用来阻止跳转函数的执行
const isAnimatingRef = useRef(false)- 主要监听
[direction, pathname]- 已经正在动画中,则不处理
if (isAnimatingRef.current) { return } - 不正在动画中,则设置
isAnimatingRef.current = true - 并执行判断跳转逻辑
if (direction === 'left') { nav(welcomeLinkMap[pathname]) }
- 已经正在动画中,则不处理
- 在
onRest时还原isAnimatingRef.current = false - 由于监听依赖了
direction如果不重置的话,就监听不了变化- 需要在
touchEnd时,重新设置setDirection('')
- 需要在
@/modules/welcome/SwiperGuide.tsx
|
|
@/hooks/useSwipe.tsx
|
|
阻止手机浏览器默认的弹性动画,左右滑动出现空白区间 ⇧
- 可以在
onTouchStart阻止默认事件e.preventDefault() - 或者让用户传参选项来控制是否需要阻止默认事件
@/hooks/useSwipe.tsx
|
|
使用时传参阻止默认事件
|
|
还有 bug,初始上下滑动,判断错误 ⇧
- 判断仅当左滑时,才设置
isAnimatingRef.current = true
@/modules/welcome/SwiperGuide.tsx
|
|
参考文章
- 初始代码:https://github.com/FrankFang/kcSgKu5Z
- 最终代码:https://github.com/FrankFang/j3PKOSwA
- 提交历史:https://github.com/FrankFang/j3PKOSwA/commits/master
相关文章
- 无
- 作者: Joel
- 文章链接:
- 版权声明
- 非自由转载-非商用-非衍生-保持署名