HarmonyOS菜谱——分类页面的筛选逻辑与路由传参
CategoryPage 是菜谱 App 里代码量最少的页面——没有搜索、没有计时器、没有收藏,只有一个导航栏和一个菜谱列表。但它的核心价值在于"按条件筛选数据"——接收首页传来的分类名,从 8 道菜谱里过滤出属于该分类的子集。这个"传参 → 查询 → 展示"的流程是所有列表筛选页面的标准模式。
完整效果
一、路由参数的接收与解析
@Staterecipes:Recipe[]=[]@Statecat:string=''aboutToAppear():void{constp=router.getParams()asRecord<string,Object>if(p&&p['cat']){this.cat=p['cat']asstring;this.recipes=getRecipesByCat(this.cat)}}三个关键步骤:
第一步:router.getParams() as Record<string, Object>——获取路由参数并断言类型。router.getParams()返回的是Record<string, Object> | undefined——可能有参数也可能没有。用as Record<string, Object>强制断言为对象类型,方便后续通过 key 访问。
第二步:if (p && p['cat'])——防御性检查。先判断p是否存在(用户直接打开 CategoryPage 时没有参数),再判断p['cat']是否有值。两层检查保证不会在空参数上调用as string导致运行时错误。
第三步:this.recipes = getRecipesByCat(this.cat)——用分类名查询菜谱。getRecipesByCat遍历 RECIPES 数组,过滤category === cat的菜谱,返回新数组。
这三个步骤可以缩写成一行:
// 缩写版本aboutToAppear():void{constp=router.getParams()asRecord<string,Object>this.cat=(p?.['cat']asstring)??''this.recipes=this.cat?getRecipesByCat(this.cat):[]}?.可选链在 p 为 undefined 时返回 undefined,??空值合并在 undefined 时返回默认值。但缩写版本的可读性不如原始版本——在教程和博客里,展开写更清晰。
和其他 App 的参数接收对比
// 智能家居 RoomPageconstp:Record<string,Object>=router.getParams()asRecord<string,Object>this.room=(p['room']asObject)asstring// 旅行探索 CityDetailconstp=router.getParams()asRecord<string,Object>constcityId=(p['cityId']asObject)asnumber三个 App 的参数接收方式完全一致——router.getParams()+as Record<string, Object>+ key 取值 + 类型断言。这是 ArkTS 路由传参的标准写法,没有其他替代方案。
二、导航栏的特殊处理
Row(){Row(){SymbolGlyph($r('sys.symbol.chevron_left')).fontSize(20).fontColor([T1])}.width(34).height(34).borderRadius(17).backgroundColor('rgba(0,0,0,0.03)').justifyContent(FlexAlign.Center).onClick(()=>{router.back()})Text(this.cat).fontSize(20).fontWeight(FontWeight.Bold).fontColor(T1).margin({left:10}).layoutWeight(1)Text(this.recipes.length+'道').fontSize(12).fontColor(T3)}.width('100%').padding({left:16,right:16,top:12,bottom:8})导航栏结构和其他页面一致(返回按钮 + 标题 + layoutWeight(1)),但右侧多了一个菜谱数量显示——this.recipes.length + '道'。
这个数量是动态的:传入"家常菜"显示"3道",传入"汤粥"显示"1道"。用this.recipes.length动态计算,而不是写死数字。这保证了分类页显示的数量和实际过滤结果一致。
导航栏的标题用this.cat(分类名)而不是固定字符串——“家常菜”“汤粥”"面食"等,取决于用户从首页点击了哪个分类。这让同一个 CategoryPage 能复用于所有 6 个分类。
三、菜谱列表的渲染
Scroll(){Column({space:10}){ForEach(this.recipes,(r:Recipe)=>{Row(){Text(r.icon).fontSize(36).width(64).height(64).borderRadius(16).backgroundColor('#F5F5FA').textAlign(TextAlign.Center)Column(){Text(r.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor(T1)Text(r.desc).fontSize(11).fontColor(T3).maxLines(1).margin({top:2})Text('⏱ '+r.time+' · '+r.difficulty).fontSize(10).fontColor(T2).margin({top:4})}.alignItems(HorizontalAlign.Start).margin({left:12}).layoutWeight(1)SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(14).fontColor(['#DDD'])}.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(16).onClick(()=>{router.pushUrl({url:'pages/RecipeDetail',params:{'id':r.id}asRecord<string,Object>})})})Blank().height(20)}.width('100%').padding({left:16,right:16})}.width('100%').layoutWeight(1)菜谱列表和首页几乎完全一致——同样的卡片结构、同样的布局参数。唯一区别是第三行信息:首页用了三个独立 Text + “·” 分隔,CategoryPage 用了一个 Text 拼接字符串。
this.recipes而不是RECIPES
这是一个关键区别——CategoryPage 遍历的是this.recipes(@State 数组,经过过滤的子集),不是RECIPES(全量数组)。这意味着:
- 传入"家常菜"→
this.recipes只有 3 道菜(红烧肉、清蒸鲈鱼、酸辣土豆丝)→ 列表只渲染 3 个卡片 - 传入"汤粥"→
this.recipes只有 1 道菜(皮蛋瘦肉粥)→ 列表只渲染 1 个卡片 - 传入不存在的分类→
this.recipes为空数组→ 列表不渲染任何卡片
过滤在aboutToAppear里完成,this.recipes一旦赋值就不会再变化——因为 CategoryPage 没有搜索功能,不需要动态重新过滤。
空列表的处理
当传入不存在的分类名时,getRecipesByCat返回空数组,ForEach不渲染任何卡片,页面只有导航栏和空白区域。当前没有做"暂无菜谱"的空状态提示——这是一个可以优化的点:
if(this.recipes.length===0){Column(){Text('🍽').fontSize(48).margin({bottom:12})Text('该分类暂无菜谱').fontSize(14).fontColor(T2)}.width('100%').padding(40).justifyContent(FlexAlign.Center)}在ForEach之前加一个空状态判断——数组为空时显示提示,不为空时显示列表。这种"空状态处理"是列表页面的标准实践,能避免用户看到空白页面时困惑"是加载失败还是真的没有数据"。
四、分类数据的完整结构
exportconstCATS:Category[]=[{name:'家常菜',icon:'🍳',color:'#FF6B6B'},{name:'汤粥',icon:'🍲',color:'#FF9F43'},{name:'面食',icon:'🍜',color:'#FECA57'},{name:'烘焙',icon:'🎂',color:'#E8734A'},{name:'凉菜',icon:'🥗',color:'#00B894'},{name:'饮品',icon:'🍹',color:'#5DADE2'},]6 个分类,每个有独立的 name、icon、color。分类名和 Recipe 的 category 字段一一对应:
| 分类 | 颜色 | 对应菜谱 |
|---|---|---|
| 家常菜 | #FF6B6B 红 | 红烧肉、清蒸鲈鱼、酸辣土豆丝 |
| 汤粥 | #FF9F43 橙 | 皮蛋瘦肉粥 |
| 面食 | #FECA57 黄 | 番茄鸡蛋面 |
| 烘焙 | #E8734A 深橙 | 戚风蛋糕 |
| 凉菜 | #00B894 绿 | 拍黄瓜 |
| 饮品 | #5DADE2 蓝 | 杨枝甘露 |
分类数量分布不均匀——家常菜有 3 道,其他分类各 1 道。这是 mock 数据的典型特征:为了让每个分类都有内容,给最多的分类(家常菜)多分配了几道菜。
五、"传参 → 查询 → 展示"的标准模式
CategoryPage 的数据流可以用三步概括:
首页点击分类 → router.pushUrl(params: { cat: '家常菜' }) ↓ CategoryPage 接收参数 → getRecipesByCat('家常菜') ↓ 过滤结果赋值 @State → ForEach 渲染 3 个菜谱卡片这个模式适用于所有"列表筛选"场景:
| 应用场景 | 传参 | 查询函数 | 展示 |
|---|---|---|---|
| 菜谱分类页 | cat: string | getRecipesByCat(cat) | 菜谱列表 |
| 智能家居房间页 | room: string | getDevicesByRoom(room) | 设备列表 |
| 旅行城市详情 | cityId: number | CITIES 常量遍历 | 城市信息 |
三个步骤的代码结构几乎一样——接收参数用router.getParams(),查询数据用遍历过滤函数,展示数据用ForEach+@State。掌握了这个模式,就能快速实现任何"按条件筛选列表"的页面。
六、和首页的导航栏对比
两个页面的导航栏结构一致,但右侧内容不同:
首页:右侧无内容,标题固定"美味菜谱"
CategoryPage:右侧有菜谱数量,标题动态显示分类名
如果要统一导航栏,可以抽取 Builder:
@BuilderNavBar(title:string,extra?:string){Row(){Row(){SymbolGlyph($r('sys.symbol.chevron_left')).fontSize(20).fontColor([T1])}.width(34).height(34).borderRadius(17).backgroundColor('rgba(0,0,0,0.03)').justifyContent(FlexAlign.Center).onClick(()=>{router.back()})Text(title).fontSize(20).fontWeight(FontWeight.Bold).fontColor(T1).margin({left:10}).layoutWeight(1)if(extra){Text(extra).fontSize(12).fontColor(T3)}}.width('100%').padding({left:16,right:16,top:12,bottom:8})}extra是可选参数——传了就显示,不传就不显示。首页调用NavBar('美味菜谱'),CategoryPage 调用NavBar(this.cat, this.recipes.length + '道')。
当前没有抽取——因为只有两个页面用到,且首页没有返回按钮(是入口页面),强行统一需要加更多参数判断。在 3 个页面的规模下,各自写导航栏更清晰。