往期回顾
- ahooks:知你所难,解你所忧——01
useSetState的源码分析。TypeScript的泛型、约束、联合等等的基本使用。
前言
在系列文章的第一篇里,我们一起聊了聊useSetState这个hook,掌握了它的用法(如何帮助我们解决表格分页时的状态管理)以及实现原理(阅读源码),并由单个业务场景延伸出通用性的使用建议,即在面对复杂对象的状态管理时(比如一个对象内部包含十几个、几十个字段),可以使用useSetState减轻我们要写的代码量。
作为系列文章的第二篇,我们会继续聊一聊在日常工作过程中遇到的那些痛点。
本文的主角有俩,分别是频繁设置的弹窗显隐和到处都有的状态切换。
基于上述的痛点,我们会谈一谈对useBoolean和useToggle这两个hook的使用,看看它们俩到底能够帮我们省多少力气。
当然,请不要错过文章后半部分的【源码分析】环节,在那里我们将深入探讨代码的细节,共同学习其中的编程技巧和逻辑思维。
本期的TypeScript关键词:重载 与 函数重载。
开场白就到这里,让我们开始今天的学习吧~
频繁设置的弹窗显隐
在现代的网页中,好像很难找出一个不具备弹窗功能的网页。之前我做的业务是面向B端的,其中就有很多交互需要以弹窗的形式实现。
哪怕先不谈偏向后台管理类型的网站,就是掘金这种技术博客平台,👇也能找到弹窗出现的场景👇:
那么我们来回忆一下,当我们使用市面上的UI组件库(比如:antd),去往页面中添加Modal组件时,我们需要写哪些代码?
- 我们需要通过useState创建一个state变量,比如
[open,setOpen],用于代表Modal的状态- true:显示
- false:隐藏
- 针对这个state变量,我们要去写关闭Modal和打开Modal的函数:
- showModal:打开Modal
- closeModal:关闭Modal
- 针对Modal的确认和取消事件,我们需要写对应的回调函数,比如onClick、onCancel,用于处理复杂的逻辑,如:
- 表单数据提交
- 发起异步请求
- 等等...
我们来写一个简单的代码示例:
import React, { useState, useEffect } from "react";
import { Button, Modal } from "antd";
// import { useSetState } from "ahooks";
function ModalButton() {
const [open, setOpen] = useState(false);
const showModal = () => {
setOpen(true);
};
const closeModal = () => {
setOpen(false);
};
return (
<>
<Button
type="primary"
onClick={() => {
showModal();
}}
>
Click Me
</Button>
<Modal
title="ModalDemo"
open={open}
okText="Submit"
onCancel={() => {
closeModal();
}}
onOk={() => {
closeModal();
}}
>
<>children</>
</Modal>
</>
);
}
export default ModalButton;
运行后如下:
在这个简单的代码示例中,我们为了打开弹窗/关闭弹窗,都需要调用封装好的函数show/closeModal,在函数的内部,我们需要调用setOpen,手动传入true和false。
setOpen(true);setOpen(false);
我不想每次写个弹窗,写
setOpen的时候,都要手动打字拼一遍true和false,这让我感到很痛苦,对于布尔值来说,就这两种赋值的情况,在进行状态管理的时候,能不能替我省了额外的这种“显示输入”呢?
你好,可以的。
useBoolean : “
”
useBoolean
优雅的管理 boolean 状态的 Hook。
我们看下它的使用方式:
API
const [state, { toggle, set, setTrue, setFalse }] = useBoolean(
defaultValue?: boolean,
);
useBoolean接收一个boolean类型的可选默认值(不传入的话,默认是false),并返回一个数组,数组包含2位成员:
- state:状态值,可以理解成之前的代码示例
const [open, setOpen] = useState(false)中的open。 - actions:操作集合,包含4种操作行为:
set:设置state,使用方式:set(true),效果类似setOpen(true)toggle:切换state,使用方式:toggle(),效果类似setOpen(!open),即取反setTrue:设置为true,使用方式:setTrue(),效果类似setOpen(true)setFalse:设置为true,使用方式:setFalse(),效果类似setOpen(false)
使用
我们将useBoolean引入我们的项目,替换掉原来的useState,看下代码改动:
import { useBoolean } from "ahooks";
// 解构赋值时,我们可以给解构出来的变量一个新的名称。
// 这里我们把 state 命名为 open,便于语义化理解。
const [open, { setTrue, setFalse }] = useBoolean();
const showModal = () => {
setTrue();
};
const closeModal = () => {
setFalse();
};
这下当我们需要在后续的逻辑,处理布尔值类型数据的状态时,也无需手动拼写false和true传入了。
源码分析
观前提醒📢:去掉
import语句和空行,useBoolean的源码也只有20行出头一点,无需担心,放心食用。
import { useMemo } from 'react';
import useToggle from '../useToggle';
export interface Actions {
setTrue: () => void;
setFalse: () => void;
set: (value: boolean) => void;
toggle: () => void;
}
export default function useBoolean(defaultValue = false): [boolean, Actions] {
const [state, { toggle, set }] = useToggle(!!defaultValue);
const actions: Actions = useMemo(() => {
const setTrue = () => set(true);
const setFalse = () => set(false);
return {
toggle,
set: (v: any) => set(!!v),
setTrue,
setFalse,
};
}, []);
return [state, actions];
}
最先吸引我们注意力的自然是interface Actions。还记得我们在第一篇文章中一起完成的type SetState吗?
注意了,这里就出现了一个面试官也许会考察我们的知识点:
interface 和 type
在
TypeScript中,interface和type有什么区别?
实际上,interface (接口)和 type (类型别名)非常相似,在大多数情况下,我们可以任意选择二者之一,去帮我们完成对象类型的命名。
interface 的几乎所有功能都在 type 中可用。
举个例子,interface 和 type 均可以支持扩展。假设我们现在有个基类叫做Vehicle,我们可以用两种方式去完成类型的定义。
-
interface:- 基类定义:
interface Vehicle { engine: string; } - 扩展类型定义:通过
extends扩展接口,即实现接口继承interface Car extends Vehicle { wheel: number; }
- 基类定义:
-
type:- 基类定义:
type Vehicle = { engine: string; }; - 扩展类型定义:通过交集扩展类型
type Car = Vehicle & { wheel: number; };
- 基类定义:
由上我们可以看到,虽然可以使用interface 和 type完成对Car的类型命名,但是如果将一个空对象传给由他俩分别定义的Car时,校验报错的错误提示是不一样的。
-
interface:会展示全部的缺失属性 -
type:会根据顺序,从左往右校验。- 举个例子,在上面的代码示例中,我们是这么写的
Vehicle & {...},因此就会优先校验是否满足Vehicle,所以报错先提示缺失engine。 - 假如我们换个顺序,这样写
{...} & Vehicle,则由优先校验{...},即是否包含wheel。
- 举个例子,在上面的代码示例中,我们是这么写的
如果期望向现有的接口/类型 添加新字段,而不是通过新创建一个类型完成字段的扩展,那么interface 和 type就有很大的区别了。
-
interface:- 我们期望直接给
Vehicle本身添加新字段,则可以这么写:
interface Vehicle { engine: string; } interface Vehicle { wheel: number; }- 然后我们看下结果:
可以看到,新字段添加成功。
- 我们期望直接给
-
type:- 我们期望直接给
Vehicle本身添加新字段,尝试和interface相同的写法:
type Vehicle = { engine: string; }; type Vehicle = { wheel: number; };- 然后我们看到如下结果:
直接报错了,这种写法不允许。
- 我们期望直接给
由此我们可以得出一个结论:
我们无法通过重新打开类型的方式来添加新属性,类型别名本质上是创建了一个新的类型,它并不保留任何的可扩展性。
但是接口是始终可扩展的。使用接口的这种可扩展性,可以很容易地对现有的类型定义进行增量修改,而不需要重写整个类型定义。这在处理大型项目或第三方库的扩展时非常有用。
接下来我们回到源码本身,看看为什么这里定义Actions要使用interface。
return [state, actions];
从源码中可以看到,useBoolean返回了一个包含 state 和 actions 的数组。actions代表着操作集合。
也许在具体使用它的时候,不同的公司会根据不同的业务去对通用性的hook做一层自己的封装。一些较大型的公司都会基于开源的UI组件库项目做一层封装,将其改造为支持自己公司某些业务需求的UI组件库。
比如我司就用Bit.dev封装了antd的组件库,给Button组件新开了一个props叫做compact,用于取消按钮的内边距,这样在表格内展示某些指定为文本类型的按钮时,不会出现异常边距的现象。
同理,也许后续我们也期望useBoolean能够提供更多的便捷操作,即返回的actions内部包含更多的操作。
那么我们就需要扩展它,要做功能上的扩展自然也避免不了类型定义的同步扩展。此时使用interface就很方便了,我们从useBoolean中导入Actions,然后使用声明合并的写法,也就是刚刚展示在上方的写法,无需额外定义一个类型别名,即可完成扩展。
有关interface 和 type的话题就先聊到这,我们继续阅读源码。
export default function useBoolean(defaultValue = false): [boolean, Actions] {...
这是一行非常基础的附带类型定义的函数声明。通过: [boolean, Actions] 的方式定义了函数的返回值类型。
但是可能你会疑问,对于函数参数的类型定义,不应该是这样才对吗?
export default function useBoolean(defaultValue: boolean = false): [boolean, Actions] {...
即关于 defaultValue = false 和 defaultValue: boolean = false 的写法讨论。
类型推断
首先,你的细心观察值得肯定👍。
确实,按照习惯的写法,我们是要在声明一个函数时,给函数的参数都显示地定义类型。
但是这也有例外,TypeScript 支持类型推断,在没有显式类型注释的情况下使用类型推断来提供类型信息。
举个例子:
let num = 3
我们声明一个变量num,此时将鼠标悬停在num上,👇可以看到如下信息👇:
这种推断发生在初始化变量和成员、设置参数默认值以及确定函数返回类型时。
因此当我们给一个函数的参数设置默认值,且没有其他复杂逻辑的时候,我们无需显示地对它进行类型定义,依靠TypeScript 的类型推断即可。
聊完这个小插曲,我们继续阅读源码。
const [state, { toggle, set }] = useToggle(!!defaultValue);
可以看到,原来 useBoolean 这个hook内部是调用了 useToggle 这个hook。通过获取useToggle 的返回值,并做进一步的改造处理,再作为自己的返回值返回。
这一行代码,我们主要关注一下 !!defaultValue的这种写法。
!!,双重逻辑非
!!操作符将任何值转换为布尔类型(boolean),这是最直接的好处。如果操作数是“truthy”值,结果将是true;如果操作数是“falsy”值,结果将是false。
举个例子,假设我们有一个判断语句是这样的:
if(state === true){
console.log("success");
}
当state的值是 1 和0 时,如果我们期望 state 为 1 时,能够通过条件语句的判断,从而执行内部逻辑,我们就需要进行类型转换。
使用!!操作符,则结果如下:
还有一个好处就是,使用!!可以避免更长的类型转换代码,比如使用Boolean()构造函数或显式的条件判断。
现在回到我们的源码中,为什么我们这里还要对 defaultValue 使用 !! 来做一次强制类型转换,而确保它一定是布尔值呢?
根据我们刚刚聊的内容,defaultValue 明明已经通过类型推断,明确定义为boolean类型了,难不成我还能给它传个非 boolean的值 ?
这个难不成,还真有可能成。
我们要知道,TypeScript 只是作用于代码编写时以及代码阅读时,在代码运行之前捕获潜在的错误的,它并不能保证程序运行时的情况。
TypeScript 的类型信息在编译成 JavaScript 时会被移除,因此,在运行时,TypeScript 代码的行为与相应的 JavaScript 代码完全相同。
当我们在 JavaScript 的项目调用 useBoolean 这个hook时,此时我们就可以往useBoolean中传入任何类型的值了,并且传入后程序会被正常编译和运行。
这时如果我们不做强制类型转换这一保护的行为,确保将入参转换为 boolean 类型的话,则就会使得我们的代码在运行后出现预期之外的情况了。
就像刚刚介绍!!操作符举的例子一样,也许一个期望能够通过条件语句的入参,会因为值匹配而类型不匹配导致未能成功进入条件语句的内部处理逻辑,从而产生期望外的异常行为。
因此大家不要认为源码中的 !! defaultValue是多此一举的行为,这反而增强了程序的健壮性。
在讨论这个代码的实现细节之后,我们继续往下阅读。接下来就是该hook内容占比最多的部分了:
const actions: Actions = useMemo(() => {
const setTrue = () => set(true);
const setFalse = () => set(false);
return {
toggle,
set: (v: any) => set(!!v),
setTrue,
setFalse,
};
}, []);
首先,actions 是被useMemo 包裹后的对象。这就意味着它只会在组件的整个生命周期内被创建一次。
这是必要的性能优化,因为我们不期望在函数组件每次重新渲染时,都重新创建一次actions对象,这可能会导致不必要的子组件渲染,从而影响整体页面的性能。
这其实和第一篇里我们提到的使用useCallback 包裹 setMergedState函数,模拟 useState 返回的setState的行为是同一个道理。
接着我们看内部的逻辑,返回一个对象,并对对象中的几个属性完成赋值操作。
对于toggle 、setTrue 、setFalse 这三个字段的赋值我们都好理解。
为什么这里对 set 函数要使用set: (v: any) => set(!!v)这样的方式赋值,而不是和 toggle 类似,直接返回一个 set 呢?
从源码最顶层的interface Action我们可以知道,set的类型定义是这样的:set: (value: boolean) => void;
而在 useBoolean 内部,set 是从 useToggle 的返回值中解构出来的。我们不妨看下此时 set 的类型。
这里的set实际上接收的是一个泛型。
我个人的理解是,useBoolean放开了对于使用 set 函数时的入参类型限制,这种放宽可能是有意为之(比如继承 useToggle 的 set 的设计理念),以便提供更多的灵活性。例如,允许传递null、undefined、数字、字符串等等。
然后最终它们都会被!!操作符强制类型转换为boolean,不会影响 state 的赋值和正确的类型限制。
如果大家有更多不同的想法,欢迎评论区留言分享!
结语
在本篇文章里,我们结合一个业务场景示例,指出了使用普通的useState对 boolean 类型的数据进行状态管理时的痛点,并由此介绍了ahooks中的useBoolean。
频繁设置的弹窗显隐是我们在日常开发中经常会遇到的痛点,但是在分析完useBoolean的源码后,我们发现它主要的功能都来自于 useToggle。
考虑到篇幅,故将系列文章的第二篇分为上下两部分,我们将在下半部分着重聊一聊 useToggle 的源码,并且结合实际业务场景到处都有的状态切换来看看 useToggle 又能为我们带来多少便利。
期待与你在下一篇文章相遇~