适用读者: 已经完成菜谱 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 支持 forEachmapfilter 等高阶函数,但 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 stringas 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 的限制与应对

限制 表现 应对方式
高阶函数支持有限 filtermap 可能有兼容问题 用 for 循环替代
@State 只检测引用变化 修改数组元素不触发刷新 创建新数组替换引用
@Builder 不能跨组件 不能在另一个页面调用 改用 @Component 或重复代码
路由参数只支持基本类型 不能传对象/数组 传 id,目标页面重新查询
Set/Map 支持不完善 可能有兼容问题 用数组 + for 循环替代

这些限制不是缺点——它们是 ArkTS 为了性能和安全性做的取舍。 限制高阶函数是为了避免闭包带来的性能问题;限制 @State 检测引用变化是为了降低追踪成本;限制路由参数类型是为了保证序列化安全。理解这些限制背后的原因,才能写出更符合 ArkTS 设计理念的代码。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