HarmonyOS7 泛型组件怎么写才不废?TypeScript 类型安全通用列表实战
前言
做过几个鸿蒙项目之后你会发现,列表组件是写得最多的东西。用户列表、订单列表、商品列表……结构都差不多,但每次都要重新写一遍。拷贝一份改改字段名?能跑,但维护起来让人头疼——改了一个地方的 bug,另外三个地方忘了改。
泛型组件就是来解决这个问题的。一套组件代码,配合 TypeScript 泛型,既能复用在不同数据类型上,又能保证类型安全。编译期就能抓到类型错误,不用等到运行时才翻车。
为什么不用 any?

很多人的第一反应是用 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
}

这里有两个关键点。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%')
}
}

这个组件有几个设计要点。排序逻辑封装在组件内部,不污染外部代码。列的渲染通过 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 个通用组件开始泛型化,比如列表、表格、表单。别一上来就把所有组件都改成泛型——过度设计的代价和重复代码一样让人难受。
更多推荐


所有评论(0)