跳到主要内容

从自定义 Hooks 学习 TypeScript

· 阅读需 10 分钟

React 16.8 后的 hooks api 出现以后,大大解决了代码的简介性,然而除了官方提供的几个 hooks api 之外,我们还可以自定义自己的 hooks api,以实现业务代码的简化和复用。

我们可以通过下列的例子,真正进入 hooks 的世界。

本案例各依赖包版本

{
"react": "17.0.2",
"react-dom": "17.0.2",
"antd": "4.16.0",
"typescript": "4.0.3"
}

doing

对于增删改查的业务在后台管系统中可以说是非常的常见了,我们先用常规 hooks 配合 antd 的 table 组件去实践一个基本的查询功能。

api

写业务模块最优先考虑的应该是 api 接口的设计

模型定义

定义列的数据模型

interface Book {
id: number;
bookName: string;
author: string;
}

type BookList = Book[];

定义请求和返回的模型

interface GetBookListParams {
pageNum: number;
pageSize: number;
}

interface PaginationData<T> {
pageNum: number;
pageSize: number;
total: number;
data: T[];
}

interface ResponseData<T> {
success: boolean;
message?: string;
result: T;
}

type GetBookListResult<T> = ResponseData<PaginationData<T>>;
信息

请求和返回的模型应该根据实际的接口设置

伪造请求

创建一个虚拟的数据库

const sourceBookList: BookList = new Array(100).fill(null).map((item, index) => ({
bookName: `book ${index}`,
author: `author ${index}`,
id: index
}));

添加带分页功能的 api

const apiGetBookList = (params?: GetBookListParams) => {
const defaultParams: Partial<GetBookListParams> = {
pageNum: 1,
pageSize: 10
};
const { pageNum, pageSize } = Object.assign(defaultParams, params);

return new Promise<GetBookListResult<Book>>(resolve => {
const startIndex = (pageNum - 1) * pageSize;
const endIndex = pageNum * pageSize;
const list: BookList = sourceBookList.slice(startIndex, endIndex);
const result: GetBookListResult<Book> = {
success: true,
result: {
pageNum,
pageSize,
total: sourceBookList.length,
data: list
}
};
// 为了更加的接近真实,每次调用时设定 100 毫秒的延迟
setTimeout(resolve, 100, result);
});
};

编写组件

定义组件基本状态和信息

import { FC, useCallback, useEffect, useState } from 'react';
import Table, { ColumnProps } from 'antd/lib/table';

const column: ColumnProps<Book>[] = [
{
dataIndex: 'bookName',
title: '书名',
width: 200
},
{
dataIndex: 'author',
title: '作者',
width: 100
}
];

const BookListComponent: FC = () => {
const [pageSize, setPageSize] = useState(10);
const [pageNum, setPageNum] = useState(1);
const [total, setTotal] = useState(0);
const [bookList, setBookList] = useState<Book[]>([]);
const [loading, setLoading] = useState(false);

return (
<Table
style={{ width: 500 }}
size="small"
rowKey="id"
bordered
columns={column}
dataSource={bookList}
loading={loading}
pagination={{
current: pageNum,
pageSize: pageSize,
total,
onChange,
showTotal: t => `共(${t})条`,
showQuickJumper: true
}}
/>
);
};

处理请求数据

const getData = async () => {
setLoading(true);
const res = await apiGetBookList({
pageNum,
pageSize
});

setLoading(false);

if (res.success) {
setBookList(res.result.data);
setTotal(res.result.total);
}
};

// 页面初始化时触发一次请求
useEffect(() => {
getData();
}, []);

处理 table 交互

// 处理操作 table 的分页
const onChange = (currentPage: number, currentSize: number) => {
// 切换每页条数时把页码重置到第一页,否则会有难以启齿的 bug ~
const newPageNum = pageSize !== currentSize ? 1 : currentPage;
setPageNum(newPageNum);
setPageSize(currentSize);
};

// 现在如果 pageSize, pageNum 改变时,也将调用 getData
useEffect(() => {
getData();
}, [pageSize, pageNum]);

return (
<Table
style={{ width: 500 }}
size="small"
rowKey="id"
bordered
columns={column}
dataSource={bookList}
pagination={{
current: pageNum,
pageSize: pageSize,
total,
onChange,
showTotal: t => `共(${t})条`,
showQuickJumper: true
}}
loading={loading}
/>
);

到此一个基本的简单查询功能已经完成,对比过去的 class 组件来说,已经很简洁了。虽然组件顶部的 useState 用的过于多了些,但是其实也可以完全改造成对象的形式,这样只需要一个 useState 即可。

然而这种组件在后台管理页面的重复率非常高,如果每个页面都有这样的数据,唯一不同的只是接口和表格的列表字段不同,那我们是不是可以利用自定义 hooks 进行数据抽离和复用呢?

自定义 hooks

暂定 hooks 名字为 usePagination,我们只把分页相关的变量和方法进行抽离,最终发现该组件除了 jsx 代码全都都能复用

构思

上述说到不同的地方只有 api 接口 和 列组件(table),但我们决定 jsx 不在我们的封装范围内,第一 antd table 自带的配置很多,封装起来不利于用户的扩展,第二如果真的有大面积 jsx 可复用则优先考虑的应该是封装成普通组件,数据封装优先考虑 hooks。

