HarmonyOS菜谱——ArkTS语言特性的实战应用总结
适用读者已经完成菜谱 App 开发、想系统梳理 ArkTS 语言特性的开发者。本文不讲解语法基础而是从项目实际代码出发分析每个语言特性在什么场景下用、为什么这样用、不用会怎样。完整效果一、四个装饰器的职责划分菜谱 App 用到了四个装饰器Entry、Component、State、Builder。每个装饰器解决一个特定问题。Entry Component页面入口声明EntryComponentstruct Index{build(){/* ... */}}Entry标记这个 struct 是页面入口。一个 .ets 文件里只能有一个 Entry struct。多个 Entry 会导致编译错误——系统不知道哪个是入口。Component标记这个 struct 是可复用组件。没有 Component 的 struct 不能用build()方法也不能使用 State 等状态装饰器。为什么 Entry 和 Component 要一起用Entry声明这是页面入口Component声明这是一个组件。两者缺一不可——只有 Entry 没有 Componentstruct 不是组件无法渲染只有 Component 没有 Entrystruct 是组件但不是页面入口无法独立运行。踩坑记录最初给 RecipeData 的接口也加了 Component导致编译报错——接口不能用 Component 装饰。Component 只能用在 struct 上不能用在 interface 或 class 上。State页面级状态管理StatesearchText:stringStaterecipes:Recipe[][]Statefav:booleanfalseStateservings:number1State 让 struct 的属性成为响应式状态。当 State 变量的值变化时框架会自动重新调用build()方法更新 UI。State 的三个限制限制原因项目中的体现只能在 Component 内使用State 是组件级状态RecipeData 里的函数不能用 State修改基本类型直接赋值触发框架检测值变化this.servings直接触发刷新修改数组需要替换引用框架检测引用变化this.recipes copy必须创建新数组检测引用变化是 State 最容易踩坑的地方。直接修改数组元素this.recipes[0].name 新名字不会触发刷新——因为数组引用没变。必须创建新数组并替换引用this.recipes newArray才能触发。Builder组件内复用的 UI 片段BuilderNavBtn(label:string,active:boolean){Column({space:2}){Text(label菜谱?:(label收藏?❤️:)).fontSize(18)Text(label).fontSize(9).fontColor(active?#FF6B6B:#C0BFC6)}.onClick((){/* ... */})}Builder 是组件内的 UI 复用机制——不是独立组件。Builder 定义在 Component 内部只能在当前组件的build()方法里调用。它不能跨组件复用——如果另一个页面也需要 NavBtn需要重新定义。Builder vs Component 的选择标准特性BuilderComponent定义位置Component 内部独立 struct复用范围当前组件内跨组件、跨页面参数传递支持支持状态管理不能用 State可以用 State适用场景简单的 UI 片段有独立状态的组件菜谱 App 只有 1 个 BuilderNavBtn因为页面结构简单没有需要跨组件复用的复杂 UI 片段。二、interface 的设计模式单层接口interfaceCategory{name:string;icon:string;color:string}interfaceIngredient{name:string;amount:string}简单的数据结构用单层接口——字段都是基本类型string、number没有嵌套。嵌套接口interfaceRecipe{id:number;name:string;icon:string;category:string;time:string;difficulty:stringservings:string;desc:string;ingredients:Ingredient[];steps:string[];tips:string}复杂的数据结构用嵌套——Recipe 的ingredients字段是Ingredient[]类型引用了另一个接口。嵌套接口的设计原则如果一个字段的数据结构会被多处复用Ingredient 在 RecipeDetail 的食材列表和加入清单功能里都用到就抽成独立接口。如果只在一个地方用比如 Recipe 的 tips 字段不需要抽接口。接口 vs type alias// 接口interfaceRecipe{id:number;name:string}// type aliastypeRecipe{id:number;name:string}项目里全部用 interface 而不是 type。原因是 interface 在 TypeScript/ArkTS 里有更好的错误信息——当类型不匹配时interface 的报错会指出具体哪个字段有问题type 的报错可能更模糊。在开发阶段清晰的错误信息能节省调试时间。三、类型系统的四个实战用法用法一联合类型处理可选数据exportfunctiongetRecipeById(id:number):Recipe|undefined{for(leti:number0;iRECIPES.length;i){if(RECIPES[i].idid)returnRECIPES[i]}returnundefined}Recipe | undefined是联合类型——返回值可能是 Recipe 也可能是 undefined。调用方必须处理 undefined 的情况否则访问recipe.name时会报错。为什么不返回 nullTypeScript/ArkTS 的惯例是用 undefined 表示不存在null 表示空值。undefined在语义上更明确——这个 id 没有对应的菜谱比找到一个空的菜谱更准确。用法二类型断言处理路由参数constprouter.getParams()asRecordstring,Objectthis.catp[cat]asstringas Recordstring, Object是类型断言——告诉编译器我知道这个值的类型。router.getParams()返回的是Recordstring, Object | undefined用 as 强制断言为对象类型方便后续通过 key 访问。类型断言的风险如果断言的类型和实际类型不匹配运行时会报错。比如p[cat] as number实际上p[cat]是字符串后续用数字操作会出错。类型断言是开发者和编译器之间的信任契约——开发者保证类型正确编译器不再检查。为什么不用p?.[cat] as string可选链 断言也能实现但代码可读性不如先判断再断言// 推荐先判断再断言if(pp[cat]){this.catp[cat]asstring}// 不推荐可选链 断言嵌套太深this.cat(p?.[cat]asstring)??用法三泛型数组的类型标注Staterecipes:Recipe[][]let_favs:number[][]let_shop:string[][]数组类型用T[]标注——明确数组元素的类型。Recipe[]表示Recipe 类型的数组number[]表示数字类型的数组。没有类型标注的数组let arr []会被推断为any[]失去类型检查。踩坑记录最初getFavs的返回类型写成了Recipe[]但没有在函数签名里标注编译器推断为any[]。调用方this.recipes getFavs()不报错但this.recipes[0].name会报any 上不存在 name 属性的错误。加上显式返回类型Recipe[]后问题解决。用法四非空断言处理 State 变量if(idxthis.recipe!.steps.length-1){Row(){}.width(2).height(16).backgroundColor(#FFF0F0).margin({left:14})}this.recipe!是非空断言——告诉编译器this.recipe 一定不是 undefined。外层已经有if (this.recipe)判断进入这个分支后this.recipe肯定有值。但 TypeScript 编译器不会自动推断 if 分支内的类型收窄因为 State 变量可能在异步操作中被改为 undefined所以需要手动断言。为什么不用可选链this.recipe?.steps.length虽然也能避免 undefined 错误但可选链返回的是number | undefined后续的比较运算 this.recipe!.steps.length - 1需要额外处理 undefined。非空断言更直接——断言后直接当 Recipe 类型使用。四、字符串操作的实战技巧拼接显示文本Text(this.recipe.name).fontSize(15)Text(⏱ this.recipe.time · this.recipe.difficulty)Text(this.timerMin.toString():(this.timerSec10?0:)this.timerSec.toString())三种拼接场景场景写法例子简单拼接前缀 变量⏱ r.time多段拼接A 变量1 · 变量2⏱ r.time · r.difficulty条件拼接三元表达式 toString()计时器的补零格式化toString()的必要性。数字类型不能直接和字符串拼接——this.timerMin :会把:当成数字相加而不是字符串拼接。必须先.toString()转成字符串。踩坑记录最初计时器显示写成了this.timerMin : this.timerSec结果显示的是数字相加的结果比如3:5显示为35而不是03:05。加上toString()和补零逻辑后正常。trim() 处理用户输入privateadd():void{if(this.input.trim().length0)returnaddToShop(this.input.trim())this.inputthis.refresh()}trim()去除首尾空格后再检查长度。用户可能输入空格后点击添加trim()保证不会添加空白项。addToShop也用trim()后的值保证存入的数据是干净的。五、循环与数组操作的四种模式模式一for 循环遍历for(leti:number0;iRECIPES.length;i){if(RECIPES[i].categorycat)r.push(RECIPES[i])}ArkTS 里最稳妥的遍历方式。虽然 TypeScript 支持forEach、map、filter等高阶函数但 ArkTS 对这些函数的支持不如 TypeScript 完善。用 for 循环最可靠不会遇到兼容性问题。模式二for 循环创建新数组constn:number[][]for(letj:number0;j_favs.length;j){if(j!i)n.push(_favs[j])}_favsn创建新数组 替换引用是 State 刷新的标准模式。不直接修改原数组而是创建一个新数组过滤或转换后替换 State 的引用。模式三双重嵌套循环for(leti:number0;iRECIPES.length;i){for(letj:number0;j_favs.length;j){if(RECIPES[i].id_favs[j]){r.push(RECIPES[i]);break}}}外层遍历数据源内层遍历条件列表。break在找到匹配后立即退出内层循环避免不必要的遍历。时间复杂度 O(n×m)在数据量小的场景下可接受。模式四ForEach 遍历渲染ForEach(this.recipes,(r:Recipe){Row(){/* 菜谱卡片 */}})ForEach 是 ArkTS 的 UI 遍历语法——专门为列表渲染设计。和 for 循环不同ForEach 的回调函数返回的是 UI 组件不是数据操作。ForEach 的第二个参数是当前项的索引idx在 ShoppingListPage 的删除操作里用到了。四种模式的选择场景场景模式原因查询/过滤数据for 循环遍历需要条件判断和 push修改 State 数组for 循环 创建新数组需要替换引用触发刷新多条件匹配双重嵌套循环两个数组的交叉匹配渲染列表 UIForEach专门为 UI 遍历设计六、路由系统的三个 APIrouter.pushUrl页面跳转router.pushUrl({url:pages/CategoryPage,params:{cat:cat.name}asRecordstring,Object})url是目标页面的路径——相对于 pages 目录。不需要写文件扩展名.ets也不需要写完整路径。params是传递给目标页面的参数。参数类型必须是Recordstring, Object——键是字符串值是 Object。基本类型string、number需要通过as Recordstring, Object断言。router.back返回上一页router.back()最简单的路由 API——不需要参数。自动返回上一个页面。如果上一个页面存在直接显示如果不存在比如从通知直接打开详情页行为取决于系统配置。router.getParams接收路由参数constprouter.getParams()asRecordstring,Objectif(pp[cat]){this.catp[cat]asstring}返回值是Recordstring, Object | undefined。没有参数时返回 undefined需要判断后再使用。取值后需要类型断言as string、as number因为 Object 类型不支持直接操作。三个 API 的使用时机API使用场景调用位置pushUrl从当前页跳到新页面onClick 回调back从新页面返回当前页返回按钮 onClickgetParams新页面接收上一页传来的数据aboutToAppear七、生命周期方法的使用aboutToAppear页面创建时aboutToAppear():void{constprouter.getParams()asRecordstring,Objectif(pp[id]){this.recipegetRecipeById(p[id]asnumber)}}aboutToAppear在页面首次创建时触发——接收路由参数、初始化数据。它是页面生命周期的第一个方法保证在build()之前执行。onPageShow页面显示时onPageShow():void{this.refresh()}onPageShow在页面每次显示时触发——从其他页面返回时也会调用。用于刷新数据——用户在详情页取消收藏后返回收藏页onPageShow重新查询最新数据。两个方法的区别方法触发时机适用场景aboutToAppear页面首次创建接收路由参数、初始化 StateonPageShow页面每次显示刷新数据、同步状态踩坑记录最初 FavoritesPage 只写了aboutToAppear没写onPageShow用户在详情页取消收藏后返回收藏页列表没有更新。加上onPageShow后每次显示都重新查询问题解决。八、模块导入的规则importrouterfromohos.routerimport{CATS,RECIPES,Category,Recipe}from../services/RecipeDataimport{getRecipeById,toggleFav,isFav,addToShop}from../services/RecipeData三种导入方式方式语法适用场景默认导入import X from path整个模块如 router命名导入import { A, B } from path指定的导出项路径ohos.xxx或../services/X系统模块用 ohos本地模块用相对路径命名导入的优势只导入需要的项不导入整个模块。import { CATS, RECIPES }比import * as Data from path更精确——代码里用到什么就导入什么不会引入不必要的依赖。踩坑记录最初把所有导出项写在一行import { CATS, RECIPES, Category, Recipe, getRecipeById, toggleFav, isFav, getFavs, addToShop, getShop, clearShop, removeFromShop } from ../services/RecipeData行太长不好读。改成按需导入——每个页面只导入用到的项代码更清晰。九、颜色透明度拼接的规律A12// #FF6B6B12 → 分类图标背景色7% 透明度A25// #FF6B6B25 → 投影颜色14% 透明度A30// #FF6B6B30 → 边框颜色18% 透明度rgba(0,0,0,0.03)// 返回按钮背景3% 透明度rgba(0,0,0,0.05)// 导航栏投影5% 透明度透明度值的选择规律元素类型透明度范围原因大面积背景5%-12%太浓会抢内容的视觉焦点边框/线条15%-20%需要可见但不刺眼投影3%-14%装饰性元素越淡越自然按钮背景8%-15%需要可识别但不抢文字颜色 两位十六进制透明度的格式。A 12中12是十六进制的 180x12 18表示 18/255 ≈ 7% 透明度。这种写法比rgba()更简洁但透明度值需要手动计算十六进制。十、ArkTS 的限制与应对限制表现应对方式高阶函数支持有限filter、map可能有兼容问题用 for 循环替代State 只检测引用变化修改数组元素不触发刷新创建新数组替换引用Builder 不能跨组件不能在另一个页面调用改用 Component 或重复代码路由参数只支持基本类型不能传对象/数组传 id目标页面重新查询Set/Map 支持不完善可能有兼容问题用数组 for 循环替代这些限制不是缺点——它们是 ArkTS 为了性能和安全性做的取舍。限制高阶函数是为了避免闭包带来的性能问题限制 State 检测引用变化是为了降低追踪成本限制路由参数类型是为了保证序列化安全。理解这些限制背后的原因才能写出更符合 ArkTS 设计理念的代码。