ahooks:知你所难,解你所忧——02(下篇)

618 阅读26分钟

往期回顾

  1. ahooks:知你所难,解你所忧——01

    • useSetState 的源码分析。
    • TypeScript 的泛型、约束、联合等等的基本使用。
  2. ahooks:知你所难,解你所忧——02(上篇)

    • useBoolean 的源码分析。
    • TypeScript 的类型推断、interfacetype等等的介绍。

前言

在本系列的前两篇文章里,我们一起聊了聊 useSetStateuseBoolean 这两个hook。在每一篇的【源码分析】章节里,我们都一起探讨了代码的实现细节,学习了其中的设计理念。

我们发现,我们在分析hook的源码的同时,也在对 TypeScript 的知识点进行回顾。

这真是一件一石二鸟的事情,一方面我们在准备 react hook 相关的知识点,另一方面,我们还能顺手把 TypeScript比较热门的考点这也进一步证明了,这些热门考点并不是所谓的靠记忆靠背诵的陈旧知识点,而是市面上热门的开源项目也经常使用的香饽饽)给复习了。

本期的TypeScript关键词:函数签名 与 函数重载

在本篇文章中,我们将会结合到处都有的状态切换这一业务场景,一起聊一聊 useToggle 这个hook。

看看它又带给了我们哪些便利。

chufa.jpg

到处都有的状态切换

2013年,被誉为中国通信行业的一个重要里程碑,被亲切地称为我国的4G(第四代无线蜂窝通信协议)元年。

image.png

在这一年的12月4号,工业和信息化部(即工信部)正式向中国移动、中国电信、中国联通颁发三张4G牌照,均为TD-LTE制式,标志着中国正式步入了第四代无线蜂窝通信协议时代

几十倍的网速提升(2 M/S => 100 M/S),为移动通信用户带来了前所未有的便捷,也改变了整个社会的生活节奏和信息交流方式。

画外音🤦‍♂️:不是大哥,说重点,扯到4G是什么情况,我没看错的话,咱这篇文章应该是讲 ahooks 的吧?

咳咳,不好意思🙇,其实我是想说通信技术的发展让自媒体平台如雨后春笋般冒出。

小红书、微博、b站、贴吧、抖音、快手、火山视频、西瓜视频等等,平台涌现的同时,也伴随着大量用户的涌入。

有博主,就有观众朋友、粉丝朋友。作为平台用户,当我们遇到感兴趣的博主时,会自然而然地选择关注他,方便持续了解他后续的分享内容。

(在此感谢在掘金上关注我的朋友们🌹,咱们交流学习,共同进步✊)

那么这里就会遇到【状态切换】的情况了,即【+关注】和【已关注】

image.png

我们需要根据用户的交互行为,来渲染不同的UI样式,实现 A <=> B两种状态之间的转换。

除了自媒体平台的【用户是否关注另一用户】的行为外,还有很多业务场景涉及到了A <=> B两种状态之间的转换,比如:

  • 物联网设备的【开启】与【关闭】:某些品牌的空调插座支持APP控制,很方便在空调遥控器找不到的时候,用手机来管理。

    image.png

  • 一些面向B端的网站,某些设备的【在线】和【离线】。

  • 线上的问卷平台,问卷的【发布】和【停止】。

    image.png

    image.png

和之前的文章相同,我们通过一个代码示例,来看看实现这样的状态切换都需要写哪些代码。

(我们先不考虑国际化多语言的情况,只针对中文文案进行切换)

(国际化也是一个业务难点,需要从众多的解决方案中找到适合自己项目的最佳实践,这个话题回头我们再聊。)

我们简单地模拟一下掘金平台的搜索用户后的信息展示组件,并实现【关注】和【已关注】的状态切换。

image.png

代码示例

我还是将这个例子放到了我的基于Next.js实现的项目中。在【小工具】页面的最下方。

(由于这次的代码量比较多,因此不在正文内容中贴出,大家可以在章节最后的【示例完整代码】处查看。)

image.png

👇运行后的效果如下👇:

ahooks-usetoggle-1.gif

组件结构

image.png

