HarmonyOS7 泛型组件怎么写才不废?TypeScript 类型安全通用列表实战
文章目录
- 前言
- 为什么不用 any?
- 设计 ColumnDef:列定义的类型约束
- DataTable 组件实现
- 实际使用:用户列表
- 泛型 Select 组件
- 泛型组件的坑
- 写在最后
前言
做过几个鸿蒙项目之后你会发现,列表组件是写得最多的东西。用户列表、订单列表、商品列表……结构都差不多,但每次都要重新写一遍。拷贝一份改改字段名?能跑,但维护起来让人头疼——改了一个地方的 bug,另外三个地方忘了改。
泛型组件就是来解决这个问题的。一套组件代码,配合 TypeScript 泛型,既能复用在不同数据类型上,又能保证类型安全。编译期就能抓到类型错误,不用等到运行时才翻车。
为什么不用 any?
很多人的第一反应是用any或者Object来写通用组件。确实能跑,但你丢掉了最宝贵的东西——类型信息。
// 这样写,columns 里的 key 是什么类型?value 是什么类型?全靠猜@BuilderGenericList(data:Object[],columns:ColumnDef[]){// ...}用泛型就不一样了,编译器帮你盯着:
// T 是什么类型,列定义里就只能用 T 的字段@BuilderGenericList<T>(data:T[],columns:ColumnDef<T>[]){// ...}一旦你在列定义里写了数据模型上不存在的字段,IDE 直接标红。这在大型项目里能省掉大量调试时间。
设计 ColumnDef:列定义的类型约束
DataTable 的核心思路是「数据 + 列定义」分离。数据是你传进来的,列定义告诉组件怎么展示每一列。
先把列定义的类型约束搞定:
// 列定义接口,用泛型约束字段名必须是 T 的 keyexportinterfaceColumnDef<T>{key:keyofTtitle:stringwidth?:Length align?:HorizontalAlign// 自定义渲染器,可选render?:(value:T[keyofT],row:T,index:number)=>string// 是否支持排序sortable?:boolean}这里有两个关键点。key: keyof T保证你只能填数据模型上真实存在的字段名,写错了编译器不答应。render函数让你对单列做自定义渲染,比如把时间戳格式化成日期字符串,或者把金额加上货币符号。
DataTable 组件实现
有了列定义,来搭 DataTable 的主体:
@Componentexportstruct DataTable<TextendsRecord<string,Object>>{@Propdata:T[]=[]@Propcolumns:ColumnDef<T>[]=[]@StatesortKey:string=''@StatesortOrder:'asc'|'desc'|'none'='none'onRowClick?:(row:T,index:number)=>void// 内部排序逻辑privategetSortedData():T[]{if(this.sortOrder==='none'||!this.sortKey){returnthis.data}constsorted=[...this.data]sorted.sort((a,b)=>{constva=a[this.sortKeyaskeyofT]constvb=b[this.sortKeyaskeyofT]if(va<vb)returnthis.sortOrder==='asc'?-1:1if(va>vb)returnthis.sortOrder==='asc'?1:-1return0})returnsorted}// 点击表头触发排序privatehandleSort(column:ColumnDef<T>){if(!column.sortable)returnif(this.sortKey===(column.keyasstring)){// 循环切换排序状态if(this.sortOrder==='none')this.sortOrder='asc'elseif(this.sortOrder==='asc')this.sortOrder='desc'elsethis.sortOrder='none'}else{this.sortKey=column.keyasstringthis.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.keyasstring)}.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.keyasstring)}.width('100%').padding({top:10,bottom:10,left:16,right:16}).onClick(()=>this.onRowClick?.(row,index))},(_:T,index:number)=>index.toString())}.width('100%')}}这个组件有几个设计要点。排序逻辑封装在组件内部,不污染外部代码。列的渲染通过render回调可定制,不定制就用默认的字符串转换。点击行通过回调抛出,外部自己处理跳转或详情弹窗。
实际使用:用户列表
定义一个用户数据模型,配好列定义就能用了:
interfaceUser{id:numbername:stringemail:stringrole:stringcreatedAt:number}@Componentstruct UserListPage{@Stateusers:User[]=[{id:1,name:'张三',email:'zhang@test.com',role:'管理员',createdAt:1718000000},{id:2,name:'李四',email:'li@test.com',role:'编辑',createdAt:1718100000},]privatecolumns: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)=>{constd=newDate(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,可以是任意类型:
interfaceSelectOption<T>{label:stringvalue:Tdisabled?:boolean}@Componentexportstruct GenericSelect<T>{@Propoptions:SelectOption<T>[]=[]@Linkselected:T@Stateexpanded:boolean=falseplaceholder:string='请选择'onChange?:(value:T)=>voidprivategetLabel():string{constfound=this.options.find(o=>o.value===this.selected)returnfound?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)returnthis.selected=opt.valuethis.expanded=falsethis.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 个通用组件开始泛型化,比如列表、表格、表单。别一上来就把所有组件都改成泛型——过度设计的代价和重复代码一样让人难受。
