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

2,423 阅读15分钟

前言

关于阿里巴巴,我想有一句话大家都听过,那就是“让天下没有难做的生意。

杭州G20峰会的时候,中央13台的《面对面》栏目还特意邀请马云做了专访,聊的主题就是开头提到的这句话。

image.png

为什么会突然聊到这个呢,你我同为程序员💻,在职业身份上和商家好像八竿子打不着🤷‍♂️。

这是因为我最近在使用ahooks

image.png

发现它做的事情在理念上似乎和阿里的使命定位不谋而合,稍加修改的话,可以这么说:

让天下没有难写的 React 代码。

useSetStateuseToggleuseRequest等等,这些hooks确实真的替程序员们节省了不少功夫。

平时工作中遇到一些业务需求开发任务时,可以使用它们快速、省心地解决一些写代码时令人感到疲倦、烦心的case。

口说无凭,本篇文章将结合一些笔者真实遇到的场景案例,和大家一起聊一聊为什么ahooks好用,以及分析分析源码,一起学习它是怎么写出来的。

image.png

“真的有捏(确信脸)。😋”

令人痛苦的表格分页

CRUD(增删改查)”几乎是我们每个程序员都接触过的,科班也好、非科班也好、前端也好、后端也好,不可避免地都在学习、工作中遇见它们、编写它们、使用它们。

平常我们也会调侃自己,“切图仔”、“CV工程师”。

“通过表格组件实现分页查询,展示某某业务的数据。”

image.png

这种业务需求和CRUD的关系就像天命人四妹一样,天生一对❤️。

还在黑神话,还在意难平,还在她的山外青山,终究是没能与他一起去看😭

image.png

大家在平常写类似的业务需求时,会采用什么方案呢?

我来举几个我在code review中看到的写法:

  • 全部拆开:将totalpageSizepageNum全部声明成不同的state

      const [total, setTotal] = useState(0); // 总条目数
      const [pageSize, setPageSize] = useState(10); // 每页显示的条目数
      const [pageNum, setPageNum] = useState(1); // 当前页码
    
  • 整合成一个对象:将totalpageSizepageNum作为pagination对象的属性,统一放到一个state中。

      const [pagination, setPagination] = useState({
        pageNum: 1,
        pageSize: 10,
        total: 0,
      });
    

通常来说,我们都是选择第二种方式,即整合到一起,通过一个对象来进行统一管理。

那么,既然第二种方式是推荐的写法,为什么还会有第一种方式产生呢?

因为使用第二种方式,将<Table/>组件的pagination受控,在某些写法下,会出现【onChange函数里分页更新时】和【异步行为获取到数据后】,需要多写一遍展开运算符的情况。

代码示例

我们来结合业务场景看一个代码示例,此场景如下:使用Antd的 <Table/> 组件来模拟查询设备列表,创建一个简单的设备列表,并提供一个搜索框来过滤设备。

image.png

👇运行效果如下图所示👇:

ahook表格.gif

具体代码如下:

import React, { useState, useEffect } from "react";
import { Table, Input, Button } from "antd";
const mockData = Array.from({ length: 100 }, (_, index) => ({
  key: `${index}`,
  name: `设备${index + 1}`,
  type: index % 2 === 0 ? "类型A" : "类型B",
  status: index % 3 === 0 ? "在线" : "离线",
}));
interface Device {
  key: string;
  name: string;
  type: string;
  status: string;
}