实现逻辑

样式上的实现就一笔带过了,比如利用flex布局实现垂直居中,利用flex:1flex:0 0 auto实现合理的空间分配。

ahooks-usetoggle-3.gif

我们看下处理交互行为的逻辑,我们期望在用户点击按钮时,根据是否已经关注来实现按钮样式与文本内容的切换。

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的依赖项,来处理副作用的影响,比如当followStatustrue时,则需要及时地将btnTextbtnType 切换为 "已关注""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可选项,传入默认的状态值Tfalse
reverseValue可选项,传入取反的状态值U-

我们可以明确地指定需要切换地两个状态值,而不是同 useBoolean 一样,只能在 truefalse 之间摇摆。

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]);

👇这样代码就写得更省力了,效果也是一样的👇:

ahooks-usetoggle-2.gif

源码分析

观前预防针💉:与前两篇文章不同,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,我贴一个 useBooleanuseToggleActions 的区别。

  • 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;
    }
    

可以看到,最大的区别在于 useToggleActions泛型的,用于 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 SetStateinterface Actions,这里怎么直接用 function做看起来像是类型定义的事情?

这里就要提到在上一篇文章的【前言】章节里我所说的关键词:签名函数重载

签名

我们可以使用一个函数签名,来描述这个函数在某些参数下的行为和返回类型

举个例子,我们期望有1个函数,能够返回传入的两个数字的和,则我们可以这么写代码:

function getSum(num1: number, num2: number): number;

function getSum(num1: number, num2: number) {
  return num1 + num2;
}

⚠请注意,当我们使用函数签名的形式去声明一个函数的时候,后面一定要跟着函数的具体实现,否则会报错。

image.png

好,看到这里我想你应该会怼我一句了,这函数签名不是多此一举吗?

我同意你的观点,其实我和你的看法一致🤝,不过,还是有一点需要注意。

确切的说,应该是在这种情况下多此一举,实际上,如果不是为了硬写函数签名,我们真实的、在工作中会用的写法,应该是这样的:

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);

image.png

看到这里,对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);

image.png

运行后,确实也能够实现刚刚那种通过函数重载写法所达成的效果。

而且还更省力

能提出这种想法的朋友真的很棒👍,本身我写技术文章,也不是为了仅仅向外输出知识,而是将文章作为桥梁,和有缘能阅读它的朋友交流,在交流中进行思想碰撞,从而共同进步。

剩余参数语法确实是很方便的,我在《告别面试焦虑:深入浅出解析前端手写题,助你顺利通关【EP01】》这篇文章里,也使用它完成了对一道热门手写题的回答。

不过注意我们的题干的后半句:当传入的参数总数大于等于4,则属于参数不合法

使用剩余参数语法并不能帮助我们判定不合法的入参,我们来看下两种函数在校验下的情况:

  • 基于函数重载实现的getSum:

    image.png

  • 基于剩余参数实现的getSumByRestOperator:

    image.png

经过上方的对比,函数重载的优势可见一斑。

通过上面两个小节的举例和描述,我想大家对于函数签名、函数重载已经有了一定的理解。接下来就让我们回到源码本身,看看 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>];:

    image.png

    image.png

  • function useToggle<T = boolean>(): [boolean, Actions<T>];:

    image.png

    image.png

上面如果当作boolean值来用的话,没有任何差别。

👇使用源码写法的好处在于,显示地传入类型参数时,能够被ts允许,不会报错👇。

  • 我们的写法: image.png

    传入了1个类型参数后,就不满足第一行函数签名的定义(即function useToggle(): [boolean, Actions<boolean>];)了,会判定成第二行函数签名的定义(即 function useToggle<T>(defaultValue: T): [T, Actions<T>]; ),此时defaultValue是必填的,因此ts报错提示,要求我们得传一个具体值。

  • 源码写法: image.png

虽然我不知道这样有什么用,就感觉莫名奇妙

因为假如我都显示地传入类型参数了,比如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个类型参数,分别用于 defaultValuereverseValue 的类型定义。

那这也就意味着 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;
}

👇可以看到,会报错👇: image.png

