陆续开发了几个 react 项目,也积累了不少经验。随着 react 全面拥抱 hook ,封装自定义 hook 已经变成了 react 开发的必需能力了。通过自定义 Hook,可以将组件逻辑提取到可重用的函数中,但如何封装就是一大难点了,我想各位也见过不少过度封装的例子了吧,不能很好的应对需求,有时还得直接复制代码,或者再新建一个 hook 。要么封装程度不够,要么还得拿已封装的 hook 再次组装,总之都会造成代码的膨胀
下面是我总结的一些经验,通常遇到这些情况,我不需要去纠结到底需不需要封装,会不会过度封装的问题
优先使用开源社区的 hook 库
使用社区的 hook 库比如 use-hooks 或 ahooks 等,有两个好处
- 每个 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 :typeof 和 keyof , 我们来看看是怎么用的
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 项目。因此写下了这篇文章,权当是做个总结,也当个记录,方便回顾。如果这篇文章对你有帮助的话,请给我点个赞吧,也欢迎各位在评论区交流