const DeviceCopyList: React.FC = () => {
  const [data, setData] = useState<Device[]>([]);
  const [loading, setLoading] = useState(false);
  const [searchText, setSearchText] = useState<string>("");
  const [pagination, setPagination] = useState({
    pageNum: 1,
    pageSize: 10,
    total: 0,
  });

  const columns = [
    {
      title: "设备名称",
      dataIndex: "name",
      key: "name",
    },
    {
      title: "设备类型",
      dataIndex: "type",
      key: "type",
    },
    {
      title: "设备状态",
      dataIndex: "status",
      key: "status",
    },
  ];

  const fetchData = async (
    pageNum: number,
    pageSize: number,
    searchText: string
  ) => {
    setLoading(true);
    // 模拟网络请求
    return new Promise<{ data: Device[]; total: number }>((resolve) => {
      setTimeout(() => {
        const startIndex = (pageNum - 1) * pageSize;
        const endIndex = startIndex + pageSize;

        const filteredData = searchText
          ? mockData.filter((device) => device.name.includes(searchText))
          : mockData;
        const paginatedData = filteredData.slice(startIndex, endIndex);
        resolve({ data: paginatedData, total: filteredData.length });
      }, 1000);
    });
  };

  const handleSearch = (value: string) => {
    setSearchText(value);
    fetchData(1, pagination.pageSize, value).then((data) => {
      setData(data.data);
      setLoading(false);
      setPagination({ ...pagination, pageNum: 1, total: data.total });
    });
  };

  useEffect(() => {
    fetchData(pagination.pageNum, pagination.pageSize, searchText).then(
      (data) => {
        setData(data.data);
        setLoading(false);
        setPagination({ ...pagination, total: data.total });
      }
    );
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pagination.pageNum, pagination.pageSize, searchText]);

  return (
    <div>
      <Input
        placeholder="输入设备名称搜索"
        value={searchText}
        onChange={(e) => handleSearch(e.target.value)}
        style={{ width: 200, marginBottom: 16 }}
      />
      <Button
        onClick={() => {
          handleSearch("");
          setPagination({ ...pagination, pageNum: 1 });
        }}
        style={{ marginLeft: 8 }}
      >
        重置
      </Button>
      <Table
        dataSource={data}
        columns={columns}
        loading={loading}
        pagination={{
          ...pagination,
          total: pagination.total,
          onChange: (page, pageSize) => {
            setPagination({ ...pagination, pageNum: page, pageSize });
          },
        }}
      />
    </div>
  );
};

export default DeviceCopyList;

简单提一下数据更新的逻辑,用一个useEffect依赖筛选条件(searchText)、表格当前页码(pageNum)、表格每页总数(pageSize)这三个变量,如果某一个发生变化,则重新请求数据。

  useEffect(() => {
    fetchData(pagination.pageNum, pagination.pageSize, searchText).then(
      (data) => {
        setData(data.data);
        setLoading(false);
        setPagination({ ...pagination, total: data.total });
      }
    );
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pagination.pageNum, pagination.pageSize, searchText]);

痛苦之处01

👇然后回到我们在本章节的开头部分聊的内容👇:

因为使用第二种方式,将<Table/>组件的pagination受控,某些写法下,会在onChange函数里分页更新时和异步行为获取到数据后,多写一遍展开运算符

接下来我就对着代码,来说一说上方内容提到的两处多写一遍展开运算符的地方。

先来看看onChange函数里的逻辑:

        pagination={{
          ...pagination,
          total: pagination.total,
          onChange: (page, pageSize) => {
            setPagination({ ...pagination, pageNum: page, pageSize });
          },
        }}

从上方的代码中可以看到,我们为了成功地更新pagination的值,哪怕只有pageNumpageSize发生改变total并没有改变时,我们也要为了通过类型校验,将total这个未改变的值,也得传入。否则就会报错

image.png

于是这里使用了ES6+的...展开运算符,将pagination原始值拷贝了一份传入,然后用pageNumpageSize的新值更新。

image.png

这时候我不知道你是否会和我有一种感同身受,那就是可不可以我在用state声明一个对象类型的值的时候(不仅仅只是限于表格的pagination这种情况),当这个值需要更新部分数据,我能够只传入那些发生了改变的字段setState函数呢?

比如在这个代码示例下,我期望我这么写就可以了,我不想多敲哪怕一个字符:

          onChange: (page, pageSize) => {
            setPagination({ pageNum: page, pageSize });
          },

