HarmonyOS应用<民族图鉴>开发第4篇:ArkTS入门——类型系统与基础语法深度解析

📖 引言

前两篇我们完成了环境搭建和项目结构的学习,从这一篇开始,我们正式进入代码开发的世界。而要写好鸿蒙应用,首先要掌握的就是 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 :很多人分不清 typeinterface 的区别。简单来说:

  • 描述对象结构 时,两者都可以,优先用 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) 部分可以
声明合并 ✅(重复定义会合并)
报错信息 更友好 相对复杂

选择原则

  1. 描述对象的结构 (比如数据模型、组件 Props)→ 用 interface
  2. 描述联合类型、工具类型、条件类型 → 用 type
  3. 不确定的时候 → 优先用 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 支持类的继承,但在实际的鸿蒙应用开发中,继承用得相对少一些。因为:

  1. UI 组件用组合(Component 嵌套)而不是继承
  2. 业务逻辑用分层架构(Page → Service → Model)而不是继承树
  3. 组合优于继承(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;
  }
}

用枚举的好处是:

  1. 类型安全:只能赋枚举中定义的值,不会写错
  2. 自动补全:IDE 会自动提示所有可选值
  3. 语义清晰AppLanguage.ZH_CN'zh-CN' 更清晰
  4. 便于修改:要改值只需要改枚举定义,不用到处找字符串

步骤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 [];
    }
  }
}

异步编程注意事项

  1. 错误处理:异步操作一定要用 try/catch 包裹,不要让异常裸奔
  2. 返回类型 :async 函数的返回类型一定要写 Promise<T>
  3. 不要滥用 await :如果几个异步操作没有依赖关系,可以用 Promise.all() 并行执行,更快
  4. 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、每一个类型注解,都是在为以后的开发效率和代码质量铺路。刚开始可能觉得慢,习惯了之后,你会发现------有类型的代码,写起来更安心,改起来更放心。