react 自定义 hooks 的个人实践(一)

1,960 阅读6分钟

陆续开发了几个 react 项目,个人也总结了一些关于 react 自定义 hooks 的实践。本文不会介绍诸如 useDebounce ,useScript 之类的 hook,关于这方面已经有很多大佬写了非常不错的文章。本文主要介绍个人面对的一些真实业务场景以及为此类场景所做的封装,这些 hook 也确实简化了代码和提高了可维护性。当然,如果有更好的实践,欢迎在评论区指教。

useRouter

使用框架:react-router-dom

每个项目都涉及大量的路由跳转,其或多或少都需要携带参数,有的传参是动态的,但也有大量的参数是固定的,如果不使用自定义 hooks ,一般流程是这样的

import {useHistory, useLocation} from 'react-router-dom'

const Demo = () => {
    const router = useHistory()
    const location = useLocation<{ userType: 'admin' | 'customer' }>()
    const {userType} = location.state

    const viewNews = () => {
        router.push('/news')
    }

    const changeAvatar = () => {
        router.push({
            pathname: '/settings',
            state: {
                flag: 'avatar'
            }
        })
    }

    const viewEmail = (eid: string) => {
        router.push({
            pathname: '/email',
            state: {
                eid
            }
        })
    }
    
    const back = () => {
        router.goBack()
    }
    
    // 初始化页面需要依赖上一个路由传来的 userType
    const init = () => {
        console.log(userType)
    }
    
    // do something

}

除了 viewEmail ,其他传参都是固定的,back 回退操作更是使用频率极高的一个函数,因此做了下列封装

import {useHistory, useLocation} from 'react-router-dom'

type Url = {
    pathname: string
    state: { [key: string]: unknown }
} | string

const useRouter = <State>(urls: Url[] = []) => {
    const router = useHistory()
    const location = useLocation<State>()
    const state = location.state
    const back = () => {
        router.goBack()
    }
    const pushFns: (() => void)[] = urls.map((url: Url) => () => {
        router.push(url)
    })
    return {
        router,
        state,
        pushFns,
        back
    }
}

export default useRouter

代码可简化成下列所示,事实上,随着可能的传固定参数的路由的方法的增多,减少的代码量越多

import useRouter from "../hooks/useRouter"

const Demo = () => {
    const {router, state: {userType}, back, pushFns: [viewNews, changeAvatar]} = useRouter<{ userType: 'admin' | 'customer' }>(
        [
            '/news',
            {
                pathname: '/settings',
                state: {flag: 'avatar'}
            }
        ]
    )

    const viewEmail = (eid: string) => {
        router.push({
            pathname: '/email',
            state: {
                eid
            }
        })
    }

    const init = () => {
        console.log(userType)
    }
    
    // do something
    
}

useDisplayError

使用框架:axios

  • 在公司的项目中有可以全局处理的错误(展示一个 Dialog 告诉用户错误信息以及错误码),也有一些需要特殊处理的,跳转到新的页面之类,因此需要一个 specificApis 数组来做区分,其类型是这样的,axios 导出了类型包含了 GETget ,具有健壮性的匹配应该统一转成小写

    import {Method} from 'axios'
    
    type SpecificApi = {
        method: Method
        url: string
    }
    
    type SpecificApis = SpecificApi[]
    

    如果你的项目没有需要特殊处理的错误,那 err 的操作就不需要那么复杂

    err: (error: AxiosError) => {
        setErrorData(error.response?.data)
        setHasError(true)
        return Promise.reject(error)
    }
    
  • AxiosInstance 经过一层请求拦截器和响应拦截器处理后,导出能无限再使用请求和响应拦截器,下面代码中的 service 是导出的 AxiosInstance

  • hasError 作为一个 boolean 类型,用来判断 api 是否返回错误,errorData 为错误的信息,ErrorData 是与后端约定好的错误格式

下面是最终的代码

import {useEffect, useMemo, useState} from "react"
import {AxiosError, AxiosRequestConfig} from 'axios'
import service from "../../utils/service"
import specificApis from "./specificApis"
import {handleUrl} from "../../utils/handleApi"

type ErrorData = {
    errorCode: string
    message: string
}

type DisplayErrorResult = {
    hasError: boolean
    setHasError: (hasError: boolean) => void
    errorData: ErrorData
}

const useDisplayError = (): DisplayErrorResult => {
    const [hasError, setHasError] = useState<boolean>(false)
    const [errorData, setErrorData] = useState<ErrorData>({errorCode: '', message: ''})

    const interceptors = useMemo(() => ({
        req: request => {
            setHasError(false)
            return request
        },
        resp: response => {
            setHasError(false)
            return response
        },
        err: (error: AxiosError) => {
            const {url, method} = error.response?.config as AxiosRequestConfig
            const [originUrl] = (url as string).split('?')
            if (!specificApis.some(specificApi => specificApi.method === method?.toLowerCase() && specificApi.url === handleUrl(originUrl))) {
                setErrorData(error.response?.data)
                setHasError(true)
            }
            return Promise.reject(error)
        }
    }), [])

    useEffect(() => {
        const reqInterceptors = service.interceptors.request.use(interceptors.req, interceptors.err)
        const respInterceptors = service.interceptors.response.use(interceptors.resp, interceptors.err)
        return () => {
            service.interceptors.request.eject(reqInterceptors)
            service.interceptors.response.eject(respInterceptors)
        }
    }, [interceptors])

    return {
        hasError,
        setHasError,
        errorData
    }
}