痛苦之处02

然后再让我们看一下

异步行为获取到数据后,多写一遍展开运算符

的这种情况。

  useEffect(() => {
    fetchData(pagination.pageNum, pagination.pageSize, searchText).then(
      (data) => {
        setData(data.data);
        setLoading(false);
        setPagination({ ...pagination, total: data.total });
      }
    );
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pagination.pageNum, pagination.pageSize, searchText]);

可以看到,刚好和第一种情况相反,这里就是pagination中只有total发生了变化,pageNumpageSize其实是没必要重新set的。但是同样的原因,我们就得多写一遍展开运算符

我们也期望能够在这种情况下,可以这么写代码:

  useEffect(() => {
    fetchData(pagination.pageNum, pagination.pageSize, searchText).then(
      (data) => {
        setData(data.data);
        setLoading(false);
        setPagination({ total: data.total });
      }
    );
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pagination.pageNum, pagination.pageSize, searchText]);

那针对我们的这种需求,ahooks就提供了useSetState这个hook,专门处理对象类型的状态管理。

useSetState

管理 object 类型 state 的 Hooks,用法与 class 组件的 this.setState 基本一致。

自动合并对象

此时,我们使用useSetState替换掉useState,然后看看代码里的效果:

(我们将Total展示在表格上方,以确保替换后真实的效果是可见的)

👇代码改动如下👇:

