类型体操的实践与总结: 从useInfiniteScroll 到 InfiniteList

最近用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
相关推荐
谢小飞3 分钟前
Echarts高级柱状图开发:渐变与3D效果实现
前端·echarts
FogLetter6 分钟前
Vite vs Webpack:前端构建工具的双雄对决
前端·面试·vite
tianchang8 分钟前
JS 排序神器 sort 的正确打开方式
前端·javascript·算法
怪可爱的地球人11 分钟前
ts的类型兼容性
前端
方圆fy18 分钟前
探秘Object.prototype.toString(): 揭开 JavaScript 深层数据类型的神秘面纱
前端
FliPPeDround21 分钟前
🚀 定义即路由:definePage宏如何让uni-app路由配置原地起飞?
前端·vue.js·uni-app
怪可爱的地球人22 分钟前
ts的类型推论
前端
林太白28 分钟前
动态角色权限和动态权限到底API是怎么做的你懂了吗
前端·后端·node.js
每一天,每一步33 分钟前
React页面使用ant design Spin加载遮罩指示符自定义成进度条的形式
前端·react.js·前端框架
moyu8444 分钟前
Pinia 状态管理:现代 Vue 应用的优雅解决方案
前端