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

392 阅读6分钟

陆续开发了几个 react 项目,也积累了不少经验。随着 react 全面拥抱 hook ,封装自定义 hook 已经变成了 react 开发的必需能力了。通过自定义 Hook,可以将组件逻辑提取到可重用的函数中,但如何封装就是一大难点了,我想各位也见过不少过度封装的例子了吧,不能很好的应对需求,有时还得直接复制代码,或者再新建一个 hook 。要么封装程度不够,要么还得拿已封装的 hook 再次组装,总之都会造成代码的膨胀

下面是我总结的一些经验,通常遇到这些情况,我不需要去纠结到底需不需要封装,会不会过度封装的问题

优先使用开源社区的 hook 库

使用社区的 hook 库比如 use-hooksahooks 等,有两个好处

  • 每个 hook 函数都通过了单元测试,测试用例有些是通过 issue 反馈得来的,大概率比你自己写的靠谱
  • 详细的文档(写类型和注释是好习惯,但你无法保证每个同事都会看你写的类型和注释)

提供一致的编程体验

在 react 中,用的最频繁的莫过于 useState 了吧,使用这个函数将数据保存于内存中,进行获取和修改的操作。有时需要数据在多个页面共享,那就需要 redux 了。有时又需要把数据保存在本地,那就需要使用 localStorage 了,让我们来看看一下这几个是怎么使用的

// Redux
import { useDispatch, useSelector } from 'react-redux';
import { numsSelector, changeNums } from '../../../redux/slices/file';

const Page = () => {
    const nums = useSelector(numsSelector);
    const dispatch = useDispatch();
    return (
        <button onClick={() => {
            dispatch(
                changeNums([...nums, nums.length])
            )
        }}>
            {nums.length}
        </button>
    )
}

虽然都是对数据的处理,但使用方法却大相径庭,能否让 redux 都像 useState 那样使用呢?显然,这并不难

import { useDispatch, useSelector } from 'react-redux';

/**
 *
 * @param {{
 * selector?: (state: import("react-redux").DefaultRootState) => any
 * reducer?: (action: any) => any
 * }} param0
 * @returns
 */
const useReduxState = ({ selector, reducer }) => {
  const state = useSelector(selector ?? ((state) => null));
  const dispatch = useDispatch();
  const setState = (tempState) => {
    if (reducer) {
      dispatch(reducer(tempState));
    } else {
      throw new Error('You need to pass in an action param');
    }
  };
  return [state, setState];
};

// 使用
import { numsSelector, changeNums } from '../../../redux/slices/file';
const Page = () => {
    // 3 种使用方法
    const [nums, setNums] = useReduxState({selector: numsSelector, reducer: changeNums})
    // const [nums] = useReduxState({selector: numsSelector})
    // const [, setNums] = useReduxState({reducer: changeNums})
}

localStorage 呢,还记得上一条规则吗,我常用的 ahooks 已经内置了 useLocalStorageState 了,就没必要自己造轮子了

对原有功能进行扩展

我们以国际化常用的 t 函数举例,都知道,t 函数接收一个字符串,但你无法知道这个字符串到底在你的多语言配置文件中有没有存在,很多情况下,可能你写错了一个字母或者打错了大小写,然后就失效了,那么怎么解决这个问题呢

以下的代码是需要基于 typescript 环境的,typescript 提供了两个神奇的 api :typeofkeyof , 我们来看看是怎么用的

const obj = {
    foo: true,
    bar: false
} as const

// 等价于 type Obj = {bar: boolean, foo: boolean}
type Obj = typeof obj

// 等价于 type ObjKey = "bar" | "foo"
type ObjKey = keyof Obj

通过这个,让我们为 useTranslation 扩展功能,使得错误的 key 无法通过编译

// lang/zh.ts (作为类型推断的文件,最好添加 const 断言)
const zh = {
    translation: {
        hello: "你好",
        name: "名字",
        country: "国家",
    }
} as const

export default zh

// lang/en.ts
import zh from "./zh"

// 确保 key 的数量和名称相同,避免遗漏和错误
const translation: Record<keyof (typeof zh.translation), string> = {
    hello: "hello",
    name: "name",
    country: "country"
}

const en = {
    translation
}

export default en

// hooks/useI18n.ts
import {useTranslation} from "react-i18next"
import zh from "../lang/zh"

