前言

做过几个鸿蒙项目之后你会发现,列表组件是写得最多的东西。用户列表、订单列表、商品列表……结构都差不多,但每次都要重新写一遍。拷贝一份改改字段名?能跑,但维护起来让人头疼——改了一个地方的 bug,另外三个地方忘了改。

泛型组件就是来解决这个问题的。一套组件代码,配合 TypeScript 泛型,既能复用在不同数据类型上,又能保证类型安全。编译期就能抓到类型错误,不用等到运行时才翻车。

为什么不用 any?

A clean, minimalist Notion-style technical diagram

很多人的第一反应是用 any 或者 Object 来写通用组件。确实能跑,但你丢掉了最宝贵的东西——类型信息。

// 这样写,columns 里的 key 是什么类型?value 是什么类型?全靠猜
@Builder
GenericList(data: Object[], columns: ColumnDef[]) {
  // ...
}

用泛型就不一样了,编译器帮你盯着:

// T 是什么类型,列定义里就只能用 T 的字段
@Builder
GenericList<T>(data: T[], columns: ColumnDef<T>[]) {
  // ...
}

一旦你在列定义里写了数据模型上不存在的字段,IDE 直接标红。这在大型项目里能省掉大量调试时间。

设计 ColumnDef:列定义的类型约束

DataTable 的核心思路是「数据 + 列定义」分离。数据是你传进来的,列定义告诉组件怎么展示每一列。

先把列定义的类型约束搞定:

// 列定义接口,用泛型约束字段名必须是 T 的 key
export interface ColumnDef<T> {
  key: keyof T
  title: string
  width?: Length
  align?: HorizontalAlign
  // 自定义渲染器,可选
  render?: (value: T[keyof T], row: T, index: number) => string
  // 是否支持排序
  sortable?: boolean
}

A Notion-style database property card or schema di

这里有两个关键点。key: keyof T 保证你只能填数据模型上真实存在的字段名,写错了编译器不答应。render 函数让你对单列做自定义渲染,比如把时间戳格式化成日期字符串,或者把金额加上货币符号。

DataTable 组件实现

有了列定义,来搭 DataTable 的主体:

@Component
export struct DataTable<T extends Record<string, Object>> {
  @Prop data: T[] = []
  @Prop columns: ColumnDef<T>[] = []
  @State sortKey: string = ''
  @State sortOrder: 'asc' | 'desc' | 'none' = 'none'
  onRowClick?: (row: T, index: number) => void

  // 内部排序逻辑
  private getSortedData(): T[] {
    if (this.sortOrder === 'none' || !this.sortKey) {
      return this.data
    }
    const sorted = [...this.data]
    sorted.sort((a, b) => {
      const va = a[this.sortKey as keyof T]
      const vb = b[this.sortKey as keyof T]
      if (va < vb) return this.sortOrder === 'asc' ? -1 : 1
      if (va > vb) return this.sortOrder === 'asc' ? 1 : -1
      return 0
    })
    return sorted
  }

  // 点击表头触发排序
  private handleSort(column: ColumnDef<T>) {
    if (!column.sortable) return
    if (this.sortKey === (column.key as string)) {
      // 循环切换排序状态
      if (this.sortOrder === 'none') this.sortOrder = 'asc'
      else if (this.sortOrder === 'asc') this.sortOrder = 'desc'
      else this.sortOrder = 'none'
    } else {
      this.sortKey = column.key as string
      this.sortOrder = 'asc'
    }
  }

  build() {
    Column() {
      // 表头行
      Row() {
        ForEach(this.columns, (col: ColumnDef<T>) => {
          Text(col.title)
            .fontSize(14)
            .fontWeight(FontWeight.Medium)
            .fontColor('#333')
            .width(col.width ?? 'auto')
            .textAlign(col.align ?? HorizontalAlign.Start)
            .onClick(() => this.handleSort(col))
        }, (col: ColumnDef<T>) => col.key as string)
      }
      .width('100%')
      .padding({ top: 12, bottom: 12, left: 16, right: 16 })
      .backgroundColor('#F5F5F5')

      // 数据行
      ForEach(this.getSortedData(), (row: T, index: number) => {
        Row() {
          ForEach(this.columns, (col: ColumnDef<T>) => {
            Text(col.render
              ? col.render(row[col.key], row, index)
              : String(row[col.key] ?? '')
            )
              .fontSize(14)
              .fontColor('#666')
              .width(col.width ?? 'auto')
              .textAlign(col.align ?? HorizontalAlign.Start)
          }, (col: ColumnDef<T>) => col.key as string)
        }
        .width('100%')
        .padding({ top: 10, bottom: 10, left: 16, right: 16 })
        .onClick(() => this.onRowClick?.(row, index))
      }, (_: T, index: number) => index.toString())
    }
    .width('100%')
  }
}