根据这条规则,我们明白,要写好 useToggle 的函数实现,就一定要特别注意之前的3种函数签名。

按照 useToggle 最多接收两个任意类型且可能并不相同的参数,我们需要设置2个类型参数,即:

function useToggle(defaultValue: D , reverseValue?: R){}

这样写完之后,使用泛型,我们要记得用<>包裹我们需要捕获的类型信息,于是我们加上它:

function useToggle<D, R>(defaultValue: D , reverseValue?: R){}

到这一步,我们是不是写完了?我们看下IDE中实际的情况:

image.png

由上图可以看到,并不满足第1种函数签名。

直接解决这个问题可能让我们无奈,我们通过一个简单的例子先复现一下:

function demoTest<T = boolean>(): T

function demoTest(){

}

demoTest()

image.png

此时,不会报任何错误,现在我们给下方的函数实现加上泛型,再看看:

function demoTest<T = boolean>(): T

function demoTest<D>(defaultValue:D){

}

demoTest()

image.png

我们成功复现出了和 useToggle 一样的错误。

这个错误我们可以这么去看:

  • 首先,函数实现所定义的 defaultValue 是个必填参数,这显然不符合上方的函数签名。因为在函数签名里,demoTest的参数是空的。

因此,我们先要把函数实现中的demoTest的参数设置为可选项。

function demoTest<T = boolean>(): T

function demoTest<D>(defaultValue?:D ){

}

demoTest()

image.png

大家可以看到,这样报错就被解决了。

但是我们还期望defaultValue在不传时,有个默认值是 false 。于是我们再做进一步的改造:

function demoTest<T = boolean>(): T

function demoTest<D>(defaultValue:D = false ){

}

demoTest()

image.png

此时,报错的并不是函数签名了,而是函数实现本身的参数类型了。

离成功,就差这一步了,我们该如何解决这个报错呢?

类型断言:我来助你!

image.png

类型断言

类型断言可以用来手动指定一个值的类型,即允许变量从一种类型更改为另一种类型.

我们通过一个简单的代码例子来直观地看一下类型断言的用处:

let str:string = '123'

let str2:number = 123

str = str2

image.png

如果我们期望str = str2这行代码不会报错,可以使用类型断言,告诉 TypeScriptstr2 就是 string 类型。

let str:string = '123'

let str2:number = 123 

str = str2 as unknown as string

image.png

改造后,报错解决。

回到源码,我们改造一下刚刚举的例子:

function demoTest<T = boolean>(): T

function demoTest<D>(defaultValue:D = false as unknown as D ){

}

demoTest()

image.png

如预期般,报错解决。

经过刚刚对于函数重载的规则与类型断言的学习,我们成功理解了在 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】章节卖的关子吗?

image.png

可以看到,针对 reverseValue 为空的情况, useToggle 的处理方式真的很简单粗暴,即直接通过 ! 操作符对 defaultValue 进行取反操作。

我真晕倒了😵,如果我传入的 defaultValue 是个字符串,比如 "Hello",那么这个 reverseValue 真的是我想要的吗?

image.png

我感觉无论是前面的在函数实现中使用类型断言让ts不报错,还是这里的粗暴处理 reverseValue 为空的情况,都让我觉得 useToggle 的代码写得并不是那么优雅.........😶

出于篇幅考虑,这里我简单说一下 JavaScript 中的类型转换

逻辑非!,逻辑连接取反)运算符将真值或假值转换为对应的相反值,经常用于布尔(逻辑)值。当与非布尔值使用时,如果其操作数可以转化为 true,则返回 false,否则返回 true

——MDN

能够转化为 false 的表达式的示例如下:

  • null

  • NaN

  • 0

    • -0
    • +0

    image.png

  • 空字符串("" 或 '' 或 ``);

  • 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

剩下的 setsetLeftsetRight 无非都是对setState不同方式的调用。

比起它们,我们关注一下很有意思的这两行注释:

    // useToggle ignore value change
    // }, [defaultValue, reverseValue]);
  }, []);

为什么 useToggle 是允许忽视 defaultValuereverseValue 的?