开始

定义接收参数

interface UsePaginationOptions {
apiMethod: (...arg: any) => Promise<ResponseData<PaginationData<any>>>;
apiParams?: any;
}

编写 hooks 复用代码

把能复用的代码完全拷贝过来,唯一注意的是,getData 方法的 http 请求中,我们需要使用外部传递进来的函数

export function usePagination(options: UsePaginationOptions) {
const { apiMethod, apiParams } = options;
const [pageSize, setPageSize] = useState(10);
const [pageNum, setPageNum] = useState(1);
const [total, setTotal] = useState(0);
const [tableData, setTableData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);

const getData = async () => {
setLoading(true);
const defaultParams = {
pageNum,
pageSize
};
// 合并默认参数
if (typeof apiParams === 'object') {
Object.assign(defaultParams, apiParams);
}
// api 来自于外部
const res = await apiMethod(defaultParams);

setLoading(false);
if (res.success) {
setTableData(res.result.data);
setTotal(res.result.total);
}
};

const onTableChange = (currentPage: number, currentSize = pageSize) => {
const newPageNum = pageSize !== currentSize ? 1 : currentPage;
setPageNum(newPageNum);
setPageSize(currentSize);
};

useEffect(() => {
getData();
}, [pageSize, pageNum]);

// 返回外部需要的结果,
// 为了数据的雅观和方便外部使用,pagination 组合成了 antd table 所需的 pagination 结构
return {
loading,
tableData,
setTableData,
pagination: {
current: pageNum,
pageSize: pageSize,
total,
onChange: onTableChange,
showSizeChanger: true,
showTotal: (d: number) => `${d}`,
showQuickJumper: true
}
};
}

封装完成

外部调用

// ...
const BookListComponent: FC = () => {
const { pagination, tableData, loading } = usePagination({
apiMethod: apiGetBookList
});

return (
<Table
style={{ width: 500 }}
size="small"
rowKey="id"
bordered
columns={column}
dataSource={tableData}
pagination={pagination}
loading={loading}
/>
);
};

清爽多了,业务逻辑基本全部被抽离了。

就这吗?然而在组件的编写中,持续优化才是最难的地方,如何制作一个易扩展,高体验的组件?

类型优化

在这个 usePagination 的外部调用中,会发现有 2 个比较明显的类型问题:

  1. 返回的 tableData 是类型不完整的,为 any[]
  2. 通过 apiParams 属性传递接口参数缺失提示和校验,为 any 类型

先解决第一个问题,这个再简单不过了,通常情况会用泛型去处理

usePagination 接收一个泛型参数

export function usePagination<T>(options: UsePaginationOptions) {
const [tableData, setTableData] = useState<T[]>([]);
// ...

调用 usePagination 时传入泛型参数

const { pagination, tableData, loading } = usePagination<Book>({
apiMethod: apGetBookList
});

此时 tableData 的类型就是 Book[] 了,紧接着处理第二个 apiParams 类型问题:再传一个泛型吗?不用。

apiParamsapiMethod 方法的参数, 我们可以可以通过全局泛型 Parameters 取得函数参数的类型

因为我们要取得 apiMethod 的类型,所以需要将其设置为泛型参数

// 封装一下 apiMethod 的类型,方便后续
interface ApiMethod<T = any> {
(...arg: any): Promise<ResponseData<PaginationData<T>>>;
}

// 这里的 T 也可以不 extends ApiMethod,但是 Parameters 已经将参数约束,至少要需要 extends 一个类 function 的类型
interface UsePaginationOptions<T extends ApiMethod> {
// Parameters 会返回函数的 arguments 集合,所以我们、取第一个参数
apiMethod: T;
apiParams?: Parameters<T>[0];
}

同时推理到,既然函数参数可以反取,那么返回值也可以获取到,毕竟还有 ReturnType 类型呢,而我们需要的 tableData 的类型,正好也来自于 apiMethod 的返回值中。

TableDataItem 反推

type TableDataItem<T> = T extends Promise<ResponseData<PaginationData<infer R>>> ? R : unknown;

T 会接收 apiMethod 的返回类型

其中 Promise<ResponseData<PaginationData<infer R>> 是对泛型参数的解构

如何理解 infer R

在数学的一元一次方程中,2 * 1 = 2X,X 就是 1,infer 就是类似的作用,使得 R 为方程中的 X,帮助我们做类型推导出 R 的类型

信息

就像一元一次方程一样,左右两边要完全相等,在我们写解构表达式式的时候,也一定要和参数的类型一样才能正确推导,

接下来就可以把 usePagination 的泛型参数定义为承载 apiMethod 的类型。

// 在泛型形参处 extends 是为了限制入参的类型
export function usePagination<T extends ApiMethod>(options: UsePaginationOptions<T>) {
const [tableData, setTableData] = useState<TableDataItem<ReturnType<T>>[]>([]);
// ...
}

外部使用的泛型参数可以删掉了,这样我们只需要正确的传入 apiMethod,就能够正确的对 apiParamstableData 进行类型检测和提示了