const useI18n = () => {
    const translation = useTranslation()
    
    const t = (key: keyof (typeof zh.translation)): string => translation.t(key)

    return {
        ...translation,
        t
    }
}

export default useI18n

曾经遇到一个需求,判断当前页面的表单是否填写完成,如果没有填写完的话,用户点击顶部栏或底部栏时,需要弹出一个弹框,警告用户表单未完成,因此对 useHistory 进行改造

const useHistoryPlus = () => {
    const history = useHistory()
    const dialog = useDialog()
    // 每个表单维护的状态,默认为 true,进入表单页会根据状态设置为 true 或 false,离开当前表单后再次设置为 true
    const [isPageAComplete] = useReduxState({selector: pageACompleteState})
    const [isPageBComplete] = useReduxState({selector: pageBCompleteState})

    const push = (path) => {
        if ([isPageAComplete, isPageBComplete].includes(false)) {
            // 触发 dialog,如果用户忽视警告,通过第二个参数传入的 path 可到达想要的页面
            dialog.display("form_unfinished_warning", {path})
            return
        }
        history.push(path)
    }

    return {
        ...history,
        push
    }
}

UI 与逻辑分离

组织代码有多种方式,有人喜欢以代码功能为依据来分文件夹,有人则喜欢以业务为依据来划分文件夹。假如有一个很复杂的页面,你会如何组织代码来提高这个页面的可维护性呢?

我个人总结的经验是将逻辑抽离出来,形成一个业务 hook ,下面举的是一个真实的例子,当然命名做了简化

const TableA = () => {
    // 还有其他方法就不一一列举了
    const {table, changePage, deleteItem} = useTableA()
    return (
        // ...code
    )
}

const TableB = () => {
    const {table, changePage, deleteItem} = useTableB()
    return (
        // ...code
    )
}

通过这种方式,维护起来更轻松了,当然好处不止于此。项目二期的时候,客户提了一个新要求,希望有一个页面可以浏览一部分重要的表格,而不需要点进去,如果你写的 UI 和逻辑耦合在一起的话,那么就需要运用复制的哲学了,但我们这种实现方式不用,可以很轻松地利用原有的 hook 进行组装

const Tables = () => {
    const {table: tableA, changePage: changeAPage, deleteItem: deleteAItem} = useTableA()
    const {table: tableB, changePage: changeBPage, deleteItem: deleteBItem} = useTableB()
    return (
        // ...code
    )
}

基于 hook 的限定规则进行封装

我们都知道 hook 只能在组件或其他 hook 函数里面使用。涉及到这些,如果想要多处复用的话,不想封装都不行。但假设你的 hook 函数是下面这种

// 每个 fn 都不涉及其他 hook 函数
const useFn = () => {
    const fn1 = () => {}
    const fn2 = () => {}
    return {
        fn1,
        fn2
    }
}

其实像这种并不需要封装在 hook 上,等价的写法有

// OOP 写法
class FnUtil {
    
    static fn1 () {

    }

    static fn2 () {

    }
}

// 对象写法
const fns = {
    fn1: () => {},
    fn2: () => {},
}

// 这种写法能按需引入,打包的时候还能 tree-shaking ,实际项目我更推荐这种
const fn1 = () => {}
const fn2 = () => {}
export {
    fn1, fn2
}

但涉及到了其他 hook 函数,那封装就是必须做的事情了,比如做过国际化的朋友就会很熟悉的 t 函数,在下面的函数中,我们的项目业务很多表单都用到了这个

import { useTranslation } from 'react-i18next';

const useI18NCollect = () => {
  const { t } = useTranslation();

  const titles = [
    { display: t('Mr'), code: 'TT_MR' },
    { display: t('Ms'), code: 'TT_MS' },
    { display: t('Miss'), code: 'TT_MI' },
    { display: t('Mrs'), code: 'TT_MRS' },
    { display: t('Dr'), code: 'TT_DR' },
  ];

  const languages = [
    { display: t('English'), code: 'LG_EN' },
    { display: t('Traditional_Chinese'), code: 'LG_ZHHK' },
    { display: t('Simplified_Chinese'), code: 'LG_ZHCN' },
  ];

  return {
    titles,
    languages
  };
};

export default useI18NCollect;

最近都在开发原生安卓项目,得开发到九月了,接下来估计接的也是 vue 项目。因此写下了这篇文章,权当是做个总结,也当个记录,方便回顾。如果这篇文章对你有帮助的话,请给我点个赞吧,也欢迎各位在评论区交流