(完整的源码放在了文章的最后部分,这里仅展示改动之处)

  • 引入useSetState,替换掉useState

    import { useSetState } from "ahooks";
    
      const [pagination, setPagination] = useSetState({
        pageNum: 1,
        pageSize: 10,
        total: 0,
      });
    
  • 我们给最初版的搜索框做了防抖操作:

            onChange={debounce((e) => {
              setSearchText(e.target.value);
              setPagination({ pageNum: 1 });
            }, 1000)}
    
  • 优化了两处使用setPagination的地方:

    // useEffect
      useEffect(() => {
        fetchData(pagination.pageNum, pagination.pageSize, searchText).then(
          (data) => {
            setData(data.data);
            setLoading(false);
            setPagination({ total: data.total });
          }
        );
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [pagination.pageNum, pagination.pageSize, searchText]);
    
    // Table
            pagination={{
              pageSize: pagination.pageSize,
              current: pagination.pageNum,
              total: pagination.total,
              onChange: (page, pageSize) => {
                setPagination({ pageNum: page, pageSize });
              },
            }}
    

👇改完代码后,让我们一起来看看实际运行后的效果吧👇:

ahook表格plus.gif

可以看到、页码、total等,都如预期一般变化,并且我们还省了一些力气。

当然,我这里给的demo比较简单,健壮性也不是那么强,但我想表达的意思就是,useSetState能够让我们在对对象类型(Object)的数据进行状态管理时,更加地省心。

有部分字段更新时,只要传入更新的那部分即可,它会完成自动合并对象的操作。

使用回调更新

除此之外呢,它还提供了另一个能力,那就是使用回调更新,这里我直接贴一个官方文档的例子,大家了解一下即可:

(通过回调进行更新,可以获取上一次的状态,并且也会自动合并返回的对象。)

import React from 'react';
import { useSetState } from 'ahooks';

interface State {
  hello: string;
  count: number;
}

export default () => {
  const [state, setState] = useSetState<State>({
    hello: 'world',
    count: 0,
  });

  return (
    <div>
      <pre>{JSON.stringify(state, null, 2)}</pre>
      <p>
        <button type="button" onClick={() => setState((prev) => ({ count: prev.count + 1 }))}>
          count + 1
        </button>
      </p>
    </div>
  );
};

👇运行后的效果图如下👇:

ahook第二种用途.gif

源码分析

去掉import语句和export语句和空行,代码行数只有10+,大家大可放心,好理解的!

所以我直接将源码贴出来了,然后我们再逐行分析。

import { useCallback, useState } from 'react';
import { isFunction } from '../utils';

export type SetState<S extends Record<string, any>> = <K extends keyof S>(
  state: Pick<S, K> | null | ((prevState: Readonly<S>) => Pick<S, K> | S | null),
) => void;

const useSetState = <S extends Record<string, any>>(
  initialState: S | (() => S),
): [S, SetState<S>] => {
  const [state, setState] = useState<S>(initialState);

  const setMergeState = useCallback((patch) => {
    setState((prevState) => {
      const newState = isFunction(patch) ? patch(prevState) : patch;
      return newState ? { ...prevState, ...newState } : prevState;
    });
  }, []);

  return [state, setMergeState];
};

export default useSetState;

先从类型定义type SetState开始看,这里使用了TypeScript中的泛型、使用到了keyof运算符、使用到了联合类型Pick语法、extends从句等等。

这正好是一次我们回顾复习ts的机会,我们先把上述的这些名词搞清楚,我们才能更好地理解源码。

TypeScript复习
泛型

软件工程的一个主要部分是构建组件,这些组件不仅具有定义明确且一致的 API,而且还可以重用。能够处理今天和明天的数据的组件将为您提供构建大型软件系统的最灵活的能力。

在 C# 和 Java 等语言中,工具箱中用于创建可重用组件的主要工具之一是泛型,也就是说,能够创建一个可以在多种类型而不是单一类型上工作的组件。这允许用户使用这些组件并使用他们自己的类型。

—— TypeScript 官方文档

泛型的全部内容有很多,我们这里挑一部分讲一下,不做过多的扩展~

export type SetState<S extends Record<string, any>> = <K extends keyof S>(
  state: Pick<S, K> | null | ((prevState: Readonly<S>) => Pick<S, K> | S | null),
) => void;

这一大串代码直接看,说实话,看的人头大😵。

要一口吃成大胖子,显然不现实,这不是靠记忆就能应付的事情。

我们不妨试着从最基础的版本,一点一点往上丰富类型,在丰富的过程中,循序渐进地理解知识点。

说干就干,我们先写一个最最最基础的版本,type SetState,仅仅用来声明一个函数变量的类型。

举个例子,假设现在有个变量testFun,它主要的作用是接收1个数字,并返回这个数字+1后的值,那么我们要如何定义这个testFuntype呢?

很简单吧,我们可以这么写:

type TestFun = (param:number) => number

const testFun: TestFun = (param) => param + 1;

同样的,type SetStateuseSetState这个hook返回值中setMergeState的类型定义。因此我们先不考虑复杂的东西,可以写一个基础版的类型定义:

type SetState = () => void;

const setMergeState: SetState = () => {};

setMergeState调用完之后没有返回值,自然也就没有返回值的类型,因此我们这里用void

接下来就同给type TestFun指定函数的入参类型一样((param:number)),我们也要给type SetState指定入参类型。

我们回想一下useSetState的作用,是专门用于Object类型的数据的状态管理的。这就意味着,type SetState的入参类型得是个对象。

pagination为例子的话,我们可以这样修改一下代码

type SetState = (state: {
  pageNum: number;
  pageSize: number;
  total: number;
}) => void;

这样我们给setMergeState传入pagination时,就不会报错了:

  const setMergeState: SetState = (state) => {
    console.log(state);
  };

  setMergeState({
    pageNum: 1,
    pageSize: 10,
    total: 0,
  });

image.png

好,到这一步,我们完成了针对某种固定对象类型的类型声明,但是从useSetState的真实作用来看,它需要支持通用的对象类型作为入参state的类型,而不仅仅是固定写死的,传入的参数很有可能有十几个字段。

那么这时候该怎么办呢?我们就可以用泛型来帮助我们了,我们来修改一下代码:

type SetState<S> = (state: S) => void;

我们可以由此创建几个处理不同类型的函数实例:

  
  // 处理字符串类型的状态
  const setStringState: SetState<string> = (state) => {
    console.log(`Current string state: ${state}`);
  };
  
  setStringState('Hello');

  // 处理数字类型的状态
  const setNumberState: SetState<number> = (state) => {
    console.log(`Current number state: ${state}`);
  };

  setNumberState(42);

  // 处理对象类型的状态
  interface Person {
    name: string;
    age: number;
  }

  const setPersonState: SetState<Person> = (state) => {
    console.log(`Current person state: Name: ${state.name}, Age:${state.age}`);
  };

  const person: Person = { name: "Bob", age: 25 };
  setPersonState(person);

  // 处理数组类型的状态
  type Todo = {
    id: number;
    text: string;
  };

  const setTodosState: SetState<Todo[]> = (state) => {
    console.log(`Current todos state:`, state);
  };

  const todos: Todo[] = [
    { id: 1, text: "Learn TypeScript" },
    { id: 2, text: "Write examples" },
  ];
  setTodosState(todos);

有上方的这几个函数实例我们可以看到,这时setXYZState就可以处理各种各样的入参了, type SetState<S> = (state: S) => void;S代表 state 的 type ,它允许我们捕获用户提供的类型(例如 numberstringPersonTodo)。

针对这些类型,创建函数实例的时候,要把具体的类型以<>包裹的形式传入,用法如下:

  • SetState<number>
  • SetState<string>
  • SetState<Person>
  • SetState<Todo>

这样写之后,代表捕获了具体的类型,以便我们以后可以使用该信息。

当我们把type SetState更新成这个版本之后,虽然通用性是扩展了,但是从我刚刚列的代码例子来看,却丧失了对对象类型(Object)的约束。现在这个版本还可以传入numberstring,这并不是我们期望的。

我们该如何去实现类型约束呢?

答:使用extends从句。

extends

当我们期望设置的泛型能被约束为某种类型时,我们就可以使用extends从句。

我们来看一下简单的代码例子:

type SetState<S extends string> = (state: S) => void;

在这个例子中我们让S被约束为string类型,这样,如果我们传入非string类型的入参,则会报错:

image.png

从上图可以看到,原先举个那几个例子中,非string类型的入参均报错了。

那么接下来,就是使用extends从句去将S约束为对象类型了。

TypeScript中,我们使用Record<Keys, Type>去构造一个对象类型,其属性键为 Keys,其属性值为 Type

在这里我们无需关心键名的类型,统一定义为string即可,而对于键值的类型,则设置为any,如下:

Record<string, any>

利用它,我们再来修改一下type SetState的代码:

type SetState<S extends Record<string, any>> = (state: S) => void;

这下,我们就成功将S约束为对象类型了,我们再看一下 VsCode 中那几个例子的报错情况:

image.png

可以看到,现在报错的就是原始值类型了,而PersonTodo这些对象类型都没问题。

🎇真不错,到这里为止,我们已经让type SetState初具雏形。上面提到的内容也比较多,可能对于一些水平不错的老哥来说,有很多内容不值得去重复。

但是我就是想写一份保姆级的教程,哈哈。

看到这里不容易,大家可以小憩一下,回顾一下目前为止的知识点。然后我们就继续改造~

image.png

image.png

OK,休息结束,我们继续。

根据我们在【代码示例】章节提到的内容,我们期望只给setState方法传入那些发生数据变化的字段。

我们在由useState提供的setState函数里尝试这么做了,但是不行,ts会报错,提示我们一定要传入类型声明里的全部字段。

👇还记得下面这张图吗?👇

image.png

但是我们使用useSetState提供的setState函数时,就没有这个问题。

image.png

这意味着对于type SetState来说,由它声明的函数所接收的state参数,可以是S部分字段

要实现对部分字段的选取,我们就需要用到Pick语法了

Pick

TypeScript中,我们使用Pick<Type, Keys>, 通过从 Type 中选取一组属性 Keys(字符串字面或字符串字面的并集)来构造一个类型。

同样的,我们先看一个简单的代码示例,了解一下基本使用:

type UserDetail = {
  name: string;
  age: number;
  gender: "male" | "female";
  school: string;
};

type UserBriefDetail = Pick<UserDetail, "name" | "age">;

当我们将鼠标悬浮在UserBriefDetail上时,可以看到这样的悬浮窗:

image.png

我们通过Pick,将UserDetail的两个属性选了出来,用于构造UserBriefDetail这个类型。

通过上面这个代码例子,Pick的用法一目了然。接下来我们就用它来改造type SetState

还是先以 pagination 作为例子,假设我期望只传入一个total字段,ts不报错,我们可以这么写:

type SetState<S extends Record<string, any>> = (
  state: Pick<S, "total">
) => void;

type Pagination = {
  pageNum: number;
  pageSize: number;
  total: number;
};


  const setPaginationState: SetState<Pagination> = (state) => {
    console.log(`Current Pagination state: ${state}`);
  };
  
setPaginationState({total:1})

image.png

从上方的代码和图片可以看到,ts不会报错

接着,我们就要继续改造了,因为我们明白,不可能手动去给 state 指定需要 Pick 的属性。这同样也是一件不现实的事情。

事实上,我们期望的是state能够根据真实的入参来 Pick 属性,而入参是会千变万化的,可不仅仅是type Pagination这一种类型。

说到这里,不知道你是否有一种似曾相识的感觉?

没错,我们在最开始介绍泛型的时候,也是这么一套说法。

那么接下来要怎么做,是不是心里已经有数了?

不要忘了最开始的S,我们给state所新构建的类型,它的属性应该来自于S

如何获取某一个类型的Keys

我们可以使用 keyof 运算符。

keyof 运算符采用对象类型并生成其键的字符串或数字字面联合。

type UserDetail = {
  name: string;
  age: number;
  gender: "male" | "female";
  school: string;
};

type UserDetailKeys = keyof UserDetail

在上面的代码中,keyof UserDetail和下方代码是等价的:

type UserDetailKeys = 'name' | 'age' | 'gender' | 'school'

这显然就是 Pick语法中,Keys参数所期望的样子。

于是我们又可以修改一下我们的代码,让type SetState变成这样:

type SetState<S extends Record<string, any>> = (
  state: Pick<S, keyof S>
) => void;

这不对吧,keyof 会返回对象类型的全部键名,上方代码的这种写法 state: Pick<S, keyof S>,和state: S是完全等价的,这不是无用功吗?

这和我们的预期结果不一致,我们期望的是state利用S部分或全部的键名,重新构建一个类型。

我们整理一下目前的信息,总结一下目标:

  • 我们期望state是一个新的类型,它不是S,我们之所以用它,就是为了摆脱每次调用时都要传入对象类型的全部属性。
  • state 在接收参数时,这个参数是不固定的,它可能包含了完整的属性,也可能不完整。
  • state在接收参数时,虽然这个参数包含的属性可能是不完整的,但是属性一定来自于S

很好,以上三点总结完毕后,我们会发现它们的要求其实就是我们一步一步走下来时遇到过的要求。提取一下关键字:

  • 新的类型,需要用Pick构建
  • 不固定,是个泛型
  • 约束,必须来自S

假设有某个类型K,它要遵循约束的规则,则我们可以这么写K extends keyof S,这样,我们就确保K的属性一定是来自于S的。

然后我们将抽象的想法转换为实际的代码试试:

  const logUserInfo = <K extends keyof UserDetail>(info: K) => {
    console.log(`Current User info: ${info}`);
  };

  logUserInfo('name');

可以看到,keyof 返回的只是键名的字符字面量的联合类型,就和我们之前声明gender: 'male' | 'female'一样,这不是对象类型

于是我们在结合Pick语法一起使用,改造一下代码:

  const logUserInfo = <K extends keyof UserDetail>(
    info: Pick<UserDetail, K>
  ) => {
    console.log(`Current User info: ${info}`);
  };

  logUserInfo({ name: "123" });

上面代码的效果等同于下方代码:

  const logUserInfo = (
    info: Pick<UserDetail, 'name'>
  ) => {
    console.log(`Current User info: ${info}`);
  };

  logUserInfo({ name: "123" });

通过这个例子,我们终于掌握了该如何给state传入包含部分属性,并且满足约束条件的参数了。将上方的努力同步到type SetState的代码中:

type SetState<S extends Record<string, any>> = <K extends keyof S>(
  state: Pick<S, K>
) => void;


  const setPaginationState: SetState<Pagination> = (state) => {
    console.log(`Current Pagination state: ${state}`);
  };
  setPaginationState({ total: 1, pageNum: 1 });

  setPaginationState({ gender: "male" });

image.png

从上图可以看到,当我们指定SPagination后,传入只包含部分属性的对象,ts不会报错。传入包含Pagination属性之外的对象,ts会报错。

到这里为止,我们又向源码靠近了一大步。我在下方放个对比:

  • 我们写的:

        type SetState<S extends Record<string, any>> = <K extends keyof S>(
          state: Pick<S, K>
        ) => void;
    
  • 源码:

    export type SetState<S extends Record<string, any>> = <K extends keyof S>(
      state: Pick<S, K> | null | ((prevState: Readonly<S>) => Pick<S, K> | S | null),
    ) => void;
    

可以看到,差别只在于state:的类型定义上了,源码中还有((prevState: Readonly<S>) => Pick<S, K>,这个是为了实现之前我们提到过的使用回调更新的功能,比如:

setState((prev) => ({ count: prev.count + 1 }))

具体的推理即实现过程,和我们刚刚的过程,大同小异,我便不再赘述。

我们将缺少的类型补充到我们自己的type SetState上,得到目标代码:

export type SetState<S extends Record<string, any>> = <K extends keyof S>(
    state: Pick<S, K> | null | ((prevState: Readonly<S>) => Pick<S, K> | S | null),
 ) => void;

这可真是不容易啊!!!

回顾全过程,我们从0到1,一步一步拆解分析,成功实现了和useSetState源码中一模一样的类型定义。

a22f9a747dea6d34003b5984f248738.png

我想经此一役,大家对于TypeScript的泛型和一些运算符、语法的使用,能够更上一个台阶了~

接下来我们继续往下看源码中逻辑的部分。

内部逻辑
const useSetState = <S extends Record<string, any>>( initialState: S | (() => S), ): [S, SetState<S>]

关于上方的这个类型定义,就当作练习题,大家可以自己动手试试分析一遍,加强自己的掌握。处于篇幅考虑,我就不重复了。

  const [state, setState] = useState<S>(initialState);

useSetState内部,使用React.useState创建一个状态state和更新该状态的函数setState。初始状态由initialState参数确定。

没啥好分析的,过。

接下来就是源码中最核心的部分了。

  const setMergeState = useCallback((patch) => {
    setState((prevState) => {
      const newState = isFunction(patch) ? patch(prevState) : patch;
      return newState ? { ...prevState, ...newState } : prevState;
    });
  }, []);

使用useCallback创建一个记忆化的函数setMergeState,它接收一个参数patch。这个函数会根据patch的类型来更新状态:

  • 如果patch是一个函数,那么它会调用这个函数并传入当前状态prevState,然后用返回的新状态来更新;
    • 这实现了使用回调更新这一功能。
  • 如果patch是一个对象,它会与当前状态合并;
    • 这实现了自动合并对象这一功能。
  • 如果patchnull,它会保留当前状态。

useCallback的依赖项数组为空,这意味着setMergeState只会在组件挂载时创建一次。这一点就模拟了原生useState中返回的setState

在 React 中,useState 返回的 setState 函数是稳定的,它不会在组件的每次渲染时重新创建。

至此,我们完成了对于useSetState全部源码的分析。

完结撒花!!(不是)

结语

在本篇文章里,我们结合一个业务场景示例,指出了使用普通的useStateObject类型数据进行状态管理时的痛点,并由此介绍了ahooks中的useSetState

通过实际的使用,我们明白了自动合并对象这一功能有多方便(尤其是针对那种包含多个属性的对象,比如十几条属性,却只有一两条属性发生改变的场景),最后通过源码的阅读,理解了它是如何生效的。

我们在日常开发中遇到的痛点当然不仅仅是这一个,还比如:频繁设置的弹窗显隐到处都有的状态切换等等。

光是列举文本,就可以想象到当初写的那些代码了,令人头疼🤦‍。

针对这些痛点ahooks都有推出相应的hook,这些hook就在以后的文章中和大家一起学习吧。

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

image.png

改版后的完整代码

import React, { useState, useEffect } from "react";
import { Table, Input, Button, Typography, Space } from "antd";
import { useSetState } from "ahooks";
import { debounce } from "lodash";
const mockData = Array.from({ length: 100 }, (_, index) => ({
  key: `${index}`,
  name: `设备${index + 1}`,
  type: index % 2 === 0 ? "类型A" : "类型B",
  status: index % 3 === 0 ? "在线" : "离线",
}));
interface Device {
  key: string;
  name: string;
  type: string;
  status: string;
}

const DeviceCopyList: React.FC = () => {
  const [data, setData] = useState<Device[]>([]);
  const [loading, setLoading] = useState(false);
  const [searchText, setSearchText] = useState<string>("");
  const [pagination, setPagination] = useSetState({
    pageNum: 1,
    pageSize: 10,
    total: 0,
  });

  const columns = [
    {
      title: "设备名称",
      dataIndex: "name",
      key: "name",
    },
    {
      title: "设备类型",
      dataIndex: "type",
      key: "type",
    },
    {
      title: "设备状态",
      dataIndex: "status",
      key: "status",
    },
  ];

  const fetchData = async (
    pageNum: number,
    pageSize: number,
    searchText: string
  ) => {
    setLoading(true);
    // 模拟网络请求
    return new Promise<{ data: Device[]; total: number }>((resolve) => {
      setTimeout(() => {
        const startIndex = (pageNum - 1) * pageSize;
        const endIndex = startIndex + pageSize;

        const filteredData = searchText
          ? mockData.filter((device) => device.name.includes(searchText))
          : mockData;
        const paginatedData = filteredData.slice(startIndex, endIndex);
        resolve({ data: paginatedData, total: filteredData.length });
      }, 1000);
    });
  };

  useEffect(() => {
    fetchData(pagination.pageNum, pagination.pageSize, searchText).then(
      (data) => {
        setData(data.data);
        setLoading(false);
        setPagination({ total: data.total });
      }
    );
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pagination.pageNum, pagination.pageSize, searchText]);

  return (
    <div>
      <Input
        placeholder="输入设备名称搜索"
        onChange={debounce((e) => {
          setSearchText(e.target.value);
          setPagination({ pageNum: 1 });
        }, 1000)}
        style={{ width: 200, marginBottom: 16 }}
      />
      <Space>
        <Button
          style={{ marginLeft: 8 }}
          onClick={() => {
            setPagination({ pageNum: 1 });
          }}
        >
          重置
        </Button>
        <Typography.Text>Total: {pagination.total}</Typography.Text>
      </Space>

      <Table
        dataSource={data}
        columns={columns}
        loading={loading}
        pagination={{
          pageSize: pagination.pageSize,
          current: pagination.pageNum,
          total: pagination.total,
          onChange: (page, pageSize) => {
            setPagination({ pageNum: page, pageSize });
          },
        }}
      />
    </div>
  );
};

export default DeviceCopyList;