
前言上一篇写商品列表页时页面还很基础——只有排序和商品卡片。这次重构加了不少东西分类 Chip 横向滚动筛选、网格/列表双视图切换、折扣百分比显示、评分和销量、加购按钮。功能密度上了一个台阶。与上一版的核心差异维度上一版本版分类筛选无横向滚动 Chip7 个分类视图模式仅列表列表 网格双视图排序交互文字按钮带背景色的 SortChip商品卡片基础信息标签 评分 折扣 加购背景纯色 #F8F7FC三色渐变数据处理简单筛选分类筛选 选择排序完整效果状态管理Stateproducts:Product[][]// 当前显示的商品StateallProducts:Product[][]// 筛选前的全量数据Statetitle:string全部商品StatesortBy:number0// 0综合 1价格↑ 2价格↓ 3销量StateviewGrid:booleanfalse// false列表 true网格StateactiveCat:string全部// 当前选中的分类StatecatList:string[][全部,手机数码,电脑办公,家电家居,服饰美妆,食品生鲜,运动户外]为什么需要两个数组products和allProducts分类筛选和排序是两个独立的操作。用户可能先选了手机数码再按价格排序。如果只用一个数组排序后就丢失了分类信息切换排序方式时需要重新筛选。allProducts保存筛选后的数据products保存排序后的数据。分类切换时更新allProducts排序时只操作products。路由参数处理aboutToAppear():void{constprouter.getParams()asRecordstring,Objectif(p){if(p[category](p[category]asstring)!全部分类){constcat:stringp[category]asstringthis.allProductsgetProductsByCategory(cat)this.titlecatthis.activeCatcat}elseif(p[search]){constkw:stringp[search]asstringthis.allProductssearchProducts(kw)this.title搜索: kw}else{this.allProductsPRODUCTS}}else{this.allProductsPRODUCTS}this.productsthis.allProducts}三种入口分类跳转category ! 全部分类→ 按分类筛选标题设为分类名activeCat同步搜索跳转有search参数 → 关键词搜索标题设为搜索: xxx默认进入无参数 → 显示全部商品为什么category 全部分类要特殊处理首页底部导航的分类按钮传的是全部分类——这个值是占位符不是真正的分类名。如果不过滤getProductsByCategory(全部分类)可能返回空数组。分类 Chip 横向滚动Scroll(){Row({space:6}){ForEach(this.catList,(cat:string){Text(cat).fontSize(12).fontWeight(this.activeCatcat?FontWeight.Bold:FontWeight.Regular).fontColor(this.activeCatcat?#FFFFFF:T2).padding({left:14,right:14,top:6,bottom:6}).borderRadius(16).backgroundColor(this.activeCatcat?A:#F0EEF4).onClick((){this.filterByCat(cat)})})}.width(100%).padding({left:12,right:12})}.width(100%).height(40).scrollBar(BarState.Off).scrollable(ScrollDirection.Horizontal)分类列表比可视区域宽——7 个分类名 间距会超出屏幕。用ScrollScrollDirection.Horizontal实现横向滚动。选中状态的视觉区分选中白色文字 红色背景#FF4757未选中灰色文字 浅灰背景#F0EEF4filterByCatprivatefilterByCat(cat:string):void{this.activeCatcatthis.productscat全部?this.allProducts:getProductsByCategory(cat)}点击分类 Chip 时调用。选全部时恢复allProducts否则按分类重新筛选。注意这里直接赋值this.products ...ArkTS 检测到引用变化会触发 UI 更新。不需要[...list]复制——因为getProductsByCategory返回的是新数组。排序选择排序实现privatedoSort(idx:number):void{this.sortByidxconsts:Product[][]for(leti:number0;ithis.products.length;i){s.push(this.products[i])}if(idx1){// 价格升序for(leti0;is.length;i){for(letji1;js.length;j){if(s[j].prices[i].price){constt:Products[i];s[i]s[j];s[j]t}}}}// ... 价格降序、销量降序类似this.productss}为什么用选择排序而不是.sort()ArkTS 的Array.sort()在某些版本有类型兼容问题——回调函数的参数类型推断可能报错。手写选择排序虽然效率低O(n²)但类型安全商品数量少100时性能没有差别。为什么要先复制到s直接排序this.products会修改State数组的内部结构——ArkTS 不会检测到原地修改UI 不会更新。复制到新数组再排序最后赋值this.products sArkTS 检测到引用变化才会触发重渲染。双视图切换// Header 中的切换按钮Text(this.viewGrid?☷:⊞).fontSize(18).fontColor(this.viewGrid?A:T3).onClick((){this.viewGrid!this.viewGrid})// 内容区条件渲染if(this.viewGrid){// 网格视图Scroll(){Column(){Grid(){ForEach(this.products,(p:Product){GridItem(){this.GridCard(p)}})}.columnsTemplate(1fr 1fr).columnsGap(8).rowsGap(8)Blank().height(20)}.width(100%).padding({left:16,right:16})}.width(100%).layoutWeight(1).scrollBar(BarState.Off)}else{// 列表视图Scroll(){Column({space:10}){ForEach(this.products,(p:Product){this.ListCard(p)})Blank().height(20)}.width(100%).padding({left:16,right:16})}.width(100%).layoutWeight(1).scrollBar(BarState.Off)}为什么用if而不是同时渲染两个再控制显隐网格和列表的布局结构完全不同——Grid 用columnsTemplate列表用Column({ space: 10 })。同时渲染两套 UI 会浪费内存而且 Grid 和列表的滚动状态需要独立维护。if条件渲染只保留当前视图切换时销毁旧视图、创建新视图。虽然有重建开销但商品数量少时感知不到。ListCard增强版商品卡片BuilderListCard(p:Product){Row(){// 左侧图片 标签Stack({alignContent:Alignment.TopStart}){Column(){Text(p.image).fontSize(38)}.width(72).height(72).borderRadius(12).backgroundColor(#F5F5F7).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)if(p.tags.length0){Text(p.tags[0]).fontSize(8).fontColor(Color.White).padding({left:4,right:4,top:1,bottom:1}).backgroundColor(A).borderRadius(3)}}// 中间信息Column(){Text(p.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(T1).maxLines(1).textOverflow({overflow:TextOverflow.Ellipsis})Text(p.desc).fontSize(11).fontColor(T3).maxLines(1).textOverflow({overflow:TextOverflow.Ellipsis}).margin({top:2})Row(){Text(¥p.price.toString()).fontSize(16).fontWeight(FontWeight.Bold).fontColor(A)if(p.originalPricep.price){Text(¥p.originalPrice.toString()).fontSize(10).fontColor(T3).decoration({type:TextDecorationType.LineThrough}).margin({left:4})}if(p.originalPricep.price){Text( Math.round((1-p.price/p.originalPrice)*100)%off).fontSize(9).fontColor(A).fontWeight(FontWeight.Bold)}Blank()Text(⭐p.rating.toString()).fontSize(10).fontColor(T3)Text( (p.sales/1000).toFixed(0)k).fontSize(10).fontColor(T3)}.width(100%).margin({top:4})}.alignItems(HorizontalAlign.Start).margin({left:12}).layoutWeight(1)// 右侧加购按钮Row(){Text().fontSize(16).fontWeight(FontWeight.Bold).fontColor(A)}.width(26).height(26).borderRadius(13).backgroundColor(#FFF0F0).justifyContent(FlexAlign.Center).alignItems(VerticalAlign.Center).onClick((){addToCart(p)})}}和上一版的 ListCard 相比新增了标签左上角红色角标p.tags[0]折扣百分比Math.round((1-p.price/p.originalPrice)*100) %off评分⭐ rating加购按钮右侧圆形红色按钮折扣计算Math.round((1-p.price/p.originalPrice)*100)比如原价 100、现价 75(1 - 75/100) * 100 25显示25%off。为什么用Math.round不四舍五入的话可能出现33.333333333333336%off这种丑陋的数字。加购按钮的事件冲突// 外层 Row.onClick((){router.pushUrl({url:pages/ProductDetail,...})})// 加购按钮.onClick((){addToCart(p)})外层卡片点击跳转详情页加购按钮点击加入购物车。两个onClick嵌套——ArkTS 的事件冒泡机制会先触发加购再触发外层跳转。这意味着点加购按钮会同时加购和跳转详情页。如果只想加购不跳转需要在加购按钮的onClick里调用onClick: (event) event.stopPropagation()ArkTS 支持的话。GridCard网格视图BuilderGridCard(p:Product){Column(){Stack({alignContent:Alignment.TopEnd}){Text(p.image).fontSize(36).width(100%).height(80).textAlign(TextAlign.Center).backgroundColor(#F5F5F7).borderRadius(10)if(p.tags.length0){Text(p.tags[0]).fontSize(8).fontColor(Color.White).padding({left:4,right:4,top:1,bottom:1}).backgroundColor(A).borderRadius(3).position({x:4,y:4})}}Text(p.name).fontSize(12).fontWeight(FontWeight.Medium).fontColor(T1).maxLines(1).textOverflow({overflow:TextOverflow.Ellipsis}).margin({top:6})Row(){Text(¥p.price.toString()).fontSize(14).fontWeight(FontWeight.Bold).fontColor(A)Blank()Text((p.sales/1000).toFixed(0)k).fontSize(9).fontColor(T3)}.width(100%).margin({top:4})}}网格卡片比列表卡片精简——只有图片、标签、名称、价格、销量。没有描述、没有评分、没有加购按钮。为什么网格视图省略这么多信息网格视图的每个卡片宽度只有屏幕一半——放不下太多文字。如果硬塞文字会挤压图片空间视觉很乱。网格的核心是快速浏览用户先看图片和价格感兴趣再点进详情页。背景渐变.linearGradient({angle:170,colors:[[#FFF5F5,0],[#F8F7FC,0.4],[#F5F7FF,1]]})从淡红#FFF5F5→ 淡紫#F8F7FC→ 淡蓝#F5F7FF170° 角度。和首页的纯色背景相比渐变更有层次感但不抢商品的视觉焦点。SortChipBuilderSortChip(label:string,idx:number){Text(label).fontSize(12).fontWeight(this.sortByidx?FontWeight.Bold:FontWeight.Regular).fontColor(this.sortByidx?A:T2).padding({left:10,right:10,top:5,bottom:5}).backgroundColor(this.sortByidx?#FFF0F0:transparent).borderRadius(8).onClick((){this.doSort(idx)})}排序按钮的选中状态红色文字 淡红背景。未选中灰色文字 透明背景。和分类 Chip 的视觉风格一致——都是选中时红色高亮。保持整个页面的视觉统一。踩坑记录1. 分类名和路由参数的对齐catList里的分类名如手机数码必须和ProductData.ts里CATEGORIES的name字段完全一致。差一个字都会导致getProductsByCategory()返回空数组。建议从CATEGORIES动态生成catListcatList:string[][全部,...CATEGORIES.map(cc.name)]2. 排序后分类筛选的交互用户先按价格排序再切换分类——此时filterByCat会用getProductsByCategory()的原始顺序综合排序覆盖掉之前的价格排序。这是预期行为还是 bug取决于产品需求。如果是 bug需要在filterByCat里记住当前排序方式筛选后重新排序。3. 网格视图的滚动性能Grid 里的ForEach一次性渲染所有商品。如果商品数量多50初次渲染可能卡顿。可以用LazyForEach按需加载。4. 加购按钮的样式一致性列表视图的加购按钮是圆形红色背景 号26×26px。网格视图没有加购按钮。两个视图的交互方式不一致——列表可以直接加购网格必须点进详情页。写在最后这次重构的核心是信息密度和操作效率的平衡。分类 Chip 让用户一键切换品类不用每次重新搜索。双视图让用户根据场景选择——列表看详情网格看图片。折扣百分比让用户不用心算就能知道便宜了多少。加购按钮是列表视图的快捷操作——用户不需要进详情页就能加购。网格视图省略了这个按钮因为网格卡片的空间有限放不下。