A step-by-step logic flowchart in a Notion-style a

这个组件有几个设计要点。排序逻辑封装在组件内部,不污染外部代码。列的渲染通过 render 回调可定制,不定制就用默认的字符串转换。点击行通过回调抛出,外部自己处理跳转或详情弹窗。

实际使用:用户列表

定义一个用户数据模型,配好列定义就能用了:

interface User {
  id: number
  name: string
  email: string
  role: string
  createdAt: number
}

@Component
struct UserListPage {
  @State users: User[] = [
    { id: 1, name: '张三', email: 'zhang@test.com', role: '管理员', createdAt: 1718000000 },
    { id: 2, name: '李四', email: 'li@test.com', role: '编辑', createdAt: 1718100000 },
  ]

  private columns: ColumnDef<User>[] = [
    { key: 'name', title: '姓名', width: '20%', sortable: true },
    { key: 'email', title: '邮箱', width: '30%' },
    { key: 'role', title: '角色', width: '20%' },
    {
      key: 'createdAt',
      title: '创建时间',
      width: '30%',
      sortable: true,
      render: (value: number) => {
        const d = new Date(value * 1000)
        return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
      }
    },
  ]

  build() {
    DataTable<User>({
      data: this.users,
      columns: this.columns,
      onRowClick: (user) => {
        console.info(`点击了用户: ${user.name}`)
      }
    })
  }
}

注意 createdAt 那列用了自定义 render,把时间戳转成了可读格式。如果哪天接口字段名变了,key: 'createdAt' 这里会直接报编译错误,逼着你同步修改。

泛型 Select 组件

同样的思路,Select 下拉组件也能泛型化。不再限制选项必须是 string,可以是任意类型:

interface SelectOption<T> {
  label: string
  value: T
  disabled?: boolean
}

@Component
export struct GenericSelect<T> {
  @Prop options: SelectOption<T>[] = []
  @Link selected: T
  @State expanded: boolean = false
  placeholder: string = '请选择'
  onChange?: (value: T) => void

  private getLabel(): string {
    const found = this.options.find(o => o.value === this.selected)
    return found ? found.label : this.placeholder
  }

  build() {
    Column() {
      Row() {
        Text(this.getLabel())
          .fontSize(16)
          .fontColor(this.selected ? '#333' : '#999')
          .layoutWeight(1)
        Image($r('app.media.ic_arrow_down'))
          .width(16).height(16)
          .rotate({ angle: this.expanded ? 180 : 0 })
      }
      .width('100%')
      .padding(12)
      .onClick(() => { this.expanded = !this.expanded })

      if (this.expanded) {
        ForEach(this.options, (opt: SelectOption<T>) => {
          Row() {
            Text(opt.label)
              .fontSize(15)
              .fontColor(opt.disabled ? '#CCC' : '#333')
              .layoutWeight(1)
            if (opt.value === this.selected) {
              Image($r('app.media.ic_check'))
                .width(18).height(18)
            }
          }
          .width('100%')
          .padding(12)
          .opacity(opt.disabled ? 0.5 : 1)
          .onClick(() => {
            if (opt.disabled) return
            this.selected = opt.value
            this.expanded = false
            this.onChange?.(opt.value)
          })
        }, (opt: SelectOption<T>) => opt.label)
      }
    }
    .width('100%')
    .borderRadius(8)
    .border({ width: 1, color: '#E0E0E0' })
  }
}

这个 Select 的选项可以是数字、字符串、甚至枚举值。外边用的时候 @Link selected 绑定具体类型就行,类型不匹配编译器会拦下来。

泛型组件的坑

实际用下来有几个地方需要注意。

ArkTS 对泛型的支持不如标准 TypeScript 那么完整,keyof T 在某些复杂嵌套场景下可能表现不一致。遇到这种情况可以用类型断言兜底,但别滥用。

@Prop 配合泛型数组时,深拷贝的性能开销要留意。数据量大的场景建议换成 @Link 或者在组件内部用 @State 接管。

ForEach 的 keyGenerator 函数里,泛型对象的 key 提取需要转成 string,这块容易忽略,忘了转就会报类型错误。

写在最后

泛型组件的价值不只是少写几行代码,更重要的是把类型契约沉淀到了组件接口里。团队协作的时候,新人拿到 DataTable 组件,看一眼 ColumnDef 的定义就知道该怎么传数据、怎么写列配置,不需要翻文档或者问老员工。

我的建议是从项目里最常见的 2-3 个通用组件开始泛型化,比如列表、表格、表单。别一上来就把所有组件都改成泛型——过度设计的代价和重复代码一样让人难受。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