最近用ahooks封装了一个无限滚动列表,里面写了一些类型体操,里面有一些细节和坑,记录一下。
思路还是用 useInfiteScroll 实现无线滚动的逻辑,增加了UI适配列表和表格,用法参考了react native的虚拟列表 (renderItem) 。
组件参数
想设计的跟 ahooks 中的 useInfiniteScroll 一致,所以入参如下:
ts
interface Props<F extends (...args: any[]) => Promise<PageData<unknown>>> {
fetchApi: F
renderHeader?: (pageData?: ScrollData<ExtractDataType<F>>) => React.ReactNode | React.JSX.Element
renderFooter?: (pageData?: ScrollData<ExtractDataType<F>>) => React.ReactNode | React.JSX.Element
emptyContent?: React.ReactNode
renderItem: (item: ExtractDataType<F>) => React.ReactNode | React.JSX.Element
renderLoading?: () => React.ReactNode
renderNoMore?: () => React.ReactNode
wrapperClassName?: string
wrapperStyle?: CSSProperties
dataKey: Extract<keyof ExtractDataType<F>, React.Key> // dataKey是Item中的字段,并且符合React.Key的类型
options?: {
target?: HTMLElement | Document | null
pageSize?: number
gridCols?: number
gridGap?: number
}
}
list字段约定
fetcherApi 就是ahooks中useInfiniteScroll的service, 用一个泛型定义,返回一个Promise ,由于ahooks这里约定必须返回带list的字段,但是我们的接口返回的结构跟这个不一致,为了区分 PageData 是接口返回的类型,需要转换成 ahooks 定义的类型 ScrollData
ts
export type PageData<D> = {
items: D[]
page: number
pageSize: number
totalCount: number
totalPage: number
}
// ahooks约定 必须有个list字段
export type ScrollData<D> = {
list: D[]
page: number
totalPage: number
totalCount: number
pageSize: number
}
这里对fetchApi进行改写,将其中的 items 改写成 list
ts
const service = (currentData?: ScrollData<ListItemType>) => {
const nextPage = currentData ? currentData.page + 1 : 1
return fetchApi({
page: nextPage,
pageSize: options?.pageSize || DEFAULT_PAGE_SIZE,
} as ExtractParams<F>).then((res) => {
return {
...res,
list: (res.items || []) as ListItemType[],
}
})
}
类型推断得到接口返回值类型
本来设计是希望使用方通过泛型传入item的类型,但是使用起来就得用组件泛型,非常丑陋:
ts
<InfiniteList<ItemType> />
所以希望能通过类型推断,直接拿到fetchApi返回items的类型, 这其实就是类型体操中用 infer推断promise返回值的那一题
首先定义 泛型 F ,fetchApi类型就是F:
ts
F extends (...args: any[]) => Promise<PageData<unknown>>
然后对这个 F 进行类型推断 得到返回值中的items的类型
ts
type ExtractDataType<T> = T extends (...args: any[]) => Promise<PageData<infer U>> ? U : never
dataKey推断
由于渲染每个item的时候,需要用户指定 key 属性,我们希望填写这个key的时候也能有类型提示,能够识别key是否是item中的字段
思路很简单,因为react的key的类型是 React.Key
我们前面又使用 ExtractDataType 得到了Item 的类型,那么使用 keyof 得到item所有的字段,看哪个字段满足React.Key 类型,最后返回一个新的联合类型:
ts
// dataKey是Item中的字段,并且符合React.Key的类型
dataKey: Extract<keyof ExtractDataType<F>, React.Key>
Extract 是 ts 中的工具函数,能从一个类型里抽取出来特定的类型,返回联合类型
ForwardRef的坑
由于想要用forwardRef给组件包裹一下,但是发现包裹后类型推断失败了
查找后发现这是箭头函数的问题
因为我习惯用箭头函数定义组件
ts
const A = ()<T> => {
return (...)
}
但是这样再用forwardRef包裹的话类型推断会有问题
ts
const List = forwardRef<HTMLUListElement, ListProps<T>>(
<T,>(props, ref) => { ... }
)
正确写法:先定义组件,再forwardRef包裹:
ts
// 不要在 forwardRef 的匿名箭头函数参数里直接写泛型,TS 无法正确推断
const InfiniteListRef = forwardRef(InfiniteList) as <F extends (...args: any[]) => Promise<PageData<unknown>>>(
props: Props<F> & { ref?: React.Ref<InfiniteListHandle<ExtractDataType<F>>> }
) => React.ReactElement
完整代码
ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { CSSProperties, forwardRef, useImperativeHandle } from 'react'
import { PageData } from '@/typings/res'
import { useInfiniteScroll } from 'ahooks'
import { Loading } from '@jd/jdesign-react'
const DEFAULT_PAGE_SIZE = 15
// 推断Item的类型,不用传入泛型参数
type ExtractDataType<T> = T extends (...args: any[]) => Promise<PageData<infer U>> ? U : never
type ExtractParams<T> = T extends (params: infer P) => Promise<any> ? P : never
interface Props<F extends (...args: any[]) => Promise<PageData<unknown>>> {
fetchApi: F
renderHeader?: (pageData?: ScrollData<ExtractDataType<F>>) => React.ReactNode | React.JSX.Element
renderFooter?: (pageData?: ScrollData<ExtractDataType<F>>) => React.ReactNode | React.JSX.Element
emptyContent?: React.ReactNode
renderItem: (item: ExtractDataType<F>) => React.ReactNode | React.JSX.Element
renderLoading?: () => React.ReactNode | React.JSX.Element
renderNoMore?: () => React.ReactNode | React.JSX.Element
wrapperClassName?: string
wrapperStyle?: CSSProperties
dataKey: Extract<keyof ExtractDataType<F>, React.Key> // dataKey是Item中的字段,并且符合React.Key的类型
options?: {
target?: HTMLElement | Document | null
pageSize?: number
gridCols?: number
gridGap?: number
}
}
// 暴露出的方法
export type InfiniteListHandle<D> = {
data?: ScrollData<D> | undefined
loading: boolean
loadingMore: boolean
error?: Error
noMore: boolean
loadMore: () => void
loadMoreAsync: () => Promise<ScrollData<D>>
reload: () => void
reloadAsync: () => Promise<ScrollData<D>>
cancel: () => void
mutate: (data?: ScrollData<D>) => void
}
// ahooks约定 必须有个list字段
export type ScrollData<D> = {
list: D[]
page: number
totalPage: number
totalCount: number
pageSize: number
}
const InfiniteList = <F extends (...args: any[]) => Promise<PageData<unknown>>>(
{
fetchApi,
renderItem,
renderLoading,
renderNoMore,
renderHeader,
renderFooter,
wrapperClassName,
wrapperStyle,
dataKey,
options,
emptyContent,
}: Props<F>,
ref: React.Ref<InfiniteListHandle<ExtractDataType<F>>>
) => {
type ListItemType = ExtractDataType<F>
const sentinelRef = React.useRef<HTMLDivElement>(null)
const listContainerRef = React.useRef<HTMLDivElement>(null)
const service = (currentData?: ScrollData<ListItemType>) => {
const nextPage = currentData ? currentData.page + 1 : 1
return fetchApi({
page: nextPage,
pageSize: options?.pageSize || DEFAULT_PAGE_SIZE,
} as ExtractParams<F>).then((res) => {
return {
...res,
list: (res.items || []) as ListItemType[],
}
})
}
const infiniteState = useInfiniteScroll<ScrollData<ListItemType>>(service, {
isNoMore: (d) => !!d && d.page >= d.totalPage,
target: () => options?.target || listContainerRef.current,
onBefore() {
console.debug('loading more data...')
},
})
const { data, loadingMore, loading, noMore } = infiniteState
const gridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: `repeat(${options?.gridCols || 1}, 1fr)`,
gap: options?.gridGap || '16px',
width: '100%',
overflowY: 'auto',
...wrapperStyle,
}
const flexStyle: CSSProperties = {
display: 'flex',
flexDirection: 'row',
gap: options?.gridGap || '16px',
width: '100%',
overflowY: 'auto',
...wrapperStyle,
}
useImperativeHandle(ref, () => ({
...infiniteState,
}))
return (
<div className="flex flex-col">
{renderHeader && renderHeader(data)}
<div
className={wrapperClassName || 'list-container'}
ref={listContainerRef}
style={options?.gridCols ? gridStyle : flexStyle}
>
{(!data || !data.list || data.list.length === 0) && !loading && emptyContent}
{data?.list?.map((item, index) => (
<div key={dataKey ? (item[dataKey] as React.Key) : index}>{renderItem(item)}</div>
))}
<div ref={sentinelRef} />
{renderLoading ? renderLoading() : <Loading spinning={loadingMore || loading} className="w-full" />}
{noMore && renderNoMore && renderNoMore()}
</div>
{data && renderFooter && renderFooter(data)}
</div>
)
}
// 不要在 forwardRef 的匿名箭头函数参数里直接写泛型,TS 无法正确推断
const InfiniteListRef = forwardRef(InfiniteList) as <F extends (...args: any[]) => Promise<PageData<unknown>>>(
props: Props<F> & { ref?: React.Ref<InfiniteListHandle<ExtractDataType<F>>> }
) => React.ReactElement
export default InfiniteListRef