
📖 引言
前两篇我们完成了环境搭建和项目结构的学习,从这一篇开始,我们正式进入代码开发的世界。而要写好鸿蒙应用,首先要掌握的就是 ArkTS 语言。
你可能会问:ArkTS 是什么?它和 TypeScript 有什么关系?为什么鸿蒙不直接用 TypeScript,还要搞一个 ArkTS?ArkTS 的"严格模式"到底严格在哪里?为什么写代码的时候到处都是类型报错?
这些问题非常好。理解一门语言的设计理念 和约束边界 ,比死记硬背语法要重要得多。ArkTS 不是"另一个 TypeScript",它是 TypeScript 的一个超集+子集------在 TypeScript 的基础上,增加了声明式 UI 能力,同时也去掉了一些动态特性,以换取更好的性能和可预测性。
本文将以「民族图鉴」项目中的实际代码为载体,从类型系统的底层原理讲起,带你彻底搞懂 ArkTS 的核心语法和设计思想。读完本文,你不仅能写对 ArkTS 代码,更能理解"为什么要这么写"。
🎯 学习目标
完成本文后,你将能够:
- ✅ 理解 ArkTS 的设计理念与 TypeScript 的关系
- ✅ 掌握 ArkTS 类型系统的核心:基本类型、接口、类、枚举
- ✅ 理解严格模式(Strict Mode)的约束与背后的原因
- ✅ 掌握函数式编程特性与泛型的使用
- ✅ 能够设计合理的数据模型(以民族信息建模为例)
- ✅ 避开 ArkTS 常见的类型陷阱
- ✅ 写出符合规范的高质量 ArkTS 代码
💡 需求分析
为什么要重视类型系统?
很多初学者觉得"类型"是个麻烦事------写 JS 的时候不用管类型,写得飞快;加上类型之后,代码量变多了,还老是报错。但实际上,类型系统是大型项目的基石。
类型系统的价值:
| 价值维度 | 说明 |
|---|---|
| 代码可靠性 | 编译时就能发现很多错误,不用等到运行时 |
| 代码可读性 | 看类型就知道函数的输入输出是什么 |
| 开发效率 | IDE 智能提示更准确,重构更安全 |
| 团队协作 | 类型即文档,新人接手更容易 |
| 性能优化 | 编译器可以根据类型信息做更多优化 |
对于鸿蒙应用来说,ArkTS 的类型系统还有一个特殊的意义:声明式 UI 的状态驱动依赖于类型系统。只有明确的类型,ArkUI 框架才能准确追踪状态变化,高效地更新 UI。
「民族图鉴」中的类型应用
「民族图鉴」项目虽然规模不算特别大,但类型系统用得非常规范。让我们看看项目中有哪些类型:
| 类型分类 | 示例文件 | 说明 |
|---|---|---|
| 业务模型 | EthnicModels.ets | 民族、服饰、节日、美食等数据结构 |
| 枚举定义 | EnumModels.ets | 主题、语言、内容模式等状态枚举 |
| 服务层类型 | MusicService.ets | 播放器状态、监听器等 |
| API 类型 | ApiModels.ets | 网络请求响应结构 |
| 页面状态 | 各页面 .ets 文件 | 组件内部的状态类型 |
本文将以这些实际代码为例,逐步讲解 ArkTS 的类型系统。
🛠️ 核心实现
步骤1:ArkTS 语言基础与设计理念
1.1 ArkTS 是什么?
ArkTS(Ark TypeScript)是 HarmonyOS 应用开发的主力语言。它的定位是:基于 TypeScript,扩展声明式 UI 能力,同时做适当的约束以保证性能和可维护性。
ArkTS 与 TypeScript 的关系:
TypeScript (JavaScript 的超集)
│
├─ 保留的部分:基本语法、类型系统、类、接口、枚举
│
├─ 扩展的部分:
│ ├─ 声明式 UI 装饰器(@Component, @Entry, @State...)
│ ├─ UI 描述语法(build 方法、属性链式调用)
│ └─ 状态管理机制(@State, @Prop, @Link...)
│
└─ 约束/去掉的部分(严格模式):
├─ 禁用 any 类型
├─ 禁用动态属性访问
├─ 禁用 eval、with 等动态特性
├─ 更严格的空值检查
└─ 更严格的类型兼容性
💡 为什么要做约束? 因为动态特性虽然灵活,但也带来了性能问题和安全隐患。鸿蒙作为面向全场景的操作系统,需要保证应用的流畅性和稳定性。通过约束,编译器可以做更多的静态分析和优化,运行时也不需要处理各种动态情况。
1.2 ArkTS 的两大编程范式
ArkTS 支持两种编程范式:
| 范式 | 用途 | 特点 |
|---|---|---|
| 命令式编程 | 业务逻辑、数据处理 | 和普通 TS/JS 一样 |
| 声明式 UI | 界面描述 | ArkUI 扩展的语法,用声明的方式描述 UI |
命令式编程示例(业务逻辑):
typescript
// services/StorageService.ets
export class StorageService {
private static instance: StorageService;
static getInstance(): StorageService {
if (!StorageService.instance) {
StorageService.instance = new StorageService();
}
return StorageService.instance;
}
async toggleFavoriteEthnic(ethnicId: string): Promise<boolean> {
const favorites: string[] = await this.getFavoriteEthnics();
const index: number = favorites.indexOf(ethnicId);
if (index > -1) {
favorites.splice(index, 1);
await this.saveFavoriteEthnics(favorites);
return false;
} else {
favorites.push(ethnicId);
await this.saveFavoriteEthnics(favorites);
return true;
}
}
}
声明式 UI 示例(界面描述):
typescript
// pages/EthnicDetailPage.ets
@Entry
@Component
struct EthnicDetailPage {
@State ethnic: EthnicGroup | undefined = undefined;
@State isFavorite: boolean = false;
build() {
Column({ space: 16 }) {
Text(this.ethnic?.name ?? '')
.fontSize(24)
.fontWeight(FontWeight.Bold)
Text(this.ethnic?.description ?? '')
.fontSize(14)
.lineHeight(22)
Button(this.isFavorite ? '取消收藏' : '收藏')
.onClick(() => {
this.toggleFavorite();
})
}
.padding(24)
.width('100%')
}
}
可以看到,声明式 UI 是 ArkTS 最独特的地方------你只需要描述"UI 应该是什么样子",框架会自动处理更新。这和传统的"获取元素→修改属性"的命令式思路完全不同。
1.3 严格模式(Strict Mode)
ArkTS 默认开启严格模式,这是很多初学者容易踩坑的地方。但严格模式不是为难你,而是帮你写出更高质量的代码。
严格模式的核心约束:
| 约束 | 说明 | 为什么要约束? |
|---|---|---|
| 禁用 any | 不能用 any 类型 | any 会让类型系统失效,所有类型检查都绕过了 |
| 禁用 unknown 的不安全使用 | unknown 不能直接调用方法/访问属性 | 防止运行时类型错误 |
| 严格空值检查 | null/undefined 不能随意赋值 | 避免空指针异常(NPE) |
| 严格的类型兼容性 | 隐式类型转换更少 | 保证类型安全 |
| 禁用动态属性 | 不能随意给对象加属性 | 保证对象结构可预测,便于优化 |
| 禁用 eval / with | 不能动态执行代码 | 安全 + 性能 |
| 显式类型注解 | 函数参数和返回值要明确类型 | 代码可读性 + 编译效率 |
最常见的严格模式报错:
typescript
// ❌ 错误:Use explicit types instead of "any", "unknown"
function processData(data: any) {
return data.value;
}
// ❌ 错误:Object literal must correspond to some explicitly declared class or interface
const user = { name: '张三', age: 25 };
// ❌ 错误:Type 'string | undefined' is not assignable to type 'string'
const name: string = ethnic?.name;
看到这些报错不要慌------严格模式在保护你,让你在编译阶段就发现潜在的 bug。
步骤2:基本类型系统详解
2.1 原始类型(Primitive Types)
ArkTS 有 7 种原始类型,和 TypeScript 基本一致:
| 类型 | 说明 | 示例 |
|---|---|---|
string |
字符串 | '汉族', "Zhuang" |
number |
数字(整数+浮点数) | 56, 3.14 |
boolean |
布尔值 | true, false |
null |
空值 | null |
undefined |
未定义 | undefined |
symbol |
唯一标识符 | 较少用 |
bigint |
大整数 | 较少用 |
「民族图鉴」中的实际应用:
typescript
// models/EthnicModels.ets
export interface EthnicGroup {
id: string; // string:民族ID
name: string; // string:民族名称
population: string; // string:人口数量(用字符串,因为带单位)
populationRank: number; // number:人口排名
pinyin: string; // string:拼音
emblemColor: string; // string:代表色(十六进制颜色值)
provinces: string[]; // string[]:分布省份列表
}
2.2 联合类型(Union Types)
联合类型表示一个值可以是几种类型之一,用竖线 | 分隔。
typescript
// 民族信息可能存在,也可能不存在
@State ethnic: EthnicGroup | undefined = undefined;
// 字符串或数字
let id: string | number = '01';
id = 1; // OK
// 多种状态
type LoadState = 'loading' | 'success' | 'error' | 'empty';
为什么需要联合类型?
因为真实世界中,很多值不是单一类型的。比如:
- 一个民族详情页的数据,可能正在加载(undefined),也可能加载完成(EthnicGroup)
- 一个 ID 可能是字符串,也可能是数字
- 一个请求的状态可能是加载中、成功、失败、空数据
联合类型让你能准确地描述这些情况,而不是用 any 糊弄过去。
联合类型的使用注意事项:
typescript
function printName(ethnic: EthnicGroup | undefined): void {
// ❌ 错误:ethnic 可能是 undefined,不能直接访问 name
console.log(ethnic.name);
// ✅ 正确:先判断类型
if (ethnic) {
console.log(ethnic.name); // OK,类型收窄为 EthnicGroup
}
// ✅ 正确:使用可选链
console.log(ethnic?.name); // OK,返回 string | undefined
}
这个过程叫类型收窄(Type Narrowing)------通过条件判断,把宽泛的联合类型收窄为更具体的类型。
2.3 数组类型(Array Types)
数组有两种写法:
typescript
// 写法1:类型 + 方括号(推荐,更简洁)
const names: string[] = ['汉族', '壮族', '满族'];
const ranks: number[] = [1, 2, 3];
// 写法2:泛型写法
const names: Array<string> = ['汉族', '壮族', '满族'];
多维数组:
typescript
// 二维数组
const matrix: number[][] = [
[1, 2, 3],
[4, 5, 6]
];
「民族图鉴」中的数组应用:
typescript
// models/EthnicModels.ets
export interface EthnicGroup {
provinces: string[]; // 分布省份列表
}
// 实际使用
const hanProvinces: string[] = [
'北京', '上海', '广东', '江苏', '四川' // ... 等等
];
2.4 类型别名(Type Aliases)
type 关键字可以给一个类型起个新名字,让代码更清晰。
typescript
// 简单的类型别名
type EthnicId = string;
// 联合类型别名
type ThemeMode = 'light' | 'dark' | 'system';
type AppLanguage = 'zh-CN' | 'en';
// 对象类型别名
type TabItem = {
title: string;
icon: string;
index: number;
};
⚠️ type vs interface :很多人分不清
type和interface的区别。简单来说:
- 描述对象结构 时,两者都可以,优先用
interface(可以被继承和实现)- 描述联合类型、元组、工具类型 等时,用
type- 后面讲接口的时候会详细对比
步骤3:接口(Interface)------类型系统的基石
3.1 什么是接口?
接口(Interface)用来定义对象的结构------对象有哪些属性、每个属性是什么类型、有哪些方法。
typescript
// 定义一个"民族"的接口
interface EthnicGroup {
id: string;
name: string;
pinyin: string;
population: string;
populationRank: number;
}
为什么叫"接口"? 因为它定义了一个契约------凡是实现这个接口的对象,都必须有这些属性。就像硬件的 USB 接口一样,只要符合这个规范,就能插进去用。
3.2 接口的完整用法
属性修饰符:
typescript
interface EthnicGroup {
// 必填属性
id: string;
name: string;
// 可选属性(加 ?)
alias?: string; // 别称,可能没有
// 只读属性(加 readonly)
readonly populationRank: number; // 人口排名,初始化后不能改
}
const han: EthnicGroup = {
id: '01',
name: '汉族',
populationRank: 1,
// alias 可以不写,因为是可选的
};
han.populationRank = 2; // ❌ 错误:只读属性不能修改
方法定义:
typescript
interface Player {
play(): void;
pause(): void;
seek(time: number): boolean;
getCurrentPosition(): number;
}
索引签名(Index Signatures):
typescript
// 字典:键是民族ID,值是民族信息
interface EthnicDictionary {
[id: string]: EthnicGroup;
}
const dict: EthnicDictionary = {
'01': { id: '01', name: '汉族', /* ... */ },
'02': { id: '02', name: '壮族', /* ... */ },
};
3.3 接口的继承(Extends)
接口可以继承另一个接口,复用已有定义:
typescript
// 基础的民族信息
interface EthnicBase {
id: string;
name: string;
pinyin: string;
}
// 详细的民族信息,继承自基础信息
interface EthnicDetail extends EthnicBase {
population: string;
populationRank: number;
language: string;
religion: string;
region: string;
description: string;
}
多继承:
typescript
interface Named {
name: string;
}
interface Colored {
color: string;
}
// 同时继承两个接口
interface EthnicCard extends Named, Colored {
id: string;
image: string;
}
3.4 interface vs type:怎么选?
这是最常问的问题之一。让我们做一个全面的对比:
| 特性 | interface | type |
|---|---|---|
| 描述对象结构 | ✅ | ✅ |
| 描述联合类型 | ❌ | ✅ |
| 描述元组 | ❌ | ✅ |
| 描述映射类型 | ❌ | ✅ |
| 继承(extends) | ✅ | ❌(用 & 交叉) |
| 实现(implements) | ✅ | 部分可以 |
| 声明合并 | ✅(重复定义会合并) | ❌ |
| 报错信息 | 更友好 | 相对复杂 |
选择原则:
- 描述对象的结构 (比如数据模型、组件 Props)→ 用
interface - 描述联合类型、工具类型、条件类型 → 用
type - 不确定的时候 → 优先用
interface,需要扩展的时候再改
「民族图鉴」中的接口设计:
typescript
// models/EthnicModels.ets
// 用 interface 描述各个业务模型
// 民族基本信息
export interface EthnicGroup {
id: string;
name: string;
nameEn: string;
alias: string;
pinyin: string;
population: string;
populationRank: number;
language: string;
languageEn: string;
languageFamily: string;
languageFamilyEn: string;
script: string;
religion: string;
region: string;
provinces: string[];
description: string;
descriptionEn: string;
emblemColor: string;
coverImage: string;
}
// 服饰信息
export interface Costume {
id: string;
groupId: string;
maleCostume: string;
maleCostumeEn: string;
femaleCostume: string;
femaleCostumeEn: string;
features: string;
featuresEn: string;
}
// 节日信息
export interface Festival {
id: string;
groupId: string;
name: string;
nameEn: string;
lunarDate: string;
origin: string;
originEn: string;
customs: string;
customsEn: string;
}
可以看到,项目中的所有数据模型都用 interface 定义,结构清晰,便于扩展。
步骤4:类(Class)------面向对象编程
4.1 类的基本结构
类是面向对象编程的核心,用来创建具有相同属性和方法的对象。
typescript
class User {
// 属性
name: string;
age: number;
// 构造函数
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
// 方法
greet(): string {
return `你好,我是${this.name},今年${this.age}岁`;
}
}
// 使用
const zhangsan = new User('张三', 25);
console.log(zhangsan.greet());
4.2 访问修饰符
ArkTS 有三种访问修饰符,控制属性和方法的可见性:
| 修饰符 | 说明 | 谁能访问 |
|---|---|---|
public |
公开的(默认) | 任何地方都能访问 |
private |
私有的 | 只有类内部能访问 |
protected |
受保护的 | 类内部和子类能访问 |
「民族图鉴」中的单例模式:
typescript
// services/StorageService.ets
export class StorageService {
// 私有静态属性:保存唯一实例
private static instance: StorageService;
// 私有构造函数:外部不能直接 new
private constructor() {
// 初始化逻辑...
}
// 公开静态方法:获取唯一实例
static getInstance(): StorageService {
if (!StorageService.instance) {
StorageService.instance = new StorageService();
}
return StorageService.instance;
}
// 公开方法:业务逻辑
async toggleFavoriteEthnic(ethnicId: string): Promise<boolean> {
// ...
}
// 私有方法:内部辅助方法
private async getPreferences(): Promise<Preferences> {
// ...
}
}
这是一个典型的单例模式(Singleton):
- 构造函数是
private的,外部不能new StorageService() - 通过
getInstance()静态方法获取全局唯一实例 - 保证整个应用中只有一个 StorageService 实例,状态统一
Service 层(StorageService、ThemeService、MusicService 等)全都用单例模式,这是「民族图鉴」项目的一个重要设计模式。
4.3 继承(Extends)
类可以继承另一个类,复用父类的属性和方法:
typescript
// 父类:基础服务
class BaseService {
protected tag: string = 'BaseService';
protected log(message: string): void {
console.log(`[${this.tag}] ${message}`);
}
}
// 子类:存储服务
class StorageService extends BaseService {
constructor() {
super(); // 调用父类构造函数
this.tag = 'StorageService'; // 覆盖父类属性
}
async saveData(key: string, value: string): Promise<void> {
this.log(`Saving ${key}`); // 调用父类方法
// 保存逻辑...
}
}
💡 ArkTS 中的继承使用场景:虽然 ArkTS 支持类的继承,但在实际的鸿蒙应用开发中,继承用得相对少一些。因为:
- UI 组件用组合(Component 嵌套)而不是继承
- 业务逻辑用分层架构(Page → Service → Model)而不是继承树
- 组合优于继承(Composition over Inheritance)是现代编程的共识
4.4 实现接口(Implements)
类可以实现(implement)一个或多个接口,保证自己符合接口的契约:
typescript
// 播放器接口
interface IPlayer {
play(): void;
pause(): void;
stop(): void;
isPlaying(): boolean;
}
// 音乐播放器实现了播放器接口
class MusicPlayer implements IPlayer {
private playing: boolean = false;
play(): void {
this.playing = true;
}
pause(): void {
this.playing = false;
}
stop(): void {
this.playing = false;
}
isPlaying(): boolean {
return this.playing;
}
}
接口和实现分离的好处是:依赖接口而不是具体实现 。比如你的代码只依赖 IPlayer 接口,那么以后想换成 VideoPlayer、RadioPlayer,只要它们实现了同一个接口,就不用改业务代码。
步骤5:枚举(Enum)------固定集合的类型安全
5.1 为什么需要枚举?
很多时候,一个变量的取值是固定的几个选项。比如:
- 主题模式:浅色 / 深色 / 跟随系统
- 语言:中文 / 英文
- 反馈类型:Bug / 建议 / 内容 / 其他
你可以用字符串字面量联合类型:
typescript
type ThemeMode = 'light' | 'dark' | 'system';
也可以用枚举:
typescript
enum ThemeMode {
LIGHT = 'light',
DARK = 'dark',
SYSTEM = 'system'
}
两者的对比:
| 特性 | 字符串联合类型 | 枚举(enum) |
|---|---|---|
| 类型安全 | ✅ | ✅ |
| 自动补全 | ✅ | ✅ |
| 运行时存在 | ❌(编译后消失) | ✅(编译后还有对象) |
| 可以遍历 | ❌ | ✅(可以枚举所有值) |
| 可以加方法 | ❌ | ✅(可以有静态方法) |
| 代码量 | 少 | 稍多 |
选择原则:
- 简单的几个选项 → 字符串联合类型(轻量)
- 需要遍历、需要映射、选项比较多 → 枚举(功能强)
5.2 枚举的使用
「民族图鉴」中的枚举设计:
typescript
// models/EnumModels.ets
// 主题模式
export enum ThemeMode {
LIGHT = 'light',
DARK = 'dark',
SYSTEM = 'system'
}
// 应用语言
export enum AppLanguage {
ZH_CN = 'zh-CN',
EN = 'en'
}
// 内容模式
export enum ContentMode {
FULL = 'full',
BASIC = 'basic'
}
// TTS语速
export enum TtsSpeed {
SLOW = 'slow',
NORMAL = 'normal',
FAST = 'fast'
}
// 反馈类型
export enum FeedbackType {
BUG = 'bug',
SUGGESTION = 'suggestion',
CONTENT = 'content',
OTHER = 'other'
}
// 冷知识分类
export enum TriviaCategory {
FUN_FACT = 'fun_fact',
LEGEND = 'legend',
CUSTOM = 'custom',
FOOD = 'food',
ARCHITECTURE = 'architecture'
}
枚举的实际使用:
typescript
// pages/Index.ets
import { AppLanguage } from '../models/EnumModels';
@Entry
@Component
struct Index {
// 语言状态,用枚举类型
@StorageLink('currentAppLanguage') currentLanguage: AppLanguage = AppLanguage.ZH_CN;
private isChinese(): boolean {
// 用枚举值比较,类型安全
return this.currentLanguage === AppLanguage.ZH_CN;
}
private getLocalizedText(zhText: string, enText: string): string {
return this.isChinese() ? zhText : enText;
}
}
用枚举的好处是:
- 类型安全:只能赋枚举中定义的值,不会写错
- 自动补全:IDE 会自动提示所有可选值
- 语义清晰 :
AppLanguage.ZH_CN比'zh-CN'更清晰 - 便于修改:要改值只需要改枚举定义,不用到处找字符串
步骤6:函数------一等公民
6.1 函数类型
在 ArkTS 中,函数是"一等公民"------可以赋值给变量、作为参数传递、作为返回值。
函数的类型定义:
typescript
// 命名函数
function add(a: number, b: number): number {
return a + b;
}
// 函数表达式
const multiply = function(a: number, b: number): number {
return a * b;
};
// 箭头函数
const divide = (a: number, b: number): number => a / b;
函数类型作为参数:
typescript
// 定义一个函数类型:接收两个 number,返回 number
type MathOperation = (a: number, b: number) => number;
// 使用函数类型
function calculate(a: number, b: number, op: MathOperation): number {
return op(a, b);
}
// 传不同的函数
calculate(1, 2, add); // 3
calculate(3, 4, multiply); // 12
6.2 箭头函数与 this
箭头函数不只是写法简洁,它还有一个重要特性:没有自己的 this,继承外层的 this。
这在声明式 UI 中非常重要,因为回调函数里经常要访问组件的状态:
typescript
@Entry
@Component
struct MyComponent {
@State count: number = 0;
build() {
Column() {
Text(`计数:${this.count}`)
// ✅ 箭头函数:this 指向组件实例
Button('增加')
.onClick(() => {
this.count++; // 这里的 this 是对的
})
// ❌ 普通函数:this 会有问题(取决于调用方式)
// Button('增加')
// .onClick(function() {
// this.count++; // 这里的 this 可能不对
// })
}
}
}
💡 经验法则 :在 ArkUI 的回调函数(onClick、onChange 等)中,永远用箭头函数,不要用普通函数。这样 this 的指向才是正确的。
6.3 可选参数与默认参数
typescript
// 可选参数(加 ?)
function greet(name: string, greeting?: string): string {
if (greeting) {
return `${greeting},${name}!`;
}
return `你好,${name}!`;
}
// 默认参数(直接赋值)
function greet2(name: string, greeting: string = '你好'): string {
return `${greeting},${name}!`;
}
greet('张三'); // 你好,张三!
greet('张三', '欢迎'); // 欢迎,张三!
6.4 异步函数(async/await)
异步操作是应用开发中最常见的场景之一------网络请求、文件读写、Preferences 操作都是异步的。
Promise 基础:
typescript
// 返回 Promise 的函数
function fetchData(): Promise<string> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('数据加载完成');
}, 1000);
});
}
async/await 写法:
typescript
// async 函数返回 Promise
async function loadData(): Promise<void> {
try {
const data: string = await fetchData();
console.log(data);
} catch (error) {
console.error('加载失败:', error);
}
}
「民族图鉴」中的异步应用:
typescript
// services/StorageService.ets
export class StorageService {
// 切换收藏状态:异步函数
async toggleFavoriteEthnic(ethnicId: string): Promise<boolean> {
const favorites: string[] = await this.getFavoriteEthnics();
const index: number = favorites.indexOf(ethnicId);
if (index > -1) {
favorites.splice(index, 1);
await this.saveFavoriteEthnics(favorites);
return false;
} else {
favorites.push(ethnicId);
await this.saveFavoriteEthnics(favorites);
return true;
}
}
// 获取收藏列表
private async getFavoriteEthnics(): Promise<string[]> {
try {
const prefs = await this.getPreferences();
const value = await prefs.get('favorite_ethnics', '[]');
return JSON.parse(value as string) as string[];
} catch (e) {
console.error('[StorageService] get favorites failed:', JSON.stringify(e));
return [];
}
}
}
异步编程注意事项:
- 错误处理:异步操作一定要用 try/catch 包裹,不要让异常裸奔
- 返回类型 :async 函数的返回类型一定要写
Promise<T> - 不要滥用 await :如果几个异步操作没有依赖关系,可以用
Promise.all()并行执行,更快 - aboutToAppear 中不能直接 async/await:这是 ArkUI 的限制,需要用"fire-and-forget"模式
步骤7:泛型(Generics)------类型的参数化
7.1 为什么需要泛型?
假设你要写一个函数,返回数组的第一个元素。你可能会这么写:
typescript
function getFirst(arr: number[]): number | undefined {
return arr[0];
}
但如果数组是字符串类型的呢?再写一个?
typescript
function getFirstString(arr: string[]): string | undefined {
return arr[0];
}
如果还有其他类型呢?总不能每个类型都写一遍吧。
泛型就是解决这个问题的------让类型也能像参数一样传递:
typescript
// T 是类型参数,调用时指定
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
// 使用时指定具体类型
const firstNum: number | undefined = getFirst<number>([1, 2, 3]);
const firstStr: string | undefined = getFirst<string>(['a', 'b', 'c']);
// 也可以让编译器自动推断
const firstNum2 = getFirst([1, 2, 3]); // T 自动推断为 number
泛型的核心思想 :类型参数化------把类型当成参数,在使用的时候再传入。这样既能复用代码,又能保证类型安全。
7.2 泛型接口
typescript
// 通用的 API 响应结构
interface ApiResponse<T> {
code: number;
message: string;
data: T; // data 的类型由 T 决定
}
// 民族列表响应
type EthnicListResponse = ApiResponse<EthnicGroup[]>;
// 单个民族响应
type EthnicDetailResponse = ApiResponse<EthnicGroup>;
7.3 泛型约束
有时候你想给泛型加一些限制,比如"必须有 id 属性":
typescript
// 定义一个约束:必须有 id 属性
interface HasId {
id: string;
}
// T 必须满足 HasId 接口
function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
// 可以用在 EthnicGroup 上(因为它有 id)
const han = findById(ETHNIC_GROUPS, '01'); // OK
// 用在没有 id 的类型上会报错
const nums: number[] = [1, 2, 3];
findById(nums, '01'); // ❌ 错误:number 没有 id 属性
7.4 「民族图鉴」中的泛型应用
typescript
// utils/LazyDataSource.ets
// 懒加载数据源的泛型实现
export class LazyDataSource<T> {
private data: T[] = [];
private loadedCount: number = 0;
private pageSize: number = 20;
constructor(data: T[], pageSize: number = 20) {
this.data = data;
this.pageSize = pageSize;
}
totalCount(): number {
return this.data.length;
}
loadMore(): T[] {
const end = Math.min(this.loadedCount + this.pageSize, this.data.length);
const newItems = this.data.slice(this.loadedCount, end);
this.loadedCount = end;
return newItems;
}
getItem(index: number): T | undefined {
return this.data[index];
}
reset(): void {
this.loadedCount = 0;
}
}
这个 LazyDataSource 是一个通用的懒加载数据源,可以用在任何类型的数据上------民族列表、测验题目、音乐列表等,只要指定 T 的类型就行了。
步骤8:类型守卫与类型收窄
8.1 为什么需要类型收窄?
当你有一个联合类型的值时,比如 EthnicGroup | undefined,你不能直接访问 ethnic.name,因为 ethnic 可能是 undefined。
你需要先做一个判断,把类型"收窄"到更具体的类型:
typescript
function printName(ethnic: EthnicGroup | undefined): void {
// 类型是 EthnicGroup | undefined
if (ethnic) {
// 进入这个分支后,类型收窄为 EthnicGroup
console.log(ethnic.name); // OK
}
}
这个过程就叫类型收窄(Type Narrowing)。
8.2 常见的类型收窄方式
1. if 判断(真值收窄):
typescript
if (ethnic) {
// ethnic 是 EthnicGroup
}
2. typeof 判断:
typescript
function processValue(value: string | number): void {
if (typeof value === 'string') {
// value 是 string
console.log(value.toUpperCase());
} else {
// value 是 number
console.log(value.toFixed(2));
}
}
3. instanceof 判断:
typescript
if (error instanceof Error) {
console.log(error.message);
}
4. 可选链(?.):
typescript
// 不用写 if,直接用可选链
const name = ethnic?.name; // string | undefined
5. 非空断言(!.):
typescript
// 你确定 ethnic 一定不是 null/undefined
// 可以用 ! 告诉编译器(慎用!)
const name = ethnic!.name; // string
⚠️ 非空断言要谨慎使用 :
!.相当于你跟编译器保证"这个值一定不为空"。如果实际运行时空了,还是会报错。能不用就不用,最好用 if 判断或者可选链。
步骤9:类型系统设计最佳实践(以民族图鉴为例)
学了这么多类型知识,让我们看看怎么在实际项目中设计合理的类型系统。
9.1 数据模型的设计原则
原则1:接口定义数据结构,不要用 any
typescript
// ❌ 不好:用 any,类型系统完全失效
const ethnic: any = { name: '汉族', population: '13亿' };
// ✅ 好:用接口明确结构
interface EthnicGroup {
name: string;
population: string;
// ...
}
原则2:按领域划分模型文件
models/
├── EthnicModels.ets // 民族相关:EthnicGroup, Costume, Festival...
├── QuizModels.ets // 测验相关:QuizQuestion, QuizResult...
├── MusicModels.ets // 音乐相关:Song, Album, PlayerState...
├── EnumModels.ets // 枚举:ThemeMode, AppLanguage...
└── UserModels.ets // 用户相关:UserProfile, ViewRecord...
原则3:合理使用可选属性
typescript
// ❌ 不好:所有属性都是可选的,等于没有类型
interface EthnicGroup {
id?: string;
name?: string;
population?: string;
}
// ✅ 好:必填的就是必填,可选的才加 ?
interface EthnicGroup {
id: string; // 必填
name: string; // 必填
population: string; // 必填
alias?: string; // 可选:不是所有民族都有别称
}
9.2 状态类型的设计
页面的状态也要有明确的类型:
typescript
// ❌ 不好:状态散落在各处,没有统一管理
@State name: string = '';
@State loading: boolean = false;
@State error: string = '';
// ✅ 好:用接口定义页面状态
interface DetailPageState {
ethnic: EthnicGroup | undefined;
isLoading: boolean;
errorMessage: string | null;
isFavorite: boolean;
}
9.3 避免过度设计
类型系统是工具,不是目的。不要为了"用类型"而把简单的事情搞复杂:
typescript
// ❌ 过度设计:简单的事情搞复杂
type Name = string;
type Age = number;
type Address = {
province: string;
city: string;
district: string;
street: string;
detail: string;
};
type User = {
name: Name;
age: Age;
address: Address;
};
// ✅ 适度设计:够用就好
interface User {
name: string;
age: number;
province: string;
city: string;
}
判断标准:这个类型能让代码更清晰、更安全吗?能就加,不能就别加。
⚠️ 常见问题与解决方案
问题1:看到 any 报错就头大,怎么破?
现象 :
写代码的时候到处报 Use explicit types instead of "any", "unknown",觉得类型太麻烦了。
原因分析 :
ArkTS 的严格模式禁用了 any,这是很多从 JS/TS 转过来的开发者最不适应的地方。但请相信我------这是为了你好。
解决思路:
| 场景 | 解决方案 |
|---|---|
| 不知道数据长什么样 | 先定义 interface |
| 函数参数类型不确定 | 用泛型,或者用具体的联合类型 |
| API 返回值不确定 | 定义响应类型接口 |
| 临时调试 | 可以先用 as unknown as XXX 过渡(但不建议长期留着) |
具体示例:
typescript
// ❌ 不要:用 any 逃避
function process(data: any): any {
return data.value;
}
// ✅ 正确:定义接口
interface DataItem {
value: string;
count: number;
}
function process(data: DataItem): string {
return data.value;
}
💡 心态调整:把类型报错当成你的朋友。它在告诉你"这里可能有问题",帮你在上线前发现 bug。刚开始可能觉得慢,习惯了之后,你会发现找 bug 的时间少多了。
问题2:Object literal must correspond to some explicitly declared class or interface
现象 :
写 const user = { name: '张三', age: 25 } 的时候,报 Object literal must correspond to some explicitly declared class or interface。
原因 :
ArkTS 严格模式下,不能随便写"匿名对象",所有对象都必须有明确的类型(接口或类)。
解决方法:
typescript
// ❌ 错误:匿名对象
const user = { name: '张三', age: 25 };
// ✅ 正确1:先定义接口
interface User {
name: string;
age: number;
}
const user: User = { name: '张三', age: 25 };
// ✅ 正确2:用类型断言(不推荐,应急用)
const user = { name: '张三', age: 25 } as { name: string; age: number };
为什么要这么严格?
因为匿名对象的结构是"隐式的"------你知道它有 name 和 age,但编译器不确定,阅读代码的人也得去猜。明确的类型让代码更清晰,也让编译器能做更多优化。
问题3:Type 'xxx | undefined' is not assignable to type 'xxx'
现象 :
const name: string = ethnic?.name; 报错,说 string | undefined 不能赋值给 string。
原因 :
可选链 ?. 的返回值包含 undefined,因为 ethnic 可能是 undefined。而你要赋值的变量类型是 string,不接受 undefined。
解决方法:
typescript
// 情况1:ethnic 可能是 undefined
@State ethnic: EthnicGroup | undefined = undefined;
// ❌ 错误:可能返回 undefined
const name: string = ethnic?.name;
// ✅ 方法1:给默认值(推荐)
const name: string = ethnic?.name ?? '';
// ✅ 方法2:if 判断后再用
if (ethnic) {
const name: string = ethnic.name; // OK,类型收窄了
}
// ✅ 方法3:非空断言(你确定 ethnic 一定存在时用)
const name: string = ethnic!.name;
选择建议:
- 大多数情况 → 用
??给默认值,最安全 - 有 if 判断的场景 → 利用类型收窄
- 实在确定不为空 → 用
!.,但尽量少用
问题4:接口 vs 类型别名,到底用哪个?
现象 :
不知道什么时候用 interface,什么时候用 type。
快速决策树:
你要定义什么?
│
├─ 对象的结构(数据模型、Props)
│ └─ → 用 interface(可以被 implements/extends,报错更友好)
│
├─ 联合类型(A | B | C)
│ └─ → 用 type
│
├─ 元组([string, number])
│ └─ → 用 type
│
├─ 工具类型(Partial、Pick 等)
│ └─ → 用 type
│
└─ 不确定
└─ → 先试试 interface,不够用再改成 type
「民族图鉴」中的实际选择:
typescript
// 用 interface:业务数据模型
export interface EthnicGroup { /* ... */ }
export interface Costume { /* ... */ }
// 用 type:联合类型
export type CuisineCategory = 'meat' | 'vegetable' | 'dessert' | 'staple';
// 用 type:函数类型
type PlayerStateListener = (state: PlayerState) => void;
问题5:泛型什么时候用?会不会过度设计?
现象 :
觉得泛型很高级,但又不知道什么时候该用,担心过度设计。
判断标准:
| 需要用泛型的信号 | 不需要的信号 |
|---|---|
| 同样的逻辑要写好几次,只是类型不同 | 只用一次,逻辑很简单 |
| 是通用工具/组件,会被很多地方用 | 只在一个地方用,业务性很强 |
| 类型和逻辑是分离的(比如数组操作) | 类型和业务逻辑紧密耦合 |
「民族图鉴」中的例子:
typescript
// ✅ 应该用泛型:通用数据源,很多地方会用
class LazyDataSource<T> { /* ... */ }
// ❌ 不需要用泛型:具体的业务逻辑
class StorageService {
// 直接用具体类型就行了,没必要搞泛型
async getFavoriteEthnics(): Promise<string[]> { /* ... */ }
}
经验法则:当你发现自己在复制粘贴代码,只是改了类型的时候,就该考虑用泛型了。如果只是写一次的业务代码,不用硬套泛型。
📝 本章小结
核心知识点
本文从设计理念到实际应用,系统讲解了 ArkTS 的类型系统和基础语法:
1. ArkTS 语言基础
- ArkTS 是 TypeScript 的超集+子集,扩展了声明式 UI,约束了动态特性
- 两大编程范式:命令式(业务逻辑)+ 声明式(UI 描述)
- 严格模式:禁用 any、禁用动态属性、严格空值检查
2. 基本类型
- 原始类型:string, number, boolean, null, undefined
- 联合类型:A | B,配合类型收窄使用
- 数组:T\[\] 或 Array
- 类型别名:type 关键字
3. 接口(Interface)
- 定义对象结构,是类型系统的基石
- 可选属性(?)、只读属性(readonly)、索引签名
- 继承(extends),支持多继承
- interface vs type:描述对象结构优先用 interface
4. 类(Class)
- 属性、构造函数、方法
- 访问修饰符:public / private / protected
- 单例模式:Service 层的常用设计模式
- 实现接口(implements):依赖接口而非具体实现
5. 枚举(Enum)
- 固定选项集合的类型安全
- 字符串枚举最常用
- 对比字符串联合类型:枚举功能更强,联合类型更轻量
6. 函数
- 函数类型:一等公民,可赋值、可传参、可返回
- 箭头函数:UI 回调中必用,保证 this 指向正确
- 可选参数、默认参数
- async/await:异步编程的标准写法
7. 泛型(Generics)
- 类型参数化:让类型也能像参数一样传递
- 泛型函数、泛型接口、泛型类
- 泛型约束:extends 限制类型范围
- 通用工具用泛型,业务代码不用硬套
8. 类型收窄
- if 判断、typeof、instanceof
- 可选链(?.)、空值合并(??)
- 非空断言(!.):慎用
最佳实践总结
✅ 所有数据模型都用 interface 定义
typescript
// 不要用 any,不要用匿名对象
// 每个业务实体都有明确的接口定义
export interface EthnicGroup {
id: string;
name: string;
// ...
}
✅ Service 层用单例模式
typescript
export class StorageService {
private static instance: StorageService;
static getInstance(): StorageService {
if (!StorageService.instance) {
StorageService.instance = new StorageService();
}
return StorageService.instance;
}
private constructor() {}
}
✅ UI 回调用箭头函数
typescript
Button('点击')
.onClick(() => {
// this 指向组件实例,不会出错
this.handleClick();
})
✅ 异步操作一定要处理错误
typescript
async loadData(): Promise<void> {
try {
const data = await fetchData();
// 处理数据
} catch (e) {
console.error('加载失败:', JSON.stringify(e));
// 错误处理
}
}
✅ 遇到类型报错不要慌,更不要用 any 逃避
报错 → 分析原因 → 定义正确的类型 → 修复
类型系统在帮你发现 bug,不是在为难你
✅ 不要过度设计,够用就好
简单场景 → 简单类型
复杂场景 → 复杂类型
不要为了"用高级特性"而把代码搞复杂
下一步预告
在下一篇文章中,我们将:
- 🎨 深入理解声明式 UI 的核心思想(状态驱动视图)
- 🏗️ 掌握 @Component、@Entry 装饰器的作用与原理
- 📐 理解 build 方法的执行机制与属性链式调用
- 🔄 学习状态管理的基本概念(@State 装饰器)
- 🚀 为后续的组件开发和状态管理打下坚实基础
🔗 相关链接
💡 提示:类型系统是 ArkTS 最重要的基础,也是最容易被忽视的部分。不要急于写 UI,先把类型基础打扎实。你写的每一个 interface、每一个类型注解,都是在为以后的开发效率和代码质量铺路。刚开始可能觉得慢,习惯了之后,你会发现------有类型的代码,写起来更安心,改起来更放心。