HarmonyOS菜谱——ArkTS语言特性的实战应用总结
适用读者: 已经完成菜谱 App 开发、想系统梳理 ArkTS 语言特性的开发者。本文不讲解语法基础,而是从项目实际代码出发,分析每个语言特性"在什么场景下用、为什么这样用、不用会怎样"。
完整效果


一、四个装饰器的职责划分
菜谱 App 用到了四个装饰器:@Entry、@Component、@State、@Builder。每个装饰器解决一个特定问题。
@Entry + @Component:页面入口声明
@Entry
@Component
struct 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:页面级状态管理
@State searchText: string = ''
@State recipes: Recipe[] = []
@State fav: boolean = false
@State servings: 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 片段
@Builder NavBtn(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 的设计模式
单层接口
interface Category { name: string; icon: string; color: string }
interface Ingredient { name: string; amount: string }
简单的数据结构用单层接口——字段都是基本类型(string、number),没有嵌套。
嵌套接口
interface Recipe {
id: number; name: string; icon: string; category: string; time: string; difficulty: string
servings: string; desc: string; ingredients: Ingredient[]; steps: string[]; tips: string
}
复杂的数据结构用嵌套——Recipe 的 ingredients 字段是 Ingredient[] 类型,引用了另一个接口。
嵌套接口的设计原则: 如果一个字段的数据结构会被多处复用(Ingredient 在 RecipeDetail 的食材列表和加入清单功能里都用到),就抽成独立接口。如果只在一个地方用(比如 Recipe 的 tips 字段),不需要抽接口。
接口 vs type alias
// 接口
interface Recipe { id: number; name: string }
// type alias
type Recipe = { id: number; name: string }
项目里全部用 interface 而不是 type。原因是 interface 在 TypeScript/ArkTS 里有更好的错误信息——当类型不匹配时,interface 的报错会指出具体哪个字段有问题,type 的报错可能更模糊。在开发阶段,清晰的错误信息能节省调试时间。
三、类型系统的四个实战用法
用法一:联合类型处理可选数据
export function getRecipeById(id: number): Recipe | undefined {
for (let i: number = 0; i < RECIPES.length; i++) {
if (RECIPES[i].id === id) return RECIPES[i]
}
return undefined
}
Recipe | undefined 是联合类型——返回值可能是 Recipe 也可能是 undefined。 调用方必须处理 undefined 的情况,否则访问 recipe.name 时会报错。
为什么不返回 null? TypeScript/ArkTS 的惯例是用 undefined 表示"不存在",null 表示"空值"。undefined 在语义上更明确——"这个 id 没有对应的菜谱"比"找到一个空的菜谱"更准确。
用法二:类型断言处理路由参数
const p = router.getParams() as Record<string, Object>
this.cat = p['cat'] as string
as 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'] as string }
// 不推荐:可选链 + 断言(嵌套太深)
this.cat = (p?.['cat'] as string) ?? ''
用法三:泛型数组的类型标注
@State recipes: 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() 处理用户输入
private add(): void {
if (this.input.trim().length === 0) return
addToShop(this.input.trim())
this.input = ''
this.refresh()
}
trim() 去除首尾空格后再检查长度。 用户可能输入空格后点击添加,trim() 保证不会添加空白项。addToShop 也用 trim() 后的值,保证存入的数据是干净的。
五、循环与数组操作的四种模式
模式一:for 循环遍历
for (let i: number = 0; i < RECIPES.length; i++) {
if (RECIPES[i].category === cat) r.push(RECIPES[i])
}
ArkTS 里最稳妥的遍历方式。 虽然 TypeScript 支持 forEach、map、filter 等高阶函数,但 ArkTS 对这些函数的支持不如 TypeScript 完善。用 for 循环最可靠,不会遇到兼容性问题。
模式二:for 循环创建新数组
const n: number[] = []
for (let j: number = 0; j < _favs.length; j++) {
if (j !== i) n.push(_favs[j])
}
_favs = n
"创建新数组 + 替换引用"是 @State 刷新的标准模式。 不直接修改原数组,而是创建一个新数组,过滤或转换后替换 @State 的引用。
模式三:双重嵌套循环
for (let i: number = 0; i < RECIPES.length; i++) {
for (let j: 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 } as Record<string,Object> })
url 是目标页面的路径——相对于 pages 目录。 不需要写文件扩展名(.ets),也不需要写完整路径。
params 是传递给目标页面的参数。 参数类型必须是 Record<string, Object>——键是字符串,值是 Object。基本类型(string、number)需要通过 as Record<string, Object> 断言。
router.back:返回上一页
router.back()
最简单的路由 API——不需要参数。 自动返回上一个页面。如果上一个页面存在,直接显示;如果不存在(比如从通知直接打开详情页),行为取决于系统配置。
router.getParams:接收路由参数
const p = router.getParams() as Record<string, Object>
if (p && p['cat']) { this.cat = p['cat'] as string }
返回值是 Record<string, Object> | undefined。 没有参数时返回 undefined,需要判断后再使用。取值后需要类型断言(as string、as number),因为 Object 类型不支持直接操作。
三个 API 的使用时机
| API | 使用场景 | 调用位置 |
|---|---|---|
| pushUrl | 从当前页跳到新页面 | onClick 回调 |
| back | 从新页面返回当前页 | 返回按钮 onClick |
| getParams | 新页面接收上一页传来的数据 | aboutToAppear |
七、生命周期方法的使用
aboutToAppear:页面创建时
aboutToAppear(): void {
const p = router.getParams() as Record<string, Object>
if (p && p['id']) { this.recipe = getRecipeById(p['id'] as number) }
}
aboutToAppear 在页面首次创建时触发——接收路由参数、初始化数据。 它是页面生命周期的第一个方法,保证在 build() 之前执行。
onPageShow:页面显示时
onPageShow(): void { this.refresh() }
onPageShow 在页面每次显示时触发——从其他页面返回时也会调用。 用于刷新数据——用户在详情页取消收藏后返回收藏页,onPageShow 重新查询最新数据。
两个方法的区别
| 方法 | 触发时机 | 适用场景 |
|---|---|---|
| aboutToAppear | 页面首次创建 | 接收路由参数、初始化 @State |
| onPageShow | 页面每次显示 | 刷新数据、同步状态 |
踩坑记录: 最初 FavoritesPage 只写了 aboutToAppear 没写 onPageShow,用户在详情页取消收藏后返回收藏页,列表没有更新。加上 onPageShow 后每次显示都重新查询,问题解决。
八、模块导入的规则
import router from '@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 设计理念的代码。
更多推荐

所有评论(0)