往期回顾
-
useSetState的源码分析。TypeScript的泛型、约束、联合等等的基本使用。
-
useBoolean的源码分析。TypeScript的类型推断、interface、type等等的介绍。
前言
在本系列的前两篇文章里,我们一起聊了聊 useSetState 和 useBoolean 这两个hook。在每一篇的【源码分析】章节里,我们都一起探讨了代码的实现细节,学习了其中的设计理念。
我们发现,我们在分析hook的源码的同时,也在对 TypeScript 的知识点进行回顾。
这真是一件一石二鸟的事情,一方面我们在准备 react hook 相关的知识点,另一方面,我们还能顺手把 TypeScript 中比较热门的考点
(这也进一步证明了,这些热门考点并不是所谓的靠记忆靠背诵的陈旧知识点,而是市面上热门的开源项目也经常使用的香饽饽)给复习了。
本期的TypeScript关键词:函数签名 与 函数重载。
在本篇文章中,我们将会结合到处都有的状态切换这一业务场景,一起聊一聊 useToggle 这个hook。
看看它又带给了我们哪些便利。
到处都有的状态切换
2013年,被誉为中国通信行业的一个重要里程碑,被亲切地称为我国的4G(第四代无线蜂窝通信协议)元年。
在这一年的12月4号,工业和信息化部(即工信部)正式向中国移动、中国电信、中国联通颁发三张4G牌照,均为TD-LTE制式,标志着中国正式步入了第四代无线蜂窝通信协议时代。
几十倍的网速提升(2 M/S => 100 M/S),为移动通信用户带来了前所未有的便捷,也改变了整个社会的生活节奏和信息交流方式。
画外音🤦♂️:不是大哥,说重点,扯到4G是什么情况,我没看错的话,咱这篇文章应该是讲 ahooks 的吧?
咳咳,不好意思🙇,其实我是想说通信技术的发展让自媒体平台如雨后春笋般冒出。
小红书、微博、b站、贴吧、抖音、快手、火山视频、西瓜视频等等,平台涌现的同时,也伴随着大量用户的涌入。
有博主,就有观众朋友、粉丝朋友。作为平台用户,当我们遇到感兴趣的博主时,会自然而然地选择关注他,方便持续了解他后续的分享内容。
(在此感谢在掘金上关注我的朋友们🌹,咱们交流学习,共同进步✊)
那么这里就会遇到【状态切换】的情况了,即【+关注】和【已关注】
我们需要根据用户的交互行为,来渲染不同的UI样式,实现 A <=> B两种状态之间的转换。
除了自媒体平台的【用户是否关注另一用户】的行为外,还有很多业务场景涉及到了A <=> B两种状态之间的转换,比如:
-
物联网设备的【开启】与【关闭】:某些品牌的空调插座支持APP控制,很方便在空调遥控器找不到的时候,用手机来管理。
-
一些面向B端的网站,某些设备的【在线】和【离线】。
-
线上的问卷平台,问卷的【发布】和【停止】。
和之前的文章相同,我们通过一个代码示例,来看看实现这样的状态切换都需要写哪些代码。
(我们先不考虑国际化多语言的情况,只针对中文文案进行切换)
(国际化也是一个业务难点,需要从众多的解决方案中找到适合自己项目的最佳实践,这个话题回头我们再聊。)
我们简单地模拟一下掘金平台的搜索用户后的信息展示组件,并实现【关注】和【已关注】的状态切换。
代码示例
我还是将这个例子放到了我的基于Next.js实现的项目中。在【小工具】页面的最下方。
(由于这次的代码量比较多,因此不在正文内容中贴出,大家可以在章节最后的【示例完整代码】处查看。)
👇运行后的效果如下👇:
组件结构
实现逻辑
样式上的实现就一笔带过了,比如利用flex布局实现垂直居中,利用flex:1和flex:0 0 auto实现合理的空间分配。
我们看下处理交互行为的逻辑,我们期望在用户点击按钮时,根据是否已经关注来实现按钮样式与文本内容的切换。
const UserItem = ({ userId, info }: UserItemProps) => {
const userInfo = mockUserDate[userId];
const [btnText, setBtnText] = useState<"关注" | "已关注">("关注");
const [btnType, setBtnType] = useState<"default" | "primary">("default");
const [followStatus, setFollowStatus] = useState(info.followStatus);
const handleClick = () => {
setFollowStatus(!followStatus);
};
useEffect(() => {
if (followStatus) {
setBtnText("已关注");
setBtnType("primary");
} else {
setBtnText("关注");
setBtnType("default");
}
}, [followStatus]);
return (
<div className={styles["list-item"]}>
<UserAvatar id={userId} />
<Space className={styles["info-box"]} direction="vertical" size={0}>
<Typography.Title level={4}>{userInfo.name}</Typography.Title>
<Typography.Text type="secondary">{info.detail}</Typography.Text>
</Space>
<Button
className={styles["follow-btn"]}
type={btnType}
onClick={handleClick}
>
{btnText}
</Button>
</div>
);
};
使用followStatus变量来跟踪用户关注状态的变化。将其作为useEffect的依赖项,来处理副作用的影响,比如当followStatus为true时,则需要及时地将btnText 和 btnType 切换为 "已关注" 和 "primary"。
如果我们期望使用 useBoolean 这个hook(也就是上篇文章介绍的hook)来帮助我们简化代码,那么我们的期望就要落空了。
因为【按钮类型】和【文本内容】的值的类型并不是boolean,而是 string。
useBoolean 爱莫能助。
此时如果我们想要简化对这两个状态的管理,我们可以使用 useToggle。
useToggle
用于在两个状态值之间切换的 Hook。
在上一篇文章中我们说到,useBoolean 所实现的操作集合,均来自于 useToggle。
export default function useBoolean(defaultValue = false): [boolean, Actions] {
const [state, { toggle, set }] = useToggle(!!defaultValue);
所以对于 useToggle 来说,useBoolean 能做的它都能做,useBoolean 做不了的,它也能做。
API
const [state, { toggle, set, setLeft, setRight }] = useToggle(defaultValue?: boolean);
const [state, { toggle, set, setLeft, setRight }] = useToggle<T>(defaultValue: T);
const [state, { toggle, set, setLeft, setRight }] = useToggle<T, U>(defaultValue: T, reverseValue: U);
通过上方的API我们可以了解到,可以选择3种方式使用 useToggle 。
Params
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| defaultValue | 可选项,传入默认的状态值 | T | false |
| reverseValue | 可选项,传入取反的状态值 | U | - |
我们可以明确地指定需要切换地两个状态值,而不是同 useBoolean 一样,只能在 true 和 false 之间摇摆。
Result
| 参数 | 说明 | 类型 |
|---|---|---|
| state | 状态值 | - |
| actions | 操作集合 | Actions |
同 useBoolean 一致,返回的是一个数组,里面包含两个成员。
Actions
| 参数 | 说明 | 类型 |
|---|---|---|
| toggle | 切换 state | () => void |
| set | 修改 state | (state: T | U) => void |
| setLeft | 设置为 defaultValue | () => void |
| setRight | 如果传入了 reverseValue, 则设置为 reverseValue。 否则设置为 defaultValue 的反值 | () => void |
我们注意看 setRight 的说明文案: 如果传入了 reverseValue, 则设置为 reverseValue。 否则设置为 defaultValue 的反值
如果我没有设置 reverseValue,并且传入的 defaultValue 也并不是一个布尔值,那么此时 defaultValue 的反值是什么呢?
这里就考察了 JavaScript 相关的知识点了,对于类型转换,我们需要再回顾一下。
详细的内容留在源码分析环节,我们看看 useToggle 是怎么处理这种case的,以及这样处理之后的结果是什么。
使用
我们将useToggle引入我们的项目,替换掉原来的useState,看下代码改动:
我们创建一个类型定义BtnStatus,用于表示按钮的状态信息。
type BtnState = {
btnText: "关注" | "已关注";
btnType: "default" | "primary";
};
然后我们修改变量的管理方式:
// 引入
import { useToggle } from "ahooks";
// 替换 useState
const [{ btnText, btnType }, { setLeft, setRight }] = useToggle<
BtnState,
BtnState
>(
{ btnText: "关注", btnType: "default" },
{ btnText: "已关注", btnType: "primary" }
);
最后,我们修改一下 useEffect 中的逻辑
useEffect(() => {
if (followStatus) {
setRight();
} else {
setLeft();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [followStatus]);
可以看到,我们通过一个统一的变量去管理整个btn的信息,包括btn的类型和btn的文本内容。
借助于 useToggle ,我们不再需要手动去拼写值和中文,然后再调用setState,显示地传入参数, 从而给useEffect内部的代码做了一次 “瘦身”。
在某些不依赖异步请求的情况下(这里的【关注行为】在真实案例中肯定需要触发接口请求的),也就是不依赖服务端数据信息,仅作前端样式变化的情况,如果也涉及到了状态切换,我们甚至不用那么麻烦,连 useEffect 都不用写,直接跟随 followStatus 做同步变化就行了,即直接在
handleClick 函数中调用toggle()方法即可。
// 无需使用 setLeft 和 setRight
const [{ btnText, btnType }, { toggle, setLeft, setRight }] = useToggle<
BtnState,
BtnState
>(
{ btnText: "关注", btnType: "default" },
{ btnText: "已关注", btnType: "primary" }
);
const [followStatus, setFollowStatus] = useState(info.followStatus);
const handleClick = () => {
setFollowStatus(!followStatus);
// 内部调用
toggle();
};
// 直接注释掉
// useEffect(() => {
// if (followStatus) {
// setRight();
// } else {
// setLeft();
// }
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [followStatus]);
👇这样代码就写得更省力了,效果也是一样的👇:
源码分析
观前预防针💉:与前两篇文章不同,
useToggle的源码要相对复杂一些,从代码量来看将近40行。不知道大家是否听过这样一句话,来自Richard Feynman,他说:
“ What I cannot create, I do not understand. ”
当初看到这句话的时候,我觉得形容得真是贴切,大师不愧是大师😮。
只有当一个人能够亲自创造出某个事物或概念时,他才真正理解了那个事物或概念。换句话说,理解不仅仅是理论上的知识,还包括能够将这种知识转化为实际行动。
因此,就让我们一起
create useToggle!
按照惯例,我先贴出源码,然后咱们再一起逐行分析
import { useMemo, useState } from 'react';
export interface Actions<T> {
setLeft: () => void;
setRight: () => void;
set: (value: T) => void;
toggle: () => void;
}
function useToggle<T = boolean>(): [boolean, Actions<T>];
function useToggle<T>(defaultValue: T): [T, Actions<T>];
function useToggle<T, U>(defaultValue: T, reverseValue: U): [T | U, Actions<T | U>];
function useToggle<D, R>(defaultValue: D = false as unknown as D, reverseValue?: R) {
const [state, setState] = useState<D | R>(defaultValue);
const actions = useMemo(() => {
const reverseValueOrigin = (reverseValue === undefined ? !defaultValue : reverseValue) as D | R;
const toggle = () => setState((s) => (s === defaultValue ? reverseValueOrigin : defaultValue));
const set = (value: D | R) => setState(value);
const setLeft = () => setState(defaultValue);
const setRight = () => setState(reverseValueOrigin);
return {
toggle,
set,
setLeft,
setRight,
};
// useToggle ignore value change
// }, [defaultValue, reverseValue]);
}, []);
return [state, actions];
}
export default useToggle;
最先看到的自然是类型定义了,这里也声明了一个Actions,我贴一个 useBoolean 和 useToggle 的 Actions 的区别。
useBoolean:export interface Actions { setTrue: () => void; setFalse: () => void; set: (value: boolean) => void; toggle: () => void; }useToggle:export interface Actions<T> { setLeft: () => void; setRight: () => void; set: (value: T) => void; toggle: () => void; }
可以看到,最大的区别在于 useToggle 的 Actions 是泛型的,用于 set 属性的类型定义。
是否要使用泛型取决于你期望这个函数、hook、类想完成怎么样的工作。
以hook为例,当它接收的参数存在2个及以上不同的类型时,此时设置泛型是推荐的。
当一个hook的功能足够明确,只针对1种类型的变量做处理时,此时无需设置泛型,我们来看一个例子:
现在我们有一个函数,它的功能是给传入的用户名加上"掘金-"的前缀,我们可以这样声明这个函数:
type GetPrefix = (username: string) => string;
const getPrefix: GetPrefix = (name) => "掘金-" + name;
const myName = "海石";
const afterName = getPrefix(myName);
console.log("afterName", afterName);
// 输出:掘金-海石
👇如果此时用泛型的写法的话,能够实现一模一样的效果,但是就繁琐了很多 👇
type GetPrefix<T extends string> = (username: T) => T;
const getPrefix: GetPrefix<string> = (name) => "掘金-" + name;
const myName = "海石";
const afterName = getPrefix(myName);
所以大家要注意合理的使用泛型。
然后我们继续回到源码。再往下看我们发现存在3行有关类型定义的代码:
function useToggle<T = boolean>(): [boolean, Actions<T>];
function useToggle<T>(defaultValue: T): [T, Actions<T>];
function useToggle<T, U>(defaultValue: T, reverseValue: U): [T | U, Actions<T | U>];
回顾我们之前进行类型定义的写法,type SetState 和 interface Actions,这里怎么直接用 function 去做看起来像是类型定义的事情?
这里就要提到在上一篇文章的【前言】章节里我所说的关键词:签名和函数重载
签名
我们可以使用一个函数签名,来描述这个函数在某些参数下的行为和返回类型
举个例子,我们期望有1个函数,能够返回传入的两个数字的和,则我们可以这么写代码:
function getSum(num1: number, num2: number): number;
function getSum(num1: number, num2: number) {
return num1 + num2;
}
⚠请注意,当我们使用函数签名的形式去声明一个函数的时候,后面一定要跟着函数的具体实现,否则会报错。
好,看到这里我想你应该会怼我一句了,这函数签名不是多此一举吗?
我同意你的观点,其实我和你的看法一致🤝,不过,还是有一点需要注意。
确切的说,应该是在这种情况下多此一举,实际上,如果不是为了硬写函数签名,我们真实的、在工作中会用的写法,应该是这样的:
function getSum(num1: number, num2: number): number {
return num1 + num2;
}
那么什么情况下,我们才建议使用函数签名呢?
函数的实现较复杂,且需要重载。
函数重载
在 TypeScript 中,我们可以通过编写重载签名来指定一个可以以不同方式调用的函数。
举个例子,现在,我们期望这个getSum函数能够具备更强的功能,它能够处理1、2、3个传入的数字,并返回它们的和。当传入的参数总数大于等于4,则属于参数不合法,我们要怎么做?
我们可以通过写3个不同的函数签名,去描述这个函数的功能。
function getSum(num1: number): number;
function getSum(num1: number, num2: number): number;
function getSum(num1: number, num2: number, num3: number): number;
function getSum(num1: number, num2?: number, num3?: number) {
let sum = num1;
sum += num2 ?? 0;
sum += num3 ?? 0;
return sum;
}
然后我们用测试用例验证一下:
const sum1 = getSum(1);
const sum2 = getSum(1, 2);
const sum3 = getSum(1, 2, 3);
console.log(sum1);
console.log(sum2);
console.log(sum3);
看到这里,对ES6+语法比较熟练的同学可能会说了,海石哥,你怎么也犯了简单问题复杂化的毛病了,这种情况下,我使用剩余参数语法来写这个函数不就得了?
function getSumByRestOperator(...nums: number[]) {
return nums.reduce((pre, cur) => pre + cur, 0);
}
const sum1_other = getSumByRestOperator(1);
const sum2_other = getSumByRestOperator(1, 2);
const sum3_other = getSumByRestOperator(1, 2, 3);
console.log("sum1_other: ", sum1_other);
console.log("sum2_other: ", sum2_other);
console.log("sum3_other: ", sum3_other);
运行后,确实也能够实现刚刚那种通过函数重载写法所达成的效果。
而且还更省力。
能提出这种想法的朋友真的很棒👍,本身我写技术文章,也不是为了仅仅向外输出知识,而是将文章作为桥梁,和有缘能阅读它的朋友交流,在交流中进行思想碰撞,从而共同进步。
剩余参数语法确实是很方便的,我在《告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP01】》这篇文章里,也使用它完成了对一道热门手写题的回答。
不过注意我们的题干的后半句:当传入的参数总数大于等于4,则属于参数不合法
使用剩余参数语法并不能帮助我们判定不合法的入参,我们来看下两种函数在校验下的情况:
-
基于函数重载实现的
getSum: -
基于剩余参数实现的
getSumByRestOperator:
经过上方的对比,函数重载的优势可见一斑。
通过上面两个小节的举例和描述,我想大家对于函数签名、函数重载已经有了一定的理解。接下来就让我们回到源码本身,看看 useToggle 的函数重载,巧妙在什么地方。
根据 API 进行函数重载的分析
我们在【API】章节可以看到 useToggle 的3种用法,其中第1种是这样的:
const [state, { toggle, set, setLeft, setRight }] = useToggle(defaultValue?: boolean);
即入参是可选的,并且类型是boolean。那么根据这种使用情况,由结果倒推的话,我们可以这样去写 useToggle 的函数签名:
function useToggle(): [boolean, Actions<boolean>];
但是我们看到实际上源码写法是这样的:
function useToggle<T = boolean>(): [boolean, Actions<T>];
T = boolean表示如果不传类型参数的话,默认使用boolean类型。
其实从我个人的角度,我是认为源码里头这样的写法主要是为了保持编码风格的统一。
为什么我会这么说呢,因为其实我们写的版本也不影响使用,而且我觉得使用 useToggle 这个hook却不传任何值的使用场景也很幽默。既然你都没有值去进行状态切换,你为啥要用 useToggle 呢?
然后我们看看两种写法对于空值调用时的影响:
-
function useToggle(): [boolean, Actions<boolean>];: -
function useToggle<T = boolean>(): [boolean, Actions<T>];:
上面如果当作boolean值来用的话,没有任何差别。
👇使用源码写法的好处在于,显示地传入类型参数时,能够被ts允许,不会报错👇。
-
我们的写法:
当传入了1个类型参数后,就不满足第一行函数签名的定义(即
function useToggle(): [boolean, Actions<boolean>];)了,会判定成第二行函数签名的定义(即function useToggle<T>(defaultValue: T): [T, Actions<T>];),此时defaultValue是必填的,因此ts报错提示,要求我们得传一个具体值。 -
源码写法:
虽然我不知道这样有什么用,就感觉莫名奇妙。
因为假如我都显示地传入类型参数了,比如useToggle<string>,我还不给 useToggle 传值,我这不是吃饱了撑的没事干么.....😶
如果大家有自己的看法欢迎在评论区分享。
我们继续看 API 中, useToggle 的第二种用法:
const [state, { toggle, set, setLeft, setRight }] = useToggle<T>(defaultValue: T);
与之对应的,第二种函数签名如下:
function useToggle<T>(defaultValue: T): [T, Actions<T>];
这个的使用场景就很明确了,当我们指定一个类型参数 T,则期望用户传入相同类型的 defaultValue ,然后返回值自然也是 T ,在 Actions也会捕获 T。
是一个非常基础的泛型使用,不过这里有点小问题,我们放在后面的内容里讲。
最后我们看 API 中,useToggle 的第三种用法:
const [state, { toggle, set, setLeft, setRight }] = useToggle<T, U>(defaultValue: T, reverseValue: U);
这种用法是最符合我们实际的业务情况的。我们期望 useToggle 帮助我们快速实现两个状态值之间的切换,比如我在【代码示例】章节里提到的关注按钮的类型和文本内容。
第三种用法也对应第三种函数签名:
function useToggle<T, U>(defaultValue: T, reverseValue: U): [T | U, Actions<T | U>];
第三种用法期望接收2个类型参数,分别用于 defaultValue 和 reverseValue 的类型定义。
那这也就意味着 useToggle 返回的 state 可能是 defaultValue 也可能是 reverseValue 。因此在返回值的类型上,就是使用了联合类型:T | U。同理,Actions捕获的类型也得是联合类型,于是传给Actions<T | U>。
函数实现
我们在【函数重载】这一章节提到过,当我们规定好一些函数签名后,我们要及时地在后面跟上函数实现的代码。
因此在我们聊完 useToggle 的3种函数签名后,我们就要聊一聊useToggle具体的函数实现了。
先看下面这一行代码:
function useToggle<D, R>(defaultValue: D = false as unknown as D, reverseValue?: R) {...
我们先不直接聊它为什么写成这样,在此之前,我想补充一点关于函数重载的内容,那就是函数的具体实现要符合之前全部的函数签名。
还记得前文提到的getSum函数吗?它也同样具备3个签名,我们简单地回忆一下:
function getSum(num1: number): number;
function getSum(num1: number, num2: number): number;
function getSum(num1: number, num2: number, num3: number): number;
假如我们在最终的函数实现的代码里,将 num1 定义为 string 类型,会怎么样呢?
function getSum(num1: string, num2?: number, num3?: number) {
let sum = num1;
sum += num2 ?? 0;
sum += num3 ?? 0;
return sum;
}
👇可以看到,会报错👇:
根据这条规则,我们明白,要写好 useToggle 的函数实现,就一定要特别注意之前的3种函数签名。
按照 useToggle 最多接收两个任意类型且可能并不相同的参数,我们需要设置2个类型参数,即:
function useToggle(defaultValue: D , reverseValue?: R){}
这样写完之后,使用泛型,我们要记得用<>包裹我们需要捕获的类型信息,于是我们加上它:
function useToggle<D, R>(defaultValue: D , reverseValue?: R){}
到这一步,我们是不是写完了?我们看下IDE中实际的情况:
由上图可以看到,并不满足第1种函数签名。
直接解决这个问题可能让我们无奈,我们通过一个简单的例子先复现一下:
function demoTest<T = boolean>(): T
function demoTest(){
}
demoTest()
此时,不会报任何错误,现在我们给下方的函数实现加上泛型,再看看:
function demoTest<T = boolean>(): T
function demoTest<D>(defaultValue:D){
}
demoTest()
我们成功复现出了和 useToggle 一样的错误。
这个错误我们可以这么去看:
- 首先,函数实现所定义的
defaultValue是个必填参数,这显然不符合上方的函数签名。因为在函数签名里,demoTest的参数是空的。
因此,我们先要把函数实现中的demoTest的参数设置为可选项。
function demoTest<T = boolean>(): T
function demoTest<D>(defaultValue?:D ){
}
demoTest()
大家可以看到,这样报错就被解决了。
但是我们还期望defaultValue在不传时,有个默认值是 false 。于是我们再做进一步的改造:
function demoTest<T = boolean>(): T
function demoTest<D>(defaultValue:D = false ){
}
demoTest()
此时,报错的并不是函数签名了,而是函数实现本身的参数类型了。
离成功,就差这一步了,我们该如何解决这个报错呢?
类型断言:我来助你!
类型断言
类型断言可以用来手动指定一个值的类型,即允许变量从一种类型更改为另一种类型.
我们通过一个简单的代码例子来直观地看一下类型断言的用处:
let str:string = '123'
let str2:number = 123
str = str2
如果我们期望str = str2这行代码不会报错,可以使用类型断言,告诉 TypeScript ,str2 就是 string 类型。
let str:string = '123'
let str2:number = 123
str = str2 as unknown as string
改造后,报错解决。
回到源码,我们改造一下刚刚举的例子:
function demoTest<T = boolean>(): T
function demoTest<D>(defaultValue:D = false as unknown as D ){
}
demoTest()
如预期般,报错解决。
经过刚刚对于函数重载的规则与类型断言的学习,我们成功理解了在 useToggle 中,为什么函数实现的代码是这么写的了:
function useToggle<D, R>(defaultValue: D = false as unknown as D , reverseValue?: R) {...
接着,我们继续看函数内部的逻辑:
const [state, setState] = useState<D | R>(defaultValue);
我们的根本目的还是在于对变量的状态管理,因此自然离不开React原生的useState。这一行代码我们可以直接过,继续看下面的内容。
const actions = useMemo(() => {
const reverseValueOrigin = (reverseValue === undefined ? !defaultValue : reverseValue) as D | R;
const toggle = () => setState((s) => (s === defaultValue ? reverseValueOrigin : defaultValue));
const set = (value: D | R) => setState(value);
const setLeft = () => setState(defaultValue);
const setRight = () => setState(reverseValueOrigin);
return {
toggle,
set,
setLeft,
setRight,
};
// useToggle ignore value change
// }, [defaultValue, reverseValue]);
}, []);
和 useBoolean 一样,useToggle 内部的actions变量也被 useMemo 包裹了,并且 useMemo 的依赖项为空,这意味着actions变量在整个组件的生命周期只会被创建一次,不会随着re-render而重新创建。
然后我们看 useMemo 内部的代码,尤其这第一行:
const reverseValueOrigin = (reverseValue === undefined ? !defaultValue : reverseValue) as D | R;
还记得我在【Actions】章节卖的关子吗?
可以看到,针对 reverseValue 为空的情况, useToggle 的处理方式真的很简单粗暴,即直接通过 ! 操作符对 defaultValue 进行取反操作。
我真晕倒了😵,如果我传入的 defaultValue 是个字符串,比如 "Hello",那么这个 reverseValue 真的是我想要的吗?
我感觉无论是前面的在函数实现中使用类型断言让ts不报错,还是这里的粗暴处理
reverseValue为空的情况,都让我觉得useToggle的代码写得并不是那么优雅.........😶
出于篇幅考虑,这里我简单说一下 JavaScript 中的类型转换。
逻辑非(
!,逻辑连接取反)运算符将真值或假值转换为对应的相反值,经常用于布尔(逻辑)值。当与非布尔值使用时,如果其操作数可以转化为true,则返回false,否则返回true。——MDN
能够转化为 false 的表达式的示例如下:
-
null; -
NaN; -
0;-0+0
-
空字符串(
""或''或``); -
undefined。
因此,只要 defaultValue 不是上方罗列的那几项,!defaultValue的值就是 false。
比如我们刚刚举例的,defaultValue = 'Hello','Hello'不属于上方罗列的示例中的一员,因此取反后的值就是 false。
JavaScript中的类型转换会发生在很多场合,有机会的话,我们通过一个专题文章一起好好地聊一聊。这里就先遇到什么弄清楚什么,点到为止。
我们继续回到源码的内容中,接下来的内容可以一起看了,因为都是 actions 中的属性。
const toggle = () => setState((s) => (s === defaultValue ? reverseValueOrigin : defaultValue));
const set = (value: D | R) => setState(value);
const setLeft = () => setState(defaultValue);
const setRight = () => setState(reverseValueOrigin);
return {
toggle,
set,
setLeft,
setRight,
};
toggle 的内部逻辑很好理解,判断state上一次的值是否是默认值(defaultValue),是则返回 reverseValueOrigin,不是则返回 defaultValue。
剩下的 set 、setLeft 、 setRight 无非都是对setState不同方式的调用。
比起它们,我们关注一下很有意思的这两行注释:
// useToggle ignore value change
// }, [defaultValue, reverseValue]);
}, []);
为什么 useToggle 是允许忽视 defaultValue 和 reverseValue 的?
可以看到原先的版本中,useMemo 是依赖defaultValue 和 reverseValue的,为什么ahooks团队的成员在后来的维护中,将它俩移出依赖了呢?
回答这种问题,最好的方式就是反问回去:
为什么
useToggle中的useMemo需要依赖defaultValue和reverseValue?
当我们使用 useToggle 去对 defaultValue 和 reverseValue进行状态管理时,我们期望实现的是二者之间的切换,而这个切换是建立在内部通过React.useState创建的作为中转站的state变量上的,能够改变state的只有 actions 中返回的 set、toggle 、 setLeft 、setRight方法。
实际上,defaultValue 和 reverseValue本身不会因为 useToggle 的行为发生改变。
因此 defaultValue 和 reverseValue在理想的使用情况下,是不变的,那么useMemo 也没必要去依赖两个不变的变量了。
这也是 ahooks 期望我们使用
useToggle的方式。
⚠除非,当我们传给 useToggle 的 defaultValue 和 reverseValue是来自于父组件的state ,那么这就会导致预期之外的情况了。⚠
即父组件的 state 以 props 的形式流动,传递给子组件。
我们通过一个代码示例看下:
import { useState } from "react";
import { Button, Typography, Space } from "antd";
import { useToggle } from "ahooks";
const ToggleChild = ({
defaultValue,
reverseValue,
}: {
defaultValue: string;
reverseValue: string;
}) => {
const [state, { toggle }] = useToggle(defaultValue, reverseValue);
return (
<Space>
<Button
onClick={() => {
toggle();
}}
>
子组件切换
</Button>
<Typography.Text>{state}</Typography.Text>
</Space>
);
};
const ToggleComponent = () => {
const [defaultValue, setDefaultValue] = useState("父组件的defaultValue");
const [reverseValue, setReverseValue] = useState("父组件的reverseValue");
return (
<Space direction="vertical">
<Button
type="primary"
onClick={() => {
setDefaultValue("父组件的defaultValue改变了");
setReverseValue("父组件的reverseValue改变了");
}}
>
父组件切换
</Button>
<Space direction="vertical">
<Typography.Text>
<b>defaultValue当前值:</b>
{defaultValue}
</Typography.Text>
<Typography.Text>
<b>reverseValue当前值:</b>
{reverseValue}
</Typography.Text>
</Space>
<ToggleChild defaultValue={defaultValue} reverseValue={reverseValue} />
</Space>
);
};
export default ToggleComponent;
简单说下实现的逻辑,父组件中通过 useState 维护两个变量,defaultValue 和 reverseValue,它们的初始值分别是 "父组件的defaultValue" 和 "父组件的reverseValue"。
然后父组件将这俩state变量以props的形式传递给子组件,
<ToggleChild defaultValue={defaultValue} reverseValue={reverseValue} />
子组件内部将这俩state作为useToggle 的入参。
const ToggleChild = ({
defaultValue,
reverseValue,
}: {
defaultValue: string;
reverseValue: string;
}) => {
const [state, { toggle }] = useToggle(defaultValue, reverseValue);
return (
<Space>
<Button
onClick={() => {
toggle();
}}
>
子组件切换
</Button>
<Typography.Text>{state}</Typography.Text>
</Space>
);
};
运行后的结果如下:
可以看到,由于被useMemo包裹的actions并不依赖defaultValue 和 reverseValue,导致了,就算defaultValue 和 reverseValue发生变化了,toggle 切换的值还是最开始的值。
子组件实际上发生的行为和我们期望它发生的行为不一致了,这可不是什么好消息啊😫。
我们可以通过 React Dev Tool 再验证一遍我们的结论:
可以看到,子组件的props确实也更新成了父组件的state中的最新值,但是toggle切换的文案内容还是初始值。
为了确保得出的结论没有任何一点问题,我们改动一下源码,让被useMemo包裹的actions依赖defaultValue 和 reverseValue,再看看效果:
我们自定义了一个hook,然后移植了一下代码,并做了修改,接下来看下运行的结果:
可以看到,当被useMemo包裹的actions依赖defaultValue 和 reverseValue时,父组件的state发生变化后,子组件中toggle的内容也做了同步变化,切换时的文本内容也是最新版的文本内容了。
我们从一正一反两方面验证了我们得出的结论,👇这条注释👇:
// useToggle ignore value change
// }, [defaultValue, reverseValue]);
}, []);
还真是有意思呀😀。
⚠因此,当我们使用
useToggle时,可千万要注意defaultValue和reverseValue的来源哦!!!⚠
结语
通过这三篇文章的阅读,我们已经对自定义hook的代码编写和设计理念有了初步的了解。
在这几篇文章中,我提到了很多次的模拟 React 原生hook—— useState的行为。以及通过 useCallback 和 useMemo 的方式,确保相关的函数或者变量,只会在整个组件的生命周期被声明1次,而不会随着组件的 re-render 而被重复创建,避免引发性能方面的问题。
在继续深入地去聊ahooks的其他hook之前,我想是时候对 React hook 的工作机制做一次深入的探讨了。
我们常常会刷到有关 React hook 的面试题,其中,下方这两道题我想是大家最耳熟能详的:
- 为什么只能在
React函数中调用 hook ? - 为什么不能在循环语句、条件语句、嵌套函数中调用 hook ?
这些问题,或者说这两条使用原则,它们背后的原理是什么呢?
能将这个问题回答到什么程度,表达的看法与展示的理解程度又到达了什么水平,往往会影响到面试官给我们的技术定级。
因此,之后我将会梳理一篇探讨 React hook 工作机制的文章,和大家一起漫谈 React hook,这位我们合作最频繁,却又似乎不那么熟悉的 “老相识” 。
期待与你在下一篇文章相遇~
示例完整代码
我将代码push到了我的git仓库里,大家可以在这里找到。
点击这里的链接即可直接访问:useToggle Demo