适用读者:已经完成菜谱 App 开发、想系统梳理 ArkTS 语言特性的开发者。本文不讲解语法基础,而是从项目实际代码出发,分析每个语言特性"在什么场景下用、为什么这样用、不用会怎样"。
完整效果
一、四个装饰器的职责划分
菜谱 App 用到了四个装饰器:@Entry、@Component、@State、@Builder。每个装饰器解决一个特定问题。
@Entry + @Component:页面入口声明
@Entry@Componentstruct Index{build(){/* ... */}}@Entry标记这个 struct 是页面入口。一个 .ets 文件里只能有一个 @Entry struct。多个 @Entry 会导致编译错误——系统不知道哪个是入口。
@Component标记这个 struct 是可复用组件。没有 @Component 的 struct 不能用build()方法,也不能使用 @State 等状态装饰器。
为什么 @Entry 和 @Component 要一起用?@Entry声明"这是页面入口",@Component声明"这是一个组件"。两者缺一不可——只有 @Entry 没有 @Component,struct 不是组件,无法渲染;只有 @Component 没有 @Entry,struct 是组件但不是页面入口,无法独立运行。
踩坑记录:最初给 RecipeData 的接口也加了 @Component,导致编译报错——接口不能用 @Component 装饰。@Component 只能用在 struct 上,不能用在 interface 或 class 上。
@State:页面级状态管理
@StatesearchText:string=''@Staterecipes:Recipe[]=[]@Statefav:boolean=false@Stateservings:number=1@State 让 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 的选择标准:
| 特性 | @Builder | @Component |
|---|---|---|
| 定义位置 | @Component 内部 | 独立 struct |
| 复用范围 | 当前组件内 | 跨组件、跨页面 |
| 参数传递 | 支持 | 支持 |
| 状态管理 | 不能用 @State | 可以用 @State |
| 适用场景 | 简单的 UI 片段 | 有独立状态的组件 |
菜谱 App 只有 1 个 @Builder(NavBtn),因为页面结构简单,没有需要跨组件复用的复杂 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:number=0;i<RECIPES.length;i++){if(RECIPES[i].id===id)returnRECIPES[i]}returnundefined}Recipe | undefined是联合类型——返回值可能是 Recipe 也可能是 undefined。调用方必须处理 undefined 的情况,否则访问recipe.name时会报错。
为什么不返回 null?TypeScript/ArkTS 的惯例是用 undefined 表示"不存在",null 表示"空值"。undefined在语义上更明确——"这个 id 没有对应的菜谱"比"找到一个空的菜谱"更准确。
用法二:类型断言处理路由参数
constp=router.getParams()asRecord<string,Object>this.cat=p['cat']asstringas Record<string, Object>是类型断言——告诉编译器"我知道这个值的类型"。router.getParams()返回的是Record<string, Object> | undefined,用 as 强制断言为对象类型,方便后续通过 key 访问。
类型断言的风险:如果断言的类型和实际类型不匹配,运行时会报错。比如p['cat'] as number实际上p['cat']是字符串,后续用数字操作会出错。类型断言是"开发者和编译器之间的信任契约"——开发者保证类型正确,编译器不再检查。
为什么不用p?.['cat'] as string?可选链 + 断言也能实现,但代码可读性不如先判断再断言:
// 推荐:先判断再断言if(p&&p['cat']){this.cat=p['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(idx<this.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.timerSec<10?'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().length===0)returnaddToShop(this.input.trim())this.input=''this.refresh()}trim()去除首尾空格后再检查长度。用户可能输入空格后点击添加,trim()保证不会添加空白项。addToShop也用trim()后的值,保证存入的数据是干净的。
五、循环与数组操作的四种模式
模式一:for 循环遍历
for(leti:number=0;i<RECIPES.length;i++){if(RECIPES[i].category===cat)r.push(RECIPES[i])}ArkTS 里最稳妥的遍历方式。虽然 TypeScript 支持forEach、map、filter等高阶函数,但 ArkTS 对这些函数的支持不如 TypeScript 完善。用 for 循环最可靠,不会遇到兼容性问题。
模式二:for 循环创建新数组
constn:number[]=[]for(letj:number=0;j<_favs.length;j++){if(j!==i)n.push(_favs[j])}_favs=n"创建新数组 + 替换引用"是 @State 刷新的标准模式。不直接修改原数组,而是创建一个新数组,过滤或转换后替换 @State 的引用。
模式三:双重嵌套循环
for(leti:number=0;i<RECIPES.length;i++){for(letj:number=0;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 循环 + 创建新数组 | 需要替换引用触发刷新 |
| 多条件匹配 | 双重嵌套循环 | 两个数组的交叉匹配 |
| 渲染列表 UI | ForEach | 专门为 UI 遍历设计 |
六、路由系统的三个 API
router.pushUrl:页面跳转
router.pushUrl({url:'pages/CategoryPage',params:{'cat':cat.name}asRecord<string,Object>})url是目标页面的路径——相对于 pages 目录。不需要写文件扩展名(.ets),也不需要写完整路径。
params是传递给目标页面的参数。参数类型必须是Record<string, Object>——键是字符串,值是 Object。基本类型(string、number)需要通过as Record<string, Object>断言。
router.back:返回上一页
router.back()最简单的路由 API——不需要参数。自动返回上一个页面。如果上一个页面存在,直接显示;如果不存在(比如从通知直接打开详情页),行为取决于系统配置。
router.getParams:接收路由参数
constp=router.getParams()asRecord<string,Object>if(p&&p['cat']){this.cat=p['cat']asstring}返回值是Record<string, Object> | undefined。没有参数时返回 undefined,需要判断后再使用。取值后需要类型断言(as string、as number),因为 Object 类型不支持直接操作。
三个 API 的使用时机
| API | 使用场景 | 调用位置 |
|---|---|---|
| pushUrl | 从当前页跳到新页面 | onClick 回调 |
| back | 从新页面返回当前页 | 返回按钮 onClick |
| getParams | 新页面接收上一页传来的数据 | aboutToAppear |
七、生命周期方法的使用
aboutToAppear:页面创建时
aboutToAppear():void{constp=router.getParams()asRecord<string,Object>if(p&&p['id']){this.recipe=getRecipeById(p['id']asnumber)}}aboutToAppear在页面首次创建时触发——接收路由参数、初始化数据。它是页面生命周期的第一个方法,保证在build()之前执行。
onPageShow:页面显示时
onPageShow():void{this.refresh()}onPageShow在页面每次显示时触发——从其他页面返回时也会调用。用于刷新数据——用户在详情页取消收藏后返回收藏页,onPageShow重新查询最新数据。
两个方法的区别
| 方法 | 触发时机 | 适用场景 |
|---|---|---|
| aboutToAppear | 页面首次创建 | 接收路由参数、初始化 @State |
| onPageShow | 页面每次显示 | 刷新数据、同步状态 |
踩坑记录:最初 FavoritesPage 只写了aboutToAppear没写onPageShow,用户在详情页取消收藏后返回收藏页,列表没有更新。加上onPageShow后每次显示都重新查询,问题解决。
八、模块导入的规则
importrouterfrom'@ohos.router'import{CATS,RECIPES,Category,Recipe}from'../services/RecipeData'import{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',行太长不好读。改成按需导入——每个页面只导入用到的项,代码更清晰。
九、颜色透明度拼接的规律
A+'12'// #FF6B6B12 → 分类图标背景色(7% 透明度)A+'25'// #FF6B6B25 → 投影颜色(14% 透明度)A+'30'// #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'是十六进制的 18(0x12 = 18),表示 18/255 ≈ 7% 透明度。这种写法比rgba()更简洁,但透明度值需要手动计算十六进制。
十、ArkTS 的限制与应对
| 限制 | 表现 | 应对方式 |
|---|---|---|
| 高阶函数支持有限 | filter、map可能有兼容问题 | 用 for 循环替代 |
| @State 只检测引用变化 | 修改数组元素不触发刷新 | 创建新数组替换引用 |
| @Builder 不能跨组件 | 不能在另一个页面调用 | 改用 @Component 或重复代码 |
| 路由参数只支持基本类型 | 不能传对象/数组 | 传 id,目标页面重新查询 |
| Set/Map 支持不完善 | 可能有兼容问题 | 用数组 + for 循环替代 |
这些限制不是缺点——它们是 ArkTS 为了性能和安全性做的取舍。限制高阶函数是为了避免闭包带来的性能问题;限制 @State 检测引用变化是为了降低追踪成本;限制路由参数类型是为了保证序列化安全。理解这些限制背后的原因,才能写出更符合 ArkTS 设计理念的代码。