文章目录前言为什么不用 any设计 ColumnDef列定义的类型约束DataTable 组件实现实际使用用户列表泛型 Select 组件泛型组件的坑写在最后前言做过几个鸿蒙项目之后你会发现列表组件是写得最多的东西。用户列表、订单列表、商品列表……结构都差不多但每次都要重新写一遍。拷贝一份改改字段名能跑但维护起来让人头疼——改了一个地方的 bug另外三个地方忘了改。泛型组件就是来解决这个问题的。一套组件代码配合 TypeScript 泛型既能复用在不同数据类型上又能保证类型安全。编译期就能抓到类型错误不用等到运行时才翻车。为什么不用 any很多人的第一反应是用any或者Object来写通用组件。确实能跑但你丢掉了最宝贵的东西——类型信息。// 这样写columns 里的 key 是什么类型value 是什么类型全靠猜BuilderGenericList(data:Object[],columns:ColumnDef[]){// ...}用泛型就不一样了编译器帮你盯着// T 是什么类型列定义里就只能用 T 的字段BuilderGenericListT(data:T[],columns:ColumnDefT[]){// ...}一旦你在列定义里写了数据模型上不存在的字段IDE 直接标红。这在大型项目里能省掉大量调试时间。设计 ColumnDef列定义的类型约束DataTable 的核心思路是「数据 列定义」分离。数据是你传进来的列定义告诉组件怎么展示每一列。先把列定义的类型约束搞定// 列定义接口用泛型约束字段名必须是 T 的 keyexportinterfaceColumnDefT{key:keyofTtitle:stringwidth?:Length align?:HorizontalAlign// 自定义渲染器可选render?:(value:T[keyofT],row:T,index:number)string// 是否支持排序sortable?:boolean}这里有两个关键点。key: keyof T保证你只能填数据模型上真实存在的字段名写错了编译器不答应。render函数让你对单列做自定义渲染比如把时间戳格式化成日期字符串或者把金额加上货币符号。DataTable 组件实现有了列定义来搭 DataTable 的主体Componentexportstruct DataTableTextendsRecordstring,Object{Propdata:T[][]Propcolumns:ColumnDefT[][]StatesortKey:stringStatesortOrder:asc|desc|nonenoneonRowClick?:(row:T,index:number)void// 内部排序逻辑privategetSortedData():T[]{if(this.sortOrdernone||!this.sortKey){returnthis.data}constsorted[...this.data]sorted.sort((a,b){constvaa[this.sortKeyaskeyofT]constvbb[this.sortKeyaskeyofT]if(vavb)returnthis.sortOrderasc?-1:1if(vavb)returnthis.sortOrderasc?1:-1return0})returnsorted}// 点击表头触发排序privatehandleSort(column:ColumnDefT){if(!column.sortable)returnif(this.sortKey(column.keyasstring)){// 循环切换排序状态if(this.sortOrdernone)this.sortOrderascelseif(this.sortOrderasc)this.sortOrderdescelsethis.sortOrdernone}else{this.sortKeycolumn.keyasstringthis.sortOrderasc}}build(){Column(){// 表头行Row(){ForEach(this.columns,(col:ColumnDefT){Text(col.title).fontSize(14).fontWeight(FontWeight.Medium).fontColor(#333).width(col.width??auto).textAlign(col.align??HorizontalAlign.Start).onClick(()this.handleSort(col))},(col:ColumnDefT)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:ColumnDefT){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:ColumnDefT)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:zhangtest.com,role:管理员,createdAt:1718000000},{id:2,name:李四,email:litest.com,role:编辑,createdAt:1718100000},]privatecolumns:ColumnDefUser[][{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){constdnewDate(value*1000)return${d.getFullYear()}-${String(d.getMonth()1).padStart(2,0)}-${String(d.getDate()).padStart(2,0)}}},]build(){DataTableUser({data:this.users,columns:this.columns,onRowClick:(user){console.info(点击了用户:${user.name})}})}}注意createdAt那列用了自定义render把时间戳转成了可读格式。如果哪天接口字段名变了key: createdAt这里会直接报编译错误逼着你同步修改。泛型 Select 组件同样的思路Select 下拉组件也能泛型化。不再限制选项必须是string可以是任意类型interfaceSelectOptionT{label:stringvalue:Tdisabled?:boolean}Componentexportstruct GenericSelectT{Propoptions:SelectOptionT[][]Linkselected:TStateexpanded:booleanfalseplaceholder:string请选择onChange?:(value:T)voidprivategetLabel():string{constfoundthis.options.find(oo.valuethis.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:SelectOptionT){Row(){Text(opt.label).fontSize(15).fontColor(opt.disabled?#CCC:#333).layoutWeight(1)if(opt.valuethis.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.selectedopt.valuethis.expandedfalsethis.onChange?.(opt.value)})},(opt:SelectOptionT)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 个通用组件开始泛型化比如列表、表格、表单。别一上来就把所有组件都改成泛型——过度设计的代价和重复代码一样让人难受。