image.png

可以看到原先的版本中,useMemo 是依赖defaultValuereverseValue的,为什么ahooks团队的成员在后来的维护中,将它俩移出依赖了呢?

回答这种问题,最好的方式就是反问回去:

为什么 useToggle 中的 useMemo 需要依赖 defaultValuereverseValue

当我们使用 useToggle 去对 defaultValuereverseValue进行状态管理时,我们期望实现的是二者之间的切换,而这个切换是建立在内部通过React.useState创建的作为中转站的state变量上的,能够改变state的只有 actions 中返回的 settogglesetLeftsetRight方法。

实际上,defaultValuereverseValue本身不会因为 useToggle 的行为发生改变。

因此 defaultValuereverseValue在理想的使用情况下,是不变的,那么useMemo 也没必要去依赖两个不变的变量了。

这也是 ahooks 期望我们使用 useToggle 的方式

除非,当我们传给 useToggledefaultValuereverseValue是来自于父组件的state ,那么这就会导致预期之外的情况了。⚠

即父组件的 stateprops 的形式流动,传递给子组件。

我们通过一个代码示例看下:

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;

image.png

简单说下实现的逻辑,父组件中通过 useState 维护两个变量,defaultValuereverseValue,它们的初始值分别是 "父组件的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>
  );
};

运行后的结果如下:

ahooks-usetoggle-4.gif

可以看到,由于被useMemo包裹的actions并不依赖defaultValuereverseValue,导致了,就算defaultValuereverseValue发生变化了,toggle 切换的值还是最开始的值

子组件实际上发生的行为和我们期望它发生的行为不一致了,这可不是什么好消息啊😫。

我们可以通过 React Dev Tool 再验证一遍我们的结论: image.png

image.png

可以看到,子组件的props确实也更新成了父组件的state中的最新值,但是toggle切换的文案内容还是初始值

为了确保得出的结论没有任何一点问题,我们改动一下源码,让被useMemo包裹的actions依赖defaultValuereverseValue,再看看效果:

image.png

我们自定义了一个hook,然后移植了一下代码,并做了修改,接下来看下运行的结果:

ahooks-usetoggle-5.gif

可以看到,当被useMemo包裹的actions依赖defaultValuereverseValue时,父组件的state发生变化后,子组件中toggle的内容也做了同步变化,切换时的文本内容也是最新版的文本内容了

我们从一正一反两方面验证了我们得出的结论,👇这条注释👇:

    // useToggle ignore value change
    // }, [defaultValue, reverseValue]);
  }, []);

还真是有意思呀😀。

⚠因此,当我们使用 useToggle 时,可千万要注意defaultValuereverseValue的来源哦!!!⚠

结语

通过这三篇文章的阅读,我们已经对自定义hook的代码编写和设计理念有了初步的了解。

在这几篇文章中,我提到了很多次的模拟 React 原生hook—— useState的行为。以及通过 useCallbackuseMemo 的方式,确保相关的函数或者变量,只会在整个组件的生命周期被声明1次,而不会随着组件的 re-render 而被重复创建,避免引发性能方面的问题。

在继续深入地去聊ahooks的其他hook之前,我想是时候对 React hook 的工作机制做一次深入的探讨了。

我们常常会刷到有关 React hook 的面试题,其中,下方这两道题我想是大家最耳熟能详的:

  • 为什么只能在 React 函数中调用 hook ?
  • 为什么不能在循环语句、条件语句、嵌套函数中调用 hook ?

这些问题,或者说这两条使用原则,它们背后的原理是什么呢?

能将这个问题回答到什么程度,表达的看法与展示的理解程度又到达了什么水平,往往会影响到面试官给我们的技术定级

因此,之后我将会梳理一篇探讨 React hook 工作机制的文章,和大家一起漫谈 React hook,这位我们合作最频繁,却又似乎不那么熟悉的 “老相识” 。

期待与你在下一篇文章相遇~

image.png

示例完整代码

我将代码push到了我的git仓库里,大家可以在这里找到。

image.png

点击这里的链接即可直接访问:useToggle Demo

image.png