export default useDisplayError

useLoading

上面我们知道了 AxiosInstance 可无限导出并添加请求和响应拦截器,同理可写成 Loading 功能,为了避免频繁显示 Loading 图标导致闪屏,用了 number 变量来标记

const useLoading = (): boolean => {
    const [counter, setCounter] = useState<number>(0)

    const interceptors = useMemo(() => ({
        req: request => {
            setCounter(counter + 1)
            return request
        },
        resp: response => {
            setCounter(counter - 1)
            return response
        },
        err: error => Promise.reject(error)
    }), [])

    useEffect(() => {
        const reqInterceptors = service.interceptors.request.use(interceptors.req, interceptors.err)
        const respInterceptors = service.interceptors.response.use(interceptors.resp, interceptors.err)
        return () => {
            service.interceptors.request.eject(reqInterceptors)
            service.interceptors.response.eject(respInterceptors)
        }
    }, [interceptors])

    return counter > 0
}

export default useLoading

useCountdown

一个接收后端传来的 seconds 并做倒计时的功能,很多页面都用到,很容易写出来,但减少了很多模板代码

import {useEffect, useState} from 'react'

const useCountdown = (): { countdown: number, setCountdown: (seconds: number) => void } => {

    const [countdown, setCountdown] = useState<number>(0)

    useEffect(() => {
        const interval = setInterval(() => {
            if (countdown > 0) {
                setCountdown(countdown - 1)
            } else if (countdown === 0) {
                clearInterval(interval)
            }
        }, 1000)
        return () => {
            clearInterval(interval)
        }
    }, [countdown])

    return {
        countdown,
        setCountdown
    }
}

export default useCountdown

业务 hooks

如果一个组件或一个页面功能比较简单,把页面和逻辑都写在一块并不是问题,但如果页面比较复杂,将逻辑抽离出来是个比较好的选择,以我做过的一个 Dashboard 功能为例

useDashboard.ts

type DashboardResult = {
    // ...type
}

const useDashboard = (): DashboardResult => {
    // ...logic
}

export {
    useDashboard
}

export type {
    DashboardResult
}

Dashboard.tsx

const Dashboard = (): JSX.Element => {

    const model = useDashboard()
    const {
        // ...data
    } = model

    // something
    
}

如果有子组件,这时候导出的 DashboardResult 就有了用处,可以把 model 传递下去,获取到类型推导的好处以及类型限制。当然,子组件的深度最好不超过 3 层

type ChildProps = {
    model: DashboardResult
}

const Child = ({model}: ChildProps): JSX.Element => {

    const {
        // ...data
    } = model

    // something
    
}

业务 hook 还可以继续再分,以我做过的一个最复杂的银行转账功能为例子,单页面涉及了总共 21 种转账类型,每种转账类型都有不同的表单要填写,每种分支内表单数据的填写是相互关联的,这就涉及到了精细的控制。另外每种分支都会随机提取几个固定参数进行填充和固定替换组成字符串交给后端做验证。还有国际化的需求。如果用户的输入条件不正确,还要提前显示错误提示,滚动条要跳到发生错误的表单,这就涉及到了 dom 的交互。因此如果不能合理地拆分,代码势必会膨胀到不可维护的地步,最后大致的代码如下

// 合理地使用 typescript 的交集能少写很多类型代码
type TransferResult = {
    // ...type
} & I18nResult & FormResult & AccountListResult & BanksResult

// 以下仅展示几个 hook,实际的 hook 会更多
const useTransfer = (): TransferResult => {
    // 国际化
    const {} = useI18n()
    // 表单
    const {form} = useForm()
    // 获取可转账账户列表
    const {acctList} = useAccountList()
    // 错误提示,如何报错依赖于 form 和 acctList 字段(实际更多)
    const {} = useError(form, acctList)
    // 获取银行列表
    const {banks} = useBanks()
    // 截取表单部分字段进行加密
    const {} = useEncrypt(form)
    
    // something
    
}

通过这样的拆分,大大提高了可维护性,每次修改需求不必在一大堆代码中翻来翻去,也做到了解耦

总结

在这个项目中,我们使用了 typescript ,typescript 的类型本身就是最好的文档,其他开发同事可以通过看输入输出来使用 hooks。命名也很关键,合理地使用单复数和英文缩写,如 banks ,你很容易得出这是一个关于银行的列表数据。但这无法避免一种情况,即重复封装相同或类似功能的 hook ,针对这种情况,我们写了一个 md 文档,阐明每个 hook 的应用场景及作用(组件同理),规范化的文档能有效降低开发成本

希望这篇文章对你有帮助,也欢迎各位在评论区交流